├── .codacy.yml
├── .github
└── workflows
│ └── ios.yml
├── .gitignore
├── .swiftformat
├── .swiftlint.yml
├── .travis.yml
├── AppIcon.sketch
├── EmonCMSiOS.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcshareddata
│ └── xcschemes
│ ├── EmonCMSiOS.xcscheme
│ ├── EmonCMSiOSSelectFeedIntent.xcscheme
│ ├── EmonCMSiOSToday.xcscheme
│ └── EmonCMSiOSWidgetExtension.xcscheme
├── EmonCMSiOS
├── API
│ ├── EmonCMSAPI+Dashboard.swift
│ ├── EmonCMSAPI+Feed.swift
│ ├── EmonCMSAPI+Input.swift
│ ├── EmonCMSAPI+System.swift
│ ├── EmonCMSAPI+User.swift
│ ├── EmonCMSAPI.swift
│ ├── HTTPRequestProvider.swift
│ └── NSURLSessionHTTPRequestProvider.swift
├── AppDelegate.swift
├── Apps
│ ├── AppConfig.swift
│ ├── AppConfigViewController.swift
│ ├── AppConfigViewModel.swift
│ ├── AppPageViewController.swift
│ ├── AppSelectFeedViewController.swift
│ ├── AppViewController.swift
│ ├── Apps.swift
│ ├── MyElectric
│ │ ├── MyElectricAppViewController.swift
│ │ └── MyElectricAppViewModel.swift
│ ├── MySolar
│ │ ├── MySolarAppPage1ViewController.swift
│ │ ├── MySolarAppPage1ViewModel.swift
│ │ ├── MySolarAppPage2ViewController.swift
│ │ ├── MySolarAppPage2ViewModel.swift
│ │ └── MySolarAppViewModel.swift
│ └── MySolarDivert
│ │ ├── MySolarDivertAppPage1ViewController.swift
│ │ ├── MySolarDivertAppPage1ViewModel.swift
│ │ ├── MySolarDivertAppPage2ViewController.swift
│ │ ├── MySolarDivertAppPage2ViewModel.swift
│ │ └── MySolarDivertAppViewModel.swift
├── Assets.xcassets
│ ├── AppIcon.appiconset
│ │ ├── Contents.json
│ │ ├── Icon-60@2x.png
│ │ ├── Icon-60@3x.png
│ │ ├── Icon-76.png
│ │ ├── Icon-76@2x.png
│ │ ├── Icon-83.5@2x.png
│ │ └── iTunesArtwork@2x.png
│ ├── Contents.json
│ ├── tab_chart.imageset
│ │ ├── Contents.json
│ │ ├── tab_chart@2x.png
│ │ └── tab_chart@3x.png
│ ├── tab_dashboard.imageset
│ │ ├── Contents.json
│ │ ├── tab_dashboard@2x.png
│ │ └── tab_dashboard@3x.png
│ ├── tab_leaf.imageset
│ │ ├── Contents.json
│ │ ├── tab_leaf@2x.png
│ │ └── tab_leaf@3x.png
│ ├── tab_list.imageset
│ │ ├── Contents.json
│ │ ├── tab_list@2x.png
│ │ └── tab_list@3x.png
│ ├── tab_right_arrow.imageset
│ │ ├── Contents.json
│ │ ├── tab_right_arrow@2x.png
│ │ └── tab_right_arrow@3x.png
│ ├── tab_wrench.imageset
│ │ ├── Contents.json
│ │ ├── tab_wrench@2x.png
│ │ └── tab_wrench@3x.png
│ └── warning.imageset
│ │ ├── Contents.json
│ │ ├── warning@2x.png
│ │ └── warning@3x.png
├── Base.lproj
│ ├── Apps.storyboard
│ ├── LaunchScreen.storyboard
│ └── Main.storyboard
├── Controllers
│ ├── DataController.swift
│ ├── KeychainController.swift
│ ├── LogController.swift
│ └── RealmController.swift
├── EmonCMSiOS.entitlements
├── FakeServer
│ ├── FakeEmonCMSFeedEngine.swift
│ └── FakeHTTPProvider.swift
├── Helpers
│ ├── ChartHelpers.swift
│ ├── Constants
│ │ ├── AccessibilityIdentifiers.swift
│ │ ├── EmonCMSColors.swift
│ │ └── SharedConstants.swift
│ ├── Double+Emoncms.swift
│ ├── Formatters
│ │ ├── ChartDateValueFormatter.swift
│ │ └── DayRelativeToTodayValueFormatter.swift
│ ├── Rx
│ │ ├── ActivityIndicator.swift
│ │ ├── CombineOperators.swift
│ │ ├── CombineTableViewDataSource.swift
│ │ ├── RowFormer+Combine.swift
│ │ └── UIControl+Combine.swift
│ └── SemanticVersion.swift
├── Info.plist
├── Model
│ ├── AccountController.swift
│ ├── DataPoint.swift
│ ├── DateRange.swift
│ └── RealmObjects
│ │ ├── Account.swift
│ │ ├── AppData.swift
│ │ ├── Dashboard.swift
│ │ ├── Feed.swift
│ │ ├── Input.swift
│ │ └── TodayWidgetFeed.swift
├── SceneDelegate.swift
├── Settings.bundle
│ ├── Licenses.latest_result.txt
│ ├── Licenses.plist
│ ├── Licenses
│ │ ├── Charts.plist
│ │ ├── Entwine.plist
│ │ ├── Former.plist
│ │ ├── KeychainAccess.plist
│ │ ├── Nimble.plist
│ │ ├── Quick.plist
│ │ ├── XCGLogger.plist
│ │ ├── realm-cocoa.plist
│ │ ├── realm-core.plist
│ │ └── swift-snapshot-testing.plist
│ ├── Root.plist
│ └── en.lproj
│ │ └── Root.strings
├── UI
│ ├── FormerCells
│ │ └── ChartCell.swift
│ ├── TableCells
│ │ ├── FeedCell.swift
│ │ ├── FeedCell.xib
│ │ ├── InputCell.swift
│ │ └── InputCell.xib
│ ├── ViewControllers
│ │ ├── AccountListViewController.swift
│ │ ├── AddAccountQRViewController.swift
│ │ ├── AddAccountViewController.swift
│ │ ├── AppListViewController.swift
│ │ ├── DashboardListViewController.swift
│ │ ├── FeedChartViewController.swift
│ │ ├── FeedListViewController.swift
│ │ ├── InputListViewController.swift
│ │ ├── SettingsViewController.swift
│ │ └── TodayWidgetFeedsListViewController.swift
│ └── Views
│ │ ├── AppBoxesArrowView.swift
│ │ ├── AppBoxesFeedView.swift
│ │ └── AppTitleAndValueView.swift
└── ViewModel
│ ├── AccountListViewModel.swift
│ ├── AccountViewModel.swift
│ ├── AddAccountViewModel.swift
│ ├── AppListViewModel.swift
│ ├── DashboardListViewModel.swift
│ ├── DashboardUpdateHelper.swift
│ ├── FeedChartViewModel.swift
│ ├── FeedListHelper.swift
│ ├── FeedListViewModel.swift
│ ├── FeedUpdateHelper.swift
│ ├── InputListViewModel.swift
│ ├── InputUpdateHelper.swift
│ ├── SettingsViewModel.swift
│ └── TodayWidgetFeedsListViewModel.swift
├── EmonCMSiOSSelectFeedIntent
├── EmonCMSiOSSelectFeedIntent.entitlements
├── Info.plist
└── IntentHandler.swift
├── EmonCMSiOSTests
├── CombineTests
│ ├── ActivityIndicatorTests.swift
│ ├── CombineTableViewDataSourceTests.swift
│ ├── RxOperatorsTests.swift
│ └── UIControlCombineTests.swift
├── EntwineAdditions.swift
├── Info.plist
├── LogicTests
│ ├── AccountControllerTests.swift
│ ├── ChartDateValueFormatterTests.swift
│ ├── ChartHelpersTests.swift
│ ├── DataPointTests.swift
│ ├── DateRangeTests.swift
│ ├── DayRelativeToTodayValueFormatter.swift
│ ├── EmoncmsDoubleTests.swift
│ ├── FeedTests.swift
│ ├── KeychainControllerTests.swift
│ ├── LogControllerTests.swift
│ ├── NSURLSessionHTTPRequestProviderTests.swift
│ ├── RealmMigrationTests.swift
│ └── SemanticVersionTests.swift
├── MockHTTPRequestProvider.swift
├── Realms
│ ├── account_v0.realm
│ ├── account_v1.realm
│ ├── account_v2.realm
│ ├── account_v3.realm
│ ├── main_v1.realm
│ ├── main_v2.realm
│ └── main_v3.realm
├── SnapshotTests
│ ├── AppBoxViewsTests.swift
│ ├── FeedCellTests.swift
│ ├── InputCellTests.swift
│ └── __Snapshots__
│ │ ├── AppBoxViewsTests
│ │ ├── testArrowViewArrowColor.1.png
│ │ ├── testArrowViewDown.1.png
│ │ ├── testArrowViewLeft.1.png
│ │ ├── testArrowViewRight.1.png
│ │ ├── testArrowViewUp.1.png
│ │ └── testFeedView.1.png
│ │ ├── FeedCellTests
│ │ ├── spec.1.png
│ │ └── spec.2.png
│ │ └── InputCellTests
│ │ └── spec.1.png
└── ViewModelTests
│ ├── AccountListViewModelTests.swift
│ ├── AccountViewModelTests.swift
│ ├── AddAccountViewModelTests.swift
│ ├── AppListViewModelTests.swift
│ ├── DashboardListViewModelTests.swift
│ ├── DashboardUpdateHelperTests.swift
│ ├── EmonCMSAPIFailureTests.swift
│ ├── EmonCMSAPITests.swift
│ ├── EmonCMSTestCase.swift
│ ├── FeedChartViewModelTests.swift
│ ├── FeedListHelperTests.swift
│ ├── FeedListViewModelTests.swift
│ ├── FeedUpdateHelperTests.swift
│ ├── InputListViewModelTests.swift
│ └── InputUpdateHelperTests.swift
├── EmonCMSiOSToday
├── Base.lproj
│ └── MainInterface.storyboard
├── EmonCMSiOSToday.entitlements
├── Info.plist
├── TodayViewController.swift
├── TodayViewFeedCell.swift
├── TodayViewFeedCell.xib
└── TodayViewModel.swift
├── EmonCMSiOSUITests
├── EmonCMSiOSUITests.swift
├── Info.plist
└── XCUIElement+EmonCMS.swift
├── EmonCMSiOSWidget
├── EmonCMSiOSWidgetBundle.swift
├── FeedChartView.swift
├── FeedListWidget.swift
├── FeedRowView.swift
├── FeedViewModel.swift
├── FeedWidgetItem.swift
├── Info.plist
├── SelectFeedIntent.intentdefinition
├── SingleFeedView.swift
└── SingleFeedWidget.swift
├── EmonCMSiOSWidgetExtension.entitlements
├── LICENSE
├── Makefile
├── README.md
├── WatchComplicationIcons.sketch
├── codecov.yml
├── images
├── app1.png
├── app2.png
├── app3.png
├── app4.png
├── app5.png
├── screen1.png
├── screen2.png
├── screen3.png
├── screen4.png
├── screen5.png
├── today1.png
├── widget1.png
└── widget2.png
└── scripts
├── generate_licenses
└── travis-ci.sh
/.codacy.yml:
--------------------------------------------------------------------------------
1 | exclude_paths:
2 | - "Carthage/**"
3 | - "images/**"
4 | - "scripts/**"
5 |
--------------------------------------------------------------------------------
/.github/workflows/ios.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | name: Build and Test
12 | runs-on: macos-12
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v3
17 | - name: Build
18 | env:
19 | scheme: ${{ 'EmonCMSiOS' }}
20 | platform: ${{ 'iOS Simulator' }}
21 | project: ${{ 'EmonCMSiOS.xcodeproj' }}
22 | run: |
23 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
24 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
25 | xcodebuild build-for-testing -scheme "$scheme" -project "$project" -destination "platform=$platform,name=$device" -enableCodeCoverage YES
26 | - name: Test
27 | env:
28 | scheme: ${{ 'EmonCMSiOS' }}
29 | platform: ${{ 'iOS Simulator' }}
30 | project: ${{ 'EmonCMSiOS.xcodeproj' }}
31 | run: |
32 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
33 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
34 | xcodebuild test-without-building -scheme "$scheme" -project "$project" -destination "platform=$platform,name=$device" -enableCodeCoverage YES
35 | - name: Convert code coverage file
36 | uses: sersoft-gmbh/swift-coverage-action@v2
37 | id: coverage-files
38 | with:
39 | target-name-filter: '^EmonCMSiOS$'
40 | - name: Upload code coverage
41 | uses: codecov/codecov-action@v3
42 | with:
43 | verbose: true
44 | files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }}
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Build generated
6 | build/
7 | DerivedData/
8 |
9 | ## Various settings
10 | *.pbxuser
11 | !default.pbxuser
12 | *.mode1v3
13 | !default.mode1v3
14 | *.mode2v3
15 | !default.mode2v3
16 | *.perspectivev3
17 | !default.perspectivev3
18 | xcuserdata/
19 |
20 | ## Other
21 | *.moved-aside
22 | *.xcuserstate
23 |
24 | ## Obj-C/Swift specific
25 | *.hmap
26 | *.ipa
27 | *.dSYM.zip
28 | *.dSYM
29 |
30 | ## Playgrounds
31 | timeline.xctimeline
32 | playground.xcworkspace
33 |
34 | # Swift Package Manager
35 | #
36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
37 | # Packages/
38 | .build/
39 |
40 | # CocoaPods
41 | #
42 | # We recommend against adding the Pods directory to your .gitignore. However
43 | # you should judge for yourself, the pros and cons are mentioned at:
44 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
45 | #
46 | # Pods/
47 |
48 | # Carthage
49 | #
50 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
51 | # Carthage/Checkouts
52 |
53 | Carthage/Build
54 |
55 | # fastlane
56 | #
57 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
58 | # screenshots whenever they are needed.
59 | # For more information about the recommended setup visit:
60 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
61 |
62 | fastlane/report.xml
63 | fastlane/Preview.html
64 | fastlane/screenshots
65 | fastlane/test_output
66 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --swiftversion 5
2 |
3 | --binarygrouping none
4 | --closingparen same-line
5 | --commas inline
6 | --decimalgrouping none
7 | --hexgrouping none
8 | --hexliteralcase lowercase
9 | --indent 2
10 | --maxwidth 120
11 | --octalgrouping none
12 | --operatorfunc spaced
13 | --patternlet inline
14 | --self insert
15 | --stripunusedargs closure-only
16 | --wrapcollections before-first
17 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | opt_in_rules:
2 | - explicit_self
3 | excluded:
4 | - EmonCMSiOSTests
5 | - EmonCMSiOSUITests
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: objective-c
2 | osx_image: xcode12
3 | os: osx
4 | before_install:
5 | - gem install xcpretty-travis-formatter
6 | script:
7 | - sh scripts/travis-ci.sh
8 | after_success:
9 | - bash <(curl -s https://codecov.io/bash) -J '^EmonCMSiOS$'
10 | env:
11 | matrix:
12 | - TEST_SDK=iphonesimulator14.0 OS=14.0 NAME='iPhone 11'
13 | global:
14 | secure: DKJhMWi5WghhvvEcW3iuU5cUD9xyIOSPc6c6IjuDML4A6ytE3CKs0oHZJ1DP2xejWbPVolkvuk0IS0OXIvFeJR3e4To5BFTdgfmMrrUknmESI6BleH1AOV/CBFVLF+I3/PEGPzC3RmUr1WcQ3AANty6HN+Sgr8MsI8Te6gNMQ55Gpl596oYELooSZZyHZRNNwlRdCMKVv33rPFh1FUlyy4/r+GWdlq0QYYHmbYKKBEJhinox2/4HX7argOo+l1Hf/Cpz9SrDoOZJk/Kf7ZjSI43lMo7I0ot8NZgLMi8KR92xTYqxxxD426NRQYe/Marttis7Wx0CHpodmDTsBAQ0eq/awai6hkhXhzEcWd4o51/U8/Ngs7bKeB2dfMmAcCjfVa5p54I9gFFXOpiVSiOSPgAF0Y4DROFOIFl4QsdJ7iitXOsbolKB3GV52GwxvRifBJ/sjxsMQ6iZFU5wMCJbs8ZgVzltzvxDiur0KZmYi4k6CnEW2jbSIe5zFx5kA922diBh4qAbkV3uxlEk3fZcfEQnak/FcBWt4W0HgjAzJXsh4nsRkdEmBCeH6CoOuk1VsTdUYEFZmQ3/vYp98lZQPrWIZZAhDhvjSKAoTRjWEzti12yOt4EMdEGqo4uM8fxhbI+el+sgtT5iZfdqnAIs8SNiGYqo0cxmX8MB2Z8q/EI=
15 |
--------------------------------------------------------------------------------
/AppIcon.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/AppIcon.sketch
--------------------------------------------------------------------------------
/EmonCMSiOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/EmonCMSiOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/EmonCMSAPI+Dashboard.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSAPI+Dashboard.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | extension EmonCMSAPI {
13 | func dashboardList(_ account: AccountCredentials) -> AnyPublisher<[Dashboard], APIError> {
14 | return self.request(account, path: "dashboard/list").tryMap { resultData -> [Dashboard] in
15 | guard let anyJson = try? JSONSerialization.jsonObject(with: resultData, options: []),
16 | let json = anyJson as? [Any]
17 | else {
18 | throw APIError.invalidResponse
19 | }
20 |
21 | var dashboards: [Dashboard] = []
22 | for i in json {
23 | if let dashboardJson = i as? [String: Any],
24 | let dashboard = Dashboard.from(json: dashboardJson)
25 | {
26 | dashboards.append(dashboard)
27 | }
28 | }
29 |
30 | return dashboards
31 | }
32 | .mapError { error -> APIError in
33 | if let error = error as? APIError { return error }
34 | return APIError.requestFailed
35 | }
36 | .eraseToAnyPublisher()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/EmonCMSAPI+Input.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSAPI+Input.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 21/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | extension EmonCMSAPI {
13 | func inputList(_ account: AccountCredentials) -> AnyPublisher<[Input], APIError> {
14 | return self.request(account, path: "input/list").tryMap { resultData -> [Input] in
15 | guard let anyJson = try? JSONSerialization.jsonObject(with: resultData, options: []),
16 | let json = anyJson as? [Any]
17 | else {
18 | throw APIError.invalidResponse
19 | }
20 |
21 | var inputs: [Input] = []
22 | for i in json {
23 | if let inputJson = i as? [String: Any],
24 | let input = Input.from(json: inputJson)
25 | {
26 | inputs.append(input)
27 | }
28 | }
29 |
30 | return inputs
31 | }
32 | .mapError { error -> APIError in
33 | if let error = error as? APIError { return error }
34 | return APIError.requestFailed
35 | }
36 | .eraseToAnyPublisher()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/EmonCMSAPI+System.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSAPI+System.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 23/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | extension EmonCMSAPI {
13 | func version(_ account: AccountCredentials) -> AnyPublisher {
14 | return self.request(account, path: "version", contentType: .plain)
15 | .tryMap { resultData -> String in
16 | String(data: resultData, encoding: .utf8) ?? "0"
17 | }
18 | .mapError { error -> APIError in
19 | if let error = error as? APIError { return error }
20 | return APIError.requestFailed
21 | }
22 | .eraseToAnyPublisher()
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/EmonCMSAPI+User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSAPI+User.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 25/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | extension EmonCMSAPI {
13 | func userAuth(url: String, username: String, password: String) -> AnyPublisher {
14 | return self.request(url, path: "user/auth", username: username, password: password).tryMap { resultData -> String in
15 | guard let anyJson = try? JSONSerialization.jsonObject(with: resultData, options: []),
16 | let json = anyJson as? [String: Any]
17 | else {
18 | throw APIError.invalidResponse
19 | }
20 |
21 | guard
22 | let success = json["success"] as? Bool,
23 | success == true,
24 | let apiKey = json["apikey_read"] as? String
25 | else {
26 | throw APIError.invalidCredentials
27 | }
28 |
29 | return apiKey
30 | }
31 | .mapError { error -> APIError in
32 | if let error = error as? APIError { return error }
33 | return APIError.requestFailed
34 | }
35 | .eraseToAnyPublisher()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/HTTPRequestProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPRequestProvider.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 15/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | enum HTTPRequestProviderError: Error, Equatable {
13 | case unknown
14 | case networkError
15 | case atsFailed
16 | case httpError(code: Int)
17 | }
18 |
19 | protocol HTTPRequestProvider {
20 | func request(url: URL) -> AnyPublisher
21 | func request(url: URL, formData: [String: String]) -> AnyPublisher
22 | }
23 |
--------------------------------------------------------------------------------
/EmonCMSiOS/API/NSURLSessionHTTPRequestProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSURLSessionHTTPRequestProvider.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 04/10/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | final class NSURLSessionHTTPRequestProvider: HTTPRequestProvider {
13 | private let session: URLSession
14 |
15 | init(session: URLSession) {
16 | self.session = session
17 | }
18 |
19 | convenience init() {
20 | let configuration = URLSessionConfiguration.default
21 | let session = URLSession(configuration: configuration)
22 | self.init(session: session)
23 | }
24 |
25 | func request(url: URL) -> AnyPublisher {
26 | let request = URLRequest(url: url)
27 | return self.data(forRequest: request)
28 | }
29 |
30 | func request(url: URL, formData: [String: String]) -> AnyPublisher {
31 | var request = URLRequest(url: url)
32 | request.httpMethod = "POST"
33 |
34 | let postString = formData.map { "\($0)=\($1)" }.joined(separator: "&")
35 | request.httpBody = postString.data(using: .utf8)
36 |
37 | return self.data(forRequest: request)
38 | }
39 |
40 | private func data(forRequest request: URLRequest) -> AnyPublisher {
41 | return self.session.dataTaskPublisher(for: request)
42 | .tryMap { data, response -> Data in
43 | guard let httpResponse = response as? HTTPURLResponse else {
44 | throw HTTPRequestProviderError.networkError
45 | }
46 | guard 200 ..< 300 ~= httpResponse.statusCode else {
47 | throw HTTPRequestProviderError.httpError(code: httpResponse.statusCode)
48 | }
49 | return data
50 | }
51 | .mapError { error -> HTTPRequestProviderError in
52 | if let e = error as? HTTPRequestProviderError {
53 | return e
54 | }
55 |
56 | if let e = error as? URLError {
57 | switch e.code {
58 | case URLError.appTransportSecurityRequiresSecureConnection:
59 | return .atsFailed
60 | default:
61 | return .unknown
62 | }
63 | }
64 |
65 | return .unknown
66 | }
67 | .receive(on: DispatchQueue.main)
68 | .eraseToAnyPublisher()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/EmonCMSiOS/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 11/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @UIApplicationMain
12 | final class AppDelegate: UIResponder, UIApplicationDelegate {
13 | override init() {
14 | LogController.shared.initialise()
15 | super.init()
16 | }
17 |
18 | func application(
19 | _ application: UIApplication,
20 | willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
21 | {
22 | return true
23 | }
24 |
25 | func application(
26 | _ application: UIApplication,
27 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
28 | {
29 | return true
30 | }
31 |
32 | func application(
33 | _ application: UIApplication,
34 | configurationForConnecting connectingSceneSession: UISceneSession,
35 | options: UIScene.ConnectionOptions) -> UISceneConfiguration
36 | {
37 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
38 | }
39 |
40 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {}
41 | }
42 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/AppConfig.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDataModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 14/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol AppConfigField {
12 | var id: String { get }
13 | var name: String { get }
14 | var optional: Bool { get }
15 | }
16 |
17 | struct AppConfigFieldString: AppConfigField {
18 | let id: String
19 | let name: String
20 | let optional: Bool
21 | }
22 |
23 | struct AppConfigFieldFeed: AppConfigField {
24 | let id: String
25 | let name: String
26 | let optional: Bool
27 |
28 | let defaultName: String
29 | }
30 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/AppPageViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppPageViewController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 01/02/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | class AppPageViewController: UIViewController {
13 | var viewModel: AppPageViewModel!
14 |
15 | @IBOutlet private var bannerView: UIView!
16 | @IBOutlet private var bannerLabel: UILabel!
17 | @IBOutlet private var bannerSpinner: UIActivityIndicatorView!
18 |
19 | private var cancellables = Set()
20 |
21 | override func viewDidLoad() {
22 | super.viewDidLoad()
23 |
24 | self.bannerLabel.accessibilityIdentifier = AccessibilityIdentifiers.Apps.TimeBannerLabel
25 |
26 | self.setupBindings()
27 | }
28 |
29 | override func viewWillAppear(_ animated: Bool) {
30 | super.viewWillAppear(animated)
31 | self.viewModel.active = true
32 | }
33 |
34 | override func viewDidDisappear(_ animated: Bool) {
35 | super.viewDidDisappear(true)
36 | self.viewModel.active = false
37 | }
38 |
39 | private func setupBindings() {
40 | self.viewModel.errors
41 | .sink(receiveValue: { [weak self] error in
42 | guard let self = self else { return }
43 | guard let error = error else { return }
44 |
45 | switch error {
46 | case .initialFailed:
47 | let alert = UIAlertController(title: "Error", message: "Failed to connect to emoncms. Please try again.",
48 | preferredStyle: .alert)
49 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
50 | self.present(alert, animated: true, completion: nil)
51 | default:
52 | break
53 | }
54 | })
55 | .store(in: &self.cancellables)
56 |
57 | let dateFormatter = DateFormatter()
58 | dateFormatter.dateStyle = .none
59 | dateFormatter.timeStyle = .medium
60 | self.viewModel.bannerBarState
61 | .sink(receiveValue: { [weak self] state in
62 | guard let self = self else { return }
63 |
64 | switch state {
65 | case .loading:
66 | self.bannerSpinner.startAnimating()
67 | self.bannerLabel.text = "Loading\u{2026}"
68 | self.bannerView.backgroundColor = UIColor.lightGray
69 | case .error(let message):
70 | self.bannerSpinner.stopAnimating()
71 | self.bannerLabel.text = message
72 | self.bannerView.backgroundColor = EmonCMSColors.ErrorRed
73 | case .loaded(let updateTime):
74 | self.bannerSpinner.stopAnimating()
75 | self.bannerLabel.text = "\(dateFormatter.string(from: updateTime))"
76 | self.bannerView.backgroundColor = UIColor.lightGray
77 | }
78 | })
79 | .store(in: &self.cancellables)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/Apps.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Apps.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 31/12/2018.
6 | // Copyright © 2018 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | enum AppBannerBarState {
13 | case loading
14 | case error(String)
15 | case loaded(Date)
16 | }
17 |
18 | enum AppError: Error {
19 | case generic(String)
20 | case notConfigured
21 | case initialFailed
22 | case updateFailed
23 | }
24 |
25 | enum AppPageRefreshKind {
26 | case initial
27 | case update
28 | case dateRangeChange
29 | }
30 |
31 | protocol AppViewModel: AnyObject {
32 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI, appDataId: String)
33 |
34 | var title: AnyPublisher { get }
35 | var isReady: AnyPublisher { get }
36 | var accessibilityIdentifier: String { get }
37 | var pageViewControllerStoryboardIdentifiers: [String] { get }
38 | var pageViewModels: [AppPageViewModel] { get }
39 |
40 | func configViewModel() -> AppConfigViewModel
41 | }
42 |
43 | protocol AppPageViewModel: AnyObject {
44 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI, appDataId: String)
45 |
46 | var active: Bool { get set }
47 | var dateRange: DateRange { get set }
48 | var errors: AnyPublisher { get }
49 | var bannerBarState: AnyPublisher { get }
50 | var isRefreshing: AnyPublisher { get }
51 | }
52 |
53 | typealias AppUUIDAndCategory = (uuid: String, category: AppCategory)
54 |
55 | extension AppCategory {
56 | var displayName: String {
57 | switch self {
58 | case .myElectric:
59 | return "MyElectric"
60 | case .mySolar:
61 | return "MySolar"
62 | case .mySolarDivert:
63 | return "MySolarDivert"
64 | }
65 | }
66 |
67 | var viewModelInit: (RealmController, AccountController, EmonCMSAPI, String) -> AppViewModel {
68 | switch self {
69 | case .myElectric:
70 | return MyElectricAppViewModel.init
71 | case .mySolar:
72 | return MySolarAppViewModel.init
73 | case .mySolarDivert:
74 | return MySolarDivertAppViewModel.init
75 | }
76 | }
77 |
78 | var feedConfigFields: [AppConfigFieldFeed] {
79 | switch self {
80 | case .myElectric:
81 | return [
82 | AppConfigFieldFeed(id: "use", name: "Power Feed", optional: false, defaultName: "use"),
83 | AppConfigFieldFeed(id: "kwh", name: "kWh Feed", optional: false, defaultName: "use_kwh")
84 | ]
85 | case .mySolar:
86 | return [
87 | AppConfigFieldFeed(id: "use", name: "Power Feed", optional: false, defaultName: "use"),
88 | AppConfigFieldFeed(id: "useKwh", name: "Power kWh Feed", optional: false, defaultName: "use_kwh"),
89 | AppConfigFieldFeed(id: "solar", name: "Solar Feed", optional: false, defaultName: "solar"),
90 | AppConfigFieldFeed(id: "solarKwh", name: "Solar kWh Feed", optional: false, defaultName: "solar_kwh")
91 | ]
92 | case .mySolarDivert:
93 | return [
94 | AppConfigFieldFeed(id: "use", name: "Power Feed", optional: false, defaultName: "use"),
95 | AppConfigFieldFeed(id: "useKwh", name: "Power kWh Feed", optional: false, defaultName: "use_kwh"),
96 | AppConfigFieldFeed(id: "solar", name: "Solar Feed", optional: false, defaultName: "solar"),
97 | AppConfigFieldFeed(id: "solarKwh", name: "Solar kWh Feed", optional: false, defaultName: "solar_kwh"),
98 | AppConfigFieldFeed(id: "divert", name: "Divert Feed", optional: false, defaultName: "divert"),
99 | AppConfigFieldFeed(id: "divertKwh", name: "Divert kWh Feed", optional: false, defaultName: "divert_kwh")
100 | ]
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/MySolar/MySolarAppPage2ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MySolarAppPage2ViewController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 29/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | import Charts
13 |
14 | final class MySolarAppPage2ViewController: AppPageViewController {
15 | var typedViewModel: MySolarAppPage2ViewModel {
16 | return self.viewModel as! MySolarAppPage2ViewModel
17 | }
18 |
19 | @IBOutlet private var dateSegmentedControl: UISegmentedControl!
20 | @IBOutlet private var useBarChart: BarChartView!
21 | @IBOutlet private var solarBarChart: BarChartView!
22 |
23 | private var cancellables = Set()
24 |
25 | override func viewDidLoad() {
26 | super.viewDidLoad()
27 |
28 | self.setupCharts()
29 | self.setupBindings()
30 | }
31 |
32 | override func viewWillAppear(_ animated: Bool) {
33 | super.viewWillAppear(animated)
34 | self.viewModel.active = true
35 | }
36 |
37 | override func viewDidDisappear(_ animated: Bool) {
38 | super.viewDidDisappear(true)
39 | self.viewModel.active = false
40 | }
41 |
42 | private func setupBindings() {
43 | self.dateSegmentedControl.publisher(for: \.selectedSegmentIndex)
44 | .map {
45 | DateRange.fromWMYSegmentedControlIndex($0)
46 | }
47 | .assign(to: \.dateRange, on: self.viewModel)
48 | .store(in: &self.cancellables)
49 |
50 | self.typedViewModel.$data
51 | .map { $0?.barChartData }
52 | .sink { [weak self] dataPoints in
53 | guard let self = self else { return }
54 | self.updateBarChartData(dataPoints)
55 | }
56 | .store(in: &self.cancellables)
57 | }
58 | }
59 |
60 | extension MySolarAppPage2ViewController {
61 | private func setupCharts() {
62 | ChartHelpers.setupAppBarChart(self.useBarChart)
63 | ChartHelpers.setupAppBarChart(self.solarBarChart)
64 | }
65 |
66 | private func updateBarChartData(_ dataPoints: (use: [DataPoint], solar: [DataPoint])?) {
67 | if let dataPoints = dataPoints {
68 | let useData = self.useBarChart.barData ?? BarChartData()
69 | self.useBarChart.data = useData
70 |
71 | ChartHelpers.updateBarChart(withData: useData, forSet: 0, withPoints: dataPoints.use) {
72 | $0.setColor(EmonCMSColors.Chart.Blue)
73 | if let formatter = $0.valueFormatter as? DefaultValueFormatter {
74 | formatter.decimals = 0
75 | }
76 | }
77 |
78 | let solarData = self.solarBarChart.barData ?? BarChartData()
79 | self.solarBarChart.data = solarData
80 |
81 | ChartHelpers.updateBarChart(withData: solarData, forSet: 0, withPoints: dataPoints.solar) {
82 | $0.setColor(EmonCMSColors.Chart.Yellow)
83 | if let formatter = $0.valueFormatter as? DefaultValueFormatter {
84 | formatter.decimals = 0
85 | }
86 | }
87 | } else {
88 | self.useBarChart.data = nil
89 | self.solarBarChart.data = nil
90 | }
91 |
92 | self.useBarChart.notifyDataSetChanged()
93 | self.solarBarChart.notifyDataSetChanged()
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/MySolar/MySolarAppViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MySolarAppViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 27/12/2018.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class MySolarAppViewModel: AppViewModel {
15 | private let realmController: RealmController
16 | private let account: AccountController
17 | private let api: EmonCMSAPI
18 | private let realm: Realm
19 | private let appData: AppData
20 |
21 | // Inputs
22 |
23 | // Outputs
24 | let title: AnyPublisher
25 | let isReady: AnyPublisher
26 |
27 | var accessibilityIdentifier: String {
28 | return AccessibilityIdentifiers.Apps.MySolar
29 | }
30 |
31 | var pageViewControllerStoryboardIdentifiers: [String] {
32 | return ["mySolarPage1", "mySolarPage2"]
33 | }
34 |
35 | var pageViewModels: [AppPageViewModel] {
36 | return [self.page1ViewModel, self.page2ViewModel]
37 | }
38 |
39 | let page1ViewModel: MySolarAppPage1ViewModel
40 | let page2ViewModel: MySolarAppPage2ViewModel
41 |
42 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI, appDataId: String) {
43 | self.realmController = realmController
44 | self.account = account
45 | self.api = api
46 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
47 | self.appData = self.realm.object(ofType: AppData.self, forPrimaryKey: appDataId)!
48 |
49 | self
50 | .page1ViewModel = MySolarAppPage1ViewModel(realmController: realmController, account: account, api: api,
51 | appDataId: appDataId)
52 | self
53 | .page2ViewModel = MySolarAppPage2ViewModel(realmController: realmController, account: account, api: api,
54 | appDataId: appDataId)
55 |
56 | self.title = self.appData.publisher(for: \.name)
57 | .receive(on: DispatchQueue.main)
58 | .eraseToAnyPublisher()
59 |
60 | self.isReady = self.appData.publisher(for: \.name)
61 | .map { $0 != "" }
62 | .receive(on: DispatchQueue.main)
63 | .eraseToAnyPublisher()
64 | }
65 |
66 | func configViewModel() -> AppConfigViewModel {
67 | return AppConfigViewModel(realmController: self.realmController, account: self.account, api: self.api,
68 | appDataId: self.appData.uuid, appCategory: .mySolar)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/MySolarDivert/MySolarDivertAppPage2ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MySolarDivertAppPage2ViewController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 31/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | import Charts
13 |
14 | final class MySolarDivertAppPage2ViewController: AppPageViewController {
15 | var typedViewModel: MySolarDivertAppPage2ViewModel {
16 | return self.viewModel as! MySolarDivertAppPage2ViewModel
17 | }
18 |
19 | @IBOutlet private var dateSegmentedControl: UISegmentedControl!
20 | @IBOutlet private var useBarChart: BarChartView!
21 | @IBOutlet private var solarBarChart: BarChartView!
22 | @IBOutlet private var divertBarChart: BarChartView!
23 |
24 | private var cancellables = Set()
25 |
26 | override func viewDidLoad() {
27 | super.viewDidLoad()
28 |
29 | self.setupCharts()
30 | self.setupBindings()
31 | }
32 |
33 | private func setupBindings() {
34 | self.dateSegmentedControl.publisher(for: \.selectedSegmentIndex)
35 | .map {
36 | DateRange.fromWMYSegmentedControlIndex($0)
37 | }
38 | .assign(to: \.dateRange, on: self.viewModel)
39 | .store(in: &self.cancellables)
40 |
41 | self.typedViewModel.$data
42 | .map { $0?.barChartData }
43 | .sink { [weak self] dataPoints in
44 | guard let self = self else { return }
45 | self.updateBarChartData(dataPoints)
46 | }
47 | .store(in: &self.cancellables)
48 | }
49 | }
50 |
51 | extension MySolarDivertAppPage2ViewController {
52 | private func setupCharts() {
53 | ChartHelpers.setupAppBarChart(self.useBarChart)
54 | ChartHelpers.setupAppBarChart(self.solarBarChart)
55 | ChartHelpers.setupAppBarChart(self.divertBarChart)
56 | }
57 |
58 | private func updateBarChartData(_ dataPoints: (use: [DataPoint], solar: [DataPoint],
59 | divert: [DataPoint])?)
60 | {
61 | if let dataPoints = dataPoints {
62 | let useData = self.useBarChart.barData ?? BarChartData()
63 | self.useBarChart.data = useData
64 |
65 | ChartHelpers.updateBarChart(withData: useData, forSet: 0, withPoints: dataPoints.use) {
66 | $0.setColor(EmonCMSColors.Chart.Blue)
67 | if let formatter = $0.valueFormatter as? DefaultValueFormatter {
68 | formatter.decimals = 0
69 | }
70 | }
71 |
72 | let solarData = self.solarBarChart.barData ?? BarChartData()
73 | self.solarBarChart.data = solarData
74 |
75 | ChartHelpers.updateBarChart(withData: solarData, forSet: 0, withPoints: dataPoints.solar) {
76 | $0.setColor(EmonCMSColors.Chart.Yellow)
77 | if let formatter = $0.valueFormatter as? DefaultValueFormatter {
78 | formatter.decimals = 0
79 | }
80 | }
81 |
82 | let divertData = self.divertBarChart.barData ?? BarChartData()
83 | self.divertBarChart.data = divertData
84 |
85 | ChartHelpers.updateBarChart(withData: divertData, forSet: 0, withPoints: dataPoints.divert) {
86 | $0.setColor(EmonCMSColors.Chart.Orange)
87 | if let formatter = $0.valueFormatter as? DefaultValueFormatter {
88 | formatter.decimals = 0
89 | }
90 | }
91 | } else {
92 | self.useBarChart.data = nil
93 | self.solarBarChart.data = nil
94 | self.divertBarChart.data = nil
95 | }
96 |
97 | self.useBarChart.notifyDataSetChanged()
98 | self.solarBarChart.notifyDataSetChanged()
99 | self.divertBarChart.notifyDataSetChanged()
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Apps/MySolarDivert/MySolarDivertAppViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MySolarDivertAppViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 15/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class MySolarDivertAppViewModel: AppViewModel {
15 | typealias MySolarDivertData = (updateTime: Date, houseNow: Double, divertNow: Double, totalUseNow: Double,
16 | importNow: Double, solarNow: Double,
17 | lineChartData: (use: [DataPoint], solar: [DataPoint],
18 | divert: [DataPoint]))
19 |
20 | private let realmController: RealmController
21 | private let account: AccountController
22 | private let api: EmonCMSAPI
23 | private let realm: Realm
24 | private let appData: AppData
25 |
26 | private var cancellables = Set()
27 |
28 | // Inputs
29 | @Published var active = false
30 |
31 | // Outputs
32 | let title: AnyPublisher
33 | let isReady: AnyPublisher
34 |
35 | var accessibilityIdentifier: String {
36 | return AccessibilityIdentifiers.Apps.MySolarDivert
37 | }
38 |
39 | var pageViewControllerStoryboardIdentifiers: [String] {
40 | return ["mySolarDivertPage1", "mySolarDivertPage2"]
41 | }
42 |
43 | var pageViewModels: [AppPageViewModel] {
44 | return [self.page1ViewModel, self.page2ViewModel]
45 | }
46 |
47 | let page1ViewModel: MySolarDivertAppPage1ViewModel
48 | let page2ViewModel: MySolarDivertAppPage2ViewModel
49 |
50 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI, appDataId: String) {
51 | self.realmController = realmController
52 | self.account = account
53 | self.api = api
54 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
55 | self.appData = self.realm.object(ofType: AppData.self, forPrimaryKey: appDataId)!
56 |
57 | self
58 | .page1ViewModel = MySolarDivertAppPage1ViewModel(realmController: realmController, account: account, api: api,
59 | appDataId: appDataId)
60 | self
61 | .page2ViewModel = MySolarDivertAppPage2ViewModel(realmController: realmController, account: account, api: api,
62 | appDataId: appDataId)
63 |
64 | self.title = self.appData.publisher(for: \.name)
65 | .receive(on: DispatchQueue.main)
66 | .eraseToAnyPublisher()
67 |
68 | self.isReady = self.appData.publisher(for: \.name)
69 | .map { $0 != "" }
70 | .receive(on: DispatchQueue.main)
71 | .eraseToAnyPublisher()
72 | }
73 |
74 | func configViewModel() -> AppConfigViewModel {
75 | return AppConfigViewModel(realmController: self.realmController, account: self.account, api: self.api,
76 | appDataId: self.appData.uuid, appCategory: .mySolarDivert)
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "size" : "60x60",
35 | "idiom" : "iphone",
36 | "filename" : "Icon-60@2x.png",
37 | "scale" : "2x"
38 | },
39 | {
40 | "size" : "60x60",
41 | "idiom" : "iphone",
42 | "filename" : "Icon-60@3x.png",
43 | "scale" : "3x"
44 | },
45 | {
46 | "idiom" : "ipad",
47 | "size" : "20x20",
48 | "scale" : "1x"
49 | },
50 | {
51 | "idiom" : "ipad",
52 | "size" : "20x20",
53 | "scale" : "2x"
54 | },
55 | {
56 | "idiom" : "ipad",
57 | "size" : "29x29",
58 | "scale" : "1x"
59 | },
60 | {
61 | "idiom" : "ipad",
62 | "size" : "29x29",
63 | "scale" : "2x"
64 | },
65 | {
66 | "idiom" : "ipad",
67 | "size" : "40x40",
68 | "scale" : "1x"
69 | },
70 | {
71 | "idiom" : "ipad",
72 | "size" : "40x40",
73 | "scale" : "2x"
74 | },
75 | {
76 | "size" : "76x76",
77 | "idiom" : "ipad",
78 | "filename" : "Icon-76.png",
79 | "scale" : "1x"
80 | },
81 | {
82 | "size" : "76x76",
83 | "idiom" : "ipad",
84 | "filename" : "Icon-76@2x.png",
85 | "scale" : "2x"
86 | },
87 | {
88 | "size" : "83.5x83.5",
89 | "idiom" : "ipad",
90 | "filename" : "Icon-83.5@2x.png",
91 | "scale" : "2x"
92 | },
93 | {
94 | "size" : "1024x1024",
95 | "idiom" : "ios-marketing",
96 | "filename" : "iTunesArtwork@2x.png",
97 | "scale" : "1x"
98 | }
99 | ],
100 | "info" : {
101 | "version" : 1,
102 | "author" : "xcode"
103 | }
104 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-76.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_chart.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_chart@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_chart@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_chart.imageset/tab_chart@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_chart.imageset/tab_chart@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_chart.imageset/tab_chart@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_chart.imageset/tab_chart@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_dashboard.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_dashboard@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_dashboard@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_dashboard.imageset/tab_dashboard@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_dashboard.imageset/tab_dashboard@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_dashboard.imageset/tab_dashboard@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_dashboard.imageset/tab_dashboard@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_leaf.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_leaf@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_leaf@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_leaf.imageset/tab_leaf@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_leaf.imageset/tab_leaf@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_leaf.imageset/tab_leaf@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_leaf.imageset/tab_leaf@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_list.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_list@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_list@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_list.imageset/tab_list@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_list.imageset/tab_list@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_list.imageset/tab_list@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_list.imageset/tab_list@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_right_arrow.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_right_arrow@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_right_arrow@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_right_arrow.imageset/tab_right_arrow@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_right_arrow.imageset/tab_right_arrow@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_right_arrow.imageset/tab_right_arrow@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_right_arrow.imageset/tab_right_arrow@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_wrench.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "tab_wrench@2x.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "tab_wrench@3x.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_wrench.imageset/tab_wrench@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_wrench.imageset/tab_wrench@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/tab_wrench.imageset/tab_wrench@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/tab_wrench.imageset/tab_wrench@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/warning.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "warning@2x.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "warning@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/warning.imageset/warning@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/warning.imageset/warning@2x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Assets.xcassets/warning.imageset/warning@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Assets.xcassets/warning.imageset/warning@3x.png
--------------------------------------------------------------------------------
/EmonCMSiOS/Controllers/DataController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 01/11/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class DataController {
12 | static var sharedDataDirectory: URL {
13 | return FileManager.default
14 | .containerURL(forSecurityApplicationGroupIdentifier: SharedConstants.SharedApplicationGroupIdentifier)!
15 | }
16 |
17 | private init() {}
18 | }
19 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Controllers/KeychainController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 13/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import KeychainAccess
12 |
13 | final class KeychainController {
14 | static let ServiceIdentifier = "org.openenergymonitor.emoncms"
15 | static let SharedKeychainIdentifier = "4C898RE43H.org.openenergymonitor.emoncms"
16 |
17 | enum KeychainControllerError: Error {
18 | case generic
19 | case keychainFailed
20 | }
21 |
22 | private func keychain(useShared: Bool = false) -> Keychain {
23 | let keychain: Keychain
24 | if useShared {
25 | keychain = Keychain(service: KeychainController.ServiceIdentifier,
26 | accessGroup: KeychainController.SharedKeychainIdentifier)
27 | } else {
28 | keychain = Keychain(service: KeychainController.ServiceIdentifier)
29 | }
30 | return keychain.accessibility(.afterFirstUnlock)
31 | }
32 |
33 | init() {}
34 |
35 | private func save(data: [String: Any], forUserAccount account: String) throws {
36 | let keychain = self.keychain()
37 | let archivedData = try NSKeyedArchiver.archivedData(withRootObject: data, requiringSecureCoding: false)
38 | try keychain.set(archivedData, key: account)
39 | }
40 |
41 | private func loadData(forUserAccount account: String) throws -> [String: Any] {
42 | let keychain = self.keychain()
43 | guard
44 | let data = try keychain.getData(account),
45 | let unarchivedData = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSDictionary.self, from: data),
46 | let dict = unarchivedData as? [String: Any]
47 | else {
48 | throw KeychainControllerError.generic
49 | }
50 |
51 | if let attributes = keychain[attributes: account] {
52 | if attributes.accessible != Accessibility.afterFirstUnlock.rawValue {
53 | try? self.save(data: dict, forUserAccount: account)
54 | }
55 | }
56 |
57 | return dict
58 | }
59 |
60 | private func deleteData(forUserAccount account: String) throws {
61 | let keychain = self.keychain()
62 | try keychain.remove(account)
63 | }
64 |
65 | func saveAccount(forId id: String, apiKey: String) throws {
66 | do {
67 | let data = ["apikey": apiKey]
68 | try self.save(data: data, forUserAccount: id)
69 | } catch {
70 | throw KeychainControllerError.keychainFailed
71 | }
72 | }
73 |
74 | func apiKey(forAccountWithId id: String) throws -> String {
75 | let data: [String: Any]
76 | do {
77 | data = try self.loadData(forUserAccount: id)
78 | } catch {
79 | throw KeychainControllerError.keychainFailed
80 | }
81 | guard let apiKey = data["apikey"] as? String else {
82 | throw KeychainControllerError.generic
83 | }
84 | return apiKey
85 | }
86 |
87 | func logout(ofAccountWithId id: String) throws {
88 | do {
89 | try self.deleteData(forUserAccount: id)
90 | } catch {
91 | throw KeychainControllerError.keychainFailed
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/EmonCMSiOS/EmonCMSiOS.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.org.openenergymonitor.emoncms
8 |
9 | keychain-access-groups
10 |
11 | $(AppIdentifierPrefix)org.openenergymonitor.emoncms
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Constants/AccessibilityIdentifiers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessibilityIdentifiers.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 20/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct AccessibilityIdentifiers {
12 | enum Lists {
13 | static let Account = "AccountList"
14 | static let App = "AppList"
15 | static let Feed = "FeedList"
16 | static let Input = "InputList"
17 | static let Dashboard = "DashboardList"
18 | static let TodayWidgetFeed = "TodayWidgetFeedList"
19 | static let AppSelectFeed = "AppSelectFeedList"
20 | }
21 |
22 | enum Apps {
23 | static let MyElectric = "MyElectricApp"
24 | static let MySolar = "MySolar"
25 | static let MySolarDivert = "MySolarDivert"
26 |
27 | static let TimeBannerLabel = "AppsTimeBannerLabel"
28 | }
29 |
30 | enum FeedList {
31 | static let ChartContainer = "FeedListChartContainer"
32 | }
33 |
34 | static let AddAccountQRView = "AddAccountQRView"
35 | static let FeedChartView = "FeedChartView"
36 | static let Settings = "Settings"
37 | }
38 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Constants/EmonCMSColors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSColors.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 15/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit
11 |
12 | struct EmonCMSColors {
13 | enum Chart {
14 | static let Blue = UIColor(hexString: "3399ff")
15 | static let DarkBlue = UIColor(hexString: "0779c1")
16 | static let Yellow = UIColor(hexString: "dccc1f")
17 | static let Orange = UIColor(hexString: "fb7b50")
18 | }
19 |
20 | enum Apps {
21 | static let Solar = UIColor(hexString: "dccc1f")
22 | static let Grid = UIColor(hexString: "d52e2e")
23 | static let House = UIColor(hexString: "82cbfc")
24 | static let Use = UIColor(hexString: "0598fa")
25 | static let Divert = UIColor(hexString: "fb7b50")
26 | static let Import = UIColor(hexString: "d52e2e")
27 | static let Export = UIColor(hexString: "2ed52e")
28 | }
29 |
30 | enum ActivityIndicator {
31 | static let Green = UIColor(red: 0.196, green: 0.784, blue: 0.196, alpha: 1.0)
32 | static let Yellow = UIColor(red: 0.94, green: 0.71, blue: 0.078, alpha: 1.0)
33 | static let Orange = UIColor(red: 1.0, green: 0.49, blue: 0.078, alpha: 1.0)
34 | static let Red = UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
35 | }
36 |
37 | static let ErrorRed = UIColor(hexString: "e24522")
38 | }
39 |
40 | private extension UIColor {
41 | convenience init(hexString: String) {
42 | let r, g, b: CGFloat
43 |
44 | let scanner = Scanner(string: hexString)
45 | var hexNumber: UInt64 = 0
46 |
47 | if scanner.scanHexInt64(&hexNumber) {
48 | r = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255
49 | g = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255
50 | b = CGFloat((hexNumber & 0x000000ff) >> 0) / 255
51 |
52 | self.init(red: r, green: g, blue: b, alpha: 1)
53 | } else {
54 | self.init(white: 0, alpha: 1)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Constants/SharedConstants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SharedConstants.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 25/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct SharedConstants {
12 | enum ApplicationContextKeys: String {
13 | case accountUUID
14 | case accountURL
15 | case accountApiKey
16 | }
17 |
18 | enum UserDefaultsKeys: String {
19 | case accountURL
20 | case accountUUID
21 | case lastSelectedAccountUUID
22 | }
23 |
24 | static let SharedApplicationGroupIdentifier = "group.org.openenergymonitor.emoncms"
25 | static let EmonCMSdotOrgURL = "https://www.emoncms.org/"
26 | }
27 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Double+Emoncms.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Double+Emoncms.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 17/10/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Double {
12 | static func from(_ value: Any) -> Double? {
13 | switch value {
14 | case let double as Double:
15 | return double
16 | case let float as Float:
17 | return Double(float)
18 | case let int as Int:
19 | return Double(int)
20 | case let string as String:
21 | return Double(string)
22 | default:
23 | return nil
24 | }
25 | }
26 |
27 | func prettyFormat(decimals: Int? = nil) -> String {
28 | let actualDecimals: Int
29 | if let decimals = decimals {
30 | actualDecimals = decimals
31 | } else {
32 | if self < 10 {
33 | actualDecimals = 2
34 | } else if self < 100 {
35 | actualDecimals = 1
36 | } else {
37 | actualDecimals = 0
38 | }
39 | }
40 |
41 | return String(format: "%.\(actualDecimals)f", self)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Formatters/ChartDateValueFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartXAxisDateFormatter.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 15/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import Charts
12 |
13 | final class ChartDateValueFormatter: NSObject, AxisValueFormatter {
14 | enum FormatType {
15 | case auto(Locale?)
16 | case format(String)
17 | case formatter(DateFormatter)
18 | }
19 |
20 | private let dateFormatter: DateFormatter
21 | private let autoUpdateFormat: Bool
22 | var timeZone: TimeZone {
23 | get {
24 | return self.dateFormatter.timeZone
25 | }
26 | set {
27 | self.dateFormatter.timeZone = newValue
28 | }
29 | }
30 |
31 | static let posixLocale = Locale(identifier: "en_US_POSIX")
32 |
33 | private var dateRange: TimeInterval? {
34 | didSet {
35 | if oldValue != self.dateRange {
36 | self.updateAutoFormat()
37 | }
38 | }
39 | }
40 |
41 | init(_ type: FormatType) {
42 | let dateFormatter: DateFormatter
43 | switch type {
44 | case .auto(let locale):
45 | dateFormatter = DateFormatter()
46 | dateFormatter.locale = locale
47 | self.autoUpdateFormat = true
48 | case .format(let formatString):
49 | dateFormatter = DateFormatter()
50 | dateFormatter.locale = ChartDateValueFormatter.posixLocale
51 | dateFormatter.dateFormat = formatString
52 | self.autoUpdateFormat = false
53 | case .formatter(let formatter):
54 | dateFormatter = formatter
55 | self.autoUpdateFormat = false
56 | }
57 | self.dateFormatter = dateFormatter
58 |
59 | super.init()
60 | }
61 |
62 | override convenience init() {
63 | self.init(.auto(nil))
64 | }
65 |
66 | private func updateAutoFormat() {
67 | guard self.autoUpdateFormat else { return }
68 |
69 | let range = self.dateRange ?? 0
70 |
71 | if range < 86400 { // < 1 day
72 | self.dateFormatter.dateFormat = nil
73 | self.dateFormatter.timeStyle = .short
74 | self.dateFormatter.dateStyle = .none
75 | } else {
76 | self.dateFormatter.dateFormat = nil
77 | self.dateFormatter.timeStyle = .none
78 | self.dateFormatter.dateStyle = .short
79 | }
80 | }
81 |
82 | func stringForValue(_ value: Double, axis: AxisBase?) -> String {
83 | self.dateRange = axis?.axisRange
84 | let date = Date(timeIntervalSince1970: value)
85 | var string = self.dateFormatter.string(from: date)
86 |
87 | if self.autoUpdateFormat {
88 | let range = self.dateRange ?? 0
89 | if range > 86400 {
90 | let components = string.split(separator: "/")
91 | if components.count == 3 {
92 | if range < 31536000 { // < 1 year
93 | string = components[0 ... 1].joined(separator: "/")
94 | }
95 | }
96 | }
97 | }
98 |
99 | return string
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Formatters/DayRelativeToTodayValueFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DayRelativeToTodayValueFormatter.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 09/10/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import Charts
12 |
13 | final class DayRelativeToTodayValueFormatter: NSObject, AxisValueFormatter {
14 | private let dateFormatter: DateFormatter
15 | private let relativeTo: Date?
16 |
17 | static let posixLocale = Locale(identifier: "en_US_POSIX")
18 |
19 | init(relativeTo: Date?) {
20 | self.relativeTo = relativeTo
21 | let dateFormatter = DateFormatter()
22 | self.dateFormatter = dateFormatter
23 |
24 | super.init()
25 | }
26 |
27 | override convenience init() {
28 | self.init(relativeTo: nil)
29 | }
30 |
31 | func stringForValue(_ value: Double, axis: AxisBase?) -> String {
32 | let range = axis?.axisRange ?? 0
33 |
34 | switch range {
35 | case let x where x <= 14:
36 | self.dateFormatter.dateFormat = "eeeee"
37 | case let x where x > 14 && x <= 31:
38 | self.dateFormatter.dateFormat = "dd"
39 | default:
40 | self.dateFormatter.dateFormat = "MMM dd"
41 | }
42 |
43 | let timeAdd = value * 86400
44 | let date: Date
45 | if let relativeTo = self.relativeTo {
46 | date = relativeTo.addingTimeInterval(timeAdd)
47 | } else {
48 | date = Date(timeIntervalSinceNow: timeAdd)
49 | }
50 | return self.dateFormatter.string(from: date)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Rx/ActivityIndicator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActivityIndicator.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 01/08/19.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | public final class ActivityIndicatorCombine {
13 | private let lock = NSRecursiveLock()
14 | private var count = 0
15 | private let subject = PassthroughSubject()
16 |
17 | var loading: Bool {
18 | self.lock.lock()
19 | let loading = self.count > 0
20 | self.lock.unlock()
21 | return loading
22 | }
23 |
24 | func asPublisher() -> AnyPublisher {
25 | self.subject.removeDuplicates().eraseToAnyPublisher()
26 | }
27 |
28 | fileprivate func increment() {
29 | self.lock.lock()
30 | self.count += 1
31 | self.subject.send(self.count > 0)
32 | self.lock.unlock()
33 | }
34 |
35 | fileprivate func decrement() {
36 | self.lock.lock()
37 | self.count -= 1
38 | self.subject.send(self.count > 0)
39 | self.lock.unlock()
40 | }
41 | }
42 |
43 | extension Publishers {
44 | public struct TrackActivity: Publisher {
45 | public typealias Output = Upstream.Output
46 | public typealias Failure = Upstream.Failure
47 |
48 | private let upstream: Upstream
49 | private let indicator: ActivityIndicatorCombine
50 |
51 | init(upstream: Upstream, indicator: ActivityIndicatorCombine) {
52 | self.upstream = upstream
53 | self.indicator = indicator
54 | }
55 |
56 | public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input {
57 | let subscription = TrackActivitySubscription(
58 | upstream: self.upstream,
59 | downstream: subscriber,
60 | indicator: self.indicator)
61 | subscriber.receive(subscription: subscription)
62 | }
63 | }
64 |
65 | private class TrackActivitySubscription: Subscription, Subscriber
66 | where Upstream.Output == Downstream.Input, Upstream.Failure == Downstream.Failure
67 | {
68 | typealias Input = Upstream.Output
69 | typealias Failure = Upstream.Failure
70 |
71 | var upstreamSubscription: Subscription?
72 | let upstream: Upstream
73 | let downstream: Downstream
74 | let indicator: ActivityIndicatorCombine
75 |
76 | init(upstream: Upstream, downstream: Downstream, indicator: ActivityIndicatorCombine) {
77 | self.upstream = upstream
78 | self.downstream = downstream
79 | self.indicator = indicator
80 | upstream.subscribe(self)
81 | }
82 |
83 | func request(_ demand: Subscribers.Demand) {
84 | self.upstreamSubscription?.request(demand)
85 | }
86 |
87 | func cancel() {
88 | self.cancelUpstreamSubscription()
89 | }
90 |
91 | func receive(subscription: Subscription) {
92 | self.upstreamSubscription = subscription
93 | self.indicator.increment()
94 | }
95 |
96 | func receive(_ input: Input) -> Subscribers.Demand {
97 | return self.downstream.receive(input)
98 | }
99 |
100 | func receive(completion: Subscribers.Completion) {
101 | self.downstream.receive(completion: completion)
102 | self.cancelUpstreamSubscription()
103 | }
104 |
105 | private func cancelUpstreamSubscription() {
106 | self.indicator.decrement()
107 | self.upstreamSubscription?.cancel()
108 | self.upstreamSubscription = nil
109 | }
110 | }
111 | }
112 |
113 | public extension Publisher {
114 | func trackActivity(_ indicator: ActivityIndicatorCombine) -> Publishers.TrackActivity {
115 | Publishers.TrackActivity(upstream: self, indicator: indicator)
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Rx/CombineOperators.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RxOperators.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 16/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import XCGLogger
11 |
12 | public extension Publisher {
13 | func becomeVoid() -> Publishers.Map {
14 | return self.map { _ in () }
15 | }
16 | }
17 |
18 | // INSPIRED BY: https://twitter.com/peres/status/1159972724577583110
19 | struct Producer: Publisher {
20 | typealias Output = T
21 | typealias Failure = E
22 |
23 | private let handler: (Producer.Subscriber) -> Void
24 |
25 | init(_ handler: @escaping (Producer.Subscriber) -> Void) {
26 | self.handler = handler
27 | }
28 |
29 | func receive(subscriber: Downstream)
30 | where
31 | Downstream: Combine.Subscriber,
32 | E == Downstream.Failure,
33 | T == Downstream.Input
34 | {
35 | let wrap = Producer.Subscriber(downstream: AnySubscriber(subscriber))
36 | let subscription = Producer.Subscription(subscriber: wrap)
37 | subscriber.receive(subscription: subscription)
38 | self.handler(wrap)
39 | }
40 |
41 | public class Subscriber {
42 | private var downstream: AnySubscriber
43 | fileprivate var cancelled = false
44 |
45 | init(downstream: AnySubscriber) {
46 | self.downstream = downstream
47 | }
48 |
49 | func receive(_ value: T) -> Subscribers.Demand {
50 | return self.downstream.receive(value)
51 | }
52 |
53 | func receive(completion: Subscribers.Completion) {
54 | self.downstream.receive(completion: completion)
55 | }
56 | }
57 |
58 | private class Subscription: Combine.Subscription {
59 | var subscriber: Producer.Subscriber?
60 |
61 | init(subscriber: Producer.Subscriber) {
62 | self.subscriber = subscriber
63 | }
64 |
65 | func request(_ demand: Subscribers.Demand) {}
66 |
67 | func cancel() {
68 | self.subscriber?.cancelled = true
69 | self.subscriber = nil
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/Rx/RowFormer+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RowFormer+Combine.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 19/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import Former
13 |
14 | public extension RowFormer {
15 | static func publisher(_ updater: @escaping (@escaping ((E) -> Void)) -> RowFormer)
16 | -> AnyPublisher
17 | {
18 | return Producer { observer in
19 | _ = updater { value in
20 | _ = observer.receive(value)
21 | }
22 | }.eraseToAnyPublisher()
23 | }
24 |
25 | static func publisher<
26 | E,
27 | F
28 | >(_ updater: @escaping (@escaping ((E, F) -> Void)) -> RowFormer) -> AnyPublisher<(E, F),
29 | Never>
30 | {
31 | return Producer<(E, F), Never> { observer in
32 | _ = updater { e, f in
33 | _ = observer.receive((e, f))
34 | }
35 | }.eraseToAnyPublisher()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Helpers/SemanticVersion.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SemanticVersion.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 29/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct SemanticVersion {
12 | let major: Int
13 | let minor: Int
14 | let patch: Int
15 |
16 | var string: String {
17 | return "\(self.major).\(self.minor).\(self.patch)"
18 | }
19 |
20 | init?(string: String) {
21 | let components = string
22 | .split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
23 | .compactMap { Int($0) }
24 | guard components.count == 3 else { return nil }
25 | self.init(major: components[0], minor: components[1], patch: components[2])
26 | }
27 |
28 | init(major: Int, minor: Int, patch: Int) {
29 | self.major = major
30 | self.minor = minor
31 | self.patch = patch
32 | }
33 | }
34 |
35 | extension SemanticVersion: Equatable {
36 | static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
37 | return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
38 | }
39 | }
40 |
41 | extension SemanticVersion: Comparable {
42 | static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
43 | let a = [lhs.major, lhs.minor, lhs.patch]
44 | let b = [rhs.major, rhs.minor, rhs.patch]
45 | return a.lexicographicallyPrecedes(b)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleDisplayName
8 | Emoncms
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.2.3
21 | CFBundleURLTypes
22 |
23 |
24 | CFBundleTypeRole
25 | Viewer
26 | CFBundleURLName
27 | org.openenergymonitor.emoncms
28 | CFBundleURLSchemes
29 |
30 | emoncms
31 |
32 |
33 |
34 | CFBundleVersion
35 | 33
36 | ITSAppUsesNonExemptEncryption
37 |
38 | LSRequiresIPhoneOS
39 |
40 | NSAppTransportSecurity
41 |
42 | NSExceptionDomains
43 |
44 | emonpi
45 |
46 | NSIncludesSubdomains
47 |
48 | NSTemporaryExceptionAllowsInsecureHTTPLoads
49 |
50 |
51 | home
52 |
53 | NSIncludesSubdomains
54 |
55 | NSTemporaryExceptionAllowsInsecureHTTPLoads
56 |
57 |
58 | local
59 |
60 | NSIncludesSubdomains
61 |
62 | NSTemporaryExceptionAllowsInsecureHTTPLoads
63 |
64 |
65 |
66 |
67 | NSCameraUsageDescription
68 | Used for QR code scanning
69 | NSPhotoLibraryUsageDescription
70 | Used for choosing photos to display
71 | UIApplicationSceneManifest
72 |
73 | UIApplicationSupportsMultipleScenes
74 |
75 | UISceneConfigurations
76 |
77 | UIWindowSceneSessionRoleApplication
78 |
79 |
80 | UILaunchStoryboardName
81 | LaunchScreen
82 | UISceneConfigurationName
83 | Default Configuration
84 | UISceneDelegateClassName
85 | $(PRODUCT_MODULE_NAME).SceneDelegate
86 |
87 |
88 |
89 |
90 | UILaunchStoryboardName
91 | LaunchScreen
92 | UIRequiredDeviceCapabilities
93 |
94 | armv7
95 |
96 | UISupportedInterfaceOrientations
97 |
98 | UIInterfaceOrientationPortrait
99 | UIInterfaceOrientationLandscapeLeft
100 | UIInterfaceOrientationLandscapeRight
101 |
102 | UISupportedInterfaceOrientations~ipad
103 |
104 | UIInterfaceOrientationPortrait
105 | UIInterfaceOrientationPortraitUpsideDown
106 | UIInterfaceOrientationLandscapeLeft
107 | UIInterfaceOrientationLandscapeRight
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/AccountController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountController.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 12/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct AccountCredentials {
12 | let url: String
13 | let apiKey: String
14 | }
15 |
16 | extension AccountCredentials: Equatable {
17 | static func == (lhs: AccountCredentials, rhs: AccountCredentials) -> Bool {
18 | return lhs.url == rhs.url &&
19 | lhs.apiKey == rhs.apiKey
20 | }
21 | }
22 |
23 | struct AccountController {
24 | let uuid: String
25 | let credentials: AccountCredentials
26 | }
27 |
28 | extension AccountController: Equatable {
29 | static func == (lhs: AccountController, rhs: AccountController) -> Bool {
30 | return lhs.uuid == rhs.uuid &&
31 | lhs.credentials == rhs.credentials
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/DataPoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataPoint.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 13/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct DataPoint {
12 | let time: Date
13 | let value: E
14 | }
15 |
16 | extension DataPoint: Equatable {
17 | static func == (lhs: DataPoint, rhs: DataPoint) -> Bool {
18 | return lhs.time == rhs.time && lhs.value == rhs.value
19 | }
20 | }
21 |
22 | extension DataPoint {
23 | static func from(json: [Any]) -> DataPoint? {
24 | guard json.count == 2 else { return nil }
25 |
26 | guard let timeDouble = Double.from(json[0]) else { return nil }
27 | guard let value = Double.from(json[1]) else { return nil }
28 |
29 | let time = Date(timeIntervalSince1970: timeDouble / 1000)
30 |
31 | return DataPoint(time: time, value: value)
32 | }
33 | }
34 |
35 | extension DataPoint {
36 | @discardableResult
37 | static func merge(pointsFrom points: [[DataPoint]],
38 | mergeBlock: (TimeInterval, Date, [E]) -> Void) -> [DataPoint<[E]>]
39 | {
40 | guard points.count > 0 else { return [] }
41 | var indices = points.map { $0.startIndex }
42 | var lastTime: Date?
43 |
44 | var outputPoints = [DataPoint<[E]>]()
45 |
46 | while true {
47 | let finished = indices.enumerated().reduce(false) { result, item in
48 | result || (item.element >= points[item.offset].endIndex)
49 | }
50 | if finished { break }
51 |
52 | let thisPoints = points.enumerated().map { $0.element[indices[$0.offset]] }
53 | let thisTimes = thisPoints.map { $0.time }
54 | if Set(thisTimes).count > 1 {
55 | let (minimumIndex, _) = thisTimes.enumerated().reduce((-1, Date.distantFuture)) {
56 | ($0.1 < $1.1) ? $0 : $1
57 | }
58 | indices[minimumIndex] = indices[minimumIndex].advanced(by: 1)
59 | continue
60 | }
61 |
62 | guard let time = thisTimes.first else { continue }
63 |
64 | guard let unwrappedLastTime = lastTime else {
65 | lastTime = time
66 | continue
67 | }
68 |
69 | let timeDelta = time.timeIntervalSince(unwrappedLastTime)
70 | lastTime = time
71 |
72 | let thisValues = thisPoints.map { $0.value }
73 | mergeBlock(timeDelta, time, thisValues)
74 | outputPoints.append(DataPoint<[E]>(time: time, value: thisValues))
75 |
76 | indices = indices.map { $0.advanced(by: 1) }
77 | }
78 |
79 | return outputPoints
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/Account.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountData.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 14/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import RealmSwift
12 |
13 | final class Account: Object {
14 | @objc dynamic var uuid: String = UUID().uuidString
15 | @objc dynamic var name: String = ""
16 | @objc dynamic var url: String = ""
17 | @objc dynamic var serverVersion: String?
18 |
19 | override class func primaryKey() -> String? {
20 | return "uuid"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/AppData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppData.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 28/12/2018.
6 | // Copyright © 2018 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | enum AppCategory: String, CaseIterable {
15 | case myElectric
16 | case mySolar
17 | case mySolarDivert
18 | }
19 |
20 | final class AppData: Object {
21 | @objc dynamic var uuid: String = UUID().uuidString
22 | @objc dynamic var name: String = "App"
23 | @objc private dynamic var category: String = "NULL"
24 | @objc private dynamic var feedsJson: Data?
25 |
26 | var feedsChanged: AnyPublisher {
27 | return self.publisher(for: \.feedsJson)
28 | .removeDuplicates()
29 | .becomeVoid()
30 | .eraseToAnyPublisher()
31 | }
32 |
33 | var appCategory: AppCategory {
34 | get {
35 | return AppCategory(rawValue: self.category)! // TODO: Handle this if it doesn't exist?
36 | }
37 | set {
38 | self.category = newValue.rawValue
39 | }
40 | }
41 |
42 | private var feeds: [String: String] {
43 | get {
44 | guard let dataJson = self.feedsJson else {
45 | return [String: String]()
46 | }
47 | do {
48 | if let feeds = try JSONSerialization.jsonObject(with: dataJson, options: []) as? [String: String] {
49 | return feeds
50 | }
51 | } catch {}
52 | return [String: String]()
53 | }
54 |
55 | set {
56 | do {
57 | let data = try JSONSerialization.data(withJSONObject: newValue, options: [])
58 | self.feedsJson = data
59 | } catch {
60 | self.feedsJson = nil
61 | }
62 | }
63 | }
64 |
65 | override class func primaryKey() -> String? {
66 | return "uuid"
67 | }
68 |
69 | override class func ignoredProperties() -> [String] {
70 | return ["appCategory", "feeds"]
71 | }
72 |
73 | func feed(forName name: String) -> String? {
74 | return self.feeds[name]
75 | }
76 |
77 | func setFeed(_ id: String, forName name: String) {
78 | var feeds = self.feeds
79 | feeds[name] = id
80 | self.feeds = feeds
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/Dashboard.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Dashboard.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import RealmSwift
12 |
13 | final class Dashboard: Object {
14 | @objc dynamic var id: String = ""
15 | @objc dynamic var name: String = ""
16 | @objc dynamic var desc: String = ""
17 | @objc dynamic var alias: String = ""
18 |
19 | override class func primaryKey() -> String? {
20 | return "id"
21 | }
22 | }
23 |
24 | extension Dashboard {
25 | static func from(json: [String: Any]) -> Dashboard? {
26 | guard let id = json["id"] as? Int else { return nil }
27 | guard let name = json["name"] as? String else { return nil }
28 | guard let desc = json["description"] as? String else { return nil }
29 | guard let alias = json["alias"] as? String else { return nil }
30 |
31 | let dashboard = Dashboard()
32 | dashboard.id = String(id)
33 | dashboard.name = name
34 | dashboard.desc = desc
35 | dashboard.alias = alias
36 |
37 | return dashboard
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/Feed.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Feed.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 12/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import RealmSwift
12 |
13 | final class Feed: Object {
14 | @objc dynamic var id: String = ""
15 | @objc dynamic var name: String = ""
16 | @objc dynamic var tag: String = ""
17 | @objc dynamic var time = Date()
18 | @objc dynamic var value: Double = 0
19 | @objc private dynamic var widgetChartPointsJson: Data?
20 |
21 | var widgetChartPoints: [DataPoint] {
22 | get {
23 | var dataPoints: [DataPoint] = []
24 | guard let dataJson = self.widgetChartPointsJson else {
25 | return dataPoints
26 | }
27 | do {
28 | if let data = try JSONSerialization.jsonObject(with: dataJson, options: []) as? [[Double]] {
29 | for d in data {
30 | dataPoints.append(DataPoint(time: Date(timeIntervalSince1970: d[0]), value: d[1]))
31 | }
32 | return dataPoints
33 | }
34 | } catch {}
35 | return dataPoints
36 | }
37 |
38 | set {
39 | do {
40 | var toSerialise: [[Double]] = []
41 | for dataPoint in newValue {
42 | toSerialise.append([dataPoint.time.timeIntervalSince1970, dataPoint.value])
43 | }
44 | let data = try JSONSerialization.data(withJSONObject: toSerialise, options: [])
45 | self.widgetChartPointsJson = data
46 | } catch {
47 | self.widgetChartPointsJson = nil
48 | }
49 | }
50 | }
51 |
52 | override class func primaryKey() -> String? {
53 | return "id"
54 | }
55 |
56 | override class func ignoredProperties() -> [String] {
57 | return ["widgetChartPoints"]
58 | }
59 | }
60 |
61 | extension Feed {
62 | static func from(json: [String: Any]) -> Feed? {
63 | guard let id = json["id"] as? String else { return nil }
64 | guard let name = json["name"] as? String else { return nil }
65 | guard let tag = json["tag"] as? String else { return nil }
66 | guard let timeAny = json["time"],
67 | let timeDouble = Double.from(timeAny) else { return nil }
68 | guard let valueAny = json["value"],
69 | let value = Double.from(valueAny) else { return nil }
70 |
71 | let time = Date(timeIntervalSince1970: timeDouble)
72 |
73 | let feed = Feed()
74 | feed.id = id
75 | feed.name = name
76 | feed.tag = tag
77 | feed.time = time
78 | feed.value = value
79 |
80 | return feed
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/Input.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Input.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 23/11/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import RealmSwift
12 |
13 | final class Input: Object {
14 | @objc dynamic var id: String = ""
15 | @objc dynamic var nodeid: String = ""
16 | @objc dynamic var name: String = ""
17 | @objc dynamic var desc: String = ""
18 | @objc dynamic var time = Date()
19 | @objc dynamic var value: Double = 0
20 |
21 | override class func primaryKey() -> String? {
22 | return "id"
23 | }
24 | }
25 |
26 | extension Input {
27 | static func from(json: [String: Any]) -> Input? {
28 | guard let id = json["id"] as? String else { return nil }
29 | guard let nodeid = json["nodeid"] as? String else { return nil }
30 | guard let name = json["name"] as? String else { return nil }
31 | guard let desc = json["description"] as? String else { return nil }
32 | guard let timeAny = json["time"],
33 | let timeDouble = Double.from(timeAny) else { return nil }
34 | guard let valueAny = json["value"],
35 | let value = Double.from(valueAny) else { return nil }
36 |
37 | let time = Date(timeIntervalSince1970: timeDouble)
38 |
39 | let input = Input()
40 | input.id = id
41 | input.nodeid = nodeid
42 | input.name = name
43 | input.desc = desc
44 | input.time = time
45 | input.value = value
46 |
47 | return input
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Model/RealmObjects/TodayWidgetFeed.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TodayWidgetFeed.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 27/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | import RealmSwift
12 |
13 | final class TodayWidgetFeed: Object {
14 | @objc dynamic var uuid: String = UUID().uuidString
15 | @objc dynamic var order: Int = 0
16 | @objc dynamic var accountId: String = ""
17 | @objc dynamic var feedId: String = ""
18 |
19 | override class func primaryKey() -> String? {
20 | return "uuid"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses.latest_result.txt:
--------------------------------------------------------------------------------
1 | name: Charts, nameSpecified: Charts, owner: danielgindi, version: 3.5.0
2 |
3 | name: Entwine, nameSpecified: Entwine, owner: tcldr, version: 0.9.1
4 |
5 | name: Former, nameSpecified: Former, owner: ra1028, version: 1.8.1
6 |
7 | name: KeychainAccess, nameSpecified: KeychainAccess, owner: kishikawakatsumi, version: 4.2.1
8 |
9 | name: Nimble, nameSpecified: Nimble, owner: Quick, version: 8.1.2
10 |
11 | name: Quick, nameSpecified: Quick, owner: Quick, version: 3.0.0
12 |
13 | name: realm-cocoa, nameSpecified: Realm, owner: realm, version: 5.4.2
14 |
15 | name: realm-core, nameSpecified: RealmCore, owner: realm, version: 6.0.26
16 |
17 | name: swift-snapshot-testing, nameSpecified: SnapshotTesting, owner: pointfreeco, version: 1.8.2
18 |
19 | name: XCGLogger, nameSpecified: XCGLogger, owner: DaveWoodCom, version: 7.0.1
20 |
21 | add-version-numbers: false
22 |
23 | LicensePlist Version: 2.16.0
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | Title
9 | Licenses
10 | Type
11 | PSGroupSpecifier
12 |
13 |
14 | File
15 | Licenses/Charts
16 | Title
17 | Charts
18 | Type
19 | PSChildPaneSpecifier
20 |
21 |
22 | File
23 | Licenses/Entwine
24 | Title
25 | Entwine
26 | Type
27 | PSChildPaneSpecifier
28 |
29 |
30 | File
31 | Licenses/Former
32 | Title
33 | Former
34 | Type
35 | PSChildPaneSpecifier
36 |
37 |
38 | File
39 | Licenses/KeychainAccess
40 | Title
41 | KeychainAccess
42 | Type
43 | PSChildPaneSpecifier
44 |
45 |
46 | File
47 | Licenses/Nimble
48 | Title
49 | Nimble
50 | Type
51 | PSChildPaneSpecifier
52 |
53 |
54 | File
55 | Licenses/Quick
56 | Title
57 | Quick
58 | Type
59 | PSChildPaneSpecifier
60 |
61 |
62 | File
63 | Licenses/realm-cocoa
64 | Title
65 | Realm
66 | Type
67 | PSChildPaneSpecifier
68 |
69 |
70 | File
71 | Licenses/realm-core
72 | Title
73 | RealmCore
74 | Type
75 | PSChildPaneSpecifier
76 |
77 |
78 | File
79 | Licenses/swift-snapshot-testing
80 | Title
81 | SnapshotTesting
82 | Type
83 | PSChildPaneSpecifier
84 |
85 |
86 | File
87 | Licenses/XCGLogger
88 | Title
89 | XCGLogger
90 | Type
91 | PSChildPaneSpecifier
92 |
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses/Entwine.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | FooterText
9 | Copyright © 2019 Tristan Celder. All rights reserved.
10 |
11 | Permission is hereby granted, free of charge, to any person obtaining a copy
12 | of this software and associated documentation files (the "Software"), to deal
13 | in the Software without restriction, including without limitation the rights
14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 | copies of the Software, and to permit persons to whom the Software is
16 | furnished to do so, subject to the following conditions:
17 |
18 | The above copyright notice and this permission notice shall be included in
19 | all copies or substantial portions of the Software.
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27 | THE SOFTWARE.
28 |
29 | Type
30 | PSGroupSpecifier
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses/Former.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | FooterText
9 | The MIT License (MIT)
10 |
11 | Copyright (c) 2015 ra1028
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
31 | Type
32 | PSGroupSpecifier
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses/KeychainAccess.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | FooterText
9 | The MIT License (MIT)
10 |
11 | Copyright (c) 2014 kishikawa katsumi
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
31 |
32 | Type
33 | PSGroupSpecifier
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses/XCGLogger.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | FooterText
9 | The MIT License (MIT)
10 |
11 | Copyright (c) 2014 Dave Wood, Cerebral Gardens http://www.cerebralgardens.com/
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
31 | Type
32 | PSGroupSpecifier
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Licenses/swift-snapshot-testing.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreferenceSpecifiers
6 |
7 |
8 | FooterText
9 | MIT License
10 |
11 | Copyright (c) 2019 Point-Free, Inc.
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
31 | Type
32 | PSGroupSpecifier
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/Root.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | StringsTable
6 | Root
7 | PreferenceSpecifiers
8 |
9 |
10 | Type
11 | PSChildPaneSpecifier
12 | Title
13 | Licenses
14 | File
15 | Licenses
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/EmonCMSiOS/Settings.bundle/en.lproj/Root.strings:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOS/Settings.bundle/en.lproj/Root.strings
--------------------------------------------------------------------------------
/EmonCMSiOS/UI/FormerCells/ChartCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartCell.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 19/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | import Charts
12 | import Former
13 |
14 | final class ChartCell: UITableViewCell {
15 | let chartView: ChartViewType
16 | let spinner: UIActivityIndicatorView
17 |
18 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
19 | self.chartView = ChartViewType()
20 | self.chartView.translatesAutoresizingMaskIntoConstraints = false
21 |
22 | self.spinner = UIActivityIndicatorView(style: .medium)
23 | self.spinner.translatesAutoresizingMaskIntoConstraints = false
24 | self.spinner.hidesWhenStopped = true
25 |
26 | super.init(style: style, reuseIdentifier: reuseIdentifier)
27 |
28 | contentView.addSubview(self.chartView)
29 | contentView.addConstraint(
30 | NSLayoutConstraint(
31 | item: self.chartView,
32 | attribute: .top,
33 | relatedBy: .equal,
34 | toItem: contentView,
35 | attribute: .top,
36 | multiplier: 1,
37 | constant: 0))
38 | contentView.addConstraint(
39 | NSLayoutConstraint(
40 | item: self.chartView,
41 | attribute: .bottom,
42 | relatedBy: .equal,
43 | toItem: contentView,
44 | attribute: .bottom,
45 | multiplier: 1,
46 | constant: 0))
47 | contentView.addConstraint(
48 | NSLayoutConstraint(
49 | item: self.chartView,
50 | attribute: .left,
51 | relatedBy: .equal,
52 | toItem: contentView,
53 | attribute: .left,
54 | multiplier: 1,
55 | constant: 0))
56 | contentView.addConstraint(
57 | NSLayoutConstraint(
58 | item: self.chartView,
59 | attribute: .right,
60 | relatedBy: .equal,
61 | toItem: contentView,
62 | attribute: .right,
63 | multiplier: 1,
64 | constant: 0))
65 |
66 | contentView.addSubview(self.spinner)
67 | contentView.addConstraint(
68 | NSLayoutConstraint(
69 | item: self.spinner,
70 | attribute: .centerX,
71 | relatedBy: .equal,
72 | toItem: contentView,
73 | attribute: .centerX,
74 | multiplier: 1,
75 | constant: 0))
76 | contentView.addConstraint(
77 | NSLayoutConstraint(
78 | item: self.spinner,
79 | attribute: .centerY,
80 | relatedBy: .equal,
81 | toItem: contentView,
82 | attribute: .centerY,
83 | multiplier: 1,
84 | constant: 0))
85 | }
86 |
87 | @available(*, unavailable)
88 | required init?(coder aDecoder: NSCoder) {
89 | fatalError("Must be initialised programatically")
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/EmonCMSiOS/UI/TableCells/InputCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InputCell.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 27/09/2020.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | import Charts
13 |
14 | final class InputCell: UITableViewCell {
15 | @IBOutlet var titleLabel: UILabel!
16 | @IBOutlet var valueLabel: UILabel!
17 | @IBOutlet var timeLabel: UILabel!
18 | @IBOutlet var activityCircle: UIView!
19 |
20 | override func layoutSubviews() {
21 | super.layoutSubviews()
22 | self.activityCircle.layer.cornerRadius = self.activityCircle.bounds.width / 2
23 | }
24 |
25 | override func setSelected(_ selected: Bool, animated: Bool) {
26 | let colour = self.activityCircle.backgroundColor
27 | super.setSelected(selected, animated: animated)
28 | self.activityCircle.backgroundColor = colour
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/EmonCMSiOS/UI/Views/AppTitleAndValueView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppTitleAndValueView.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | @IBDesignable final class AppTitleAndValueView: UIView {
13 | @IBInspectable var title: String? { get { return self.titleLabel.text } set { self.titleLabel.text = newValue } }
14 | @IBInspectable var titleColor: UIColor {
15 | get { return self.titleLabel.textColor } set { self.titleLabel.textColor = newValue }
16 | }
17 |
18 | @IBInspectable var value: String? { get { return self.valueLabel.text } set { self.valueLabel.text = newValue } }
19 | @IBInspectable var valueColor: UIColor {
20 | get { return self.valueLabel.textColor } set { self.valueLabel.textColor = newValue }
21 | }
22 |
23 | var alignment: NSTextAlignment {
24 | get { return self.titleLabel.textAlignment }
25 | set {
26 | self.titleLabel.textAlignment = newValue
27 | self.valueLabel.textAlignment = newValue
28 | }
29 | }
30 |
31 | private let titleLabel = UILabel(frame: .zero)
32 | private let valueLabel = UILabel(frame: .zero)
33 | private var internalConstraints: [NSLayoutConstraint] = []
34 |
35 | override class var requiresConstraintBasedLayout: Bool { return true }
36 |
37 | override init(frame: CGRect) {
38 | super.init(frame: frame)
39 | self.setupLabels()
40 | }
41 |
42 | required init?(coder aDecoder: NSCoder) {
43 | super.init(coder: aDecoder)
44 | self.setupLabels()
45 | }
46 |
47 | private func setupLabels() {
48 | self.translatesAutoresizingMaskIntoConstraints = false
49 |
50 | self.titleLabel.font = UIFont.systemFont(ofSize: 15.0, weight: .bold)
51 | self.titleLabel.translatesAutoresizingMaskIntoConstraints = false
52 | self.addSubview(self.titleLabel)
53 |
54 | self.valueLabel.font = UIFont.systemFont(ofSize: 24.0, weight: .bold)
55 | self.valueLabel.translatesAutoresizingMaskIntoConstraints = false
56 | self.addSubview(self.valueLabel)
57 | }
58 |
59 | override func updateConstraints() {
60 | super.updateConstraints()
61 |
62 | self.removeConstraints(self.internalConstraints)
63 | self.internalConstraints.removeAll()
64 |
65 | let views = ["titleLabel": self.titleLabel, "valueLabel": self.valueLabel]
66 |
67 | self.internalConstraints +=
68 | NSLayoutConstraint.constraints(withVisualFormat: "V:|[titleLabel]-2-[valueLabel]|",
69 | options: [],
70 | metrics: nil,
71 | views: views)
72 | self.internalConstraints +=
73 | NSLayoutConstraint.constraints(withVisualFormat: "H:|-(0)-[titleLabel]-(0)-|",
74 | options: [],
75 | metrics: nil,
76 | views: views)
77 | self.internalConstraints +=
78 | NSLayoutConstraint.constraints(withVisualFormat: "H:|-(0)-[valueLabel]-(0)-|",
79 | options: [],
80 | metrics: nil,
81 | views: views)
82 |
83 | self.addConstraints(self.internalConstraints)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/AccountViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 28/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import Realm
13 | import RealmSwift
14 |
15 | final class AccountViewModel {
16 | enum AccountViewModelError: Error {
17 | case versionNotSupported(SemanticVersion)
18 | }
19 |
20 | // v9.9.0 is the release where the `/version` API first existed properly
21 | static let minimumSupportedServerVersion = SemanticVersion(major: 9, minor: 9, patch: 0)
22 |
23 | private let realmController: RealmController
24 | private let account: AccountController
25 | private let api: EmonCMSAPI
26 | private let realm: Realm
27 |
28 | private var cancellables = Set()
29 |
30 | // Inputs
31 |
32 | // Outputs
33 |
34 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
35 | self.realmController = realmController
36 | self.account = account
37 | self.api = api
38 | self.realm = realmController.createMainRealm()
39 | }
40 |
41 | private func updateEmoncmsServerVersion() -> AnyPublisher {
42 | let account = self.realm.object(ofType: Account.self, forPrimaryKey: self.account.uuid)
43 | return self.api.version(self.account.credentials)
44 | .map { [weak self] version -> String in
45 | guard let self = self else { return version }
46 |
47 | guard let account = account else { return version }
48 |
49 | do {
50 | try self.realm.write {
51 | account.serverVersion = version
52 | }
53 | } catch {}
54 |
55 | return version
56 | }
57 | .replaceError(with: account?.serverVersion)
58 | .eraseToAnyPublisher()
59 | }
60 |
61 | func checkEmoncmsServerVersion() -> AnyPublisher {
62 | return self.updateEmoncmsServerVersion()
63 | .map { version -> SemanticVersion? in
64 | if let version = version {
65 | return SemanticVersion(string: version)
66 | }
67 | return nil
68 | }
69 | .setFailureType(to: AccountViewModelError.self)
70 | .flatMap { version -> AnyPublisher in
71 | if let version = version, version < AccountViewModel.minimumSupportedServerVersion {
72 | return Fail(error: .versionNotSupported(version)).eraseToAnyPublisher()
73 | } else {
74 | return Empty().eraseToAnyPublisher()
75 | }
76 | }
77 | .eraseToAnyPublisher()
78 | }
79 | }
80 |
81 | extension AccountViewModel.AccountViewModelError: Equatable {}
82 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/AppListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppsListViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 14/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 | import UIKit
12 |
13 | import RealmSwift
14 |
15 | final class AppListViewModel {
16 | struct ListItem {
17 | let appId: String
18 | let category: AppCategory
19 | let name: String
20 | }
21 |
22 | typealias Section = SectionModel
23 |
24 | private let realmController: RealmController
25 | private let account: AccountController
26 | private let api: EmonCMSAPI
27 | private let realm: Realm
28 |
29 | private var cancellables = Set()
30 |
31 | // Inputs
32 |
33 | // Outputs
34 | @Published private(set) var apps: [ListItem] = []
35 |
36 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
37 | self.realmController = realmController
38 | self.account = account
39 | self.api = api
40 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
41 |
42 | let appQuery = self.realm.objects(AppData.self)
43 | .sorted(byKeyPath: #keyPath(AppData.name), ascending: true)
44 | appQuery.collectionPublisher
45 | .map(self.appsToListItems)
46 | .sink(
47 | receiveCompletion: { error in
48 | AppLog.error("Query errored when it shouldn't! \(error)")
49 | },
50 | receiveValue: { [weak self] items in
51 | guard let self = self else { return }
52 | self.apps = items
53 | })
54 | .store(in: &self.cancellables)
55 | }
56 |
57 | private func appsToListItems(_ apps: Results) -> [ListItem] {
58 | let listItems = apps.map {
59 | ListItem(appId: $0.uuid, category: $0.appCategory, name: $0.name)
60 | }
61 | return Array(listItems)
62 | }
63 |
64 | func deleteApp(withId id: String) -> AnyPublisher {
65 | let realm = self.realm
66 | return Deferred { () -> Just in
67 | do {
68 | if let app = realm.object(ofType: AppData.self, forPrimaryKey: id) {
69 | try realm.write {
70 | realm.delete(app)
71 | }
72 | }
73 | } catch {}
74 |
75 | return Just(())
76 | }.eraseToAnyPublisher()
77 | }
78 |
79 | func viewController(forDataWithId id: String, ofCategory category: AppCategory) -> UIViewController {
80 | let storyboard = UIStoryboard(name: "Apps", bundle: nil)
81 | let appViewController = storyboard.instantiateInitialViewController() as! AppViewController
82 |
83 | let viewModel = category.viewModelInit(self.realmController, self.account, self.api, id)
84 | appViewController.viewModel = viewModel
85 |
86 | return appViewController
87 | }
88 |
89 | func appConfigViewModel(forCategory category: AppCategory) -> AppConfigViewModel {
90 | return AppConfigViewModel(realmController: self.realmController, account: self.account, api: self.api,
91 | appDataId: nil, appCategory: category)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/DashboardListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardListViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import Realm
13 | import RealmSwift
14 |
15 | final class DashboardListViewModel {
16 | struct ListItem {
17 | let dashboardId: String
18 | let name: String
19 | let desc: String
20 | }
21 |
22 | typealias Section = SectionModel
23 |
24 | private let realmController: RealmController
25 | private let account: AccountController
26 | private let api: EmonCMSAPI
27 | private let realm: Realm
28 | private let dashboardUpdateHelper: DashboardUpdateHelper
29 |
30 | private var cancellables = Set()
31 |
32 | // Inputs
33 | @Published var active = false
34 | let refresh = PassthroughSubject()
35 |
36 | // Outputs
37 | @Published private(set) var dashboards: [ListItem] = []
38 | @Published private(set) var updateTime: Date? = nil
39 | let isRefreshing: AnyPublisher
40 | @Published private(set) var serverNeedsUpdate = false
41 |
42 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
43 | self.realmController = realmController
44 | self.account = account
45 | self.api = api
46 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
47 | self.dashboardUpdateHelper = DashboardUpdateHelper(realmController: realmController, account: account, api: api)
48 |
49 | let isRefreshingIndicator = ActivityIndicatorCombine()
50 | self.isRefreshing = isRefreshingIndicator.asPublisher()
51 |
52 | let dashboardsQuery = self.realm.objects(Dashboard.self).sorted(byKeyPath: #keyPath(Dashboard.id))
53 | dashboardsQuery.collectionPublisher
54 | .map(self.dashboardsToListItems)
55 | .sink(
56 | receiveCompletion: { error in
57 | AppLog.error("Query errored when it shouldn't! \(error)")
58 | },
59 | receiveValue: { [weak self] items in
60 | guard let self = self else { return }
61 | self.dashboards = items
62 | self.updateTime = Date()
63 | })
64 | .store(in: &self.cancellables)
65 |
66 | let becameActive = $active
67 | .filter { $0 == true }
68 | .removeDuplicates()
69 | .becomeVoid()
70 |
71 | Publishers.Merge(self.refresh, becameActive)
72 | .map { [weak self] () -> AnyPublisher in
73 | guard let self = self else { return Empty().eraseToAnyPublisher() }
74 | return self.dashboardUpdateHelper.updateDashboards()
75 | .catch { [weak self] error -> AnyPublisher in
76 | if error == EmonCMSAPI.APIError.invalidResponse {
77 | self?.serverNeedsUpdate = true
78 | }
79 | return Just(()).eraseToAnyPublisher()
80 | }
81 | .trackActivity(isRefreshingIndicator)
82 | .eraseToAnyPublisher()
83 | }
84 | .switchToLatest()
85 | .sink(receiveValue: { _ in })
86 | .store(in: &self.cancellables)
87 | }
88 |
89 | private func dashboardsToListItems(_ dashboards: Results) -> [ListItem] {
90 | let listItems = dashboards.map {
91 | ListItem(dashboardId: $0.id, name: $0.name, desc: $0.desc)
92 | }
93 | return Array(listItems)
94 | }
95 |
96 | func urlForDashboard(withId id: String) -> URL? {
97 | let fullUrl = self.account.credentials
98 | .url + "/dashboard/view?id=\(id)&embed=1&apikey=\(self.account.credentials.apiKey)"
99 | guard let dashboardURL = URL(string: fullUrl) else { return nil }
100 | return dashboardURL
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/DashboardUpdateHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardUpdateHelper.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class DashboardUpdateHelper {
15 | private let realmController: RealmController
16 | private let account: AccountController
17 | private let api: EmonCMSAPI
18 | private let scheduler: DispatchQueue
19 |
20 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
21 | self.realmController = realmController
22 | self.account = account
23 | self.api = api
24 | self.scheduler = DispatchQueue(label: "org.openenergymonitor.emoncms.DashboardUpdateHelper")
25 | }
26 |
27 | func updateDashboards() -> AnyPublisher {
28 | return Deferred {
29 | return self.api.dashboardList(self.account.credentials)
30 | .receive(on: self.scheduler)
31 | .flatMap { [weak self] dashboards -> AnyPublisher in
32 | guard let self = self else { return Empty().eraseToAnyPublisher() }
33 | let realm = self.realmController.createAccountRealm(forAccountId: self.account.uuid)
34 | return self.saveDashboards(dashboards, inRealm: realm).eraseToAnyPublisher()
35 | }
36 | .receive(on: DispatchQueue.main)
37 | }.eraseToAnyPublisher()
38 | }
39 |
40 | private func saveDashboards(_ dashboards: [Dashboard],
41 | inRealm realm: Realm) -> AnyPublisher
42 | {
43 | return Deferred> {
44 | let existingDashboards = realm.objects(Dashboard.self).filter {
45 | var inNewArray = false
46 | for dashboard in dashboards {
47 | if dashboard.id == $0.id {
48 | inNewArray = true
49 | break
50 | }
51 | }
52 | return !inNewArray
53 | }
54 |
55 | do {
56 | try realm.write {
57 | realm.delete(existingDashboards)
58 | realm.add(dashboards, update: .all)
59 | }
60 | } catch {
61 | AppLog.error("Failed to write to Realm: \(error)")
62 | }
63 |
64 | return Just(())
65 | }
66 | .setFailureType(to: EmonCMSAPI.APIError.self)
67 | .eraseToAnyPublisher()
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/FeedChartViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedChartViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 19/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | final class FeedChartViewModel {
13 | private let account: AccountController
14 | private let api: EmonCMSAPI
15 | private let feedId: String
16 |
17 | private var cancellables = Set()
18 |
19 | // Inputs
20 | @Published var active: Bool = false
21 | @Published var dateRange: DateRange = .relative { $0.hour = -8 }
22 | let refresh = PassthroughSubject()
23 |
24 | // Outputs
25 | @Published private(set) var dataPoints: [DataPoint] = []
26 | let isRefreshing: AnyPublisher
27 |
28 | init(account: AccountController, api: EmonCMSAPI, feedId: String) {
29 | self.account = account
30 | self.api = api
31 | self.feedId = feedId
32 |
33 | let isRefreshingIndicator = ActivityIndicatorCombine()
34 | self.isRefreshing = isRefreshingIndicator.asPublisher()
35 |
36 | let becameActive = $active
37 | .filter { $0 == true }
38 | .removeDuplicates()
39 | .becomeVoid()
40 |
41 | let refreshSignal = Publishers.Merge(self.refresh, becameActive)
42 |
43 | Publishers.CombineLatest(refreshSignal, $dateRange)
44 | .map { [weak self] _, dateRange -> AnyPublisher<[DataPoint], Never> in
45 | guard let self = self else { return Empty().eraseToAnyPublisher() }
46 |
47 | let feedId = self.feedId
48 | let (startDate, endDate) = dateRange.calculateDates()
49 | let interval = Int(endDate.timeIntervalSince(startDate) / 500)
50 |
51 | return self.api
52 | .feedData(self.account.credentials, id: feedId, at: startDate, until: endDate, interval: interval)
53 | .replaceError(with: [])
54 | .trackActivity(isRefreshingIndicator)
55 | .eraseToAnyPublisher()
56 | }
57 | .switchToLatest()
58 | .sink { [weak self] dataPoints in
59 | guard let self = self else { return }
60 | self.dataPoints = dataPoints
61 | }
62 | .store(in: &self.cancellables)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/FeedListHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedListHelper.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 10/10/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class FeedListHelper {
15 | struct FeedListItem {
16 | let feedId: String
17 | let name: String
18 | }
19 |
20 | private let realmController: RealmController
21 | private let account: AccountController
22 | private let api: EmonCMSAPI
23 | private let realm: Realm
24 | private let feedUpdateHelper: FeedUpdateHelper
25 |
26 | private var cancellables = Set()
27 |
28 | // Inputs
29 | let refresh = PassthroughSubject()
30 |
31 | // Outputs
32 | @Published private(set) var feeds: [FeedListItem] = []
33 | let isRefreshing: AnyPublisher
34 |
35 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
36 | self.realmController = realmController
37 | self.account = account
38 | self.api = api
39 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
40 | self.feedUpdateHelper = FeedUpdateHelper(realmController: realmController, account: account, api: api)
41 |
42 | let isRefreshingIndicator = ActivityIndicatorCombine()
43 | self.isRefreshing = isRefreshingIndicator.asPublisher()
44 |
45 | self.realm.objects(Feed.self)
46 | .collectionPublisher
47 | .map(self.feedsToListItems)
48 | .sink(
49 | receiveCompletion: { error in
50 | AppLog.error("Query errored when it shouldn't! \(error)")
51 | },
52 | receiveValue: { [weak self] items in
53 | guard let self = self else { return }
54 | self.feeds = items
55 | })
56 | .store(in: &self.cancellables)
57 |
58 | self.refresh
59 | .map { [weak self] () -> AnyPublisher in
60 | guard let self = self else { return Empty().eraseToAnyPublisher() }
61 | return self.feedUpdateHelper.updateFeeds()
62 | .replaceError(with: ())
63 | .trackActivity(isRefreshingIndicator)
64 | .eraseToAnyPublisher()
65 | }
66 | .switchToLatest()
67 | .sink(receiveValue: { _ in })
68 | .store(in: &self.cancellables)
69 | }
70 |
71 | private func feedsToListItems(_ feeds: Results) -> [FeedListItem] {
72 | let sortedFeedItems = feeds.sorted {
73 | $0.name < $1.name
74 | }.map {
75 | FeedListItem(feedId: $0.id, name: $0.name)
76 | }
77 | return sortedFeedItems
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/FeedUpdateHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedUpdateHelper.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 10/10/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class FeedUpdateHelper {
15 | private let realmController: RealmController
16 | private let account: AccountController
17 | private let api: EmonCMSAPI
18 | private let scheduler: DispatchQueue
19 |
20 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
21 | self.realmController = realmController
22 | self.account = account
23 | self.api = api
24 | self.scheduler = DispatchQueue(label: "org.openenergymonitor.emoncms.FeedUpdateHelper")
25 | }
26 |
27 | func updateFeeds() -> AnyPublisher {
28 | return Deferred {
29 | return self.api.feedList(self.account.credentials)
30 | .receive(on: self.scheduler)
31 | .flatMap { [weak self] feeds -> AnyPublisher in
32 | guard let self = self else { return Empty().eraseToAnyPublisher() }
33 | let realm = self.realmController.createAccountRealm(forAccountId: self.account.uuid)
34 | return self.saveFeeds(feeds, inRealm: realm).eraseToAnyPublisher()
35 | }
36 | .receive(on: DispatchQueue.main)
37 | }.eraseToAnyPublisher()
38 | }
39 |
40 | private func saveFeeds(_ feeds: [Feed], inRealm realm: Realm) -> AnyPublisher {
41 | return Deferred> { [weak self] in
42 | guard let self = self else { return Empty().eraseToAnyPublisher() }
43 |
44 | let goneAwayFeeds = realm.objects(Feed.self).filter {
45 | var inNewArray = false
46 | for feed in feeds {
47 | if feed.id == $0.id {
48 | inNewArray = true
49 | break
50 | }
51 | }
52 | return !inNewArray
53 | }
54 |
55 | do {
56 | try realm.write {
57 | if goneAwayFeeds.count > 0 {
58 | realm.delete(goneAwayFeeds)
59 | let todayWidgetFeedsForGoneAwayFeeds = realm.objects(TodayWidgetFeed.self)
60 | .filter("accountId = %@ AND feedId IN %@", self.account.uuid, Array(goneAwayFeeds.map { $0.id }))
61 | realm.delete(todayWidgetFeedsForGoneAwayFeeds)
62 | }
63 | realm.add(feeds, update: .all)
64 | }
65 | } catch {
66 | AppLog.error("Failed to write to Realm: \(error)")
67 | }
68 |
69 | return Just(()).eraseToAnyPublisher()
70 | }
71 | .setFailureType(to: EmonCMSAPI.APIError.self)
72 | .eraseToAnyPublisher()
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/InputUpdateHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InputUpdateHelper.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 23/11/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class InputUpdateHelper {
15 | private let realmController: RealmController
16 | private let account: AccountController
17 | private let api: EmonCMSAPI
18 | private let scheduler: DispatchQueue
19 |
20 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
21 | self.realmController = realmController
22 | self.account = account
23 | self.api = api
24 | self.scheduler = DispatchQueue(label: "org.openenergymonitor.emoncms.InputUpdateHelper")
25 | }
26 |
27 | func updateInputs() -> AnyPublisher {
28 | return Deferred {
29 | return self.api.inputList(self.account.credentials)
30 | .receive(on: self.scheduler)
31 | .flatMap { [weak self] inputs -> AnyPublisher in
32 | guard let self = self else { return Empty().eraseToAnyPublisher() }
33 | let realm = self.realmController.createAccountRealm(forAccountId: self.account.uuid)
34 | return self.saveInputs(inputs, inRealm: realm).eraseToAnyPublisher()
35 | }
36 | .receive(on: DispatchQueue.main)
37 | }.eraseToAnyPublisher()
38 | }
39 |
40 | private func saveInputs(_ inputs: [Input], inRealm realm: Realm) -> AnyPublisher {
41 | return Deferred> {
42 | let existingInputs = realm.objects(Input.self).filter {
43 | var inNewArray = false
44 | for input in inputs {
45 | if input.id == $0.id {
46 | inNewArray = true
47 | break
48 | }
49 | }
50 | return !inNewArray
51 | }
52 |
53 | do {
54 | try realm.write {
55 | realm.delete(existingInputs)
56 | realm.add(inputs, update: .all)
57 | }
58 | } catch {
59 | AppLog.error("Failed to write to Realm: \(error)")
60 | }
61 |
62 | return Just(())
63 | }
64 | .setFailureType(to: EmonCMSAPI.APIError.self)
65 | .eraseToAnyPublisher()
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/EmonCMSiOS/ViewModel/SettingsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsViewModel.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 13/09/2016.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import RealmSwift
13 |
14 | final class SettingsViewModel {
15 | private let realmController: RealmController
16 | private let account: AccountController
17 | private let api: EmonCMSAPI
18 | private let realm: Realm
19 |
20 | private var cancellables = Set()
21 |
22 | // Inputs
23 | @Published var active = false
24 |
25 | // Outputs
26 | let feedList: FeedListHelper
27 |
28 | init(realmController: RealmController, account: AccountController, api: EmonCMSAPI) {
29 | self.realmController = realmController
30 | self.account = account
31 | self.api = api
32 | self.realm = realmController.createAccountRealm(forAccountId: account.uuid)
33 |
34 | self.feedList = FeedListHelper(realmController: realmController, account: account, api: api)
35 |
36 | $active
37 | .removeDuplicates()
38 | .filter { $0 == true }
39 | .becomeVoid()
40 | .subscribe(self.feedList.refresh)
41 | .store(in: &self.cancellables)
42 | }
43 |
44 | func todayWidgetFeedsListViewModel() -> TodayWidgetFeedsListViewModel {
45 | return TodayWidgetFeedsListViewModel(realmController: self.realmController, accountController: self.account,
46 | api: self.api)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/EmonCMSiOSSelectFeedIntent/EmonCMSiOSSelectFeedIntent.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.org.openenergymonitor.emoncms
8 |
9 | keychain-access-groups
10 |
11 | $(AppIdentifierPrefix)org.openenergymonitor.emoncms
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/EmonCMSiOSSelectFeedIntent/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | EmonCMSiOSSelectFeedIntent
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.2.3
21 | CFBundleVersion
22 | 33
23 | NSExtension
24 |
25 | NSExtensionAttributes
26 |
27 | IntentsRestrictedWhileLocked
28 |
29 | IntentsRestrictedWhileProtectedDataUnavailable
30 |
31 | IntentsSupported
32 |
33 | SelectFeedIntent
34 | SelectFeedsIntent
35 |
36 |
37 | NSExtensionPointIdentifier
38 | com.apple.intents-service
39 | NSExtensionPrincipalClass
40 | $(PRODUCT_MODULE_NAME).IntentHandler
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/EmonCMSiOSSelectFeedIntent/IntentHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentHandler.swift
3 | // EmonCMSiOSSelectFeedIntent
4 | //
5 | // Created by Matt Galloway on 20/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Intents
10 |
11 | import Realm
12 | import RealmSwift
13 |
14 | class IntentHandler: INExtension, SelectFeedIntentHandling, SelectFeedsIntentHandling {
15 | private let realmController: RealmController
16 |
17 | override init() {
18 | let dataDirectory = DataController.sharedDataDirectory
19 | self.realmController = RealmController(dataDirectory: dataDirectory)
20 |
21 | super.init()
22 | }
23 |
24 | func provideFeedOptionsCollection(
25 | for intent: SelectFeedIntent,
26 | with completion: @escaping (INObjectCollection?, Error?) -> Void)
27 | {
28 | let collection = self.fetchFeeds()
29 | completion(collection, nil)
30 | }
31 |
32 | func provideFeedsOptionsCollection(
33 | for intent: SelectFeedsIntent,
34 | with completion: @escaping (INObjectCollection?, Error?) -> Void)
35 | {
36 | let collection = self.fetchFeeds()
37 | completion(collection, nil)
38 | }
39 |
40 | private func fetchFeeds() -> INObjectCollection {
41 | let mainRealm = self.realmController.createMainRealm()
42 | let accounts = mainRealm.objects(Account.self).sorted(byKeyPath: "name")
43 |
44 | var sections: [INObjectSection] = []
45 |
46 | accounts.forEach { account in
47 | let accountRealm = self.realmController.createAccountRealm(forAccountId: account.uuid)
48 | let feeds = accountRealm.objects(Feed.self).sorted(byKeyPath: "name")
49 |
50 | var feedIntentsByTag: [String: [FeedIntent]] = [:]
51 | feeds.forEach { feed in
52 | let identifier = account.uuid + "/" + feed.id
53 | let display = feed.name
54 | let subtitle = feed.tag
55 |
56 | let feedIntent = FeedIntent(
57 | identifier: identifier,
58 | display: display,
59 | subtitle: subtitle,
60 | image: nil)
61 | feedIntent.accountId = account.uuid
62 | feedIntent.feedId = feed.id
63 |
64 | var feedIntents = feedIntentsByTag[feed.tag, default: []]
65 | feedIntents.append(feedIntent)
66 | feedIntentsByTag[feed.tag] = feedIntents
67 | }
68 |
69 | let sortedTags = feedIntentsByTag.keys.sorted { $0.compare($1, options: .numeric) == .orderedAscending }
70 | let feedIntents = sortedTags.reduce(into: [FeedIntent]()) { $0 += feedIntentsByTag[$1]! }
71 |
72 | sections.append(INObjectSection(title: account.name, items: feedIntents))
73 | }
74 |
75 | return INObjectCollection(sections: sections)
76 | }
77 |
78 | func resolveFeed(for intent: SelectFeedIntent, with completion: @escaping (FeedIntentResolutionResult) -> Void) {}
79 | func resolveFeeds(for intent: SelectFeedsIntent, with completion: @escaping ([FeedIntentResolutionResult]) -> Void) {}
80 |
81 | override func handler(for intent: INIntent) -> Any {
82 | return self
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/CombineTests/UIControlCombineTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIControlCombineTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 03/12/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import EntwineTest
11 | import Nimble
12 | import Quick
13 | import UIKit
14 |
15 | class UIControlCombineTests: QuickSpec {
16 | override func spec() {
17 | var scheduler: TestScheduler!
18 |
19 | beforeEach {
20 | scheduler = TestScheduler(initialClock: 0)
21 | }
22 |
23 | describe("UIControl") {
24 | it("should track control correctly") {
25 | let button = UIButton()
26 |
27 | scheduler.schedule(after: 250) {
28 | button.sendActions(for: .touchUpInside)
29 | }
30 |
31 | scheduler.schedule(after: 260) {
32 | button.sendActions(for: .touchDown)
33 | }
34 |
35 | let sut = button.publisher(for: .touchUpInside)
36 | let results = scheduler.start { sut }
37 |
38 | let expected: TestSequence = [
39 | (200, .subscription),
40 | (250, .input(button))
41 | ]
42 |
43 | expect(results.recordedOutput).to(equal(expected))
44 | }
45 | }
46 |
47 | describe("UIGestureRecognizer") {
48 | it("should track recognizer correctly") {
49 | let recognizer = UIGestureRecognizer()
50 |
51 | let sut = recognizer.publisher()
52 | let results = scheduler.start { sut }
53 |
54 | let expected: TestSequence = [
55 | (200, .subscription)
56 | ]
57 |
58 | expect(results.recordedOutput).to(equal(expected))
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/EntwineAdditions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EntwineAdditions.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 17/08/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | import Entwine
13 | import EntwineTest
14 |
15 | extension Signal {
16 | var value: Input? {
17 | guard case .input(let v) = self else { return nil }
18 | return v
19 | }
20 |
21 | var completion: Subscribers.Completion? {
22 | guard case .completion(let c) = self else { return nil }
23 | return c
24 | }
25 |
26 | var completionError: Failure? {
27 | guard case .completion(let c) = self else { return nil }
28 | switch c {
29 | case .finished:
30 | return nil
31 | case .failure(let e):
32 | return e
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.2.3
19 | CFBundleVersion
20 | 33
21 |
22 |
23 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/AccountControllerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountControllerTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 27/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Nimble
11 | import Quick
12 |
13 | class AccountControllerTests: QuickSpec {
14 | override func spec() {
15 | beforeEach {}
16 |
17 | describe("accountCredentials") {
18 | it("equality should be true for equal objects") {
19 | let a = AccountCredentials(url: "url", apiKey: "apiKey")
20 | let b = AccountCredentials(url: "url", apiKey: "apiKey")
21 | expect(a == b).to(equal(true))
22 | }
23 |
24 | it("equality should be false for non-equal objects") {
25 | let a = AccountCredentials(url: "url", apiKey: "apiKey")
26 | let b = AccountCredentials(url: "url", apiKey: "notApiKey")
27 | expect(a == b).to(equal(false))
28 | }
29 | }
30 |
31 | describe("accountController") {
32 | it("equality should be true for equal objects") {
33 | let credentials = AccountCredentials(url: "url", apiKey: "apiKey")
34 | let a = AccountController(uuid: "uuid", credentials: credentials)
35 | let b = AccountController(uuid: "uuid", credentials: credentials)
36 | expect(a == b).to(equal(true))
37 | }
38 |
39 | it("equality should be false for non-equal objects") {
40 | let credentials = AccountCredentials(url: "url", apiKey: "apiKey")
41 | let a = AccountController(uuid: "uuid1", credentials: credentials)
42 | let b = AccountController(uuid: "uuid2", credentials: credentials)
43 | expect(a == b).to(equal(false))
44 | }
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/ChartDateValueFormatterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartDateValueFormatterTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 23/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Charts
10 | @testable import EmonCMSiOS
11 | import Foundation
12 | import Nimble
13 | import Quick
14 |
15 | class ChartDateValueFormatterTests: QuickSpec {
16 | override func spec() {
17 | beforeEach {}
18 |
19 | describe("chartDateValueFormatter") {
20 | it("should format properly for auto type") {
21 | let formatter = ChartDateValueFormatter(.auto(Locale(identifier: "en_US_POSIX")))
22 | formatter.timeZone = TimeZone(secondsFromGMT: 0)!
23 | let axis = AxisBase()
24 |
25 | axis.axisRange = 1000
26 | expect(formatter.stringForValue(0, axis: axis)).to(equal("12:00 AM"))
27 |
28 | axis.axisRange = 1000000
29 | expect(formatter.stringForValue(0, axis: axis)).to(equal("1/1"))
30 |
31 | axis.axisRange = 1000000000
32 | expect(formatter.stringForValue(0, axis: axis)).to(equal("1/1/70"))
33 | }
34 |
35 | it("should format properly for fixed format") {
36 | let formatter = ChartDateValueFormatter(.format("dd/MM/yyyy HH:mm:ss"))
37 | formatter.timeZone = TimeZone(secondsFromGMT: 0)!
38 | expect(formatter.stringForValue(0, axis: nil)).to(equal("01/01/1970 00:00:00"))
39 | }
40 |
41 | it("should format properly with a given formatter") {
42 | let dateFormatter = DateFormatter()
43 | dateFormatter.locale = Locale(identifier: "en_US_POSIX")
44 | dateFormatter.dateFormat = "dd/MM/yyyy HH:mm:ss"
45 |
46 | let formatter = ChartDateValueFormatter(.formatter(dateFormatter))
47 | formatter.timeZone = TimeZone(secondsFromGMT: 0)!
48 | expect(formatter.stringForValue(0, axis: nil)).to(equal("01/01/1970 00:00:00"))
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/ChartHelpersTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartHelpersTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 04/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Charts
10 | @testable import EmonCMSiOS
11 | import Foundation
12 | import Nimble
13 | import Quick
14 |
15 | class ChartHelpersTests: QuickSpec {
16 | override func spec() {
17 | beforeEach {}
18 |
19 | describe("chartHelpers") {
20 | it("should process full kwh data correctly") {
21 | let startDate = Date(timeIntervalSince1970: 946684800)
22 | let interval: TimeInterval = 86400
23 | let dataPoints = [
24 | DataPoint(time: startDate + (interval * 0), value: 0),
25 | DataPoint(time: startDate + (interval * 1), value: 10),
26 | DataPoint(time: startDate + (interval * 2), value: 15),
27 | DataPoint(time: startDate + (interval * 3), value: 17),
28 | DataPoint(time: startDate + (interval * 4), value: 20),
29 | DataPoint(time: startDate + (interval * 5), value: 30)
30 | ]
31 |
32 | let processed = ChartHelpers.processKWHData(dataPoints, padTo: 5, interval: interval)
33 |
34 | let expected = [
35 | DataPoint(time: startDate + (interval * 1), value: 10),
36 | DataPoint(time: startDate + (interval * 2), value: 5),
37 | DataPoint(time: startDate + (interval * 3), value: 2),
38 | DataPoint(time: startDate + (interval * 4), value: 3),
39 | DataPoint(time: startDate + (interval * 5), value: 10)
40 | ]
41 | expect(processed).to(equal(expected))
42 | }
43 |
44 | it("should process padded kwh data correctly") {
45 | let startDate = Date(timeIntervalSince1970: 946684800)
46 | let interval: TimeInterval = 86400
47 | let dataPoints = [
48 | DataPoint(time: startDate + (interval * 3), value: 17),
49 | DataPoint(time: startDate + (interval * 4), value: 20),
50 | DataPoint(time: startDate + (interval * 5), value: 30)
51 | ]
52 |
53 | let processed = ChartHelpers.processKWHData(dataPoints, padTo: 5, interval: interval)
54 |
55 | let expected = [
56 | DataPoint(time: startDate + (interval * 1), value: 0),
57 | DataPoint(time: startDate + (interval * 2), value: 0),
58 | DataPoint(time: startDate + (interval * 3), value: 17),
59 | DataPoint(time: startDate + (interval * 4), value: 3),
60 | DataPoint(time: startDate + (interval * 5), value: 10)
61 | ]
62 | expect(processed).to(equal(expected))
63 | }
64 |
65 | it("should process single kwh data correctly") {
66 | let startDate = Date(timeIntervalSince1970: 946684800)
67 | let interval: TimeInterval = 86400
68 | let dataPoints = [
69 | DataPoint(time: startDate + (interval * 5), value: 30)
70 | ]
71 |
72 | let processed = ChartHelpers.processKWHData(dataPoints, padTo: 5, interval: interval)
73 |
74 | let expected = [
75 | DataPoint(time: startDate + (interval * 1), value: 0),
76 | DataPoint(time: startDate + (interval * 2), value: 0),
77 | DataPoint(time: startDate + (interval * 3), value: 0),
78 | DataPoint(time: startDate + (interval * 4), value: 0),
79 | DataPoint(time: startDate + (interval * 5), value: 30)
80 | ]
81 | expect(processed).to(equal(expected))
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/DayRelativeToTodayValueFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DayRelativeToTodayValueFormatterTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 23/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Charts
10 | @testable import EmonCMSiOS
11 | import Foundation
12 | import Nimble
13 | import Quick
14 |
15 | class DayRelativeToTodayValueFormatterTests: QuickSpec {
16 | override func spec() {
17 | beforeEach {}
18 |
19 | describe("dayRelativeToTodayValueFormatter") {
20 | it("should format properly for 7 day week") {
21 | let formatter = DayRelativeToTodayValueFormatter(relativeTo: Date(timeIntervalSince1970: 86400 * 7))
22 | let axis = AxisBase()
23 | axis.axisRange = 7
24 |
25 | let values = (-7 ... 0)
26 | .map { formatter.stringForValue(Double($0), axis: axis) }
27 |
28 | expect(values).to(equal(["T", "F", "S", "S", "M", "T", "W", "T"]))
29 | }
30 |
31 | it("should format properly for month") {
32 | let formatter = DayRelativeToTodayValueFormatter(relativeTo: Date(timeIntervalSince1970: 86400 * 31))
33 | let axis = AxisBase()
34 | axis.axisRange = 31
35 |
36 | let values = (-31 ... 0)
37 | .map { formatter.stringForValue(Double($0), axis: axis) }
38 |
39 | expect(values)
40 | .to(equal([
41 | "01",
42 | "02",
43 | "03",
44 | "04",
45 | "05",
46 | "06",
47 | "07",
48 | "08",
49 | "09",
50 | "10",
51 | "11",
52 | "12",
53 | "13",
54 | "14",
55 | "15",
56 | "16",
57 | "17",
58 | "18",
59 | "19",
60 | "20",
61 | "21",
62 | "22",
63 | "23",
64 | "24",
65 | "25",
66 | "26",
67 | "27",
68 | "28",
69 | "29",
70 | "30",
71 | "31",
72 | "01"
73 | ]))
74 | }
75 |
76 | it("should format properly for year") {
77 | let formatter = DayRelativeToTodayValueFormatter(relativeTo: Date(timeIntervalSince1970: 86400 * 365))
78 | let axis = AxisBase()
79 | axis.axisRange = 365
80 |
81 | let values = [-365, -61, -15, 0]
82 | .map { formatter.stringForValue(Double($0), axis: axis) }
83 |
84 | expect(values).to(equal(["Jan 01", "Nov 01", "Dec 17", "Jan 01"]))
85 | }
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/EmoncmsDoubleTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmoncmsDoubleTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 16/02/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Foundation
11 | import Nimble
12 | import Quick
13 |
14 | class EmoncmsDoubleTests: QuickSpec {
15 | override func spec() {
16 | describe("double conversion") {
17 | it("should convert float correctly") {
18 | let float: Float = 1.0
19 | let double = Double.from(float)
20 | expect(double).toNot(beNil())
21 | expect(double).to(equal(1.0))
22 | }
23 |
24 | it("should convert int correctly") {
25 | let int: Int = 1
26 | let double = Double.from(int)
27 | expect(double).toNot(beNil())
28 | expect(double).to(equal(1.0))
29 | }
30 |
31 | it("should convert string correctly") {
32 | let string: String = "1"
33 | let double = Double.from(string)
34 | expect(double).toNot(beNil())
35 | expect(double).to(equal(1.0))
36 | }
37 |
38 | it("should convert invalid string correctly") {
39 | let string: String = "1not1"
40 | let double = Double.from(string)
41 | expect(double).to(beNil())
42 | }
43 |
44 | it("should convert other type correctly") {
45 | let array: [Int] = []
46 | let double = Double.from(array)
47 | expect(double).to(beNil())
48 | }
49 | }
50 |
51 | describe("pretty format") {
52 | it("should auto-format with two decimals correctly") {
53 | let number = 5.123
54 | let string = number.prettyFormat()
55 | expect(string).to(equal("5.12"))
56 | }
57 |
58 | it("should auto-format with one decimals correctly") {
59 | let number = 12.123
60 | let string = number.prettyFormat()
61 | expect(string).to(equal("12.1"))
62 | }
63 |
64 | it("should auto-format with no decimals correctly") {
65 | let number = 123.123
66 | let string = number.prettyFormat()
67 | expect(string).to(equal("123"))
68 | }
69 |
70 | it("should format when decimals specified correctly") {
71 | let number = 123.1234567
72 | expect(number.prettyFormat(decimals: 0)).to(equal("123"))
73 | expect(number.prettyFormat(decimals: 1)).to(equal("123.1"))
74 | expect(number.prettyFormat(decimals: 2)).to(equal("123.12"))
75 | expect(number.prettyFormat(decimals: 3)).to(equal("123.123"))
76 | expect(number.prettyFormat(decimals: 4)).to(equal("123.1235"))
77 | expect(number.prettyFormat(decimals: 5)).to(equal("123.12346"))
78 | }
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/FeedTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 27/10/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Foundation
11 | import Nimble
12 | import Quick
13 |
14 | class FeedTests: QuickSpec {
15 | override func spec() {
16 | beforeEach {}
17 |
18 | describe("widgetChartPoints") {
19 | it("should save and restore correctly") {
20 | let dataPoints = [
21 | DataPoint(time: Date(timeIntervalSince1970: 0), value: 1.0),
22 | DataPoint(time: Date(timeIntervalSince1970: 1), value: 2.0),
23 | DataPoint(time: Date(timeIntervalSince1970: 2), value: 3.0),
24 | DataPoint(time: Date(timeIntervalSince1970: 3), value: 4.0),
25 | DataPoint(time: Date(timeIntervalSince1970: 4), value: 5.0)
26 | ]
27 |
28 | let feed = Feed()
29 | feed.widgetChartPoints = dataPoints
30 |
31 | expect(feed.widgetChartPoints).to(equal(dataPoints))
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/KeychainControllerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KeychainControllerTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 20/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Nimble
11 | import Quick
12 |
13 | class KeychainControllerTests: QuickSpec {
14 | override func spec() {
15 | var controller: KeychainController!
16 |
17 | beforeEach {
18 | controller = KeychainController()
19 | }
20 |
21 | describe("keychainController") {
22 | it("should save account") {
23 | let accountId = "test_account"
24 | let apiKey = "test"
25 | do {
26 | try controller.logout(ofAccountWithId: accountId)
27 | try controller.saveAccount(forId: accountId, apiKey: apiKey)
28 | let fetchedKey = try controller.apiKey(forAccountWithId: accountId)
29 | expect(fetchedKey).to(equal(apiKey))
30 | } catch {
31 | fail("Shouldn't throw")
32 | }
33 | }
34 |
35 | it("should logout of account") {
36 | let accountId = "test_account"
37 | let apiKey = "test"
38 |
39 | do {
40 | try controller.logout(ofAccountWithId: accountId)
41 | try controller.saveAccount(forId: accountId, apiKey: apiKey)
42 | try controller.logout(ofAccountWithId: accountId)
43 | } catch {
44 | fail("Shouldn't throw")
45 | }
46 |
47 | var threw = false
48 | do {
49 | _ = try controller.apiKey(forAccountWithId: accountId)
50 | } catch {
51 | threw = true
52 | }
53 | expect(threw).to(equal(true))
54 | }
55 |
56 | it("should update account if already exists") {
57 | let accountId = "test_account"
58 | let apiKey1 = "test1"
59 | let apiKey2 = "test2"
60 |
61 | do {
62 | try controller.logout(ofAccountWithId: accountId)
63 | try controller.saveAccount(forId: accountId, apiKey: apiKey1)
64 | try controller.saveAccount(forId: accountId, apiKey: apiKey2)
65 | let fetchedKey = try controller.apiKey(forAccountWithId: accountId)
66 | expect(fetchedKey).to(equal(apiKey2))
67 | } catch {
68 | fail("Shouldn't throw")
69 | }
70 | }
71 |
72 | it("should throw when account doesn't exist") {
73 | let accountId = "no_exist_account"
74 | var threw = false
75 | do {
76 | _ = try controller.apiKey(forAccountWithId: accountId)
77 | } catch {
78 | threw = true
79 | }
80 | expect(threw).to(equal(true))
81 | }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/LogControllerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogControllerTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 23/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Foundation
11 | import Nimble
12 | import Quick
13 |
14 | class LogControllerTests: QuickSpec {
15 | override func spec() {
16 | beforeEach {}
17 |
18 | describe("logController") {
19 | it("should return log files") {
20 | let controller = LogController.shared
21 |
22 | let thingToLog = UUID().uuidString
23 | AppLog.info(thingToLog)
24 |
25 | waitUntil { done in
26 | controller.flushFile {
27 | done()
28 | }
29 | }
30 |
31 | let logFiles = controller.logFiles
32 | expect(logFiles.count).to(beGreaterThan(0))
33 |
34 | if
35 | let logFile = logFiles.first,
36 | let data = try? Data(contentsOf: logFile),
37 | let string = String(data: data, encoding: .utf8)
38 | {
39 | expect(string.contains(thingToLog)).to(equal(true))
40 | } else {
41 | fail("Failed to get log file data")
42 | }
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/LogicTests/SemanticVersionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SemanticVersionTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 29/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import Nimble
11 | import Quick
12 |
13 | class SemanticVersionTests: QuickSpec {
14 | override func spec() {
15 | beforeEach {}
16 |
17 | describe("semanticVersion") {
18 | it("equality should be true for equal objects") {
19 | let a = SemanticVersion(major: 1, minor: 2, patch: 3)
20 | let b = SemanticVersion(major: 1, minor: 2, patch: 3)
21 | expect(a == b).to(equal(true))
22 | }
23 |
24 | it("equality should be false for non-equal objects") {
25 | let a = SemanticVersion(major: 1, minor: 2, patch: 3)
26 | let b = SemanticVersion(major: 2, minor: 3, patch: 4)
27 | expect(a == b).to(equal(false))
28 | }
29 |
30 | it("different major versions should order correctly") {
31 | let a = SemanticVersion(major: 1, minor: 0, patch: 0)
32 | let b = SemanticVersion(major: 2, minor: 0, patch: 0)
33 | expect(a < b).to(equal(true))
34 | expect(a > b).to(equal(false))
35 | }
36 |
37 | it("different minor versions should order correctly") {
38 | let a = SemanticVersion(major: 0, minor: 1, patch: 0)
39 | let b = SemanticVersion(major: 0, minor: 2, patch: 0)
40 | expect(a < b).to(equal(true))
41 | expect(a > b).to(equal(false))
42 | }
43 |
44 | it("different patch versions should order correctly") {
45 | let a = SemanticVersion(major: 0, minor: 0, patch: 1)
46 | let b = SemanticVersion(major: 0, minor: 0, patch: 2)
47 | expect(a < b).to(equal(true))
48 | expect(a > b).to(equal(false))
49 | }
50 |
51 | it("convert to string correctly") {
52 | let a = SemanticVersion(major: 1, minor: 2, patch: 3)
53 | let string = a.string
54 | expect(string).to(equal("1.2.3"))
55 | }
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/account_v0.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/account_v0.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/account_v1.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/account_v1.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/account_v2.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/account_v2.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/account_v3.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/account_v3.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/main_v1.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/main_v1.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/main_v2.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/main_v2.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/Realms/main_v3.realm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/Realms/main_v3.realm
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/AppBoxViewsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppBoxViewsTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 23/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | // @testable import EmonCMSiOS
10 | // import SnapshotTesting
11 | // import XCTest
12 | //
13 | // class AppBoxViewsTests: XCTestCase {
14 | // override func setUp() {
15 | // super.setUp()
16 | // isRecording = false
17 | // }
18 | //
19 | // func testArrowViewUp() {
20 | // let view = AppBoxesArrowView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
21 | // view.backgroundColor = .white
22 | // view.value = 100
23 | // view.unit = "kWh"
24 | // view.direction = .up
25 | // assertSnapshot(matching: view, as: .image)
26 | // }
27 | //
28 | // func testArrowViewDown() {
29 | // let view = AppBoxesArrowView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
30 | // view.backgroundColor = .white
31 | // view.value = 100
32 | // view.unit = "kWh"
33 | // view.direction = .down
34 | // assertSnapshot(matching: view, as: .image)
35 | // }
36 | //
37 | // func testArrowViewLeft() {
38 | // let view = AppBoxesArrowView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
39 | // view.backgroundColor = .white
40 | // view.value = 100
41 | // view.unit = "kWh"
42 | // view.direction = .left
43 | // assertSnapshot(matching: view, as: .image)
44 | // }
45 | //
46 | // func testArrowViewRight() {
47 | // let view = AppBoxesArrowView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
48 | // view.backgroundColor = .white
49 | // view.value = 100
50 | // view.unit = "kWh"
51 | // view.direction = .right
52 | // assertSnapshot(matching: view, as: .image)
53 | // }
54 | //
55 | // func testArrowViewArrowColor() {
56 | // let view = AppBoxesArrowView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
57 | // view.backgroundColor = .white
58 | // view.value = 100
59 | // view.unit = "kWh"
60 | // view.direction = .up
61 | // view.arrowColor = .red
62 | // assertSnapshot(matching: view, as: .image)
63 | // }
64 | //
65 | // func testFeedView() {
66 | // let view = AppBoxesFeedView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
67 | // view.backgroundColor = .red
68 | // view.name = "Feed"
69 | // view.unit = "kWh"
70 | // view.value = 100
71 | // assertSnapshot(matching: view, as: .image)
72 | // }
73 | // }
74 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/FeedCellTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedCellTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 27/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | // @testable import EmonCMSiOS
10 | // import Nimble
11 | // import Quick
12 | // import Realm
13 | // import RealmSwift
14 | // import SnapshotTesting
15 | // import UIKit
16 | //
17 | // class FeedCellTests: EmonCMSTestCase {
18 | // private var cellSetup: (FeedCell) -> Void = { _ in }
19 | //
20 | // override func setUp() {
21 | // super.setUp()
22 | // isRecording = false
23 | // }
24 | //
25 | // override func spec() {
26 | // var tableView: UITableView!
27 | // var traits: UITraitCollection!
28 | //
29 | // beforeEach {
30 | // tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 375, height: 700), style: .plain)
31 | // tableView.dataSource = self
32 | // tableView.delegate = self
33 | // tableView.register(UINib(nibName: "FeedCell", bundle: nil), forCellReuseIdentifier: "FeedCell")
34 | //
35 | // traits =
36 | // UITraitCollection(traitsFrom: [
37 | // UITraitCollection(displayScale: 2.0),
38 | // UITraitCollection(userInterfaceStyle: .light)
39 | // ])
40 | //
41 | // self.cellSetup = { _ in }
42 | // }
43 | //
44 | // describe("feedCell") {
45 | // it("Should display normally") {
46 | // self.cellSetup = { cell in
47 | // cell.titleLabel.text = "Feed Name"
48 | // cell.valueLabel.text = "123"
49 | // cell.timeLabel.text = "10 seconds ago"
50 | // cell.activityCircle.backgroundColor = EmonCMSColors.ActivityIndicator.Green
51 | // cell.chartViewModel.send(nil)
52 | // }
53 | // tableView.layoutIfNeeded()
54 | // RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))
55 | // assertSnapshot(matching: tableView, as: .image(traits: traits))
56 | // }
57 | //
58 | // it("Should display expanded") {
59 | // self.cellSetup = { cell in
60 | // cell.titleLabel.text = "Feed Name"
61 | // cell.valueLabel.text = "123"
62 | // cell.timeLabel.text = "10 seconds ago"
63 | // cell.activityCircle.backgroundColor = EmonCMSColors.ActivityIndicator.Green
64 | // cell.chartViewModel.send(self.makeFeedChartViewModel())
65 | // }
66 | // tableView.layoutIfNeeded()
67 | // RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))
68 | // assertSnapshot(matching: tableView, as: .image(traits: traits))
69 | // }
70 | // }
71 | // }
72 | //
73 | // private func makeFeedChartViewModel() -> FeedChartViewModel {
74 | // let realmController = RealmController(dataDirectory: self.dataDirectory)
75 | // let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
76 | // let accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
77 | // let realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
78 | // try! realm.write {
79 | // realm.deleteAll()
80 | // }
81 | //
82 | // let requestProvider = MockHTTPRequestProvider()
83 | // let api = EmonCMSAPI(requestProvider: requestProvider)
84 | // return FeedChartViewModel(account: accountController, api: api, feedId: "1")
85 | // }
86 | // }
87 | //
88 | // extension FeedCellTests: UITableViewDataSource, UITableViewDelegate {
89 | // func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
90 | // return 1
91 | // }
92 | //
93 | // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
94 | // let cell = tableView.dequeueReusableCell(withIdentifier: "FeedCell", for: indexPath) as! FeedCell
95 | // self.cellSetup(cell)
96 | // return cell
97 | // }
98 | // }
99 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/InputCellTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InputCellTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 27/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | // @testable import EmonCMSiOS
10 | // import Nimble
11 | // import Quick
12 | // import SnapshotTesting
13 | // import UIKit
14 | //
15 | // class InputCellTests: EmonCMSTestCase {
16 | // private var cellSetup: (InputCell) -> Void = { _ in }
17 | //
18 | // override func setUp() {
19 | // super.setUp()
20 | // isRecording = false
21 | // }
22 | //
23 | // override func spec() {
24 | // var tableView: UITableView!
25 | // var traits: UITraitCollection!
26 | //
27 | // beforeEach {
28 | // tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 375, height: 700), style: .plain)
29 | // tableView.dataSource = self
30 | // tableView.delegate = self
31 | // tableView.register(UINib(nibName: "InputCell", bundle: nil), forCellReuseIdentifier: "InputCell")
32 | //
33 | // traits =
34 | // UITraitCollection(traitsFrom: [
35 | // UITraitCollection(displayScale: 2.0),
36 | // UITraitCollection(userInterfaceStyle: .light)
37 | // ])
38 | //
39 | // self.cellSetup = { _ in }
40 | // }
41 | //
42 | // describe("inputCell") {
43 | // it("Should display normally") {
44 | // self.cellSetup = { cell in
45 | // cell.titleLabel.text = "Input Name"
46 | // cell.valueLabel.text = "123"
47 | // cell.timeLabel.text = "10 seconds ago"
48 | // cell.activityCircle.backgroundColor = EmonCMSColors.ActivityIndicator.Green
49 | // }
50 | // tableView.layoutIfNeeded()
51 | // RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))
52 | // assertSnapshot(matching: tableView, as: .image(traits: traits))
53 | // }
54 | // }
55 | // }
56 | // }
57 | //
58 | // extension InputCellTests: UITableViewDataSource, UITableViewDelegate {
59 | // func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
60 | // return 1
61 | // }
62 | //
63 | // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
64 | // let cell = tableView.dequeueReusableCell(withIdentifier: "InputCell", for: indexPath) as! InputCell
65 | // self.cellSetup(cell)
66 | // return cell
67 | // }
68 | // }
69 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewArrowColor.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewArrowColor.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewDown.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewDown.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewLeft.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewLeft.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewRight.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewRight.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewUp.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testArrowViewUp.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testFeedView.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/AppBoxViewsTests/testFeedView.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/FeedCellTests/spec.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/FeedCellTests/spec.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/FeedCellTests/spec.2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/FeedCellTests/spec.2.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/SnapshotTests/__Snapshots__/InputCellTests/spec.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/EmonCMSiOSTests/SnapshotTests/__Snapshots__/InputCellTests/spec.1.png
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/AccountViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountViewModelTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 29/04/2022.
6 | // Copyright © 2022 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Nimble
13 | import Quick
14 | import Realm
15 | import RealmSwift
16 |
17 | class AccountViewModelTests: EmonCMSTestCase {
18 | override func spec() {
19 | var scheduler: TestScheduler!
20 | var realmController: RealmController!
21 | var accountController: AccountController!
22 | var requestProvider: MockHTTPRequestProvider!
23 | var api: EmonCMSAPI!
24 | var viewModel: AccountViewModel!
25 |
26 | beforeEach {
27 | scheduler = TestScheduler(initialClock: 0)
28 |
29 | realmController = RealmController(dataDirectory: self.dataDirectory)
30 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
31 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
32 |
33 | requestProvider = MockHTTPRequestProvider()
34 | api = EmonCMSAPI(requestProvider: requestProvider)
35 | viewModel = AccountViewModel(realmController: realmController, account: accountController, api: api)
36 | }
37 |
38 | describe("version") {
39 | it("should fetch version") {
40 | let sut = viewModel.checkEmoncmsServerVersion()
41 |
42 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
43 | let results = scheduler.start { sut }
44 |
45 | let output = results.recordedOutput
46 | expect(output.count).toEventually(equal(2))
47 | expect(output[1].1.completion).toNot(beNil())
48 | expect(output[1].1.completion!).to(equal(.finished))
49 | }
50 |
51 | it("should fetch version and fail for old version") {
52 | requestProvider.nextResponseOverride = "1.0.0"
53 |
54 | let sut = viewModel.checkEmoncmsServerVersion()
55 |
56 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
57 | let results = scheduler.start { sut }
58 |
59 | let output = results.recordedOutput
60 | expect(output.count).toEventually(equal(2))
61 | expect(output[1].1.completion).toNot(beNil())
62 | expect(output[1].1.completion!)
63 | .to(equal(.failure(.versionNotSupported(SemanticVersion(major: 1, minor: 0, patch: 0)))))
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/AppListViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppListViewModelTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 16/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Nimble
13 | import Quick
14 | import Realm
15 | import RealmSwift
16 |
17 | class AppListViewModelTests: EmonCMSTestCase {
18 | override func spec() {
19 | var scheduler: TestScheduler!
20 | var realmController: RealmController!
21 | var accountController: AccountController!
22 | var realm: Realm!
23 | var requestProvider: MockHTTPRequestProvider!
24 | var api: EmonCMSAPI!
25 | var viewModel: AppListViewModel!
26 |
27 | beforeEach {
28 | scheduler = TestScheduler(initialClock: 0)
29 |
30 | realmController = RealmController(dataDirectory: self.dataDirectory)
31 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
32 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
33 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
34 | try! realm.write {
35 | realm.deleteAll()
36 | }
37 |
38 | requestProvider = MockHTTPRequestProvider()
39 | api = EmonCMSAPI(requestProvider: requestProvider)
40 | viewModel = AppListViewModel(realmController: realmController, account: accountController, api: api)
41 | }
42 |
43 | describe("appHandling") {
44 | it("should list all apps") {
45 | let sut = viewModel.$apps
46 |
47 | let count = 10
48 | try! realm.write {
49 | for i in 0 ..< count {
50 | let app = AppData()
51 | app.name = "App \(i)"
52 | let allAppCategories = AppCategory.allCases
53 | app.appCategory = allAppCategories[i % allAppCategories.count]
54 | realm.add(app)
55 | }
56 | }
57 |
58 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
59 | let results = scheduler.start { sut }
60 |
61 | expect(results.recordedOutput.count).toEventually(equal(3))
62 | let lastEventAppsSignal = results.recordedOutput.suffix(1).first!.1
63 | let lastEventApps = lastEventAppsSignal.value ?? []
64 | expect(lastEventApps.count).to(equal(10))
65 | for (i, app) in lastEventApps.enumerated() {
66 | expect(app.name).to(equal("App \(i)"))
67 | }
68 | }
69 |
70 | it("should delete apps properly") {
71 | var uuid: String = ""
72 | try! realm.write {
73 | let app = AppData()
74 | app.name = "TestApp"
75 | app.appCategory = .myElectric
76 | uuid = app.uuid
77 | realm.add(app)
78 | }
79 |
80 | let appQuery1 = realm.objects(AppData.self)
81 | expect(appQuery1.count).to(equal(1))
82 |
83 | _ = viewModel.deleteApp(withId: uuid)
84 | .sink(receiveCompletion: { completion in
85 | switch completion {
86 | case .finished:
87 | let appQuery2 = realm.objects(AppData.self)
88 | expect(appQuery2.count).to(equal(0))
89 | case .failure:
90 | fail("Failure is not an option")
91 | }
92 | },
93 | receiveValue: { _ in })
94 | }
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/DashboardListViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardListViewModelTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | @testable import EmonCMSiOS
10 | import EntwineTest
11 | import Nimble
12 | import Quick
13 | import Realm
14 | import RealmSwift
15 |
16 | class DashboardListViewModelTests: EmonCMSTestCase {
17 | override func spec() {
18 | var scheduler: TestScheduler!
19 | var realmController: RealmController!
20 | var accountController: AccountController!
21 | var realm: Realm!
22 | var requestProvider: MockHTTPRequestProvider!
23 | var api: EmonCMSAPI!
24 | var viewModel: DashboardListViewModel!
25 |
26 | beforeEach {
27 | scheduler = TestScheduler(initialClock: 0)
28 |
29 | realmController = RealmController(dataDirectory: self.dataDirectory)
30 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
31 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
32 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
33 | try! realm.write {
34 | realm.deleteAll()
35 | }
36 |
37 | requestProvider = MockHTTPRequestProvider()
38 | api = EmonCMSAPI(requestProvider: requestProvider)
39 | viewModel = DashboardListViewModel(realmController: realmController, account: accountController, api: api)
40 | }
41 |
42 | describe("dashboardHandling") {
43 | it("should list all dashboards") {
44 | let sut = viewModel.$dashboards
45 |
46 | let count = 10
47 | try! realm.write {
48 | for i in 0 ..< count {
49 | let dashboard = Dashboard()
50 | dashboard.id = "\(i)"
51 | dashboard.alias = "\(i)"
52 | dashboard.name = "Dashboard \(i)"
53 | dashboard.desc = "Description \(i)"
54 | realm.add(dashboard)
55 | }
56 | }
57 |
58 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
59 | let results = scheduler.start { sut }
60 |
61 | expect(results.recordedOutput.count).toEventually(equal(3))
62 | let lastEventDashboardsSignal = results.recordedOutput.suffix(1).first!.1
63 | let lastEventDashboards = lastEventDashboardsSignal.value ?? []
64 | expect(lastEventDashboards.count).to(equal(10))
65 | for (i, dashboard) in lastEventDashboards.enumerated() {
66 | expect(dashboard.dashboardId).to(equal("\(i)"))
67 | expect(dashboard.name).to(equal("Dashboard \(i)"))
68 | expect(dashboard.desc).to(equal("Description \(i)"))
69 | }
70 | }
71 |
72 | it("should refresh when asked to") {
73 | let subscriber = scheduler.createTestableSubscriber(Bool.self, Never.self)
74 |
75 | viewModel.isRefreshing
76 | .subscribe(subscriber)
77 |
78 | viewModel.active = true
79 |
80 | scheduler.schedule(after: 10) { viewModel.refresh.send(()) }
81 | scheduler.schedule(after: 20) { viewModel.refresh.send(()) }
82 | scheduler.resume()
83 |
84 | expect(subscriber.recordedOutput).toEventually(equal([
85 | (0, .subscription),
86 | (0, .input(true)),
87 | (10, .input(false)),
88 | (10, .input(true)),
89 | (20, .input(false)),
90 | (20, .input(true)),
91 | (20, .input(false))
92 | ]))
93 | }
94 |
95 | it("should generate urls correctly") {
96 | let url = viewModel.urlForDashboard(withId: "1")
97 | expect(url).to(equal(URL(string: "https://test/dashboard/view?id=1&embed=1&apikey=ilikecats")!))
98 | }
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/DashboardUpdateHelperTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardUpdateHelperTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 24/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Nimble
13 | import Quick
14 | import Realm
15 | import RealmSwift
16 |
17 | class DashboardUpdateHelperTests: EmonCMSTestCase {
18 | override func spec() {
19 | var realmController: RealmController!
20 | var accountController: AccountController!
21 | var realm: Realm!
22 | var requestProvider: MockHTTPRequestProvider!
23 | var api: EmonCMSAPI!
24 | var viewModel: DashboardUpdateHelper!
25 | var cancellables: Set = []
26 |
27 | beforeEach {
28 | realmController = RealmController(dataDirectory: self.dataDirectory)
29 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
30 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
31 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
32 | try! realm.write {
33 | realm.deleteAll()
34 | }
35 |
36 | requestProvider = MockHTTPRequestProvider()
37 | api = EmonCMSAPI(requestProvider: requestProvider)
38 | viewModel = DashboardUpdateHelper(realmController: realmController, account: accountController, api: api)
39 | cancellables.removeAll()
40 | }
41 |
42 | describe("dashboardHandling") {
43 | it("should update dashboards") {
44 | waitUntil { done in
45 | let cancellable = viewModel.updateDashboards()
46 | .sink(
47 | receiveCompletion: { completion in
48 | switch completion {
49 | case .finished:
50 | break
51 | case .failure(let error):
52 | fail(error.localizedDescription)
53 | }
54 | done()
55 | },
56 | receiveValue: { _ in })
57 | cancellables.insert(cancellable)
58 | }
59 |
60 | let results = realm.objects(Dashboard.self)
61 | expect(results.count).toEventually(equal(2))
62 | }
63 |
64 | it("should delete missing dashboards") {
65 | let newDashboardId = "differentId"
66 |
67 | try! realm.write {
68 | let dashboard = Dashboard()
69 | dashboard.id = newDashboardId
70 | realm.add(dashboard)
71 | }
72 |
73 | waitUntil { done in
74 | let cancellable = viewModel.updateDashboards()
75 | .sink(
76 | receiveCompletion: { completion in
77 | switch completion {
78 | case .finished:
79 | break
80 | case .failure(let error):
81 | fail(error.localizedDescription)
82 | }
83 | done()
84 | },
85 | receiveValue: { _ in })
86 | cancellables.insert(cancellable)
87 | }
88 |
89 | expect { realm.object(ofType: Dashboard.self, forPrimaryKey: newDashboardId) }
90 | .toEventually(beNil())
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/EmonCMSTestCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSTestCase.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 20/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Quick
11 |
12 | class EmonCMSTestCase: QuickSpec {
13 | var dataDirectory: URL {
14 | return FileManager.default.temporaryDirectory.appendingPathComponent("tests")
15 | }
16 |
17 | override func setUp() {
18 | try? FileManager.default.createDirectory(at: self.dataDirectory, withIntermediateDirectories: true, attributes: nil)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/FeedChartViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedChartViewModelTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 19/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Nimble
13 | import Quick
14 | import Realm
15 | import RealmSwift
16 |
17 | class FeedChartViewModelTests: EmonCMSTestCase {
18 | override func spec() {
19 | var scheduler: TestScheduler!
20 | var realmController: RealmController!
21 | var accountController: AccountController!
22 | var realm: Realm!
23 | var requestProvider: MockHTTPRequestProvider!
24 | var api: EmonCMSAPI!
25 | var viewModel: FeedChartViewModel!
26 |
27 | beforeEach {
28 | scheduler = TestScheduler(initialClock: 0)
29 |
30 | realmController = RealmController(dataDirectory: self.dataDirectory)
31 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
32 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
33 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
34 | try! realm.write {
35 | realm.deleteAll()
36 | }
37 |
38 | requestProvider = MockHTTPRequestProvider()
39 | api = EmonCMSAPI(requestProvider: requestProvider)
40 | viewModel = FeedChartViewModel(account: accountController, api: api, feedId: "1")
41 | }
42 |
43 | describe("dataHandling") {
44 | it("should fetch feed data") {
45 | let subscriber = scheduler.createTestableSubscriber([DataPoint].self, Never.self)
46 | viewModel.$dataPoints
47 | .subscribe(subscriber)
48 |
49 | viewModel.active = true
50 |
51 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
52 | scheduler.resume()
53 |
54 | expect(subscriber.recordedOutput.count).toEventually(equal(3))
55 | }
56 |
57 | it("should refresh when asked to") {
58 | let dataPointsSubscriber = scheduler.createTestableSubscriber([DataPoint].self, Never.self)
59 | viewModel.$dataPoints
60 | .subscribe(dataPointsSubscriber)
61 |
62 | let refreshSubscriber = scheduler.createTestableSubscriber(Bool.self, Never.self)
63 | viewModel.isRefreshing
64 | .subscribe(refreshSubscriber)
65 |
66 | viewModel.active = true
67 |
68 | scheduler.schedule(after: 10) { viewModel.refresh.send(()) }
69 | scheduler.schedule(after: 20) { viewModel.refresh.send(()) }
70 | scheduler.resume()
71 |
72 | expect(dataPointsSubscriber.recordedOutput.count).toEventually(equal(5))
73 | expect(refreshSubscriber.recordedOutput).to(equal([
74 | (0, .subscription),
75 | (0, .input(true)),
76 | (0, .input(false)),
77 | (10, .input(true)),
78 | (10, .input(false)),
79 | (20, .input(true)),
80 | (20, .input(false))
81 | ]))
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/FeedListHelperTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedListHelperTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 19/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Foundation
13 | import Nimble
14 | import Quick
15 | import Realm
16 | import RealmSwift
17 |
18 | class FeedListHelperTests: EmonCMSTestCase {
19 | override func spec() {
20 | var scheduler: TestScheduler!
21 | var realmController: RealmController!
22 | var accountController: AccountController!
23 | var realm: Realm!
24 | var requestProvider: MockHTTPRequestProvider!
25 | var api: EmonCMSAPI!
26 | var viewModel: FeedListHelper!
27 |
28 | beforeEach {
29 | scheduler = TestScheduler(initialClock: 0)
30 |
31 | realmController = RealmController(dataDirectory: self.dataDirectory)
32 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
33 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
34 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
35 | try! realm.write {
36 | realm.deleteAll()
37 | }
38 |
39 | requestProvider = MockHTTPRequestProvider()
40 | api = EmonCMSAPI(requestProvider: requestProvider)
41 | viewModel = FeedListHelper(realmController: realmController, account: accountController, api: api)
42 | }
43 |
44 | describe("feedHandling") {
45 | it("should list all feeds") {
46 | let sut = viewModel.$feeds
47 |
48 | let count = 10
49 | try! realm.write {
50 | for i in 0 ..< count {
51 | let feed = Feed()
52 | feed.id = "\(i)"
53 | feed.name = "Feed \(i)"
54 | feed.tag = "Tag"
55 | realm.add(feed)
56 | }
57 | }
58 |
59 | scheduler.schedule(after: 300) { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) }
60 | let results = scheduler.start { sut }
61 |
62 | expect(results.recordedOutput.count).toEventually(equal(3))
63 | let lastEventFeedsSignal = results.recordedOutput.suffix(1).first!.1
64 | let lastEventFeeds = lastEventFeedsSignal.value ?? []
65 | expect(lastEventFeeds.count).to(equal(10))
66 | for (i, feed) in lastEventFeeds.enumerated() {
67 | expect(feed.feedId).to(equal("\(i)"))
68 | expect(feed.name).to(equal("Feed \(i)"))
69 | }
70 | }
71 |
72 | it("should refresh when asked to") {
73 | let subscriber = scheduler.createTestableSubscriber(Bool.self, Never.self)
74 |
75 | viewModel.isRefreshing
76 | .subscribe(subscriber)
77 |
78 | scheduler.schedule(after: 10) { viewModel.refresh.send(()) }
79 | scheduler.schedule(after: 20) { viewModel.refresh.send(()) }
80 | scheduler.resume()
81 |
82 | expect(subscriber.recordedOutput).toEventually(equal([
83 | (0, .subscription),
84 | (10, .input(true)),
85 | (20, .input(false)),
86 | (20, .input(true)),
87 | (20, .input(false))
88 | ]))
89 | }
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/EmonCMSiOSTests/ViewModelTests/InputUpdateHelperTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InputUpdateHelperTests.swift
3 | // EmonCMSiOSTests
4 | //
5 | // Created by Matt Galloway on 20/01/2019.
6 | // Copyright © 2019 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Combine
10 | @testable import EmonCMSiOS
11 | import EntwineTest
12 | import Nimble
13 | import Quick
14 | import Realm
15 | import RealmSwift
16 |
17 | class InputUpdateHelperTests: EmonCMSTestCase {
18 | override func spec() {
19 | var realmController: RealmController!
20 | var accountController: AccountController!
21 | var realm: Realm!
22 | var requestProvider: MockHTTPRequestProvider!
23 | var api: EmonCMSAPI!
24 | var viewModel: InputUpdateHelper!
25 | var cancellables: Set = []
26 |
27 | beforeEach {
28 | realmController = RealmController(dataDirectory: self.dataDirectory)
29 | let credentials = AccountCredentials(url: "https://test", apiKey: "ilikecats")
30 | accountController = AccountController(uuid: "testaccount-\(type(of: self))", credentials: credentials)
31 | realm = realmController.createAccountRealm(forAccountId: accountController.uuid)
32 | try! realm.write {
33 | realm.deleteAll()
34 | }
35 |
36 | requestProvider = MockHTTPRequestProvider()
37 | api = EmonCMSAPI(requestProvider: requestProvider)
38 | viewModel = InputUpdateHelper(realmController: realmController, account: accountController, api: api)
39 | cancellables.removeAll()
40 | }
41 |
42 | describe("inputHandling") {
43 | it("should update inputs") {
44 | waitUntil { done in
45 | let cancellable = viewModel.updateInputs()
46 | .sink(
47 | receiveCompletion: { completion in
48 | switch completion {
49 | case .finished:
50 | break
51 | case .failure(let error):
52 | fail(error.localizedDescription)
53 | }
54 | done()
55 | },
56 | receiveValue: { _ in })
57 | cancellables.insert(cancellable)
58 | }
59 |
60 | let results = realm.objects(Input.self)
61 | expect(results.count).toEventually(equal(2))
62 | }
63 |
64 | it("should delete missing inputs") {
65 | let newInputId = "differentId"
66 |
67 | try! realm.write {
68 | let input = Input()
69 | input.id = newInputId
70 | realm.add(input)
71 | }
72 |
73 | waitUntil { done in
74 | let cancellable = viewModel.updateInputs()
75 | .sink(
76 | receiveCompletion: { completion in
77 | switch completion {
78 | case .finished:
79 | break
80 | case .failure(let error):
81 | fail(error.localizedDescription)
82 | }
83 | done()
84 | },
85 | receiveValue: { _ in })
86 | cancellables.insert(cancellable)
87 | }
88 |
89 | expect { realm.object(ofType: Input.self, forPrimaryKey: newInputId) }
90 | .toEventually(beNil())
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/EmonCMSiOSToday/EmonCMSiOSToday.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.org.openenergymonitor.emoncms
8 |
9 | keychain-access-groups
10 |
11 | $(AppIdentifierPrefix)org.openenergymonitor.emoncms
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/EmonCMSiOSToday/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | Emoncms
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | XPC!
19 | CFBundleShortVersionString
20 | 1.2.3
21 | CFBundleVersion
22 | 33
23 | NSExtension
24 |
25 | NSExtensionMainStoryboard
26 | MainInterface
27 | NSExtensionPointIdentifier
28 | com.apple.widget-extension
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/EmonCMSiOSToday/TodayViewFeedCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TodayViewFeedCell.swift
3 | // EmonCMSiOS
4 | //
5 | // Created by Matt Galloway on 27/01/2019.
6 | // Copyright © 2016 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | import Charts
12 |
13 | final class TodayViewFeedCell: UITableViewCell {
14 | @IBOutlet var feedNameLabel: UILabel!
15 | @IBOutlet var accountNameLabel: UILabel!
16 | @IBOutlet var feedValueLabel: UILabel!
17 | @IBOutlet var chartView: LineChartView!
18 |
19 | override func awakeFromNib() {
20 | super.awakeFromNib()
21 | self.setupChart()
22 | }
23 |
24 | func updateChart(withData data: [DataPoint]) {
25 | let entries = data.map {
26 | ChartDataEntry(x: $0.time.timeIntervalSince1970, y: $0.value)
27 | }
28 |
29 | let data = self.chartView.lineData ?? LineChartData()
30 | self.chartView.data = data
31 |
32 | if let dataSet = data.dataSet(at: 0) {
33 | dataSet.clear()
34 | for entry in entries {
35 | _ = dataSet.addEntry(entry)
36 | }
37 |
38 | dataSet.notifyDataSetChanged()
39 | data.notifyDataChanged()
40 | } else {
41 | let dataSet = LineChartDataSet(entries: entries)
42 | dataSet.setColor(EmonCMSColors.Chart.Blue)
43 | dataSet.valueTextColor = .black
44 | dataSet.drawFilledEnabled = false
45 | dataSet.drawCirclesEnabled = false
46 | dataSet.drawValuesEnabled = false
47 | dataSet.highlightEnabled = false
48 | dataSet.fillFormatter = DefaultFillFormatter(block: { _, _ in 0 })
49 |
50 | data.append(dataSet)
51 | }
52 |
53 | self.chartView.notifyDataSetChanged()
54 | }
55 |
56 | private func setupChart() {
57 | let lineChart = self.chartView!
58 | lineChart.drawGridBackgroundEnabled = false
59 | lineChart.legend.enabled = false
60 | lineChart.rightAxis.enabled = false
61 | lineChart.noDataText = "Loading data\u{2026}"
62 | lineChart.noDataTextColor = .black
63 | lineChart.isUserInteractionEnabled = false
64 |
65 | let xAxis = lineChart.xAxis
66 | xAxis.drawAxisLineEnabled = false
67 | xAxis.drawGridLinesEnabled = false
68 | xAxis.drawLabelsEnabled = false
69 |
70 | let yAxis = lineChart.leftAxis
71 | yAxis.drawTopYLabelEntryEnabled = false
72 | yAxis.drawZeroLineEnabled = false
73 | yAxis.drawGridLinesEnabled = false
74 | yAxis.drawAxisLineEnabled = false
75 | yAxis.drawLabelsEnabled = false
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/EmonCMSiOSUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.2.3
19 | CFBundleVersion
20 | 33
21 |
22 |
23 |
--------------------------------------------------------------------------------
/EmonCMSiOSUITests/XCUIElement+EmonCMS.swift:
--------------------------------------------------------------------------------
1 | //
2 | // XCUIElement+EmonCMS.swift
3 | // EmonCMSiOSUITests
4 | //
5 | // Created by Matt Galloway on 27/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 |
12 | extension XCUIElement {
13 | func clearAndEnterText(text: String) {
14 | guard let stringValue = self.value as? String else {
15 | XCTFail("Tried to clear and enter text into a non string value")
16 | return
17 | }
18 |
19 | self.tap()
20 | let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
21 | self.typeText(deleteString)
22 | self.typeText(text)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/EmonCMSiOSWidget/EmonCMSiOSWidgetBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmonCMSiOSWidgetBundle.swift
3 | // EmonCMSiOSWidgetExtension
4 | //
5 | // Created by Matt Galloway on 20/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 | import WidgetKit
11 |
12 | @main
13 | struct EmonCMSiOSWidgetBundle: WidgetBundle {
14 | init() {
15 | LogController.shared.initialise()
16 | }
17 |
18 | @WidgetBundleBuilder
19 | var body: some Widget {
20 | FeedListWidget()
21 | SingleFeedWidget()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/EmonCMSiOSWidget/FeedChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeedChartView.swift
3 | // EmonCMSiOSWidgetExtension
4 | //
5 | // Created by Matt Galloway on 20/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 | import WidgetKit
12 |
13 | struct FeedChartView: View {
14 | let data: [DataPoint]
15 |
16 | private var color: Color = .black
17 | private var lineWidth: CGFloat = 1.0
18 |
19 | init(data: [DataPoint]) {
20 | self.data = data
21 | }
22 |
23 | var body: some View {
24 | let lighterColor = self.color.opacity(0.5)
25 |
26 | GeometryReader { metrics in
27 | ZStack {
28 | self.path(forSize: metrics.size, type: .fill)
29 | .fill(LinearGradient(gradient: Gradient(colors: [lighterColor, Color.clear]), startPoint: .top,
30 | endPoint: .bottom))
31 | self.path(forSize: metrics.size, type: .line)
32 | .stroke(self.color, lineWidth: self.lineWidth)
33 | }
34 | }
35 | }
36 |
37 | private enum PathType {
38 | case line
39 | case fill
40 | }
41 |
42 | private func path(forSize size: CGSize, type: PathType) -> Path {
43 | guard
44 | let minTime = self.data.first?.time,
45 | let maxTime = self.data.last?.time
46 | else {
47 | return Path()
48 | }
49 |
50 | return Path { path in
51 | let timeRange = maxTime.timeIntervalSince(minTime)
52 |
53 | let (minValue, maxValue) = self.data
54 | .reduce(into: (Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude)) { result, dataPoint in
55 | if dataPoint.value < result.0 {
56 | result.0 = dataPoint.value
57 | }
58 | if dataPoint.value > result.1 {
59 | result.1 = dataPoint.value
60 | }
61 | }
62 | let valueRange = maxValue - minValue
63 |
64 | let points = self.data.map { dataPoint -> CGPoint in
65 | let x = Double(size.width) * dataPoint.time.timeIntervalSince(minTime) / timeRange
66 | let y = Double(size.height) - (Double(size.height) * (dataPoint.value - minValue) / valueRange)
67 | return CGPoint(x: x, y: y)
68 | }
69 |
70 | switch type {
71 | case .fill:
72 | path.move(to: CGPoint(x: 0, y: size.height))
73 | points.forEach { path.addLine(to: $0) }
74 | path.addLine(to: CGPoint(x: size.width, y: size.height))
75 | case .line:
76 | for (i, point) in points.enumerated() {
77 | if i == 0 {
78 | path.move(to: point)
79 | } else {
80 | path.addLine(to: point)
81 | }
82 | }
83 | }
84 | }
85 | }
86 | }
87 |
88 | extension FeedChartView {
89 | func color(_ color: Color) -> FeedChartView {
90 | var copy = self
91 | copy.color = color
92 | return copy
93 | }
94 |
95 | func lineWidth(_ lineWidth: CGFloat) -> FeedChartView {
96 | var copy = self
97 | copy.lineWidth = lineWidth
98 | return copy
99 | }
100 | }
101 |
102 | struct FeedChartView_Previews: PreviewProvider {
103 | static var previews: some View {
104 | FeedChartView(data: [
105 | DataPoint(time: Date(timeIntervalSince1970: 0), value: 8721),
106 | DataPoint(time: Date(timeIntervalSince1970: 1), value: 1000),
107 | DataPoint(time: Date(timeIntervalSince1970: 2), value: 5678),
108 | DataPoint(time: Date(timeIntervalSince1970: 3), value: 9283),
109 | DataPoint(time: Date(timeIntervalSince1970: 4), value: -1020),
110 | DataPoint(time: Date(timeIntervalSince1970: 5), value: 1234)
111 | ])
112 | .color(Color.blue)
113 | .lineWidth(2)
114 | .padding(12)
115 | .previewContext(WidgetPreviewContext(family: .systemSmall))
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/EmonCMSiOSWidget/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | EmonCMSiOSWidget
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.2.3
21 | CFBundleVersion
22 | 33
23 | NSExtension
24 |
25 | NSExtensionPointIdentifier
26 | com.apple.widgetkit-extension
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/EmonCMSiOSWidget/SingleFeedView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SingleFeedView.swift
3 | // EmonCMSiOSWidgetExtension
4 | //
5 | // Created by Matt Galloway on 20/09/2020.
6 | // Copyright © 2020 Matt Galloway. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 | import WidgetKit
12 |
13 | struct SingleFeedView: View {
14 | let item: FeedWidgetItemResult
15 |
16 | public var body: some View {
17 | switch self.item {
18 | case .success(let item):
19 | successBody(item: item)
20 | case .failure(let error):
21 | failureBody(error: error)
22 | }
23 | }
24 |
25 | private func successBody(item: FeedWidgetItem) -> some View {
26 | GeometryReader { _ in
27 | VStack(spacing: 0) {
28 | // Feed name & account name
29 | HStack(spacing: 0) {
30 | VStack(alignment: .leading) {
31 | Text(item.feedName)
32 | .font(.footnote)
33 | .fontWeight(.semibold)
34 | .minimumScaleFactor(0.5)
35 | Text(item.accountName)
36 | .font(.caption)
37 | .fontWeight(.light)
38 | .minimumScaleFactor(0.5)
39 | .foregroundColor(Color.gray)
40 | }
41 | .padding(.leading, 16)
42 | .padding(.trailing, 16)
43 | .padding(.top, 14)
44 |
45 | Spacer()
46 | }
47 |
48 | // Chart
49 | FeedChartView(data: item.feedChartData)
50 | .color(Color(EmonCMSColors.Chart.Blue))
51 | .lineWidth(2)
52 | .padding(.vertical, 8)
53 | .padding(.horizontal, 0)
54 |
55 | // Feed value
56 | HStack(spacing: 0) {
57 | Spacer()
58 | Text(item.feedChartData.last?.value.prettyFormat() ?? "---")
59 | .font(.system(size: 40))
60 | .fontWeight(.regular)
61 | .minimumScaleFactor(0.5)
62 | .padding(.leading, 16)
63 | .padding(.trailing, 16)
64 | .padding(.bottom, 6)
65 | }
66 | }
67 | }
68 | .widgetURL(URL(string: "emoncms://feed?accountId=\(item.accountId)&feedId=\(item.feedId)")!)
69 | }
70 |
71 | private func failureBody(error: FeedWidgetItemError) -> some View {
72 | return VStack {
73 | Text(error.displayTitle)
74 | .font(.footnote)
75 | .fontWeight(.bold)
76 | .lineLimit(1)
77 | Text(error.displayDescription)
78 | .font(.footnote)
79 | .fontWeight(.regular)
80 | .foregroundColor(Color.gray)
81 | .lineLimit(1)
82 | }
83 | }
84 | }
85 |
86 | struct SingleFeedView_Previews: PreviewProvider {
87 | static var previews: some View {
88 | let item = FeedWidgetItem.makePlaceholder()
89 |
90 | SingleFeedView(item: FeedWidgetItemResult.success(item))
91 | .previewContext(WidgetPreviewContext(family: .systemSmall))
92 |
93 | SingleFeedView(item: FeedWidgetItemResult.failure(.noFeedInfo))
94 | .previewContext(WidgetPreviewContext(family: .systemSmall))
95 |
96 | SingleFeedView(item: FeedWidgetItemResult.failure(.fetchFailed(.invalidFeed)))
97 | .previewContext(WidgetPreviewContext(family: .systemSmall))
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/EmonCMSiOSWidgetExtension.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.org.openenergymonitor.emoncms
8 |
9 | keychain-access-groups
10 |
11 | $(AppIdentifierPrefix)org.openenergymonitor.emoncms
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Matt Galloway
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | update:
2 | ./scripts/generate_licenses
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Emoncms for iOS
2 |
3 | [](https://travis-ci.org/emoncms/emoncms-ios) [](https://codecov.io/gh/emoncms/emoncms-ios)
4 |
5 |
6 |
7 | Emoncms for iOS is an app that allows you to connect to an [Emoncms](https://www.emoncms.org/) instance. This can either be through the official [emoncms.org](https://www.emoncms.org/), through a custom server that you run yourself, or direct to an [emonPi](https://openenergymonitor.com/emonpi-3/).
8 |
9 | ## Screenshots
10 |
11 | ### Main screens
12 |
13 |     
14 |
15 | ### Apps
16 |
17 |     
18 |
19 | ### Widgets
20 |
21 |  
22 |
23 | ### Today Widget
24 |
25 | 
26 |
27 | ## Todo list
28 |
29 | - [x] Basic app structure
30 | - [x] Login
31 | - [x] Manual URL + API key
32 | - [x] Scan QR code
33 | - [x] Data caching
34 | - [ ] Apps
35 | - [x] My Electric app
36 | - [x] My Solar PV app
37 | - [x] My Solar PV + Divert app
38 | - [x] Multi-account
39 | - [ ] Extensions
40 | - [x] Universal app (for iPad)
41 | - [ ] watchOS app
42 | - [x] Today extension
43 | - [ ] tvOS app
44 |
45 |
--------------------------------------------------------------------------------
/WatchComplicationIcons.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/WatchComplicationIcons.sketch
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | range: "50...90"
3 | ignore:
4 | - "EmonCMSiOS/FakeServer"
5 | - "Pods"
6 | - "EmonCMSiOSTests"
7 | - "EmonCMSiOSUITests"
8 |
--------------------------------------------------------------------------------
/images/app1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/app1.png
--------------------------------------------------------------------------------
/images/app2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/app2.png
--------------------------------------------------------------------------------
/images/app3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/app3.png
--------------------------------------------------------------------------------
/images/app4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/app4.png
--------------------------------------------------------------------------------
/images/app5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/app5.png
--------------------------------------------------------------------------------
/images/screen1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/screen1.png
--------------------------------------------------------------------------------
/images/screen2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/screen2.png
--------------------------------------------------------------------------------
/images/screen3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/screen3.png
--------------------------------------------------------------------------------
/images/screen4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/screen4.png
--------------------------------------------------------------------------------
/images/screen5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/screen5.png
--------------------------------------------------------------------------------
/images/today1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/today1.png
--------------------------------------------------------------------------------
/images/widget1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/widget1.png
--------------------------------------------------------------------------------
/images/widget2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emoncms/emoncms-ios/9c25a81b688eac13089188df11877b2ceed13434/images/widget2.png
--------------------------------------------------------------------------------
/scripts/generate_licenses:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Install LicensePlist first:
4 | # brew install mono0926/license-plist/license-plist
5 |
6 | license-plist \
7 | --package-path EmonCMSiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved \
8 | --output-path EmonCMSiOS/Settings.bundle \
9 | --prefix Licenses
10 |
--------------------------------------------------------------------------------
/scripts/travis-ci.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | XCPRETTY="xcpretty -f `xcpretty-travis-formatter`"
6 |
7 | # Ensure we don't have hardware keyboard - it interferes with tests
8 | defaults write com.apple.iphonesimulator ConnectHardwareKeyboard 0
9 |
10 | xcodebuild \
11 | -project EmonCMSiOS.xcodeproj \
12 | -scheme EmonCMSiOS \
13 | -sdk ${TEST_SDK} \
14 | -destination "platform=iOS Simulator,OS=${OS},name=${NAME}" \
15 | CODE_SIGN_IDENTITY="" \
16 | CODE_SIGNING_REQUIRED=NO \
17 | clean build test \
18 | | ${XCPRETTY} && exit ${PIPESTATUS[0]}
19 |
--------------------------------------------------------------------------------