├── .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 | [![Build Status](https://travis-ci.org/emoncms/emoncms-ios.svg?branch=master)](https://travis-ci.org/emoncms/emoncms-ios) [![codecov](https://codecov.io/gh/emoncms/emoncms-ios/branch/master/graph/badge.svg)](https://codecov.io/gh/emoncms/emoncms-ios) 4 | 5 | Download on the App Store 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 | ![](images/screen1.png) ![](images/screen2.png) ![](images/screen3.png) ![](images/screen4.png) ![](images/screen5.png) 14 | 15 | ### Apps 16 | 17 | ![](images/app1.png) ![](images/app2.png) ![](images/app3.png) ![](images/app4.png) ![](images/app5.png) 18 | 19 | ### Widgets 20 | 21 | ![](images/widget1.png) ![](images/widget2.png) 22 | 23 | ### Today Widget 24 | 25 | ![](images/today1.png) 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 | --------------------------------------------------------------------------------