├── .gitignore ├── PiStatsMobile ├── Config │ ├── Main.xcconfig │ └── OpenSource.xcconfig ├── IntentHandler │ ├── Info.plist │ ├── IntentHandler.entitlements │ └── IntentHandler.swift ├── PiStatsKit │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ └── PiStatsKit │ │ ├── Constants.swift │ │ ├── Core │ │ ├── DisableDurationManager.swift │ │ ├── Logger.swift │ │ ├── Pihole.swift │ │ ├── PiholeDataProvider.swift │ │ └── UserData │ │ │ ├── APIToken.swift │ │ │ ├── KeyChainPasswordItem.swift │ │ │ └── UserPreferences.swift │ │ ├── PiholeDataProviderListManager.swift │ │ ├── StatsItemType.swift │ │ ├── UIConstants.swift │ │ └── Utils │ │ └── UserDefaultExtensions.swift ├── PiStatsMobile.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ ├── IntentHandler.xcscheme │ │ ├── PiStatsMobile.xcscheme │ │ └── PiStatsWidgetExtension.xcscheme ├── PiStatsMobile │ ├── AppDelegate.swift │ ├── Base.lproj │ │ └── PiholeIntents.intentdefinition │ ├── PiStatsMobile.entitlements │ ├── PiStatsMobileApp.swift │ ├── PiholeDataProviderListManager.swift │ ├── Supporting Files │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ ├── Contents.json │ │ │ │ ├── app-icon1024.png │ │ │ │ ├── app-icon120-1.png │ │ │ │ ├── app-icon120.png │ │ │ │ ├── app-icon152.png │ │ │ │ ├── app-icon167.png │ │ │ │ ├── app-icon180.png │ │ │ │ ├── app-icon20.png │ │ │ │ ├── app-icon29.png │ │ │ │ ├── app-icon40-1.png │ │ │ │ ├── app-icon40-2.png │ │ │ │ ├── app-icon40.png │ │ │ │ ├── app-icon58-1.png │ │ │ │ ├── app-icon58.png │ │ │ │ ├── app-icon60.png │ │ │ │ ├── app-icon76.png │ │ │ │ ├── app-icon80-1.png │ │ │ │ ├── app-icon80.png │ │ │ │ └── app-icon87.png │ │ │ ├── Contents.json │ │ │ ├── StatsItem │ │ │ │ ├── Contents.json │ │ │ │ ├── DomainsOnBlockList.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── PercentBlocked.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── QueriesBlocked.colorset │ │ │ │ │ └── Contents.json │ │ │ │ └── TotalQueries.colorset │ │ │ │ │ └── Contents.json │ │ │ ├── StatusOffline.colorset │ │ │ │ └── Contents.json │ │ │ ├── StatusOnline.colorset │ │ │ │ └── Contents.json │ │ │ └── StatusWarning.colorset │ │ │ │ └── Contents.json │ │ ├── Info.plist │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ ├── en.lproj │ │ │ └── Localizable.strings │ │ └── pt-BR.lproj │ │ │ └── Localizable.strings │ ├── Utils │ │ ├── CodeScannerView.swift │ │ ├── ScannedPihole.swift │ │ └── ViewUtils.swift │ ├── Views │ │ ├── ContentView.swift │ │ ├── Piholes │ │ │ ├── MetricsView.swift │ │ │ ├── PiholeSetupView.swift │ │ │ ├── PiholeStatsList.swift │ │ │ ├── StatsItemView.swift │ │ │ ├── StatsView.swift │ │ │ └── StatusHeaderView.swift │ │ ├── Representables │ │ │ └── CountdownPickerViewRepresentable.swift │ │ ├── Settings │ │ │ ├── CustomDurationsView.swift │ │ │ └── SettingsView.swift │ │ └── UIViews │ │ │ └── CountdownPickerView.swift │ ├── en.lproj │ │ └── PiholeIntents.strings │ └── pt-BR.lproj │ │ └── PiholeIntents.strings ├── PiStatsMobileTests │ ├── Info.plist │ └── PiStatsMobileTests.swift ├── PiStatsMobileUITests │ ├── Info.plist │ └── PiStatsMobileUITests.swift └── PiStatsWidget │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── PiMonitorWidgetBackground.colorset │ │ └── Contents.json │ ├── StatsItem │ │ ├── Contents.json │ │ ├── DomainsOnBlockList.colorset │ │ │ └── Contents.json │ │ ├── PercentBlocked.colorset │ │ │ └── Contents.json │ │ ├── QueriesBlocked.colorset │ │ │ └── Contents.json │ │ └── TotalQueries.colorset │ │ │ └── Contents.json │ ├── StatusOffline.colorset │ │ └── Contents.json │ ├── StatusOnline.colorset │ │ └── Contents.json │ ├── StatusWarning.colorset │ │ └── Contents.json │ └── WidgetBackground.colorset │ │ └── Contents.json │ ├── Core │ ├── PiMonitorTimelineProvider.swift │ ├── PiholeEntry.swift │ └── PiholeTimelineProvider.swift │ ├── Info.plist │ ├── Intents │ ├── IntentsLogging.swift │ └── RefreshWidgetIntent.swift │ ├── PiMonitorWidget.swift │ ├── PiStatsWidgetExtension.entitlements │ ├── PiStatsWidgets.swift │ ├── ViewStatsWidget.swift │ └── Views │ ├── BackDeployableShims.swift │ ├── CircleBadgeStatus.swift │ ├── MediumStatsItem.swift │ ├── PiMonitor │ ├── PiMonitorStatusHeader.swift │ ├── PiMonitorView.swift │ └── PiMonitorWidgetView.swift │ ├── PiStatsDisplayWidgetView.swift │ └── SmallStatsItem.swift ├── PrivacyPolicy.md ├── README.md ├── bootstrap.sh └── images ├── icon.png ├── ios_qrcode.jpeg ├── pistatsmobile.png └── qrcode.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | 93 | # Generated Config Files 94 | 95 | TeamID.xcconfig -------------------------------------------------------------------------------- /PiStatsMobile/Config/Main.xcconfig: -------------------------------------------------------------------------------- 1 | // If you see this error: "could not find included file 'TeamID.xcconfig' in search paths" 2 | // Make sure you have run the bootstrap script from the project's root directory to set up signing for your team ID. 3 | // You may need to close and re-open the project after doing so. 4 | #include "TeamID.xcconfig" 5 | 6 | CODE_SIGN_STYLE = Automatic 7 | 8 | PISTATS_APPGROUP_ID=group.dev.bunn.PiStatsMobile 9 | -------------------------------------------------------------------------------- /PiStatsMobile/Config/OpenSource.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Main.xcconfig" 2 | 3 | // Once you set your project's development team, 4 | // you'll have a unique bundle identifier. This is because the bundle identifier 5 | // is derived based on the 'SAMPLE_CODE_DISAMBIGUATOR' value. 6 | SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM} 7 | 8 | PISTATS_APPGROUP_ID=group.dev.bunn.PiStatsMobile${DEVELOPMENT_TEAM} 9 | -------------------------------------------------------------------------------- /PiStatsMobile/IntentHandler/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | IntentHandler 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 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionAttributes 26 | 27 | IntentsRestrictedWhileLocked 28 | 29 | IntentsRestrictedWhileProtectedDataUnavailable 30 | 31 | IntentsSupported 32 | 33 | SelectPiholeIntent 34 | 35 | 36 | NSExtensionPointIdentifier 37 | com.apple.intents-service 38 | NSExtensionPrincipalClass 39 | $(PRODUCT_MODULE_NAME).IntentHandler 40 | 41 | PiStatsAppGroupID 42 | $(PISTATS_APPGROUP_ID) 43 | 44 | 45 | -------------------------------------------------------------------------------- /PiStatsMobile/IntentHandler/IntentHandler.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | $(PISTATS_APPGROUP_ID) 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PiStatsMobile/IntentHandler/IntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // IntentHandler 4 | // 5 | // Created by Fernando Bunn on 30/09/2020. 6 | // 7 | 8 | import Intents 9 | import PiStatsKit 10 | 11 | class IntentHandler: INExtension, SelectPiholeIntentHandling { 12 | 13 | func providePiholeOptionsCollection(for intent: SelectPiholeIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { 14 | let piholes = validPiholes().map { 15 | PiholeIntent( 16 | identifier: $0.id.uuidString, 17 | display: $0.title 18 | ) 19 | } 20 | let collection = INObjectCollection(items: piholes) 21 | completion(collection, nil) 22 | } 23 | 24 | func defaultPihole(for intent: SelectPiholeIntent) -> PiholeIntent? { 25 | if let pihole = validPiholes().first { 26 | return PiholeIntent( 27 | identifier: pihole.id.uuidString, 28 | display: pihole.title 29 | ) 30 | } 31 | return nil 32 | } 33 | 34 | override func handler(for intent: INIntent) -> Any { 35 | return self 36 | } 37 | 38 | private func validPiholes() -> [Pihole] { 39 | Pihole.restoreAll().filter{ $0.hasPiMonitor } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PiStatsKit", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | .library( 11 | name: "PiStatsKit", 12 | type: .dynamic, 13 | targets: ["PiStatsKit"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/Bunn/SwiftHole", from: "3.0.0"), 18 | .package(url: "https://github.com/Bunn/PiMonitor", from: "0.0.6"), 19 | ], 20 | targets: [ 21 | .target(name: "PiStatsKit", dependencies: [ 22 | .product(name: "SwiftHole", package: "SwiftHole"), 23 | .product(name: "PiMonitor", package: "PiMonitor"), 24 | ]), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 02/10/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Constants { 11 | public static let appGroup: String = { 12 | /// The PiStatsAppGroupID is included in each bundle's Info.plist with the processed value from the xcconfig file. 13 | guard let id = Bundle.main.infoDictionary?["PiStatsAppGroupID"] as? String, !id.isEmpty else { 14 | assertionFailure("Expected Info.plist for \(Bundle.main.bundleURL.lastPathComponent) to include non-empty PiStatsAppGroupID") 15 | return "group.dev.bunn.PiStatsMobile" 16 | } 17 | return id 18 | }() 19 | } 20 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/DisableDurationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisableDurationManager.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 26/09/2020. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | public class DisableTimeItem: Identifiable, Hashable { 12 | internal init(timeInterval: TimeInterval) { 13 | self.timeInterval = timeInterval 14 | } 15 | 16 | public static func == (lhs: DisableTimeItem, rhs: DisableTimeItem) -> Bool { 17 | return lhs.id == rhs.id 18 | } 19 | public func hash(into hasher: inout Hasher) { 20 | hasher.combine(id) 21 | } 22 | 23 | private lazy var formatter: DateComponentsFormatter = { 24 | let f = DateComponentsFormatter() 25 | f.unitsStyle = .full 26 | f.allowedUnits = [.second, .minute, .hour] 27 | return f 28 | }() 29 | 30 | public let id = UUID() 31 | @Published public var timeInterval: TimeInterval 32 | 33 | public var title: String { 34 | formatter.string(from: timeInterval) ?? "-" 35 | } 36 | } 37 | 38 | public final class DisableDurationManager: ObservableObject { 39 | public static let shared = DisableDurationManager(userPreferences: .shared) 40 | 41 | private let userPreferences: UserPreferences 42 | @Published public var items = [DisableTimeItem]() 43 | private var disableTimeCancellable: AnyCancellable? 44 | private var timeIntervalCancellables: [AnyCancellable]? 45 | 46 | public init(userPreferences: UserPreferences) { 47 | self.userPreferences = userPreferences 48 | self.items = userPreferences.disableTimes.map { DisableTimeItem(timeInterval: $0) } 49 | setupCancellables() 50 | } 51 | 52 | public func saveDurationTimes() { 53 | userPreferences.disableTimes = items.map { $0.timeInterval } 54 | update() 55 | } 56 | 57 | /* 58 | I need to setup this cancellabes *with* the update method because of the issue on the 59 | ForEach that doesn't like to work with bindables on a loop. 60 | So this is to force the UI to be refreshed once a property changes. 61 | I'm pretty sure there is a best way of doing this though :( 62 | */ 63 | public func setupCancellables() { 64 | disableTimeCancellable = $items.receive(on: DispatchQueue.main).sink { [weak self] _ in 65 | self?.saveDurationTimes() 66 | } 67 | timeIntervalCancellables = items.map { $0.$timeInterval.receive(on: DispatchQueue.main).sink { [weak self] _ in 68 | self?.saveDurationTimes() 69 | } } 70 | } 71 | 72 | public func update() { 73 | objectWillChange.send() 74 | } 75 | 76 | public func addNewItem() { 77 | items.append(DisableTimeItem(timeInterval: 0)) 78 | setupCancellables() 79 | } 80 | 81 | public func delete(at offsets: IndexSet) { 82 | items.remove(atOffsets: offsets) 83 | setupCancellables() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // PiHoleStats 4 | // 5 | // Created by Fernando Bunn on 21/06/2020. 6 | // Copyright © 2020 Fernando Bunn. All rights reserved. 7 | // 8 | 9 | import os.log 10 | 11 | public struct Logger { 12 | public init() { } 13 | 14 | public func osLog(category: String) -> OSLog { 15 | return OSLog(subsystem: "PiStats", category: category) 16 | } 17 | 18 | public func osLog(describing instance: Subject) -> OSLog { 19 | return osLog(category: String(describing: instance)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/Pihole.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiHole.swift 3 | // PiHoleStats 4 | // 5 | // Created by Fernando Bunn on 24/05/2020. 6 | // Copyright © 2020 Fernando Bunn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftHole 11 | import PiMonitor 12 | import Combine 13 | import os.log 14 | 15 | public class Pihole: Identifiable, ObservableObject { 16 | private let log = Logger().osLog(describing: Pihole.self) 17 | public private(set) var metrics: PiMetrics? 18 | public private(set) var active = false 19 | private lazy var keychainToken = APIToken(accountName: self.id.uuidString) 20 | private let servicesTimeout: TimeInterval = 10 21 | 22 | public var displayName: String? 23 | public var address: String 24 | public var piMonitorPort: Int? 25 | public var hasPiMonitor: Bool = false 26 | public let id: UUID 27 | public var secure: Bool 28 | 29 | @Published public var actionError: String? 30 | @Published public var pollingError: String? 31 | 32 | @Published public private(set) var summary: Summary? { 33 | didSet { 34 | if summary?.status.lowercased() == "enabled" { 35 | active = true 36 | os_log("%@ summary has enabled status", log: self.log, type: .debug, address) 37 | } else { 38 | active = false 39 | os_log("%@ summary has disabled status", log: self.log, type: .debug, address) 40 | } 41 | } 42 | } 43 | 44 | public var apiToken: String { 45 | get { 46 | keychainToken.token 47 | } 48 | set { 49 | keychainToken.token = newValue 50 | } 51 | } 52 | 53 | public var port: Int? { 54 | getPort(address) 55 | } 56 | 57 | public var host: String { 58 | address.components(separatedBy: ":").first ?? "" 59 | } 60 | 61 | public var title: String { 62 | if let name = displayName { 63 | return name 64 | } 65 | return host 66 | } 67 | 68 | private var service: SwiftHole { 69 | SwiftHole(host: host, port: port, apiToken: apiToken, timeoutInterval: servicesTimeout, secure: secure) 70 | } 71 | 72 | private var piMonitorService: PiMonitor { 73 | PiMonitor(host: host, port: piMonitorPort ?? 8088, timeoutInterval: servicesTimeout, secure: secure) 74 | } 75 | 76 | public required init(from decoder: Decoder) throws { 77 | let container = try decoder.container(keyedBy: CodingKeys.self) 78 | id = try container.decode(UUID.self, forKey: .id) 79 | address = try container.decode(String.self, forKey: .address) 80 | displayName = try container.decode(String?.self, forKey: .displayName) 81 | 82 | do { 83 | piMonitorPort = try container.decode(Int?.self, forKey: .piMonitorPort) 84 | } catch { 85 | piMonitorPort = nil 86 | } 87 | 88 | do { 89 | hasPiMonitor = try container.decode(Bool.self, forKey: .hasPiMonitor) 90 | } catch { 91 | hasPiMonitor = false 92 | } 93 | 94 | do { 95 | secure = try container.decode(Bool.self, forKey: .secure) 96 | } catch { 97 | secure = false 98 | } 99 | } 100 | 101 | public init(address: String, apiToken: String? = nil, piHoleID: UUID? = nil, secure: Bool = false) { 102 | self.address = address 103 | self.secure = secure 104 | 105 | if let piHoleID = piHoleID { 106 | self.id = piHoleID 107 | } else { 108 | self.id = UUID() 109 | } 110 | 111 | if let apiToken = apiToken { 112 | keychainToken.token = apiToken 113 | } 114 | } 115 | 116 | public static func previewData() -> Pihole { 117 | let pihole = Pihole(address: "127.0.0.1") 118 | pihole.hasPiMonitor = true 119 | return pihole 120 | } 121 | 122 | private func getPort(_ address: String) -> Int? { 123 | let split = address.components(separatedBy: ":") 124 | guard let port = split.last else { return nil } 125 | return Int(port) 126 | } 127 | } 128 | 129 | // MARK: Network Methods 130 | 131 | extension Pihole { 132 | 133 | public func updateMetrics(completion: @escaping (PiMonitorError?) -> Void) { 134 | piMonitorService.fetchMetrics { result in 135 | switch result { 136 | case .success(let metrics): 137 | self.metrics = metrics 138 | completion(nil) 139 | case .failure(let error): 140 | self.metrics = nil 141 | completion(error) 142 | } 143 | } 144 | } 145 | 146 | public func updateSummary(completion: @escaping (SwiftHoleError?) -> Void) { 147 | service.fetchSummary { result in 148 | switch result { 149 | case .success(let summary): 150 | self.summary = summary 151 | completion(nil) 152 | case .failure(let error): 153 | self.summary = nil 154 | completion(error) 155 | } 156 | } 157 | } 158 | 159 | public func enablePiHole(completion: @escaping (Result) -> Void) { 160 | service.enablePiHole { result in 161 | switch result { 162 | case .success: 163 | self.active = true 164 | os_log("%@ enable request success", log: self.log, type: .debug, self.address) 165 | completion(result) 166 | case .failure: 167 | os_log("%@ enable request failure", log: self.log, type: .debug, self.address) 168 | completion(result) 169 | } 170 | } 171 | } 172 | 173 | public func disablePiHole(seconds: Int = 0, completion: @escaping (Result) -> Void) { 174 | service.disablePiHole(seconds: seconds) { result in 175 | switch result { 176 | case .success: 177 | self.active = false 178 | os_log("%@ disable request success", log: self.log, type: .debug, self.address) 179 | completion(result) 180 | case .failure: 181 | os_log("%@ disable request failure", log: self.log, type: .debug, self.address) 182 | completion(result) 183 | } 184 | } 185 | } 186 | } 187 | 188 | // MARK: I/O Methods 189 | 190 | extension Pihole { 191 | private static let piHoleListKey = "PiHoleStatsPiHoleList" 192 | 193 | public func delete() { 194 | var piholeList = Pihole.restoreAll() 195 | 196 | if let index = piholeList.firstIndex(of: self) { 197 | piholeList.remove(at: index) 198 | } 199 | save(piholeList) 200 | self.keychainToken.delete() 201 | } 202 | 203 | public func save() { 204 | var piholeList = Pihole.restoreAll() 205 | if let index = piholeList.firstIndex(where: { $0.id == self.id }) { 206 | piholeList[index] = self 207 | } else { 208 | piholeList.append(self) 209 | } 210 | save(piholeList) 211 | } 212 | 213 | private func save(_ list: [Pihole]) { 214 | let encoder = JSONEncoder() 215 | if let encoded = try? encoder.encode(list) { 216 | UserDefaults.shared().set(encoded, forKey: Pihole.piHoleListKey) 217 | } 218 | } 219 | 220 | public static func restoreAll() -> [Pihole] { 221 | if let piHoleList = UserDefaults.shared().object(forKey: Pihole.piHoleListKey) as? Data { 222 | let decoder = JSONDecoder() 223 | 224 | do { 225 | let list = try decoder.decode([Pihole].self, from: piHoleList) 226 | return list 227 | } catch { 228 | print("error \(error)") 229 | return [Pihole]() 230 | } 231 | } 232 | return [Pihole]() 233 | } 234 | 235 | public static func restore(_ uuid: UUID) -> Pihole? { 236 | return Pihole.restoreAll().filter { $0.id == uuid }.first 237 | } 238 | } 239 | 240 | extension Pihole: Hashable { 241 | public static func == (lhs: Pihole, rhs: Pihole) -> Bool { 242 | return lhs.id == rhs.id 243 | } 244 | 245 | public func hash(into hasher: inout Hasher) { 246 | hasher.combine(id) 247 | } 248 | } 249 | 250 | extension Pihole: Codable { 251 | 252 | public enum CodingKeys: CodingKey { 253 | case id 254 | case address 255 | case displayName 256 | case piMonitorPort 257 | case hasPiMonitor 258 | case secure 259 | } 260 | 261 | public func encode(to encoder: Encoder) throws { 262 | var container = encoder.container(keyedBy: CodingKeys.self) 263 | try container.encode(id, forKey: .id) 264 | try container.encode(address, forKey: .address) 265 | try container.encode(piMonitorPort, forKey: .piMonitorPort) 266 | try container.encode(hasPiMonitor, forKey: .hasPiMonitor) 267 | try container.encode(displayName, forKey: .displayName) 268 | try container.encode(secure, forKey: .secure) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/PiholeDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // piholeservice.swift 3 | // piholestats 4 | // 5 | // Created by Fernando Bunn on 24/05/2020. 6 | // Copyright © 2020 Fernando Bunn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftHole 11 | import SwiftUI 12 | import PiMonitor 13 | import Combine 14 | 15 | public class PiholeDataProvider: ObservableObject, Identifiable { 16 | 17 | public enum PiholeStatus { 18 | case allEnabled 19 | case allDisabled 20 | case enabledAndDisabled 21 | } 22 | 23 | public enum PollingMode { 24 | case foreground 25 | case background 26 | } 27 | 28 | public private(set) var pollingTimeInterval: TimeInterval = 3 29 | private var timer: Timer? 30 | public private(set) var piholes: [Pihole] 31 | public let id = UUID() 32 | 33 | private var piSummaryCancellables: [AnyCancellable]? 34 | private var piPollingErrorCancellables: [AnyCancellable]? 35 | private var piActionErrorCancellables: [AnyCancellable]? 36 | 37 | @Published public private(set) var totalQueries = "" 38 | @Published public private(set) var queriesBlocked = "" 39 | @Published public private(set) var percentBlocked = "" 40 | @Published public private(set) var domainsOnBlocklist = "" 41 | @Published public private(set) var hasErrorMessages = false 42 | @Published public private(set) var status: PiholeStatus = .allDisabled 43 | @Published public private(set) var name = "" 44 | @Published public private(set) var pollingErrors = [String]() 45 | @Published public private(set) var actionErrors = [String]() 46 | @Published public private(set) var offlinePiholesCount = 0 47 | 48 | @Published public private(set) var uptime = "" 49 | @Published public private(set) var memoryUsage = "" 50 | @Published public private(set) var loadAverage = "" 51 | @Published public private(set) var temperature = "" 52 | 53 | public var canDisplayMetrics: Bool { 54 | return piholes.allSatisfy { 55 | return $0.hasPiMonitor 56 | } 57 | } 58 | 59 | public var canDisplayEnableDisableButton: Bool { 60 | return !piholes.allSatisfy { 61 | return $0.apiToken.isEmpty == true 62 | } 63 | } 64 | 65 | private lazy var percentageFormatter: NumberFormatter = { 66 | let n = NumberFormatter() 67 | n.numberStyle = .percent 68 | n.minimumFractionDigits = 2 69 | n.maximumFractionDigits = 2 70 | return n 71 | }() 72 | 73 | private lazy var numberFormatter: NumberFormatter = { 74 | let n = NumberFormatter() 75 | n.numberStyle = .decimal 76 | n.maximumFractionDigits = 0 77 | return n 78 | }() 79 | 80 | public init(piholes: [Pihole]) { 81 | self.piholes = piholes 82 | if piholes.count > 1 { 83 | self.name = UIConstants.Strings.allPiholesTitle 84 | } else if let firstPihole = piholes.first { 85 | self.name = firstPihole.title 86 | } 87 | setupCancellables() 88 | } 89 | 90 | public func updatePollingMode(_ pollingMode: PollingMode) { 91 | switch pollingMode { 92 | case .background: 93 | pollingTimeInterval = 10 94 | case .foreground: 95 | pollingTimeInterval = 3 96 | } 97 | startPolling() 98 | } 99 | 100 | public func startPolling() { 101 | self.fetchSummaryData() 102 | self.fetchMetricsData() 103 | timer?.invalidate() 104 | timer = Timer.scheduledTimer(withTimeInterval: pollingTimeInterval, repeats: true) { _ in 105 | self.fetchSummaryData() 106 | self.fetchMetricsData() 107 | } 108 | } 109 | 110 | public func stopPolling() { 111 | timer?.invalidate() 112 | } 113 | 114 | public func resetErrorMessage() { 115 | piholes.forEach { pihole in 116 | pihole.actionError = nil 117 | pihole.pollingError = nil 118 | } 119 | updateErrorMessageStatus() 120 | } 121 | 122 | public func add(_ pihole: Pihole) { 123 | objectWillChange.send() 124 | piholes.append(pihole) 125 | updateStatus() 126 | updateErrorMessageStatus() 127 | } 128 | 129 | public func remove(_ pihole: Pihole) { 130 | objectWillChange.send() 131 | if let index = piholes.firstIndex(of: pihole) { 132 | piholes.remove(at: index) 133 | } 134 | 135 | updateStatus() 136 | updateErrorMessageStatus() 137 | } 138 | 139 | public func setupCancellables() { 140 | 141 | piActionErrorCancellables = piholes.map { 142 | $0.$actionError.receive(on: DispatchQueue.main).sink { [weak self] value in 143 | self?.updateErrorMessageStatus() 144 | } 145 | } 146 | 147 | piPollingErrorCancellables = piholes.map { 148 | $0.$pollingError.receive(on: DispatchQueue.main).sink { [weak self] value in 149 | self?.updateErrorMessageStatus() 150 | } 151 | } 152 | 153 | piSummaryCancellables = piholes.map { 154 | $0.$summary.receive(on: DispatchQueue.main).sink { [weak self] value in 155 | self?.updateData() 156 | } 157 | } 158 | } 159 | 160 | public func disablePiHole(seconds: Int = 0) { 161 | piholes.forEach { pihole in 162 | pihole.disablePiHole(seconds: seconds) { result in 163 | DispatchQueue.main.async { 164 | switch result { 165 | case .success: 166 | pihole.actionError = nil 167 | case .failure(let error): 168 | pihole.actionError = self.errorMessage(error) 169 | } 170 | } 171 | } 172 | } 173 | } 174 | 175 | public func enablePiHole() { 176 | piholes.forEach { pihole in 177 | pihole.enablePiHole { result in 178 | DispatchQueue.main.async { 179 | switch result { 180 | case .success: 181 | pihole.actionError = nil 182 | case .failure(let error): 183 | pihole.actionError = self.errorMessage(error) 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | private func errorMessage(_ error: SwiftHoleError) -> String { 191 | switch error { 192 | case .malformedURL: 193 | return UIConstants.Strings.Error.invalidURL 194 | case .invalidDecode(let decodeError): 195 | return "\(UIConstants.Strings.Error.decodeResponseError): \(decodeError.localizedDescription)" 196 | case .noAPITokenProvided: 197 | return UIConstants.Strings.Error.noAPITokenProvided 198 | case .sessionError(let sessionError): 199 | return "\(UIConstants.Strings.Error.sessionError): \(sessionError.localizedDescription)" 200 | case .invalidResponseCode(let responseCode): 201 | return "\(UIConstants.Strings.Error.sessionError): \(responseCode)" 202 | case .invalidResponse: 203 | return UIConstants.Strings.Error.invalidResponse 204 | case .invalidAPIToken: 205 | return UIConstants.Strings.Error.invalidAPIToken 206 | case .cantAddNewListItem(_): 207 | return UIConstants.Strings.Error.cantAddNewItem 208 | } 209 | } 210 | 211 | private func updateMetrics(_ metrics: PiMetrics?) { 212 | guard let metrics = metrics else { 213 | loadAverage = "-" 214 | uptime = "-" 215 | memoryUsage = "-" 216 | temperature = "-" 217 | return 218 | } 219 | loadAverage = metrics.loadAverage.map({"\($0)"}).joined(separator: ", ") 220 | 221 | let formatter = DateComponentsFormatter() 222 | formatter.allowedUnits = [.day, .hour, .minute] 223 | formatter.unitsStyle = .abbreviated 224 | let timeInterval = TimeInterval(metrics.uptime) 225 | uptime = formatter.string(from: timeInterval) ?? "-" 226 | 227 | let usedMemory = metrics.memory.totalMemory - metrics.memory.availableMemory 228 | let percentageUsed = Double(usedMemory) / Double(metrics.memory.totalMemory) 229 | let numberFormatter = NumberFormatter() 230 | numberFormatter.numberStyle = .percent 231 | numberFormatter.maximumFractionDigits = 2 232 | memoryUsage = numberFormatter.string(for: percentageUsed) ?? "-" 233 | 234 | if UserPreferences.shared.temperatureScaleType == .celsius { 235 | temperature = "\(String(metrics.socTemperature)) \(UIConstants.Strings.temperatureScaleCelsius)" 236 | } else { 237 | let converted = metrics.socTemperature * (9.0/5.0) + 32.0 238 | let fahrenheit = String(format: "%.01f", converted) 239 | temperature = "\(fahrenheit) \(UIConstants.Strings.temperatureScaleFahrenheit)" 240 | } 241 | } 242 | 243 | public func fetchMetricsData(completion: (() -> ())? = nil) { 244 | if !canDisplayMetrics { 245 | completion?() 246 | return 247 | } 248 | let dispatchGroup = DispatchGroup() 249 | 250 | piholes.forEach { pihole in 251 | dispatchGroup.enter() 252 | pihole.updateMetrics { error in 253 | DispatchQueue.main.async { 254 | self.updateMetrics(pihole.metrics) 255 | } 256 | dispatchGroup.leave() 257 | } 258 | } 259 | dispatchGroup.notify(queue: DispatchQueue.main) { 260 | completion?() 261 | } 262 | } 263 | 264 | public func fetchSummaryData(completion: (() -> ())? = nil) { 265 | let dispatchGroup = DispatchGroup() 266 | 267 | piholes.forEach { pihole in 268 | dispatchGroup.enter() 269 | pihole.updateSummary { error in 270 | DispatchQueue.main.async { 271 | if let error = error { 272 | pihole.pollingError = self.errorMessage(error) 273 | } else { 274 | pihole.pollingError = nil 275 | } 276 | dispatchGroup.leave() 277 | } 278 | } 279 | } 280 | dispatchGroup.notify(queue: DispatchQueue.main) { 281 | self.updateData() 282 | completion?() 283 | } 284 | } 285 | 286 | private func updateData() { 287 | let sumDNSQueries = piholes.compactMap { $0.summary }.reduce(0) { value, pihole in value + pihole.dnsQueriesToday } 288 | totalQueries = numberFormatter.string(from: NSNumber(value: sumDNSQueries)) ?? "-" 289 | 290 | let sumQueriesBlocked = piholes.compactMap { $0.summary }.reduce(0) { value, pihole in value + pihole.adsBlockedToday } 291 | queriesBlocked = numberFormatter.string(from: NSNumber(value: sumQueriesBlocked)) ?? "-" 292 | 293 | let sumDomainOnBlocklist = piholes.compactMap { $0.summary }.reduce(0) { value, pihole in value + pihole.domainsBeingBlocked } 294 | domainsOnBlocklist = numberFormatter.string(from: NSNumber(value: sumDomainOnBlocklist)) ?? "-" 295 | 296 | let percentage = sumDNSQueries == 0 ? 0 : Double(sumQueriesBlocked) / Double(sumDNSQueries) 297 | percentBlocked = percentageFormatter.string(from: NSNumber(value: percentage)) ?? "-" 298 | 299 | updateStatus() 300 | updateErrorMessageStatus() 301 | } 302 | 303 | private func updateStatus() { 304 | offlinePiholesCount = piholes.reduce(0) { counter, item in 305 | if item.active != false { 306 | return counter + 1 307 | } 308 | return counter 309 | } 310 | 311 | let allStatus = Set(piholes.map { $0.active }) 312 | if allStatus.count > 1 { 313 | status = .enabledAndDisabled 314 | } else if allStatus.randomElement() == false { 315 | status = .allDisabled 316 | } else { 317 | status = .allEnabled 318 | } 319 | } 320 | 321 | private func updateErrorMessageStatus() { 322 | pollingErrors = piholes.compactMap{ $0.pollingError} 323 | actionErrors = piholes.compactMap{ $0.actionError} 324 | hasErrorMessages = pollingErrors.count != 0 || actionErrors.count != 0 325 | } 326 | } 327 | 328 | // MARK: - Preview Support 329 | 330 | public extension PiholeDataProvider { 331 | 332 | static func previewData() -> PiholeDataProvider { 333 | let provider = PiholeDataProvider.init(piholes: [Pihole.previewData()]) 334 | provider.totalQueries = "1245" 335 | provider.queriesBlocked = "1245" 336 | provider.percentBlocked = "12,3%" 337 | provider.domainsOnBlocklist = "12,345" 338 | provider.status = .allEnabled 339 | 340 | provider.temperature = "23 ºC" 341 | provider.memoryUsage = "50%" 342 | provider.loadAverage = "0.1, 0.3, 0.6" 343 | provider.uptime = "23h 2m" 344 | return provider 345 | } 346 | 347 | static func previewDataAlternate() -> PiholeDataProvider { 348 | let provider = PiholeDataProvider.init(piholes: [Pihole.previewData()]) 349 | provider.totalQueries = "1768" 350 | provider.queriesBlocked = "1524" 351 | provider.percentBlocked = "11,2%" 352 | provider.domainsOnBlocklist = "12,389" 353 | provider.status = .allEnabled 354 | 355 | provider.temperature = "22 ºC" 356 | provider.memoryUsage = "53%" 357 | provider.loadAverage = "0.1, 0.2, 0.8" 358 | provider.uptime = "26h 8m" 359 | return provider 360 | } 361 | 362 | } 363 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/UserData/APIToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIToken.swift 3 | // PiHoleStats 4 | // 5 | // Created by Fernando Bunn on 16/05/2020. 6 | // Copyright © 2020 Fernando Bunn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct APIToken { 12 | internal init(accountName: String) { 13 | self.accountName = accountName 14 | self.passwordItem = KeychainPasswordItem(service: APIToken.serviceName, account: accountName, accessGroup: Constants.appGroup) 15 | migratePasswordItemIfNecessary(accountName) 16 | } 17 | 18 | private mutating func migratePasswordItemIfNecessary(_ accountName: String) { 19 | guard UserPreferences().didMigrateAppGroup == false else { return } 20 | let oldPasswordItem = KeychainPasswordItem(service: APIToken.serviceName, account: accountName, accessGroup: nil) 21 | 22 | if let oldPassword = try? oldPasswordItem.readPassword(), oldPassword.count > 0 { 23 | self.token = oldPassword 24 | UserPreferences().didMigrateAppGroup = true 25 | } 26 | } 27 | 28 | private static let serviceName = "PiHoleStatsService" 29 | let accountName: String 30 | 31 | private let passwordItem: KeychainPasswordItem 32 | 33 | public var token: String { 34 | get { 35 | do { 36 | return try passwordItem.readPassword() 37 | } catch { 38 | return "" 39 | } 40 | } 41 | 42 | set { 43 | /* 44 | It might error out when trying to delete during development because of digital signing changing 45 | which shouldn't be a problem on released version 46 | https://forums.developer.apple.com/thread/69841 47 | */ 48 | try? passwordItem.savePassword(newValue) 49 | if newValue.isEmpty { 50 | delete() 51 | } 52 | } 53 | } 54 | 55 | public func delete() { 56 | do { 57 | try passwordItem.deleteItem() 58 | } catch { 59 | print("Keychain delete error \(error)") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/UserData/KeyChainPasswordItem.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2016 Apple Inc. All Rights Reserved. 3 | See LICENSE.txt for this sample’s licensing information 4 | 5 | Abstract: 6 | A struct for accessing generic password keychain items. 7 | */ 8 | 9 | import Foundation 10 | 11 | struct KeychainPasswordItem { 12 | // MARK: Types 13 | 14 | enum KeychainError: Error { 15 | case noPassword 16 | case unexpectedPasswordData 17 | case unexpectedItemData 18 | case unhandledError(status: OSStatus) 19 | } 20 | 21 | // MARK: Properties 22 | 23 | let service: String 24 | 25 | private(set) var account: String 26 | 27 | let accessGroup: String? 28 | 29 | // MARK: Intialization 30 | 31 | init(service: String, account: String, accessGroup: String? = nil) { 32 | self.service = service 33 | self.account = account 34 | self.accessGroup = accessGroup 35 | } 36 | 37 | // MARK: Keychain access 38 | 39 | func readPassword() throws -> String { 40 | /* 41 | Build a query to find the item that matches the service, account and 42 | access group. 43 | */ 44 | var query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) 45 | query[kSecMatchLimit as String] = kSecMatchLimitOne 46 | query[kSecReturnAttributes as String] = kCFBooleanTrue 47 | query[kSecReturnData as String] = kCFBooleanTrue 48 | 49 | // Try to fetch the existing keychain item that matches the query. 50 | var queryResult: AnyObject? 51 | let status = withUnsafeMutablePointer(to: &queryResult) { 52 | SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) 53 | } 54 | 55 | // Check the return status and throw an error if appropriate. 56 | guard status != errSecItemNotFound else { throw KeychainError.noPassword } 57 | guard status == noErr else { throw KeychainError.unhandledError(status: status) } 58 | 59 | // Parse the password string from the query result. 60 | guard let existingItem = queryResult as? [String: AnyObject], 61 | let passwordData = existingItem[kSecValueData as String] as? Data, 62 | let password = String(data: passwordData, encoding: String.Encoding.utf8) 63 | else { 64 | throw KeychainError.unexpectedPasswordData 65 | } 66 | 67 | return password 68 | } 69 | 70 | func savePassword(_ password: String) throws { 71 | // Encode the password into an Data object. 72 | let encodedPassword = password.data(using: String.Encoding.utf8)! 73 | 74 | do { 75 | // Check for an existing item in the keychain. 76 | try _ = readPassword() 77 | 78 | // Update the existing item with the new password. 79 | var attributesToUpdate = [String: AnyObject]() 80 | attributesToUpdate[kSecValueData as String] = encodedPassword as AnyObject? 81 | 82 | let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) 83 | let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) 84 | 85 | // Throw an error if an unexpected status was returned. 86 | guard status == noErr else { throw KeychainError.unhandledError(status: status) } 87 | } catch KeychainError.noPassword { 88 | /* 89 | No password was found in the keychain. Create a dictionary to save 90 | as a new keychain item. 91 | */ 92 | var newItem = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) 93 | newItem[kSecValueData as String] = encodedPassword as AnyObject? 94 | 95 | // Add a the new item to the keychain. 96 | let status = SecItemAdd(newItem as CFDictionary, nil) 97 | 98 | // Throw an error if an unexpected status was returned. 99 | guard status == noErr else { throw KeychainError.unhandledError(status: status) } 100 | } 101 | } 102 | 103 | func deleteItem() throws { 104 | // Delete the existing item from the keychain. 105 | let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) 106 | let status = SecItemDelete(query as CFDictionary) 107 | 108 | // Throw an error if an unexpected status was returned. 109 | guard status == noErr || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } 110 | } 111 | 112 | // MARK: Convenience 113 | 114 | private static func keychainQuery(withService service: String, account: String? = nil, accessGroup: String? = nil) -> [String: AnyObject] { 115 | var query = [String: AnyObject]() 116 | query[kSecClass as String] = kSecClassGenericPassword 117 | query[kSecAttrService as String] = service as AnyObject? 118 | 119 | if let account = account { 120 | query[kSecAttrAccount as String] = account as AnyObject? 121 | } 122 | 123 | if let accessGroup = accessGroup { 124 | query[kSecAttrAccessGroup as String] = accessGroup as AnyObject? 125 | } 126 | 127 | return query 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Core/UserData/UserPreferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Settings.swift 3 | // PiHoleStats 4 | // 5 | // Created by Fernando Bunn on 11/05/2020. 6 | // Copyright © 2020 Fernando Bunn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | private enum Keys: String { 13 | case disablePermanently 14 | case displayStatsAsList 15 | case displayStatsIcons 16 | case displayAllPiholes 17 | case disableTimes 18 | case temperatureScale 19 | case didMigrateAppGroup 20 | } 21 | 22 | public enum TemperatureScale { 23 | case celsius 24 | case fahrenheit 25 | } 26 | 27 | public final class UserPreferences: ObservableObject { 28 | public static let shared = UserPreferences() 29 | public var temperatureScaleType: TemperatureScale { 30 | get { 31 | if temperatureScale == 1 { 32 | return .fahrenheit 33 | } 34 | return .celsius 35 | } 36 | } 37 | 38 | public init() { 39 | migrateStandardUserDefaultToGroupIfNecessary() 40 | } 41 | 42 | private func migrateStandardUserDefaultToGroupIfNecessary() { 43 | let keys = [ 44 | Keys.displayAllPiholes.rawValue, 45 | Keys.disablePermanently.rawValue, 46 | Keys.displayStatsAsList.rawValue, 47 | Keys.displayStatsIcons.rawValue, 48 | Keys.temperatureScale.rawValue, 49 | Keys.disableTimes.rawValue 50 | ] 51 | 52 | keys.forEach { key in 53 | if let value = UserDefaults.standard.object(forKey: key) { 54 | UserDefaults.standard.removeObject(forKey: key) 55 | /* 56 | If I only set the disableTimes using the shared().setValue, it's getter returns nil and then returns the default set of intervals. 57 | */ 58 | if key == Keys.disableTimes.rawValue { 59 | if let times = value as? [TimeInterval] { 60 | disableTimes = times 61 | } 62 | } else { 63 | UserDefaults.shared().setValue(value, forKey: key) 64 | } 65 | } 66 | } 67 | 68 | UserDefaults.shared().synchronize() 69 | } 70 | 71 | @AppStorage(Keys.displayAllPiholes.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 72 | public var displayAllPiholes: Bool = false { 73 | willSet { 74 | objectWillChange.send() 75 | } 76 | } 77 | 78 | @AppStorage(Keys.disablePermanently.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 79 | public var disablePermanently: Bool = false { 80 | willSet { 81 | objectWillChange.send() 82 | } 83 | } 84 | 85 | @AppStorage(Keys.displayStatsAsList.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 86 | public var displayStatsAsList: Bool = false { 87 | willSet { 88 | objectWillChange.send() 89 | } 90 | } 91 | 92 | @AppStorage(Keys.displayStatsIcons.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 93 | public var displayStatsIcons: Bool = true { 94 | willSet { 95 | objectWillChange.send() 96 | } 97 | } 98 | 99 | @Published 100 | public var disableTimes: [TimeInterval] = UserDefaults.shared().object(forKey: Keys.disableTimes.rawValue) as? [TimeInterval] ?? [30, 60, 300] { 101 | didSet { 102 | UserDefaults.shared().set(disableTimes, forKey: Keys.disableTimes.rawValue) 103 | } 104 | } 105 | 106 | @AppStorage(Keys.temperatureScale.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 107 | public var temperatureScale: Int = 0 { 108 | willSet { 109 | objectWillChange.send() 110 | } 111 | } 112 | 113 | @AppStorage(Keys.didMigrateAppGroup.rawValue, store: UserDefaults(suiteName: Constants.appGroup)) 114 | public var didMigrateAppGroup: Bool = false 115 | } 116 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/PiholeDataProviderListManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeDataProviderListManager.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 08/07/2020. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import Combine 11 | 12 | public class PiholeDataProviderListManager: ObservableObject { 13 | 14 | public static func previewData() -> PiholeDataProviderListManager { 15 | let provider = PiholeDataProvider.init(piholes: [Pihole.previewData()]) 16 | let manager = PiholeDataProviderListManager() 17 | manager.providerList = [provider] 18 | return manager 19 | } 20 | 21 | @Published public var providerList = [PiholeDataProvider]() 22 | @Published public var allPiholesProvider = PiholeDataProvider(piholes: []) 23 | 24 | private var piholes = Pihole.restoreAll() 25 | private var offlineBadgeCancellable: AnyCancellable? 26 | public var shouldUpdateIconBadgeWithOfflinePiholes: Bool = false 27 | 28 | public var isEmpty: Bool { 29 | return providerList.count == 0 30 | } 31 | 32 | public init() { 33 | setupProviders() 34 | } 35 | 36 | private func setupCancellables() { 37 | offlineBadgeCancellable = Publishers.MergeMany(providerList.map { $0.$offlinePiholesCount } ).sink {[weak self] value in 38 | let result = self?.providerList.reduce(0) { counter, provider in 39 | counter + provider.offlinePiholesCount 40 | } 41 | self?.setupApplicationBadge(result ?? 0) 42 | } 43 | } 44 | 45 | private func setupApplicationBadge(_ badgeCount: Int) { 46 | if shouldUpdateIconBadgeWithOfflinePiholes == false { 47 | return 48 | } 49 | //UIApplication.shared.applicationIconBadgeNumber = badgeCount 50 | } 51 | 52 | private func setupProviders() { 53 | piholes.forEach { pihole in 54 | addPiholeToList(pihole) 55 | } 56 | 57 | allPiholesProvider = PiholeDataProvider(piholes: piholes) 58 | setupCancellables() 59 | } 60 | 61 | private func addPiholeToList(_ pihole: Pihole){ 62 | let dataprovider = PiholeDataProvider(piholes: [pihole]) 63 | dataprovider.startPolling() 64 | objectWillChange.send() 65 | providerList.append(dataprovider) 66 | } 67 | 68 | public func updateList(){ 69 | providerList.forEach { 70 | $0.stopPolling() 71 | } 72 | providerList.removeAll() 73 | piholes = Pihole.restoreAll() 74 | objectWillChange.send() 75 | setupProviders() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/StatsItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsItemType.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 04/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum StatsItemType { 11 | case totalQueries 12 | case queriesBlocked 13 | case percentBlocked 14 | case domainsOnBlockList 15 | 16 | public var imageName: String { 17 | switch self { 18 | case .domainsOnBlockList: 19 | return UIConstants.SystemImages.domainsOnBlockList 20 | case .totalQueries: 21 | return UIConstants.SystemImages.totalQueries 22 | case .queriesBlocked: 23 | return UIConstants.SystemImages.queriesBlocked 24 | case .percentBlocked: 25 | return UIConstants.SystemImages.percentBlocked 26 | } 27 | } 28 | 29 | public var title: String { 30 | switch self { 31 | case .domainsOnBlockList: 32 | return UIConstants.Strings.blocklist 33 | case .totalQueries: 34 | return UIConstants.Strings.totalQueries 35 | case .queriesBlocked: 36 | return UIConstants.Strings.queriesBlocked 37 | case .percentBlocked: 38 | return UIConstants.Strings.percentBlocked 39 | } 40 | } 41 | 42 | public var color: Color { 43 | switch self { 44 | case .domainsOnBlockList: 45 | return UIConstants.Colors.domainsOnBlocklist 46 | case .totalQueries: 47 | return UIConstants.Colors.totalQueries 48 | case .queriesBlocked: 49 | return UIConstants.Colors.queriesBlocked 50 | case .percentBlocked: 51 | return UIConstants.Colors.percentBlocked 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/UIConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIConstants.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 04/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct UIConstants { 11 | public struct Geometry { 12 | public static let defaultCornerRadius: CGFloat = 10.0 13 | public static let defaultPadding: CGFloat = 10.0 14 | public static let shadowRadius: CGFloat = 0 15 | public static let addPiholeButtonHeight: CGFloat = 55.0 16 | public static let widgetDefaultPadding: CGFloat = 16.0 17 | } 18 | 19 | public struct Colors { 20 | public static let background = Color("BackgroundColor") 21 | public static let cardColor = Color("CardColor") 22 | public static let cardColorGradientTop = Color("CardColorGradientTop") 23 | public static let cardColorGradientBottom = Color("CardColorGradientBottom") 24 | public static let domainsOnBlocklist = Color("DomainsOnBlockList") 25 | public static let totalQueries = Color("TotalQueries") 26 | public static let queriesBlocked = Color("QueriesBlocked") 27 | public static let percentBlocked = Color("PercentBlocked") 28 | public static let statusOffline = Color("StatusOffline") 29 | public static let statusOnline = Color("StatusOnline") 30 | public static let statusWarning = Color("StatusWarning") 31 | public static let errorMessage = Color("StatusOffline") 32 | public static let piMonitorWidgetBackground = Color("PiMonitorWidgetBackground") 33 | } 34 | 35 | public struct Strings { 36 | public static let disableButton = "Disable" 37 | public static let enableButton = "Enable" 38 | public static let totalQueries = "Total Queries" 39 | public static let percentBlocked = "Percent Blocked" 40 | public static let blocklist = "Blocklist" 41 | public static let queriesBlocked = "Queries Blocked" 42 | public static let piholeTokenFooterSection = "Token is required for some functionalities like disable/enable your pi-hole.\n\nYou can find the API Token on /etc/pihole/setupVars.conf under WEBPASSWORD or you can open the web UI and go to Settings -> API -> Show API Token" 43 | public static let piholeSetupHostPlaceholder = "Host" 44 | public static let piholeSetupPortPlaceholder = "Port (80)" 45 | public static let piholeSetupDisplayName = "Display Name (Optional)" 46 | public static let piMonitorSetupPortPlaceholder = "Port (8088)" 47 | public static let piholeSetupTokenPlaceholder = "Token (Optional)" 48 | public static let piholeSetupEnablePiMonitor = "Enable Pi Monitor" 49 | public static let piMonitorSetupAlertTitle = "Pi Monitor" 50 | public static let piMonitorSetupAlertOKButton = "OK" 51 | public static let piMonitorSetupAlertLearnMoreButton = "Learn More" 52 | public static let saveButton = "Save" 53 | public static let cancelButton = "Cancel" 54 | public static let deleteButton = "Delete" 55 | public static let statusEnabled = "Active" 56 | public static let statusDisabled = "Offline" 57 | public static let statusNeedsAttention = "Needs Attention" 58 | public static let statusEnabledAndDisabled = "Partially Active" 59 | public static let piMonitorExplanation = "Pi Monitor is a service that helps you to monitor your Raspberry Pi by showing you information like temperature, memory usage and more!\n\nIn order to use it you'll need to install it in your Raspberry Pi." 60 | 61 | public static let addFirstPiholeCaption = "Tap here to add your first pi-hole" 62 | public static let piholesNavigationTitle = "Pi-holes" 63 | public static let settingsNavigationTitle = "Settings" 64 | public static let disablePiholeOptionsTitle = "Disable Pi-hole" 65 | public static let disablePiholeOptionsPermanently = "Permanently" 66 | public static let settingsSectionPihole = "Pi-hole" 67 | public static let settingsSectionPiMonitor = "Pi Monitor" 68 | public static let qrCodeScannerTitle = "Scanner" 69 | public static let piholeSetupTitle = "Pi-hole Setup" 70 | public static let allPiholesTitle = "All Pi-holes" 71 | public static let temperatureScaleCelsius = "°C" 72 | public static let temperatureScaleFahrenheit = "°F" 73 | 74 | public struct Widget { 75 | public static let piholeNotEnabledOn = "Pi Monitor is not enabled on" 76 | } 77 | 78 | public struct Preferences { 79 | public static let sectionInterface = "Interface" 80 | public static let sectionEnableDisable = "Enable / Disable" 81 | public static let sectionPiMonitor = "Pi Monitor" 82 | public static let about = "About" 83 | public static let displayAsList = "Display Pi-hole stats as list" 84 | public static let displayIcons = "Display Pi-hole stats icons" 85 | public static let alwaysDisablePermanently = "Always disable Pi-hole permanently" 86 | public static let displayAllPiholesInSingleCard = "Display all Pi-holes in a single card" 87 | public static let version = "Version" 88 | public static let piStatsSourceCode = "Pi Stats source code" 89 | public static let piStatsForMacOS = "Pi Stats for macOS" 90 | public static let leaveReview = "Write a review on the App Store" 91 | public static let customizeDisableTimes = "Customize disable times" 92 | public static let piMonitorTemperature = "Temperature Scale" 93 | public static let protocolHTTP = "HTTP" 94 | public static let protocolHTTPS = "HTTPS" 95 | } 96 | 97 | public struct CustomizeDisabletime { 98 | public static let emptyListMessage = "Tap here to add a custom disable time" 99 | public static let title = "Disable Time" 100 | } 101 | 102 | public struct Error { 103 | public static let invalidAPIToken = "Invalid API Token" 104 | public static let invalidResponse = "Invalid Response" 105 | public static let invalidURL = "Invalid URL" 106 | public static let decodeResponseError = "Can't decode response" 107 | public static let noAPITokenProvided = "No API Token Provided" 108 | public static let sessionError = "Session Error" 109 | public static let cantAddNewItem = "Can't add new item" 110 | } 111 | } 112 | 113 | public struct SystemImages { 114 | public static let piholeSetupHost = "server.rack" 115 | public static let piholeSetupDisplayName = "person.crop.square.fill.and.at.rectangle" 116 | public static let piholeSetupPort = "globe" 117 | public static let piholeSetupToken = "key" 118 | public static let piholeSetupTokenQRCode = "qrcode" 119 | public static let piholeStatusWarning = "exclamationmark.shield.fill" 120 | public static let piholeStatusOffline = "xmark.shield.fill" 121 | public static let piholeStatusOnline = "checkmark.shield.fill" 122 | public static let errorMessageWarning = "exclamationmark.triangle.fill" 123 | public static let settingsDisablePermanently = "xmark.shield" 124 | public static let settingsDisplayAllPiholesInSingleCard = "square.split.2x2" 125 | public static let settingsDisplayIcons = "globe" 126 | public static let settingsDisplayAsList = "list.bullet" 127 | public static let settingsDisplayIconBadgeForOffline = "app.badge" 128 | public static let addPiholeButton = "plus" 129 | public static let disablePiholeButton = "stop.fill" 130 | public static let enablePiholeButton = "play.fill" 131 | public static let metricTemperature = "thermometer" 132 | public static let metricUptime = "power" 133 | public static let metricLoadAverage = "cpu" 134 | public static let metricMemoryUsage = "memorychip" 135 | public static let piholeSetupMonitor = "binoculars" 136 | public static let piMonitorInfoButton = "info.circle" 137 | public static let deleteButton = "minus.circle.fill" 138 | public static let piStatsSourceCode = "terminal" 139 | public static let piStatsMacOS = "desktopcomputer" 140 | public static let leaveReview = "heart" 141 | public static let customizeDisableTimes = "clock" 142 | public static let addNewCustomDisableTime = "plus" 143 | public static let piMonitorTemperature = "thermometer" 144 | 145 | public static let domainsOnBlockList = "list.bullet" 146 | public static let totalQueries = "globe" 147 | public static let queriesBlocked = "hand.raised" 148 | public static let percentBlocked = "chart.pie" 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsKit/Sources/PiStatsKit/Utils/UserDefaultExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultExtensions.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 12/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension UserDefaults { 11 | static func shared() -> UserDefaults { 12 | return UserDefaults(suiteName: Constants.appGroup)! 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "pimonitor", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Bunn/PiMonitor", 7 | "state" : { 8 | "revision" : "84d2d26fdcf994e1bb5e129e47d965bb47aaba0b", 9 | "version" : "0.0.6" 10 | } 11 | }, 12 | { 13 | "identity" : "swifthole", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/Bunn/SwiftHole", 16 | "state" : { 17 | "revision" : "554d7977ba771157c0a7c7197399763a9a5ecad6", 18 | "version" : "3.0.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/xcshareddata/xcschemes/IntentHandler.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 60 | 62 | 68 | 69 | 70 | 71 | 79 | 81 | 87 | 88 | 89 | 90 | 92 | 93 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/xcshareddata/xcschemes/PiStatsMobile.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile.xcodeproj/xcshareddata/xcschemes/PiStatsWidgetExtension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | 47 | 59 | 62 | 68 | 69 | 70 | 71 | 77 | 78 | 79 | 80 | 84 | 85 | 89 | 90 | 94 | 95 | 99 | 100 | 101 | 102 | 110 | 112 | 118 | 119 | 120 | 121 | 123 | 124 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 11/07/2020. 6 | // 7 | 8 | import UIKit 9 | import BackgroundTasks 10 | //import WidgetKit 11 | 12 | class AppDelegate: NSObject, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 15 | // TODO, I can't set the background fetch since scenePhase is not working https://twitter.com/fcbunn/status/1281900924798226432?s=21 16 | // BGTaskScheduler.shared.register(forTaskWithIdentifier: "dev.bunn.PiStatsMobile.updatePiholes", using: nil) { task in 17 | // self.handleAppRefresh(task: task as! BGAppRefreshTask) 18 | // } 19 | // WidgetCenter.shared.reloadAllTimelines() 20 | 21 | return true 22 | } 23 | 24 | func applicationDidEnterBackground(_ application: UIApplication) { 25 | } 26 | /* 27 | func scheduleAppRefresh() { 28 | let request = BGAppRefreshTaskRequeste(identifier: dev.bunn...) 29 | request.earlierBeginDate = Date(.... 30 | 31 | do { 32 | try BGTaskScheduler.shared.submit(request) 33 | } catch { 34 | bleh 35 | } 36 | */ 37 | private func handleAppRefresh(task: BGAppRefreshTask) { 38 | //scheduleAppRefresh 39 | let queue = OperationQueue() 40 | queue.maxConcurrentOperationCount = 1 41 | 42 | //let operations = Operations.getOperationsToFetchLatestEntries(.... 43 | task.expirationHandler = { 44 | queue.cancelAllOperations() 45 | } 46 | 47 | //let lastOperation = operations.last! 48 | //lastOperation.completionBlock = { task.setTaskCompleted(success: !lastOperation.isCancelled) 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Base.lproj/PiholeIntents.intentdefinition: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INEnums 6 | 7 | INIntentDefinitionModelVersion 8 | 1.2 9 | INIntentDefinitionNamespace 10 | pwIO52 11 | INIntentDefinitionSystemVersion 12 | 19H2 13 | INIntentDefinitionToolsBuildVersion 14 | 12A7300 15 | INIntentDefinitionToolsVersion 16 | 12.0.1 17 | INIntents 18 | 19 | 20 | INIntentCategory 21 | information 22 | INIntentDescription 23 | Select Pihole to display stats 24 | INIntentDescriptionID 25 | 9VVPSk 26 | INIntentEligibleForWidgets 27 | 28 | INIntentIneligibleForSuggestions 29 | 30 | INIntentLastParameterTag 31 | 2 32 | INIntentName 33 | SelectPihole 34 | INIntentParameters 35 | 36 | 37 | INIntentParameterConfigurable 38 | 39 | INIntentParameterDisplayName 40 | Pihole 41 | INIntentParameterDisplayNameID 42 | PiJeMO 43 | INIntentParameterDisplayPriority 44 | 1 45 | INIntentParameterName 46 | pihole 47 | INIntentParameterObjectType 48 | PiholeIntent 49 | INIntentParameterObjectTypeNamespace 50 | pwIO52 51 | INIntentParameterPromptDialogs 52 | 53 | 54 | INIntentParameterPromptDialogCustom 55 | 56 | INIntentParameterPromptDialogType 57 | Configuration 58 | 59 | 60 | INIntentParameterPromptDialogCustom 61 | 62 | INIntentParameterPromptDialogType 63 | Primary 64 | 65 | 66 | INIntentParameterSupportsDynamicEnumeration 67 | 68 | INIntentParameterTag 69 | 2 70 | INIntentParameterType 71 | Object 72 | 73 | 74 | INIntentResponse 75 | 76 | INIntentResponseCodes 77 | 78 | 79 | INIntentResponseCodeName 80 | success 81 | INIntentResponseCodeSuccess 82 | 83 | 84 | 85 | INIntentResponseCodeName 86 | failure 87 | 88 | 89 | 90 | INIntentTitle 91 | Select Pihole 92 | INIntentTitleID 93 | XxlkUM 94 | INIntentType 95 | Custom 96 | INIntentVerb 97 | View 98 | 99 | 100 | INTypes 101 | 102 | 103 | INTypeDisplayName 104 | Pihole Intent 105 | INTypeDisplayNameID 106 | YlcCql 107 | INTypeLastPropertyTag 108 | 100 109 | INTypeName 110 | PiholeIntent 111 | INTypeProperties 112 | 113 | 114 | INTypePropertyDefault 115 | 116 | INTypePropertyDisplayPriority 117 | 1 118 | INTypePropertyName 119 | identifier 120 | INTypePropertyTag 121 | 1 122 | INTypePropertyType 123 | String 124 | 125 | 126 | INTypePropertyDefault 127 | 128 | INTypePropertyDisplayPriority 129 | 2 130 | INTypePropertyName 131 | displayString 132 | INTypePropertyTag 133 | 2 134 | INTypePropertyType 135 | String 136 | 137 | 138 | INTypePropertyDefault 139 | 140 | INTypePropertyDisplayPriority 141 | 3 142 | INTypePropertyName 143 | pronunciationHint 144 | INTypePropertyTag 145 | 3 146 | INTypePropertyType 147 | String 148 | 149 | 150 | INTypePropertyDefault 151 | 152 | INTypePropertyDisplayPriority 153 | 4 154 | INTypePropertyName 155 | alternativeSpeakableMatches 156 | INTypePropertySupportsMultipleValues 157 | 158 | INTypePropertyTag 159 | 4 160 | INTypePropertyType 161 | SpeakableString 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/PiStatsMobile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | $(PISTATS_APPGROUP_ID) 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/PiStatsMobileApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsMobileApp.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 02/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | @_exported import PiStatsKit 11 | 12 | final class DataModel: ObservableObject { 13 | let piholeProviderListManager = PiholeDataProviderListManager() 14 | let userPreferences = UserPreferences.shared 15 | private var offlineBadgeCancellable: AnyCancellable? 16 | } 17 | 18 | @main 19 | struct PiStatsMobileApp: App { 20 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 21 | @StateObject var dataModel = DataModel() 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | ContentView() 26 | .environmentObject(dataModel.piholeProviderListManager) 27 | .environmentObject(dataModel.userPreferences) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/PiholeDataProviderListManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeDataProviderListManager.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 08/07/2020. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import Combine 11 | 12 | class PiholeDataProviderListManager: ObservableObject { 13 | 14 | static func previewData() -> PiholeDataProviderListManager { 15 | let provider = PiholeDataProvider.init(piholes: [Pihole.previewData()]) 16 | let manager = PiholeDataProviderListManager() 17 | manager.providerList = [provider] 18 | return manager 19 | } 20 | 21 | @Published var providerList = [PiholeDataProvider]() 22 | @Published var allPiholesProvider = PiholeDataProvider(piholes: []) 23 | 24 | private var piholes = Pihole.restoreAll() 25 | private var offlineBadgeCancellable: AnyCancellable? 26 | var shouldUpdateIconBadgeWithOfflinePiholes: Bool = false 27 | 28 | var isEmpty: Bool { 29 | return providerList.count == 0 30 | } 31 | 32 | init() { 33 | setupProviders() 34 | } 35 | 36 | private func setupCancellables() { 37 | offlineBadgeCancellable = Publishers.MergeMany(providerList.map { $0.$offlinePiholesCount } ).sink {[weak self] value in 38 | let result = self?.providerList.reduce(0) { counter, provider in 39 | counter + provider.offlinePiholesCount 40 | } 41 | self?.setupApplicationBadge(result ?? 0) 42 | } 43 | } 44 | 45 | private func setupApplicationBadge(_ badgeCount: Int) { 46 | if shouldUpdateIconBadgeWithOfflinePiholes == false { 47 | return 48 | } 49 | //UIApplication.shared.applicationIconBadgeNumber = badgeCount 50 | } 51 | 52 | private func setupProviders() { 53 | piholes.forEach { pihole in 54 | addPiholeToList(pihole) 55 | } 56 | 57 | allPiholesProvider = PiholeDataProvider(piholes: piholes) 58 | setupCancellables() 59 | } 60 | 61 | private func addPiholeToList(_ pihole: Pihole){ 62 | let dataprovider = PiholeDataProvider(piholes: [pihole]) 63 | dataprovider.startPolling() 64 | objectWillChange.send() 65 | providerList.append(dataprovider) 66 | } 67 | 68 | func updateList(){ 69 | providerList.forEach { 70 | $0.stopPolling() 71 | } 72 | providerList.removeAll() 73 | piholes = Pihole.restoreAll() 74 | objectWillChange.send() 75 | setupProviders() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "app-icon60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "app-icon58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "app-icon87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "app-icon80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "app-icon120-1.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "app-icon120.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "app-icon180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "app-icon20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "app-icon40-1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "app-icon29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "app-icon58-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "app-icon40-2.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "app-icon80-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "app-icon76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "app-icon152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "app-icon167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "app-icon1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon1024.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon120-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon120-1.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon120.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon152.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon167.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon180.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon20.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon29.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40-1.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40-2.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon40.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon58-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon58-1.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon58.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon60.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon76.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon80-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon80-1.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon80.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/AppIcon.appiconset/app-icon87.png -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatsItem/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatsItem/DomainsOnBlockList.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.225", 9 | "green" : "0.294", 10 | "red" : "0.868" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatsItem/PercentBlocked.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.072", 9 | "green" : "0.611", 10 | "red" : "0.952" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatsItem/QueriesBlocked.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.938", 9 | "green" : "0.755", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatsItem/TotalQueries.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.354", 9 | "green" : "0.652", 10 | "red" : "0.002" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatusOffline.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.224", 9 | "green" : "0.294", 10 | "red" : "0.867" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatusOnline.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.196", 9 | "green" : "0.651", 10 | "red" : "0.004" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Assets.xcassets/StatusWarning.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.071", 9 | "green" : "0.612", 10 | "red" : "0.953" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BGTaskSchedulerPermittedIdentifiers 6 | 7 | dev.bunn.PiStatsMobile.updatePiholes 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleDisplayName 12 | Pi Stats 13 | CFBundleExecutable 14 | $(EXECUTABLE_NAME) 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | $(PRODUCT_NAME) 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | $(MARKETING_VERSION) 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | ITSAppUsesNonExemptEncryption 28 | 29 | LSRequiresIPhoneOS 30 | 31 | NSAppTransportSecurity 32 | 33 | NSAllowsArbitraryLoads 34 | 35 | 36 | NSCameraUsageDescription 37 | Camera permission is necessary to read the API Token QRCode 38 | NSUserActivityTypes 39 | 40 | SelectPiholeIntent 41 | 42 | PiStatsAppGroupID 43 | $(PISTATS_APPGROUP_ID) 44 | UIApplicationSceneManifest 45 | 46 | UIApplicationSupportsMultipleScenes 47 | 48 | 49 | UIApplicationSupportsIndirectInputEvents 50 | 51 | UIBackgroundModes 52 | 53 | fetch 54 | 55 | UILaunchScreen 56 | 57 | UIRequiredDeviceCapabilities 58 | 59 | armv7 60 | 61 | UISupportedInterfaceOrientations 62 | 63 | UIInterfaceOrientationPortrait 64 | UIInterfaceOrientationLandscapeLeft 65 | UIInterfaceOrientationLandscapeRight 66 | 67 | UISupportedInterfaceOrientations~ipad 68 | 69 | UIInterfaceOrientationPortrait 70 | UIInterfaceOrientationPortraitUpsideDown 71 | UIInterfaceOrientationLandscapeLeft 72 | UIInterfaceOrientationLandscapeRight 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | PiStatsMobile 4 | 5 | Created by Fernando Bunn on 06/07/2020. 6 | 7 | */ 8 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Supporting Files/pt-BR.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | PiStatsMobile 4 | 5 | Created by Fernando Bunn on 06/07/2020. 6 | 7 | */ 8 | 9 | "Disable" = "Desligar"; 10 | "Enable" = "Ligar"; 11 | "Settings" = "Ajustes"; 12 | "Total Queries" = "Total de Consultas"; 13 | "Percent Blocked" = "Percentual Bloqueado"; 14 | "Blocklist" = "Blocklist"; 15 | "Queries Blocked" = "Consultas Bloqueadas"; 16 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Utils/CodeScannerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeScannerView.swift 3 | // 4 | // Created by Paul Hudson on 10/12/2019. 5 | // Copyright © 2019 Paul Hudson. All rights reserved. 6 | // 7 | import AVFoundation 8 | import SwiftUI 9 | 10 | /// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found. 11 | /// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to 12 | /// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`. 13 | /// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back. 14 | public struct CodeScannerView: UIViewControllerRepresentable { 15 | public enum ScanError: Error { 16 | case badInput, badOutput 17 | } 18 | 19 | public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { 20 | var parent: CodeScannerView 21 | var codeFound = false 22 | 23 | init(parent: CodeScannerView) { 24 | self.parent = parent 25 | } 26 | 27 | public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { 28 | if let metadataObject = metadataObjects.first { 29 | guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } 30 | guard let stringValue = readableObject.stringValue else { return } 31 | guard codeFound == false else { return } 32 | 33 | AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) 34 | found(code: stringValue) 35 | 36 | // make sure we only trigger scans once per use 37 | codeFound = true 38 | } 39 | } 40 | 41 | func found(code: String) { 42 | parent.completion(.success(code)) 43 | } 44 | 45 | func didFail(reason: ScanError) { 46 | parent.completion(.failure(reason)) 47 | } 48 | } 49 | 50 | #if targetEnvironment(simulator) 51 | public class ScannerViewController: UIViewController,UIImagePickerControllerDelegate,UINavigationControllerDelegate{ 52 | var delegate: ScannerCoordinator? 53 | override public func loadView() { 54 | view = UIView() 55 | view.isUserInteractionEnabled = true 56 | let label = UILabel() 57 | label.translatesAutoresizingMaskIntoConstraints = false 58 | label.numberOfLines = 0 59 | 60 | label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data." 61 | label.textAlignment = .center 62 | let button = UIButton() 63 | button.translatesAutoresizingMaskIntoConstraints = false 64 | button.setTitle("Or tap here to select a custom image", for: .normal) 65 | button.setTitleColor(UIColor.systemBlue, for: .normal) 66 | button.setTitleColor(UIColor.gray, for: .highlighted) 67 | button.addTarget(self, action: #selector(self.openGallery), for: .touchUpInside) 68 | 69 | let stackView = UIStackView() 70 | stackView.translatesAutoresizingMaskIntoConstraints = false 71 | stackView.axis = .vertical 72 | stackView.spacing = 50 73 | stackView.addArrangedSubview(label) 74 | stackView.addArrangedSubview(button) 75 | 76 | view.addSubview(stackView) 77 | 78 | NSLayoutConstraint.activate([ 79 | button.heightAnchor.constraint(equalToConstant: 50), 80 | stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), 81 | stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), 82 | stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor) 83 | ]) 84 | } 85 | 86 | override public func touchesBegan(_ touches: Set, with event: UIEvent?) { 87 | guard let simulatedData = delegate?.parent.simulatedData else { 88 | print("Simulated Data Not Provided!") 89 | return 90 | } 91 | 92 | delegate?.found(code: simulatedData) 93 | } 94 | 95 | @objc func openGallery(_ sender: UIButton){ 96 | let imagePicker = UIImagePickerController() 97 | imagePicker.delegate = self 98 | self.present(imagePicker, animated: true, completion: nil) 99 | } 100 | 101 | public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]){ 102 | if let qrcodeImg = info[.originalImage] as? UIImage { 103 | let detector:CIDetector=CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy:CIDetectorAccuracyHigh])! 104 | let ciImage:CIImage=CIImage(image:qrcodeImg)! 105 | var qrCodeLink="" 106 | 107 | let features=detector.features(in: ciImage) 108 | for feature in features as! [CIQRCodeFeature] { 109 | qrCodeLink += feature.messageString! 110 | } 111 | 112 | if qrCodeLink=="" { 113 | delegate?.didFail(reason: .badOutput) 114 | }else{ 115 | delegate?.found(code: qrCodeLink) 116 | } 117 | } 118 | else{ 119 | print("Something went wrong") 120 | } 121 | self.dismiss(animated: true, completion: nil) 122 | } 123 | } 124 | #else 125 | public class ScannerViewController: UIViewController { 126 | var captureSession: AVCaptureSession! 127 | var previewLayer: AVCaptureVideoPreviewLayer! 128 | var delegate: ScannerCoordinator? 129 | 130 | override public func viewDidLoad() { 131 | super.viewDidLoad() 132 | 133 | 134 | NotificationCenter.default.addObserver(self, 135 | selector: #selector(updateOrientation), 136 | name: Notification.Name("UIDeviceOrientationDidChangeNotification"), 137 | object: nil) 138 | 139 | view.backgroundColor = UIColor.black 140 | captureSession = AVCaptureSession() 141 | 142 | guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return } 143 | let videoInput: AVCaptureDeviceInput 144 | 145 | do { 146 | videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) 147 | } catch { 148 | return 149 | } 150 | 151 | if (captureSession.canAddInput(videoInput)) { 152 | captureSession.addInput(videoInput) 153 | } else { 154 | delegate?.didFail(reason: .badInput) 155 | return 156 | } 157 | 158 | let metadataOutput = AVCaptureMetadataOutput() 159 | 160 | if (captureSession.canAddOutput(metadataOutput)) { 161 | captureSession.addOutput(metadataOutput) 162 | 163 | metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main) 164 | metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes 165 | } else { 166 | delegate?.didFail(reason: .badOutput) 167 | return 168 | } 169 | } 170 | 171 | override public func viewWillLayoutSubviews() { 172 | previewLayer?.frame = view.layer.bounds 173 | } 174 | 175 | @objc func updateOrientation() { 176 | guard let orientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation else { return } 177 | guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return } 178 | connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait 179 | } 180 | 181 | override public func viewDidAppear(_ animated: Bool) { 182 | super.viewDidAppear(animated) 183 | previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) 184 | previewLayer.frame = view.layer.bounds 185 | previewLayer.videoGravity = .resizeAspectFill 186 | view.layer.addSublayer(previewLayer) 187 | updateOrientation() 188 | captureSession.startRunning() 189 | } 190 | 191 | override public func viewWillAppear(_ animated: Bool) { 192 | super.viewWillAppear(animated) 193 | 194 | if (captureSession?.isRunning == false) { 195 | captureSession.startRunning() 196 | } 197 | } 198 | 199 | override public func viewWillDisappear(_ animated: Bool) { 200 | super.viewWillDisappear(animated) 201 | 202 | if (captureSession?.isRunning == true) { 203 | captureSession.stopRunning() 204 | } 205 | 206 | NotificationCenter.default.removeObserver(self) 207 | } 208 | 209 | override public var prefersStatusBarHidden: Bool { 210 | return true 211 | } 212 | 213 | override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { 214 | return .all 215 | } 216 | } 217 | #endif 218 | 219 | public let codeTypes: [AVMetadataObject.ObjectType] 220 | public var simulatedData = "" 221 | public var completion: (Result) -> Void 222 | 223 | public init(codeTypes: [AVMetadataObject.ObjectType], simulatedData: String = "", completion: @escaping (Result) -> Void) { 224 | self.codeTypes = codeTypes 225 | self.simulatedData = simulatedData 226 | self.completion = completion 227 | } 228 | 229 | public func makeCoordinator() -> ScannerCoordinator { 230 | return ScannerCoordinator(parent: self) 231 | } 232 | 233 | public func makeUIViewController(context: Context) -> ScannerViewController { 234 | let viewController = ScannerViewController() 235 | viewController.delegate = context.coordinator 236 | return viewController 237 | } 238 | 239 | public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) { 240 | 241 | } 242 | } 243 | 244 | struct CodeScannerView_Previews: PreviewProvider { 245 | static var previews: some View { 246 | CodeScannerView(codeTypes: [.qr]) { result in 247 | // do nothing 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Utils/ScannedPihole.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScannedPihole.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 05/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ScannedPihole: Codable { 11 | let host: String 12 | let port: Int 13 | let token: String? 14 | let secure: Bool? 15 | } 16 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Utils/ViewUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewUtils.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 01/10/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ViewUtils { 11 | 12 | static func shieldStatusImageForDataProvider(_ dataProvider: PiholeDataProvider) -> some View { 13 | if dataProvider.hasErrorMessages || dataProvider.status == .enabledAndDisabled { 14 | return Image(systemName: UIConstants.SystemImages.piholeStatusWarning) 15 | .foregroundColor(UIConstants.Colors.statusWarning) 16 | } else if dataProvider.status == .allEnabled { 17 | return Image(systemName: UIConstants.SystemImages.piholeStatusOnline) 18 | .foregroundColor(UIConstants.Colors.statusOnline) 19 | } else { 20 | return Image(systemName: UIConstants.SystemImages.piholeStatusOffline) 21 | .foregroundColor(UIConstants.Colors.statusOffline) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 02/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | TabView { 13 | NavigationView { 14 | PiholeStatsList() 15 | } 16 | .tabItem { 17 | Image(systemName: "shield") 18 | Text(UIConstants.Strings.piholesNavigationTitle) 19 | }.tag(0) 20 | .navigationViewStyle(StackNavigationViewStyle()) 21 | 22 | NavigationView { 23 | SettingsView() 24 | } 25 | .tabItem { 26 | Image(systemName: "gear") 27 | Text(UIConstants.Strings.settingsNavigationTitle) 28 | }.tag(1) 29 | .navigationViewStyle(StackNavigationViewStyle()) 30 | 31 | } 32 | } 33 | } 34 | 35 | struct ContentView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | Group { 38 | ContentView() 39 | .preferredColorScheme(.light) 40 | .environment(\.locale, .init(identifier: "pt_br")) 41 | ContentView() 42 | .preferredColorScheme(.dark) 43 | } 44 | 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/MetricsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetricsView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import PiMonitor 10 | 11 | fileprivate struct MetricItem: Identifiable { 12 | let value: String 13 | let systemName: String 14 | let helpText: String 15 | let id: UUID = UUID() 16 | } 17 | 18 | struct MetricsView: View { 19 | @ObservedObject var dataProvider: PiholeDataProvider 20 | private let imageSize: CGFloat = 15 21 | 22 | private func getMetricItems() -> [MetricItem] { 23 | return [ 24 | MetricItem(value: dataProvider.temperature, systemName: UIConstants.SystemImages.metricTemperature, helpText: "Raspberry Pi temperature"), 25 | MetricItem(value: dataProvider.uptime, systemName: UIConstants.SystemImages.metricUptime, helpText: "Raspberry Pi uptime"), 26 | MetricItem(value: dataProvider.loadAverage, systemName: UIConstants.SystemImages.metricLoadAverage, helpText: "Raspberry Pi load average"), 27 | MetricItem(value: dataProvider.memoryUsage, systemName: UIConstants.SystemImages.metricMemoryUsage, helpText: "Raspberry Pi memory usage"), 28 | ] 29 | } 30 | 31 | 32 | private let columns = [ 33 | GridItem(.flexible()), 34 | GridItem(.flexible()) 35 | ] 36 | 37 | var body: some View { 38 | LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { 39 | ForEach(getMetricItems()) { item in 40 | Label(title: { 41 | Text(item.value) 42 | }, icon: { 43 | Image(systemName: item.systemName) 44 | .frame(width: imageSize, height: imageSize) 45 | }) 46 | .minimumScaleFactor(0.5) 47 | .lineLimit(1) 48 | .font(.footnote) 49 | .help(item.helpText) 50 | } 51 | } 52 | } 53 | } 54 | 55 | 56 | struct MetricsView_Previews: PreviewProvider { 57 | static var previews: some View { 58 | MetricsView(dataProvider: PiholeDataProvider.previewData()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/PiholeSetupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeSetupView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 06/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum SecureTag: Int { 11 | case unsecure 12 | case secure 13 | } 14 | 15 | fileprivate class SetupViewModel: ObservableObject { 16 | let piMonitorURL = URL(string: "https://github.com/Bunn/pi_monitor")! 17 | @EnvironmentObject private var piholeProviderListManager: PiholeDataProviderListManager 18 | 19 | @Published var pihole: Pihole? 20 | @Published var host: String = "" 21 | @Published var port: String = "" 22 | @Published var token: String = "" 23 | @Published var displayName: String = "" 24 | @Published var isShowingScanner = false 25 | @Published var piMonitorPort: String = "" 26 | @Published var isPiMonitorEnabled: Bool = false 27 | @Published var displayPiMonitorAlert = false 28 | @Published var httpType: SecureTag = .unsecure 29 | 30 | init(pihole: Pihole? = nil) { 31 | self.pihole = pihole 32 | 33 | if let pihole = pihole { 34 | host = pihole.host 35 | token = pihole.apiToken 36 | httpType = pihole.secure ? .secure : .unsecure 37 | isPiMonitorEnabled = pihole.hasPiMonitor 38 | 39 | if let piholePort = pihole.port { 40 | port = String(piholePort) 41 | } 42 | 43 | if let piholeMonitorPort = pihole.piMonitorPort { 44 | piMonitorPort = String(piholeMonitorPort) 45 | } 46 | 47 | } else { 48 | host = "" 49 | token = "" 50 | httpType = .unsecure 51 | isPiMonitorEnabled = false 52 | } 53 | 54 | displayName = pihole?.displayName ?? "" 55 | } 56 | 57 | func savePihole() { 58 | var piholeToSave: Pihole 59 | let address = port.isEmpty ? host : "\(host):\(port)" 60 | 61 | if let pihole = pihole { 62 | piholeToSave = pihole 63 | piholeToSave.address = address 64 | } else { 65 | piholeToSave = Pihole(address: address) 66 | } 67 | piholeToSave.hasPiMonitor = isPiMonitorEnabled 68 | piholeToSave.piMonitorPort = Int(piMonitorPort) 69 | piholeToSave.apiToken = token 70 | piholeToSave.displayName = displayName.isEmpty ? nil : displayName 71 | piholeToSave.secure = httpType == .secure 72 | piholeToSave.save() 73 | } 74 | 75 | func deletePihole() { 76 | if let pihole = pihole { 77 | pihole.delete() 78 | } 79 | } 80 | } 81 | 82 | struct PiholeSetupView: View { 83 | 84 | init(pihole: Pihole? = nil) { 85 | _viewModel = StateObject(wrappedValue: SetupViewModel(pihole: pihole)) 86 | } 87 | 88 | @StateObject private var viewModel: SetupViewModel 89 | @EnvironmentObject private var piholeProviderListManager: PiholeDataProviderListManager 90 | 91 | @Environment(\.openURL) var openURL 92 | @Environment(\.presentationMode) private var mode: Binding 93 | 94 | private let imageWidthSize: CGFloat = 20 95 | 96 | var body: some View { 97 | NavigationView { 98 | List { 99 | Section(header: Text(UIConstants.Strings.settingsSectionPihole), footer: Text(UIConstants.Strings.piholeTokenFooterSection)) { 100 | 101 | HStack { 102 | Image(systemName: UIConstants.SystemImages.piholeSetupHost) 103 | .frame(width: imageWidthSize) 104 | TextField(UIConstants.Strings.piholeSetupHostPlaceholder, text: $viewModel.host) 105 | .autocapitalization(.none) 106 | .disableAutocorrection(true) 107 | } 108 | 109 | HStack { 110 | Image(systemName: UIConstants.SystemImages.piholeSetupDisplayName) 111 | .frame(width: imageWidthSize) 112 | TextField(UIConstants.Strings.piholeSetupDisplayName, text: $viewModel.displayName) 113 | .disableAutocorrection(true) 114 | } 115 | 116 | HStack { 117 | Image(systemName: UIConstants.SystemImages.piholeSetupPort) 118 | .frame(width: imageWidthSize) 119 | TextField(UIConstants.Strings.piholeSetupPortPlaceholder, text: $viewModel.port) 120 | .keyboardType(.numberPad) 121 | .autocapitalization(.none) 122 | .disableAutocorrection(true) 123 | } 124 | HStack { 125 | Picker(selection: $viewModel.httpType, label: Text("")) { 126 | Text(UIConstants.Strings.Preferences.protocolHTTP).tag(SecureTag.unsecure) 127 | Text(UIConstants.Strings.Preferences.protocolHTTPS).tag(SecureTag.secure) 128 | }.pickerStyle(SegmentedPickerStyle()) 129 | } 130 | HStack { 131 | Image(systemName: UIConstants.SystemImages.piholeSetupToken) 132 | .frame(width: imageWidthSize) 133 | SecureField(UIConstants.Strings.piholeSetupTokenPlaceholder, text: $viewModel.token) 134 | 135 | Image(systemName: UIConstants.SystemImages.piholeSetupTokenQRCode) 136 | .foregroundColor(Color(.systemBlue)) 137 | .onTapGesture { 138 | viewModel.isShowingScanner = true 139 | }.sheet(isPresented: $viewModel.isShowingScanner) { 140 | NavigationView { 141 | CodeScannerView(codeTypes: [.qr], simulatedData: "abcd", completion: handleScan) 142 | .navigationBarItems(leading: Button(UIConstants.Strings.cancelButton) { 143 | viewModel.isShowingScanner = false 144 | }).navigationBarTitle(Text(UIConstants.Strings.qrCodeScannerTitle), displayMode: .inline) 145 | } 146 | } 147 | } 148 | } 149 | 150 | Section(header: Text(UIConstants.Strings.settingsSectionPiMonitor)) { 151 | HStack { 152 | Toggle(isOn: $viewModel.isPiMonitorEnabled.animation()) { 153 | HStack { 154 | Image(systemName: UIConstants.SystemImages.piholeSetupMonitor) 155 | .frame(width: imageWidthSize) 156 | Text(UIConstants.Strings.piholeSetupEnablePiMonitor) 157 | .lineLimit(1) 158 | 159 | Image(systemName: UIConstants.SystemImages.piMonitorInfoButton) 160 | .frame(width: imageWidthSize) 161 | .foregroundColor(Color(.systemBlue)) 162 | .onTapGesture { 163 | viewModel.displayPiMonitorAlert.toggle() 164 | }.alert(isPresented: $viewModel.displayPiMonitorAlert) { 165 | Alert(title: Text(UIConstants.Strings.piMonitorSetupAlertTitle), message: Text(UIConstants.Strings.piMonitorExplanation), primaryButton: .default(Text(UIConstants.Strings.piMonitorSetupAlertLearnMoreButton)) { 166 | openURL(viewModel.piMonitorURL) 167 | }, secondaryButton: .cancel(Text(UIConstants.Strings.piMonitorSetupAlertOKButton))) 168 | } 169 | } 170 | } 171 | } 172 | 173 | if viewModel.isPiMonitorEnabled { 174 | HStack { 175 | Image(systemName: UIConstants.SystemImages.piholeSetupPort) 176 | .frame(width: imageWidthSize) 177 | TextField(UIConstants.Strings.piMonitorSetupPortPlaceholder, text: $viewModel.piMonitorPort) 178 | .keyboardType(.numberPad) 179 | .autocapitalization(.none) 180 | .disableAutocorrection(true) 181 | } 182 | } 183 | } 184 | 185 | if viewModel.pihole != nil { 186 | Section(footer: deleteButton()) { } 187 | } 188 | 189 | }.listStyle(InsetGroupedListStyle()) 190 | 191 | .navigationBarItems(leading: 192 | Button(UIConstants.Strings.cancelButton) { 193 | dismissView() 194 | }, trailing: Button(UIConstants.Strings.saveButton) { 195 | savePihole() 196 | }) 197 | .navigationTitle(UIConstants.Strings.piholeSetupTitle) 198 | } 199 | } 200 | 201 | private func dismissView() { 202 | mode.wrappedValue.dismiss() 203 | } 204 | 205 | private func deleteButton() -> some View { 206 | Button(action: { 207 | deletePihole() 208 | }, label: { 209 | HStack (spacing: 0) { 210 | Label(UIConstants.Strings.deleteButton, systemImage: UIConstants.SystemImages.deleteButton) 211 | .font(.headline) 212 | .foregroundColor(.white) 213 | } 214 | .frame(maxWidth: .infinity, minHeight: 48) 215 | .background(Color(.systemRed)) 216 | .cornerRadius(UIConstants.Geometry.defaultCornerRadius) 217 | }) 218 | } 219 | 220 | private func savePihole() { 221 | viewModel.savePihole() 222 | piholeProviderListManager.updateList() 223 | dismissView() 224 | } 225 | 226 | private func deletePihole() { 227 | viewModel.deletePihole() 228 | piholeProviderListManager.updateList() 229 | dismissView() 230 | } 231 | 232 | private func handleScan(result: Result) { 233 | self.viewModel.isShowingScanner = false 234 | switch result { 235 | case .success(let data): 236 | handleScannedString(data) 237 | case .failure(let error): 238 | print("Scanning failed \(error)") 239 | } 240 | } 241 | 242 | private func handleScannedString(_ value: String) { 243 | 244 | let decoder = JSONDecoder() 245 | 246 | guard let data = value.data(using: .utf8) else { return } 247 | do { 248 | let result = try decoder.decode([String: ScannedPihole].self, from: data) 249 | if let scannedPihole = result["pihole"] { 250 | viewModel.token = scannedPihole.token ?? "" 251 | viewModel.host = scannedPihole.host 252 | viewModel.port = String(scannedPihole.port) 253 | 254 | if let secure = scannedPihole.secure { 255 | viewModel.httpType = secure ? .secure : .unsecure 256 | } 257 | } 258 | } catch { 259 | viewModel.token = value 260 | } 261 | } 262 | } 263 | 264 | 265 | struct PiholeSetupView_Previews: PreviewProvider { 266 | static var previews: some View { 267 | PiholeSetupView() 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/PiholeStatsList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeStatsList.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 05/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | final class StatsListConfig: ObservableObject { 12 | @Published var selectedPiHole: Pihole? 13 | @Published var isSetupPresented = false 14 | 15 | func openPiholeSetup(_ pihole: Pihole? = nil) { 16 | selectedPiHole = pihole; 17 | isSetupPresented = true 18 | } 19 | } 20 | 21 | struct PiholeStatsList: View { 22 | @StateObject private var viewModel = StatsListConfig() 23 | @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? 24 | @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? 25 | @EnvironmentObject private var userPreferences: UserPreferences 26 | @EnvironmentObject private var piholeProviderListManager: PiholeDataProviderListManager 27 | @Environment(\.scenePhase) private var phase 28 | /* 29 | I want to make this logic on the @App but it seems there's a bug on Beta2 30 | More info here: https://twitter.com/fcbunn/status/1281905574695886848?s=21 31 | */ 32 | 33 | private let columns = [ 34 | GridItem(.flexible()), 35 | GridItem(.flexible()) 36 | ] 37 | 38 | private func regularSetup() -> some View { 39 | Group { 40 | LazyVGrid(columns: columns, alignment: .center, spacing: 10) { 41 | ForEach(piholeProviderListManager.providerList, id: \.id) { provider in 42 | StatsView(dataProvider: provider) 43 | .onTapGesture() { 44 | viewModel.openPiholeSetup(provider.piholes.first) 45 | } 46 | } 47 | if piholeProviderListManager.isEmpty == false { 48 | addPiholeButton() 49 | } 50 | } 51 | if piholeProviderListManager.isEmpty == true { 52 | addPiholeButton() 53 | } 54 | } 55 | } 56 | 57 | private func addPiholeButton() -> some View { 58 | Button(action: { 59 | viewModel.openPiholeSetup() 60 | }, label: { 61 | ZStack { 62 | Circle() 63 | .frame(width: UIConstants.Geometry.addPiholeButtonHeight, height: UIConstants.Geometry.addPiholeButtonHeight, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) 64 | Image(systemName: UIConstants.SystemImages.addPiholeButton) 65 | .foregroundColor(.white) 66 | .font(.largeTitle) 67 | } 68 | }) 69 | .shadow(radius: UIConstants.Geometry.shadowRadius) 70 | .padding() 71 | } 72 | 73 | private func compactSetup() -> some View { 74 | Group { 75 | ForEach(piholeProviderListManager.providerList, id: \.id) { provider in 76 | StatsView(dataProvider: provider) 77 | .onTapGesture() { 78 | viewModel.openPiholeSetup(provider.piholes.first) 79 | } 80 | } 81 | addPiholeButton() 82 | } 83 | } 84 | 85 | var body: some View { 86 | ZStack { 87 | Color(.systemGroupedBackground) 88 | .edgesIgnoringSafeArea(.all) 89 | 90 | ScrollView { 91 | 92 | if userPreferences.displayAllPiholes { 93 | StatsView(dataProvider: piholeProviderListManager.allPiholesProvider) 94 | Divider() 95 | } 96 | if horizontalSizeClass == .regular && verticalSizeClass == .regular { 97 | regularSetup() 98 | } else { 99 | compactSetup() 100 | } 101 | 102 | if piholeProviderListManager.isEmpty { 103 | Text(UIConstants.Strings.addFirstPiholeCaption) 104 | } 105 | } 106 | .sheet(isPresented: $viewModel.isSetupPresented) { 107 | PiholeSetupView(pihole: viewModel.selectedPiHole) 108 | .environmentObject(piholeProviderListManager) 109 | } 110 | }.navigationTitle(UIConstants.Strings.piholesNavigationTitle) 111 | .onChange(of: phase) { newPhase in 112 | switch newPhase { 113 | case .active: 114 | print("active") 115 | case .inactive: 116 | print("inactive") 117 | case .background: 118 | WidgetCenter.shared.reloadAllTimelines() 119 | @unknown default: break 120 | } 121 | } 122 | } 123 | } 124 | 125 | 126 | struct PiholeStatsList_Previews: PreviewProvider { 127 | static var previews: some View { 128 | PiholeStatsList() 129 | .environmentObject(UserPreferences()) 130 | .environmentObject(PiholeDataProviderListManager.previewData()) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/StatsItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsItemView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 04/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatsItemView: View { 11 | enum StatsItemViewLayoutType { 12 | case list 13 | case rounded 14 | } 15 | var displayIcons: Bool = true 16 | var layoutType: StatsItemViewLayoutType = .rounded 17 | let contentType: StatsItemType 18 | let value: String 19 | 20 | var body: some View { 21 | if layoutType == .list { 22 | ListStatView(displayStatsIcons: displayIcons, contentType: contentType, value: value) 23 | 24 | } else { 25 | RoundedStatView(displayStatsIcons: displayIcons, contentType: contentType, label: value) 26 | } 27 | } 28 | } 29 | 30 | fileprivate struct ListStatView: View { 31 | let displayStatsIcons: Bool 32 | let contentType: StatsItemType 33 | let value: String 34 | private let imageWidth: CGFloat = 15 35 | 36 | var body: some View { 37 | HStack { 38 | Label { 39 | Text(contentType.title) 40 | .foregroundColor(.primary) 41 | } icon: { 42 | Group { 43 | if displayStatsIcons { 44 | Image(systemName: contentType.imageName) 45 | .frame(width: imageWidth) 46 | } else { 47 | Image(systemName: "circle.fill") 48 | .frame(width: imageWidth) 49 | 50 | } 51 | } 52 | .foregroundColor(contentType.color) 53 | } 54 | Spacer() 55 | Text(value) 56 | 57 | } 58 | } 59 | } 60 | 61 | fileprivate struct RoundedStatView: View { 62 | let displayStatsIcons: Bool 63 | let contentType: StatsItemType 64 | let label: String 65 | 66 | var body: some View { 67 | VStack (alignment: .leading, spacing: 5) { 68 | Text(contentType.title) 69 | .foregroundColor(.white) 70 | .lineLimit(1) 71 | .minimumScaleFactor(0.8) 72 | .frame(maxWidth: .infinity, alignment: .leading) 73 | HStack { 74 | if displayStatsIcons { 75 | Label(label, systemImage: contentType.imageName) 76 | .foregroundColor(.white) 77 | .font(.headline) 78 | } else { 79 | Text(label) 80 | .foregroundColor(.white) 81 | .font(.headline) 82 | .padding(.top, 4) 83 | } 84 | } 85 | } 86 | .padding(.horizontal, UIConstants.Geometry.defaultPadding) 87 | .padding(.vertical, UIConstants.Geometry.defaultPadding) 88 | .background(contentType.color) 89 | .cornerRadius(UIConstants.Geometry.defaultCornerRadius) 90 | } 91 | } 92 | 93 | 94 | struct StatsItemView_Previews: PreviewProvider { 95 | static var previews: some View { 96 | Group { 97 | StatsItemView(layoutType: .list, contentType: .domainsOnBlockList, value: "1234") 98 | StatsItemView(layoutType: .rounded, contentType: .domainsOnBlockList, value: "1234") 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/StatsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 04/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatsView: View { 11 | @ObservedObject var dataProvider: PiholeDataProvider 12 | @EnvironmentObject private var userPreferences: UserPreferences 13 | @State private var isShowingDisableOptions = false 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: UIConstants.Geometry.defaultPadding) { 17 | StatusHeaderView(dataProvider: dataProvider) 18 | 19 | VStack { 20 | ForEach(dataProvider.pollingErrors, id: \.self) { error in 21 | Label { 22 | Text(error) 23 | .fixedSize(horizontal: false, vertical: true) 24 | } icon: { 25 | Image(systemName: UIConstants.SystemImages.errorMessageWarning) 26 | .foregroundColor(UIConstants.Colors.errorMessage) 27 | } 28 | .font(.headline) 29 | } 30 | } 31 | 32 | if userPreferences.displayStatsAsList { 33 | statsList() 34 | } else { 35 | statsGrid() 36 | } 37 | 38 | if dataProvider.canDisplayMetrics && dataProvider.piholes.count == 1 { 39 | Divider() 40 | MetricsView(dataProvider: dataProvider) 41 | } 42 | 43 | if dataProvider.canDisplayEnableDisableButton { 44 | Divider() 45 | if dataProvider.status == .allDisabled { 46 | enableButton() 47 | } else { 48 | disableButton() 49 | } 50 | } 51 | } 52 | .padding() 53 | .background(Color(.secondarySystemGroupedBackground)) 54 | .cornerRadius(UIConstants.Geometry.defaultCornerRadius) 55 | .shadow(radius: UIConstants.Geometry.shadowRadius) 56 | .padding() 57 | 58 | } 59 | 60 | private func statsList() -> some View { 61 | return VStack (alignment: .leading, spacing: 5){ 62 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, layoutType: .list, contentType: .totalQueries, value: dataProvider.totalQueries) 63 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, layoutType: .list, contentType: .queriesBlocked, value: dataProvider.queriesBlocked) 64 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, layoutType: .list, contentType: .percentBlocked, value: dataProvider.percentBlocked) 65 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, layoutType: .list, contentType: .domainsOnBlockList, value: dataProvider.domainsOnBlocklist) 66 | } 67 | } 68 | 69 | private func statsGrid() -> some View { 70 | return Group { 71 | HStack { 72 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, contentType: .totalQueries, value: dataProvider.totalQueries) 73 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, contentType: .queriesBlocked, value: dataProvider.queriesBlocked) 74 | } 75 | HStack { 76 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, contentType: .percentBlocked, value: dataProvider.percentBlocked) 77 | StatsItemView(displayIcons: userPreferences.displayStatsIcons, contentType: .domainsOnBlockList, value: dataProvider.domainsOnBlocklist) 78 | } 79 | } 80 | } 81 | 82 | 83 | private func disableButton() -> some View { 84 | Button(action: { 85 | if userPreferences.disablePermanently { 86 | dataProvider.disablePiHole() 87 | } else { 88 | isShowingDisableOptions = true 89 | } 90 | 91 | }, label: { 92 | HStack (spacing: 0) { 93 | Label(UIConstants.Strings.disableButton, systemImage: UIConstants.SystemImages.disablePiholeButton) 94 | .font(.headline) 95 | .foregroundColor(.white) 96 | } 97 | .frame(maxWidth: .infinity, minHeight: 48) 98 | .background(Color(.systemBlue)) 99 | .cornerRadius(UIConstants.Geometry.defaultCornerRadius) 100 | }) 101 | .actionSheet(isPresented: $isShowingDisableOptions) { 102 | disableTimeActionSheet() 103 | } 104 | } 105 | 106 | func disableTimeActionSheet() -> ActionSheet { 107 | let disableTimes = userPreferences.disableTimes 108 | 109 | let intervalFormatter = DateComponentsFormatter() 110 | intervalFormatter.unitsStyle = .full 111 | intervalFormatter.allowedUnits = [.second, .minute, .hour] 112 | 113 | var buttons = disableTimes.map { timeInterval in 114 | Alert.Button.default(Text(intervalFormatter.string(from: timeInterval) ?? "-"), action: { 115 | dataProvider.disablePiHole(seconds: Int(timeInterval)) 116 | } ) 117 | } 118 | 119 | buttons.append(.destructive(Text(UIConstants.Strings.disablePiholeOptionsPermanently)) { 120 | dataProvider.disablePiHole() 121 | }) 122 | 123 | buttons.append(.cancel()) 124 | return ActionSheet(title: Text(UIConstants.Strings.disablePiholeOptionsTitle), buttons: buttons) 125 | } 126 | 127 | private func enableButton() -> some View { 128 | Button(action: { 129 | dataProvider.enablePiHole() 130 | }, label: { 131 | HStack (spacing: 0) { 132 | Label(UIConstants.Strings.enableButton, systemImage: UIConstants.SystemImages.enablePiholeButton) 133 | .font(.headline) 134 | .foregroundColor(.white) 135 | } 136 | .frame(maxWidth: .infinity, minHeight: 48) 137 | .background(Color(.systemBlue)) 138 | .cornerRadius(UIConstants.Geometry.defaultCornerRadius) 139 | }) 140 | } 141 | } 142 | 143 | 144 | struct StatsView_Previews: PreviewProvider { 145 | static var previews: some View { 146 | StatsView(dataProvider: PiholeDataProvider.previewData()) 147 | .environmentObject(UserPreferences()) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Piholes/StatusHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusHeaderView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 12/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct StatusHeaderView: View { 12 | @ObservedObject var dataProvider: PiholeDataProvider 13 | 14 | var body: some View { 15 | HStack { 16 | Label { 17 | Text(dataProvider.name) 18 | .foregroundColor(.primary) 19 | .fontWeight(.bold) 20 | } icon: { 21 | if dataProvider.hasErrorMessages { 22 | Image(systemName: UIConstants.SystemImages.piholeStatusWarning) 23 | .foregroundColor(UIConstants.Colors.statusWarning) 24 | } else if dataProvider.status == .allEnabled { 25 | Image(systemName: UIConstants.SystemImages.piholeStatusOnline) 26 | .foregroundColor(UIConstants.Colors.statusOnline) 27 | } else { 28 | Image(systemName: UIConstants.SystemImages.piholeStatusOffline) 29 | .foregroundColor(UIConstants.Colors.statusOffline) 30 | } 31 | } 32 | .font(.title2) 33 | } 34 | } 35 | } 36 | 37 | 38 | struct StatusHeaderView_Previews: PreviewProvider { 39 | static var previews: some View { 40 | StatusHeaderView(dataProvider: PiholeDataProvider.previewData()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Representables/CountdownPickerViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountdownPickerViewRepresentable.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 20/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CountdownPickerViewRepresentable: UIViewRepresentable { 11 | @Binding var duration: TimeInterval 12 | 13 | func makeUIView(context: Context) -> UIPickerView { 14 | let pickerView = CountdownPickerView() 15 | pickerView.dataSource = context.coordinator 16 | pickerView.delegate = context.coordinator 17 | return pickerView 18 | } 19 | 20 | func updateUIView(_ pickerView: UIPickerView, context: Context) { 21 | let roundedDuration = Int(duration) 22 | 23 | let seconds = roundedDuration % 60 24 | let minutes = (roundedDuration / 60) % 60 25 | let hours = (roundedDuration / 3600) 26 | 27 | pickerView.selectRow(hours, inComponent: 0, animated: false) 28 | pickerView.selectRow(minutes, inComponent: 1, animated: false) 29 | pickerView.selectRow(seconds, inComponent: 2, animated: false) 30 | } 31 | 32 | func makeCoordinator() -> Coordinator { 33 | Coordinator(self) 34 | } 35 | 36 | class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { 37 | let parent: CountdownPickerViewRepresentable 38 | 39 | init(_ parent: CountdownPickerViewRepresentable) { 40 | self.parent = parent 41 | } 42 | 43 | func numberOfComponents(in pickerView: UIPickerView) -> Int { 44 | return 3 45 | } 46 | 47 | func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { 48 | switch component { 49 | case 0: 50 | return 25 51 | case 1,2: 52 | return 60 53 | default: 54 | return 0 55 | } 56 | } 57 | 58 | func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat { 59 | switch component { 60 | case 0, 1: 61 | return pickerView.frame.size.width / 3.5 62 | default: 63 | return pickerView.frame.size.width / 2.5 64 | } 65 | } 66 | 67 | func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { 68 | switch component { 69 | case 0: 70 | return "\(row)" 71 | case 1: 72 | return "\(row)" 73 | case 2: 74 | return "\(row)" 75 | default: 76 | return "" 77 | } 78 | } 79 | func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { 80 | let selectedHour = TimeInterval(pickerView.selectedRow(inComponent: 0) * 60 * 60) 81 | let selectedMinute = TimeInterval(pickerView.selectedRow(inComponent: 1) * 60) 82 | let selectedSecond = TimeInterval(pickerView.selectedRow(inComponent: 2)) 83 | parent.duration = selectedHour + selectedMinute + selectedSecond 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Settings/CustomDurationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomDurationsView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 20/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct TimePickerRow: View { 11 | @Binding var timeInterval: TimeInterval 12 | 13 | var body: some View { 14 | Group{ 15 | HStack { 16 | Spacer() 17 | CountdownPickerViewRepresentable(duration: $timeInterval) 18 | Spacer() 19 | } 20 | } 21 | } 22 | } 23 | 24 | struct CustomDurationsView: View { 25 | @StateObject var disableDurationManager = DisableDurationManager(userPreferences: UserPreferences.shared) 26 | @State private var selectedItems = Set() 27 | 28 | var body: some View { 29 | Group { 30 | if disableDurationManager.items.count == 0 { 31 | VStack { 32 | Text(UIConstants.Strings.CustomizeDisabletime.emptyListMessage) 33 | .font(.title) 34 | .multilineTextAlignment(.center) 35 | .padding() 36 | Button(action: { 37 | addNewDuration() 38 | }, label: { 39 | ZStack { 40 | Circle() 41 | .frame(width: UIConstants.Geometry.addPiholeButtonHeight, height: UIConstants.Geometry.addPiholeButtonHeight, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) 42 | Image(systemName: UIConstants.SystemImages.addPiholeButton) 43 | .foregroundColor(.white) 44 | .font(.largeTitle) 45 | } 46 | }) 47 | .shadow(radius: UIConstants.Geometry.shadowRadius) 48 | .padding() 49 | } 50 | } else { 51 | List { 52 | ForEach(disableDurationManager.items, id: \.self) { item in 53 | Button(action: { 54 | withAnimation { 55 | if selectedItems.contains(item) { 56 | selectedItems.remove(item) 57 | } else { 58 | selectedItems.insert(item) 59 | } 60 | } 61 | }, label: { 62 | Text(item.title) 63 | .foregroundColor(.primary) 64 | }) 65 | /* 66 | This is required because once you use ForEach you can't use bindings anymore. 67 | One strategy is to use indices when looping, but when I do that the animations get really weird (more than already is) 68 | specially on delete animations 69 | */ 70 | if selectedItems.contains(item) { 71 | TimePickerRow(timeInterval: Binding( 72 | get: { item.timeInterval }, 73 | set: { item.timeInterval = $0 } )) 74 | .animation(nil) 75 | } 76 | }.onDelete(perform: delete) 77 | } 78 | } 79 | } 80 | .navigationBarTitle(UIConstants.Strings.CustomizeDisabletime.title, displayMode: .inline) 81 | .navigationBarItems(trailing: 82 | Button(action: { 83 | withAnimation { 84 | addNewDuration() 85 | } 86 | }) { 87 | Image(systemName: UIConstants.SystemImages.addNewCustomDisableTime) 88 | .resizable().frame(width: 20, height: 20) 89 | .font(.caption) 90 | } 91 | ) 92 | } 93 | 94 | private func addNewDuration() { 95 | disableDurationManager.addNewItem() 96 | } 97 | 98 | func delete(at offsets: IndexSet) { 99 | offsets.map { disableDurationManager.items[$0] }.forEach { itemToDelete in 100 | selectedItems.remove(itemToDelete) 101 | } 102 | disableDurationManager.delete(at: offsets) 103 | } 104 | } 105 | 106 | struct CustomDurationsView_Previews: PreviewProvider { 107 | static var previews: some View { 108 | CustomDurationsView(disableDurationManager: DisableDurationManager(userPreferences: UserPreferences())) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import StoreKit 10 | 11 | fileprivate struct PiStatsURL { 12 | static let review = URL(string: "https://apps.apple.com/us/app/pi-stats-mobile/id1523024268?action=write-review&mt=8")! 13 | static let piStatsMobileGitHub = URL(string: "https://github.com/Bunn/PiStatsMobile")! 14 | static let piStatsMacOSGitHub = URL(string: "https://github.com/Bunn/PiStats")! 15 | } 16 | 17 | struct SettingsView: View { 18 | @EnvironmentObject private var userPreferences: UserPreferences 19 | 20 | private var appVersion: String { 21 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" 22 | } 23 | 24 | var body: some View { 25 | List { 26 | Section(header: Text(UIConstants.Strings.Preferences.sectionInterface)) { 27 | Toggle(isOn: $userPreferences.displayStatsAsList) { 28 | Label(UIConstants.Strings.Preferences.displayAsList, systemImage: UIConstants.SystemImages.settingsDisplayAsList) 29 | } 30 | 31 | Toggle(isOn: $userPreferences.displayStatsIcons) { 32 | Label(UIConstants.Strings.Preferences.displayIcons, systemImage: UIConstants.SystemImages.settingsDisplayIcons) 33 | } 34 | 35 | Toggle(isOn: $userPreferences.displayAllPiholes) { 36 | Label(UIConstants.Strings.Preferences.displayAllPiholesInSingleCard, systemImage: UIConstants.SystemImages.settingsDisplayAllPiholesInSingleCard) 37 | } 38 | } 39 | 40 | Section(header: Text(UIConstants.Strings.Preferences.sectionEnableDisable)) { 41 | 42 | Toggle(isOn: $userPreferences.disablePermanently.animation()) { 43 | Label(UIConstants.Strings.Preferences.alwaysDisablePermanently, systemImage: UIConstants.SystemImages.settingsDisablePermanently) 44 | } 45 | 46 | if userPreferences.disablePermanently == false { 47 | NavigationLink(destination: CustomDurationsView()) { 48 | Label(UIConstants.Strings.Preferences.customizeDisableTimes, systemImage: UIConstants.SystemImages.customizeDisableTimes) 49 | } 50 | } 51 | } 52 | 53 | Section(header: Text(UIConstants.Strings.Preferences.sectionPiMonitor)) { 54 | Label(UIConstants.Strings.Preferences.piMonitorTemperature, systemImage: UIConstants.SystemImages.piMonitorTemperature) 55 | 56 | Picker(selection: userPreferences.$temperatureScale, label: Text("")) { 57 | Text(UIConstants.Strings.temperatureScaleCelsius).tag(0) 58 | Text(UIConstants.Strings.temperatureScaleFahrenheit).tag(1) 59 | }.pickerStyle(SegmentedPickerStyle()) 60 | } 61 | 62 | Section(header: Text(UIConstants.Strings.Preferences.about), footer: Text("\(UIConstants.Strings.Preferences.version) \(appVersion)")) { 63 | 64 | Button(action: { 65 | openGithubPage() 66 | }, label: { 67 | Label(UIConstants.Strings.Preferences.piStatsSourceCode, systemImage: UIConstants.SystemImages.piStatsSourceCode) 68 | .foregroundColor(.primary) 69 | }) 70 | 71 | Button(action: { 72 | openPiStatsMacOS() 73 | }, label: { 74 | Label(UIConstants.Strings.Preferences.piStatsForMacOS, systemImage: UIConstants.SystemImages.piStatsMacOS) 75 | .foregroundColor(.primary) 76 | }) 77 | 78 | Button(action: { 79 | leaveAppReview() 80 | }, label: { 81 | Label(UIConstants.Strings.Preferences.leaveReview, systemImage: UIConstants.SystemImages.leaveReview) 82 | .foregroundColor(.primary) 83 | }) 84 | } 85 | } 86 | .listStyle(InsetGroupedListStyle()) 87 | .navigationTitle(UIConstants.Strings.settingsNavigationTitle) 88 | } 89 | 90 | private func leaveAppReview() { 91 | UIApplication.shared.open(PiStatsURL.review, options: [:], completionHandler: nil) 92 | } 93 | 94 | private func openGithubPage() { 95 | UIApplication.shared.open(PiStatsURL.piStatsMobileGitHub) 96 | } 97 | 98 | private func openPiStatsMacOS() { 99 | UIApplication.shared.open(PiStatsURL.piStatsMacOSGitHub) 100 | } 101 | } 102 | 103 | struct SettingsView_Previews: PreviewProvider { 104 | static var previews: some View { 105 | SettingsView() 106 | .environmentObject(UserPreferences()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/Views/UIViews/CountdownPickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountdownPickerView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 20/09/2020. 6 | // 7 | 8 | import UIKit 9 | 10 | class CountdownPickerView: UIPickerView { 11 | let hourLabel = UILabel() 12 | let minutesLabel = UILabel() 13 | let secondsLabel = UILabel() 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | addSubview(hourLabel) 19 | addSubview(minutesLabel) 20 | addSubview(secondsLabel) 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | override func layoutSubviews() { 28 | super.layoutSubviews() 29 | 30 | hourLabel.text = "hours" 31 | hourLabel.font = UIFont.boldSystemFont(ofSize: 17) 32 | hourLabel.sizeToFit() 33 | 34 | minutesLabel.text = "min" 35 | minutesLabel.font = UIFont.boldSystemFont(ofSize: 17) 36 | minutesLabel.sizeToFit() 37 | 38 | secondsLabel.text = "sec" 39 | secondsLabel.font = UIFont.boldSystemFont(ofSize: 17) 40 | secondsLabel.sizeToFit() 41 | 42 | /* 43 | I don't think there's any way to add static labels on a UIPicker without doing something really sad like this. 44 | https://twitter.com/fcbunn/status/1307727709003558912?s=21 45 | */ 46 | let yOrigin: CGFloat = (self.frame.height / 2.0) - (hourLabel.frame.height / 2.0) 47 | 48 | hourLabel.frame = CGRect(x: 67, y: yOrigin, width: hourLabel.frame.width, height: hourLabel.frame.height) 49 | minutesLabel.frame = CGRect(x: 165, y: yOrigin, width: minutesLabel.frame.width, height: minutesLabel.frame.height) 50 | secondsLabel.frame = CGRect(x: 277, y: yOrigin, width: secondsLabel.frame.width, height: secondsLabel.frame.height) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/en.lproj/PiholeIntents.strings: -------------------------------------------------------------------------------- 1 | "PiJeMO" = "Pihole"; 2 | 3 | "XxlkUM" = "Select Pihole"; 4 | 5 | "YlcCql" = "Pihole Intent"; 6 | 7 | "9VVPSk" = "Select Pihole to display stats"; 8 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobile/pt-BR.lproj/PiholeIntents.strings: -------------------------------------------------------------------------------- 1 | "PiJeMO" = "Pihole"; 2 | 3 | "XxlkUM" = "Select Pihole"; 4 | 5 | "YlcCql" = "Pihole Intent"; 6 | 7 | "9VVPSk" = "Select Pihole to display stats"; 8 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobileTests/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 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobileTests/PiStatsMobileTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsMobileTests.swift 3 | // PiStatsMobileTests 4 | // 5 | // Created by Fernando Bunn on 02/07/2020. 6 | // 7 | 8 | import XCTest 9 | @testable import PiStatsMobile 10 | 11 | class PiStatsMobileTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobileUITests/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 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsMobileUITests/PiStatsMobileUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsMobileUITests.swift 3 | // PiStatsMobileUITests 4 | // 5 | // Created by Fernando Bunn on 02/07/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | class PiStatsMobileUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/PiMonitorWidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.110", 27 | "green" : "0.102", 28 | "red" : "0.098" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatsItem/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatsItem/DomainsOnBlockList.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.225", 9 | "green" : "0.294", 10 | "red" : "0.868" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatsItem/PercentBlocked.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.072", 9 | "green" : "0.611", 10 | "red" : "0.952" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatsItem/QueriesBlocked.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.938", 9 | "green" : "0.755", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatsItem/TotalQueries.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.354", 9 | "green" : "0.652", 10 | "red" : "0.002" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatusOffline.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.224", 9 | "green" : "0.294", 10 | "red" : "0.867" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatusOnline.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.196", 9 | "green" : "0.651", 10 | "red" : "0.004" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/StatusWarning.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.071", 9 | "green" : "0.612", 10 | "red" : "0.953" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Core/PiMonitorTimelineProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiMonitorTimelineProvider.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 30/09/2020. 6 | // 7 | 8 | 9 | import WidgetKit 10 | import Foundation 11 | import os.log 12 | 13 | struct PiMonitorTimelineProvider: IntentTimelineProvider { 14 | typealias Intent = SelectPiholeIntent 15 | 16 | typealias Entry = PiholeEntry 17 | private static let fakePihole = PiholeDataProvider.previewData() 18 | private let log = Logger().osLog(describing: PiMonitorTimelineProvider.self) 19 | 20 | func placeholder(in context: Context) -> PiholeEntry { 21 | PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemSmall) 22 | } 23 | 24 | func getSnapshot(for configuration: Intent, in context: Context, completion: @escaping (PiholeEntry) -> Void) { 25 | let entry = PiholeEntry(piholeDataProvider: PiMonitorTimelineProvider.fakePihole, date: Date(), widgetFamily: context.family) 26 | completion(entry) 27 | } 28 | 29 | 30 | func getTimeline(for configuration: SelectPiholeIntent, in context: Context, completion: @escaping (Timeline) -> Void) { 31 | let currentDate = Date() 32 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)! 33 | 34 | if let identifier = configuration.pihole?.identifier, 35 | let piholeUUID = UUID(uuidString: identifier), 36 | let pihole = Pihole.restore(piholeUUID) { 37 | let provider = PiholeDataProvider(piholes: [pihole]) 38 | os_log("get timeline called") 39 | let dispatchGroup = DispatchGroup() 40 | 41 | dispatchGroup.enter() 42 | provider.fetchSummaryData { 43 | dispatchGroup.leave() 44 | } 45 | 46 | dispatchGroup.enter() 47 | provider.fetchMetricsData { 48 | dispatchGroup.leave() 49 | } 50 | 51 | dispatchGroup.notify(queue: DispatchQueue.main) { 52 | let entry = PiholeEntry(piholeDataProvider: provider, date: Date(), widgetFamily: context.family) 53 | let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) 54 | completion(timeline) 55 | } 56 | 57 | } else { 58 | os_log("No pihole found/selected") 59 | let provider = PiholeDataProvider(piholes: []) 60 | let entry = PiholeEntry(piholeDataProvider: provider, date: Date(), widgetFamily: context.family) 61 | let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) 62 | completion(timeline) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Core/PiholeEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeEntry.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 21/07/2020. 6 | // 7 | 8 | import WidgetKit 9 | import Foundation 10 | 11 | struct PiholeEntry: TimelineEntry { 12 | let piholeDataProvider: PiholeDataProvider 13 | let date: Date 14 | let widgetFamily: WidgetFamily 15 | } 16 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Core/PiholeTimelineProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiholeTimelineProvider.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 21/07/2020. 6 | // 7 | 8 | import WidgetKit 9 | import Foundation 10 | import os.log 11 | 12 | struct PiholeTimelineProvider: TimelineProvider { 13 | typealias Entry = PiholeEntry 14 | private static let fakePihole = PiholeDataProvider.previewData() 15 | private let log = Logger().osLog(describing: PiholeTimelineProvider.self) 16 | 17 | func placeholder(in context: Context) -> PiholeEntry { 18 | PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemSmall) 19 | } 20 | 21 | func getSnapshot(in context: Context, completion: @escaping (PiholeEntry) -> Void) { 22 | let entry = PiholeEntry(piholeDataProvider: PiholeTimelineProvider.fakePihole, date: Date(), widgetFamily: context.family) 23 | completion(entry) 24 | } 25 | 26 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 27 | let currentDate = Date() 28 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)! 29 | 30 | let piholes = Pihole.restoreAll() 31 | let provider = PiholeDataProvider(piholes: piholes) 32 | os_log("get timeline called") 33 | 34 | provider.fetchSummaryData { 35 | os_log("summary returned") 36 | 37 | let entry = PiholeEntry(piholeDataProvider: provider, date: Date(), widgetFamily: context.family) 38 | let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) 39 | completion(timeline) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | PiStatsWidget 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 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | NSExtension 31 | 32 | NSExtensionPointIdentifier 33 | com.apple.widgetkit-extension 34 | 35 | PiStatsAppGroupID 36 | $(PISTATS_APPGROUP_ID) 37 | 38 | 39 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Intents/IntentsLogging.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | typealias OSLogger = os.Logger 5 | 6 | extension OSLogger { 7 | static let intents = OSLogger(subsystem: Bundle.main.bundleIdentifier ?? "PiStats", category: "PiStatsIntents") 8 | } 9 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Intents/RefreshWidgetIntent.swift: -------------------------------------------------------------------------------- 1 | import WidgetKit 2 | import AppIntents 3 | import OSLog 4 | 5 | @available(iOS 17.0, *) 6 | struct RefreshWidgetIntent: AppIntent { 7 | static var title: LocalizedStringResource = "Refresh" 8 | 9 | func perform() async throws -> some IntentResult { 10 | OSLogger.intents.debug("\(String(describing: type(of: self))) Perform") 11 | return .result() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/PiMonitorWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiMonitorWidget.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 29/09/2020. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | private struct PlaceholderView : View { 12 | var body: some View { 13 | PiMonitorView(provider: PiholeDataProvider.previewData(), shouldDisplayStats: false ) 14 | .redacted(reason: .placeholder) 15 | } 16 | } 17 | 18 | struct PiMonitorWidget: Widget { 19 | private let kind: String = "PiMonitorWidget" 20 | public var body: some WidgetConfiguration { 21 | let config = IntentConfiguration( 22 | kind: "dev.bunn.PiStatsMobile.SelectPiholeIntent", 23 | intent: SelectPiholeIntent.self, 24 | provider: PiMonitorTimelineProvider() 25 | ) { entry in 26 | PiMonitorWidgetView(entry: entry) 27 | } 28 | .configurationDisplayName("Pi Monitor") 29 | .description("Display metrics for your Raspberry Pi") 30 | .supportedFamilies([.systemSmall, .systemMedium]) 31 | 32 | if #available(iOSApplicationExtension 17.0, *) { 33 | return config.contentMarginsDisabled() 34 | } else { 35 | return config 36 | } 37 | } 38 | } 39 | 40 | 41 | struct PiMonitorWidget_Previews: PreviewProvider { 42 | static var previews: some View { 43 | 44 | PiMonitorWidgetView(entry: PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemSmall)) 45 | .disableContentMarginsForPreview() 46 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 47 | 48 | PiMonitorWidgetView(entry: PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemMedium)) 49 | .disableContentMarginsForPreview() 50 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 51 | 52 | PlaceholderView() 53 | .disableContentMarginsForPreview() 54 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/PiStatsWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | $(PISTATS_APPGROUP_ID) 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/PiStatsWidgets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsWidgets.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 21/07/2020. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | @_exported import PiStatsKit 11 | 12 | @main 13 | struct PiStatsWidgets: WidgetBundle { 14 | @WidgetBundleBuilder 15 | var body: some Widget { 16 | ViewStatsWidget() 17 | PiMonitorWidget() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/ViewStatsWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsWidget.swift 3 | // PiStatsWidget 4 | // 5 | // Created by Fernando Bunn on 11/07/2020. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | private struct PlaceholderView : View { 12 | var body: some View { 13 | VStack (spacing:0) { 14 | HStack(spacing:0) { 15 | StatsItemType.totalQueries.color 16 | StatsItemType.queriesBlocked.color 17 | } 18 | HStack(spacing:0) { 19 | StatsItemType.percentBlocked.color 20 | StatsItemType.domainsOnBlockList.color 21 | } 22 | } 23 | .widgetBackground() 24 | } 25 | } 26 | 27 | struct ViewStatsWidget: Widget { 28 | private let kind: String = "ViewStatsWidget" 29 | 30 | public var body: some WidgetConfiguration { 31 | let config = StaticConfiguration(kind: kind, provider: PiholeTimelineProvider()) { entry in 32 | PiStatsDisplayWidgetView(entry: entry) 33 | } 34 | .configurationDisplayName("Pi Stats") 35 | .description("Display the status of your pi-holes") 36 | .supportedFamilies([.systemSmall, .systemMedium]) 37 | 38 | if #available(iOSApplicationExtension 17.0, *) { 39 | return config.contentMarginsDisabled() 40 | } else { 41 | return config 42 | } 43 | } 44 | } 45 | 46 | struct ViewStatsWidget_Previews: PreviewProvider { 47 | /// NOTE: Previews do not respect `contentMarginsDisabled` from widget because they use the view directly, 48 | /// so margins will not look correct in Xcode Previews. 49 | static var previews: some View { 50 | PiStatsDisplayWidgetView(entry: PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemSmall)) 51 | .disableContentMarginsForPreview() 52 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 53 | .previewDisplayName("System Small") 54 | 55 | PiStatsDisplayWidgetView(entry: PiholeEntry(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemMedium)) 56 | .disableContentMarginsForPreview() 57 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 58 | .previewDisplayName("System Medium") 59 | 60 | PlaceholderView() 61 | .disableContentMarginsForPreview() 62 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 63 | .previewDisplayName("Placeholder") 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/BackDeployableShims.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackDeployableShims.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Guilherme Rambo on 13/06/23. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | extension View { 12 | @ViewBuilder 13 | func widgetBackground() -> some View { 14 | if #available(iOS 17.0, *) { 15 | containerBackground(.background, for: .widget) 16 | } else { 17 | self 18 | } 19 | } 20 | 21 | @ViewBuilder 22 | func numericContentTransition(countsDown: Bool = false) -> some View { 23 | if #available(iOSApplicationExtension 16.0, *) { 24 | contentTransition(.numericText(countsDown: countsDown)) 25 | } else { 26 | self 27 | } 28 | } 29 | 30 | #if DEBUG 31 | /// Workaround for WidgetKit previews using old-style PreviewProvider 32 | /// not respecting the `contentMarginsDisabled()` modifier 33 | /// when previewing on iOS 17 Simulator. 34 | @ViewBuilder 35 | func disableContentMarginsForPreview() -> some View { 36 | padding(-16) 37 | } 38 | #endif 39 | } 40 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/CircleBadgeStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleBadgeStatus.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 13/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CircleBadgeStatus: View { 11 | let dataProvider: PiholeDataProvider 12 | private let circleSize: CGFloat = 30 13 | 14 | var body: some View { 15 | ZStack { 16 | Circle() 17 | .foregroundColor(.white) 18 | .frame(width: circleSize, height: circleSize) 19 | 20 | ViewUtils.shieldStatusImageForDataProvider(dataProvider) 21 | .font(.title2) 22 | } 23 | } 24 | } 25 | 26 | struct CircleBadgeStatus_Previews: PreviewProvider { 27 | static var previews: some View { 28 | CircleBadgeStatus(dataProvider: PiholeDataProvider.previewData()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/MediumStatsItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediumStatsItem.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 13/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MediumStatsItem: View { 11 | let contentType: StatsItemType 12 | let value: String 13 | private let stackSpacing: CGFloat = 10 14 | 15 | var body: some View { 16 | ZStack { 17 | contentType.color 18 | 19 | VStack (alignment: .leading, spacing: stackSpacing) { 20 | Text(contentType.title) 21 | .foregroundColor(.white) 22 | .font(.subheadline) 23 | .lineLimit(1) 24 | .minimumScaleFactor(0.8) 25 | .frame(maxWidth: .infinity, alignment: .leading) 26 | HStack { 27 | Label(value, systemImage: contentType.imageName) 28 | .foregroundColor(.white) 29 | .font(.headline) 30 | .numericContentTransition() 31 | } 32 | } 33 | .padding(.horizontal, UIConstants.Geometry.widgetDefaultPadding) 34 | .padding(.vertical, UIConstants.Geometry.widgetDefaultPadding) 35 | } 36 | } 37 | } 38 | 39 | struct MediumStatsItem_Previews: PreviewProvider { 40 | static var previews: some View { 41 | MediumStatsItem(contentType: .domainsOnBlockList, value: "1234") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/PiMonitor/PiMonitorStatusHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiMonitorStatusHeader.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 01/10/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PiMonitorStatusHeader: View { 11 | var provider: PiholeDataProvider 12 | 13 | var body: some View { 14 | VStack (alignment:.leading) { 15 | Label(title: { 16 | Text(provider.name) 17 | }, icon: { 18 | ViewUtils.shieldStatusImageForDataProvider(provider) 19 | }) 20 | .minimumScaleFactor(0.75) 21 | .font(Font.headline.weight(.bold)) 22 | Divider() 23 | Spacer() 24 | } 25 | } 26 | } 27 | 28 | struct PiMonitorStatusHeader_Previews: PreviewProvider { 29 | static var previews: some View { 30 | PiMonitorStatusHeader(provider: PiholeDataProvider.previewData()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/PiMonitor/PiMonitorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiMonitorView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 01/10/2020. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | fileprivate struct ListItem : Identifiable{ 12 | let id = UUID() 13 | let text: String 14 | let systemImage: String 15 | let color: Color 16 | } 17 | struct PiMonitorStatsView: View { 18 | let imageSize: CGFloat = 15 19 | fileprivate let listItems: [ListItem] 20 | 21 | var body: some View { 22 | VStack (alignment:.leading, spacing: 6.0) { 23 | 24 | ForEach(listItems) { item in 25 | Label(title: { 26 | Text(item.text) 27 | }, icon: { 28 | Image(systemName: item.systemImage) 29 | .frame(width: imageSize, height: imageSize) 30 | }) 31 | .font(Font.body.weight(.medium)) 32 | .minimumScaleFactor(0.80) 33 | .foregroundColor(item.color) 34 | } 35 | } 36 | } 37 | } 38 | 39 | struct PiMonitorView: View { 40 | var provider: PiholeDataProvider 41 | let imageSize: CGFloat = 15 42 | let shouldDisplayStats: Bool 43 | 44 | var body: some View { 45 | VStack (alignment:.leading) { 46 | PiMonitorStatusHeader(provider: provider) 47 | 48 | HStack { 49 | PiMonitorStatsView(listItems: getMetricListItems(provider)) 50 | .font(Font.body.weight(.semibold)) 51 | .minimumScaleFactor(0.89) 52 | 53 | if shouldDisplayStats { 54 | Spacer() 55 | 56 | PiMonitorStatsView(listItems: getStatsListItems(provider)) 57 | } 58 | } 59 | } 60 | .frame( 61 | maxWidth: .infinity, 62 | maxHeight: .infinity, 63 | alignment: .topLeading 64 | ) 65 | .padding() 66 | .font(.headline) 67 | .widgetBackground() 68 | } 69 | 70 | private func getMetricListItems(_ provider: PiholeDataProvider) -> [ListItem] { 71 | return [ 72 | 73 | ListItem(text: provider.memoryUsage, systemImage: UIConstants.SystemImages.metricMemoryUsage, color: UIConstants.Colors.totalQueries), 74 | 75 | ListItem(text: provider.uptime, systemImage: UIConstants.SystemImages.metricUptime, color: UIConstants.Colors.queriesBlocked), 76 | 77 | ListItem(text: provider.temperature, systemImage: UIConstants.SystemImages.metricTemperature, color: UIConstants.Colors.domainsOnBlocklist), 78 | 79 | ListItem(text: provider.loadAverage, systemImage: UIConstants.SystemImages.metricLoadAverage, color: UIConstants.Colors.percentBlocked), 80 | ] 81 | } 82 | 83 | private func getStatsListItems(_ provider: PiholeDataProvider) -> [ListItem] { 84 | return [ 85 | ListItem(text: provider.totalQueries, systemImage: UIConstants.SystemImages.totalQueries, color: UIConstants.Colors.totalQueries), 86 | 87 | ListItem(text: provider.queriesBlocked, systemImage: UIConstants.SystemImages.queriesBlocked, color: UIConstants.Colors.queriesBlocked), 88 | 89 | ListItem(text: provider.domainsOnBlocklist, systemImage: UIConstants.SystemImages.domainsOnBlockList, color: UIConstants.Colors.domainsOnBlocklist), 90 | 91 | ListItem(text: provider.percentBlocked, systemImage: UIConstants.SystemImages.percentBlocked, color: UIConstants.Colors.percentBlocked), 92 | ] 93 | } 94 | } 95 | 96 | struct PiMonitorView_Previews: PreviewProvider { 97 | static var previews: some View { 98 | PiMonitorView(provider: PiholeDataProvider.previewData(), shouldDisplayStats: true) 99 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 100 | 101 | PiMonitorView(provider: PiholeDataProvider.previewData(), shouldDisplayStats: false) 102 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 103 | 104 | 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/PiMonitor/PiMonitorWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiMonitorWidgetView.swift 3 | // PiStatsMobile 4 | // 5 | // Created by Fernando Bunn on 29/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | struct PiMonitorWidgetView: View { 12 | var entry: PiholeEntry 13 | var shouldDisplayStats: Bool { 14 | entry.widgetFamily == .systemMedium 15 | } 16 | 17 | var body: some View { 18 | ZStack { 19 | UIConstants.Colors.piMonitorWidgetBackground 20 | 21 | if entry.piholeDataProvider.piholes.count == 0 { 22 | PiMonitorView(provider: PiholeDataProvider.previewData() , shouldDisplayStats: shouldDisplayStats).redacted(reason: .placeholder) 23 | } else if entry.piholeDataProvider.canDisplayMetrics == false { 24 | VStack (spacing: 10) { 25 | Image(systemName: UIConstants.SystemImages.piholeSetupMonitor) 26 | .foregroundColor(UIConstants.Colors.domainsOnBlocklist) 27 | 28 | Text("\(UIConstants.Strings.Widget.piholeNotEnabledOn) \(entry.piholeDataProvider.name)") 29 | .multilineTextAlignment(.center) 30 | } 31 | .font(Font.headline.weight(.semibold)) 32 | .padding() 33 | } 34 | else { 35 | PiMonitorView(provider: entry.piholeDataProvider, shouldDisplayStats: shouldDisplayStats) 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct PiMonitorWidgetView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | PiMonitorWidgetView(entry: PiholeEntry.init(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemMedium)) 44 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 45 | 46 | PiMonitorWidgetView(entry: PiholeEntry.init(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemMedium)) 47 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/PiStatsDisplayWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PiStatsDisplayWidgetView.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 13/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | struct PiStatsDisplayWidgetView : View { 12 | var entry: PiholeEntry 13 | 14 | var body: some View { 15 | ZStack { 16 | if entry.widgetFamily == WidgetFamily.systemSmall { 17 | ZStack { 18 | VStack (spacing:0) { 19 | HStack(spacing:0) { 20 | SmallStatsItem(itemType: StatsItemType.totalQueries, value: entry.piholeDataProvider.totalQueries) 21 | SmallStatsItem(itemType: StatsItemType.queriesBlocked, value: entry.piholeDataProvider.queriesBlocked) 22 | } 23 | HStack(spacing:0) { 24 | SmallStatsItem(itemType: StatsItemType.percentBlocked, value: entry.piholeDataProvider.percentBlocked) 25 | SmallStatsItem(itemType: StatsItemType.domainsOnBlockList, value: entry.piholeDataProvider.domainsOnBlocklist) 26 | } 27 | } 28 | circleBadge 29 | } 30 | } else { 31 | ZStack { 32 | VStack (alignment:.leading, spacing:0) { 33 | HStack(spacing:0) { 34 | MediumStatsItem(contentType: .totalQueries, value: entry.piholeDataProvider.totalQueries) 35 | MediumStatsItem(contentType: .queriesBlocked, value: entry.piholeDataProvider.queriesBlocked) 36 | } 37 | HStack(spacing:0) { 38 | MediumStatsItem(contentType: .percentBlocked, value: entry.piholeDataProvider.percentBlocked) 39 | MediumStatsItem(contentType: .domainsOnBlockList, value: entry.piholeDataProvider.domainsOnBlocklist) 40 | } 41 | } 42 | circleBadge 43 | } 44 | } 45 | } 46 | .widgetBackground() 47 | } 48 | 49 | @ViewBuilder 50 | private var circleBadge: some View { 51 | if #available(iOS 17.0, *) { 52 | Button(intent: RefreshWidgetIntent()) { 53 | CircleBadgeStatus(dataProvider: entry.piholeDataProvider) 54 | } 55 | .buttonStyle(.borderless) 56 | } else { 57 | CircleBadgeStatus(dataProvider: entry.piholeDataProvider) 58 | } 59 | } 60 | } 61 | 62 | struct PiStatsDisplayWidgetView_Previews: PreviewProvider { 63 | static var previews: some View { 64 | 65 | PiStatsDisplayWidgetView(entry: PiholeEntry.init(piholeDataProvider: PiholeDataProvider.previewData(), date: Date(), widgetFamily: .systemMedium)) 66 | .disableContentMarginsForPreview() 67 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /PiStatsMobile/PiStatsWidget/Views/SmallStatsItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmallStatsItem.swift 3 | // PiStatsWidgetExtension 4 | // 5 | // Created by Fernando Bunn on 13/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SmallStatsItem: View { 11 | let itemType: StatsItemType 12 | let value: String 13 | private let stackSpacing: CGFloat = 10 14 | 15 | var body: some View { 16 | ZStack { 17 | itemType.color 18 | VStack(spacing: stackSpacing) { 19 | Image(systemName: itemType.imageName) 20 | .font(.headline) 21 | .foregroundColor(.white) 22 | 23 | Text(value) 24 | .foregroundColor(.white) 25 | .font(.subheadline) 26 | .bold() 27 | .numericContentTransition() 28 | } 29 | } 30 | } 31 | } 32 | 33 | struct SmallStatsItem_Previews: PreviewProvider { 34 | static var previews: some View { 35 | SmallStatsItem(itemType: .domainsOnBlockList, value: "1234") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /PrivacyPolicy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ## What we collect 4 | This app doesn't log any kind of data from you. 5 | 6 | It doesn't have analytics or crash reporting tools (Besides the ones provided by Apple if you install it from the App Store) 7 | 8 | Your host address is stored as plain text and your API Token is securely stored in your keychain. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # Pi Stats Mobile 7 | 8 | Follow up and manage the status of your [Pi-hole(s)](https://github.com/pi-hole/pi-hole) with this simple iOS app. 9 | 10 | 11 | ## Authentication Token 12 | In order to use the "enable/disable" button you need to add your Authentication Token in the Settings screen. 13 | 14 | There are two different ways to get your authentication token: 15 | 16 | - /etc/pihole/setupVars.conf under WEBPASSWORD 17 | - WebUI -> Settings -> API -> Show API Token (At this point you can also scan it using the QR Code scanner) 18 | 19 | 20 | ## Screenshots 21 |

22 | 23 |

24 | 25 | ## Exporting settings from macOS Pi Stats 26 | 27 | If you use [Pi Stats for macOS](https://apps.apple.com/us/app/pi-stats/id1514075262?mt=12) you can export your Pi-holes using the QR Code feature. 28 | 29 | Open Preferences and then click on the QR Code button for each Pi-hole you have. 30 | Don't forget to change the QR Code Format to Pi Stats. 31 |

32 | 33 |

34 | 35 | Then, on Pi Stats Mobile, when you tap to setup a new Pi-hole, just tap on the QR Code button and scan it. 36 |

37 | 38 |

39 | 40 | ## Requirement 41 | This project uses SwiftUI and Widgets which requires iOS 14 or later. 42 | 43 | Tested with Pi-hole 4.4 and 5.0 44 | 45 | ## Pi Monitor 46 | If you want, you can install [Pi Monitor](https://github.com/Bunn/pi_monitor) on your Raspberry Pi to get extra information like temperature, uptime, memory usage and load average. Pi Monitor is experimental and not required to use Pi Stats, but it does make it more fun ;) 47 | 48 | ## Download 49 | Pi Stats Mobile ([SwiftHole](https://github.com/Bunn/SwiftHole) and [Pi Monitor](https://github.com/Bunn/pi_monitor)) is a open source hobby project of mine. Feel free to download the project and install on your device, but if you want to suport it's development you can buy it on the [App Store](https://apps.apple.com/us/app/id1523024268) :) 50 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TEAM_ID_FILE=PiStatsMobile/Config/TeamID.xcconfig 4 | 5 | function print_team_ids() { 6 | echo "" 7 | echo "FYI, here are the team IDs found in your Xcode preferences:" 8 | echo "" 9 | 10 | XCODEPREFS="$HOME/Library/Preferences/com.apple.dt.Xcode.plist" 11 | TEAM_KEYS=(`/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams" "$XCODEPREFS" | perl -lne 'print $1 if /^ (\S*) =/'`) 12 | 13 | for KEY in $TEAM_KEYS 14 | do 15 | i=0 16 | while true ; do 17 | NAME=$(/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams:$KEY:$i:teamName" "$XCODEPREFS" 2>/dev/null) 18 | TEAMID=$(/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams:$KEY:$i:teamID" "$XCODEPREFS" 2>/dev/null) 19 | 20 | if [ $? -ne 0 ]; then 21 | break 22 | fi 23 | 24 | echo "$TEAMID - $NAME" 25 | 26 | i=$(($i + 1)) 27 | done 28 | done 29 | } 30 | 31 | if [ -z "$1" ]; then 32 | print_team_ids 33 | echo "" 34 | echo "> What is your Apple Developer Team ID? (looks like 1A23BDCD)" 35 | read TEAM_ID 36 | else 37 | TEAM_ID=$1 38 | fi 39 | 40 | if [ -z "$TEAM_ID" ]; then 41 | echo "You must enter a team id" 42 | print_team_ids 43 | exit 1 44 | fi 45 | 46 | echo "Setting team ID to $TEAM_ID" 47 | 48 | echo "// This file was automatically generated, do not edit directly." > $TEAM_ID_FILE 49 | echo "" >> $TEAM_ID_FILE 50 | echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $TEAM_ID_FILE 51 | 52 | echo "" 53 | echo "Successfully generated configuration at $TEAM_ID_FILE, you may now build the app using the \"PiStatsMobile\" target" 54 | echo "You may need to close and re-open the project in Xcode if it's already open" 55 | echo "" -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/images/icon.png -------------------------------------------------------------------------------- /images/ios_qrcode.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/images/ios_qrcode.jpeg -------------------------------------------------------------------------------- /images/pistatsmobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/images/pistatsmobile.png -------------------------------------------------------------------------------- /images/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bunn/PiStatsMobile/e36855dfde10994fc6ccfa5e1f2992c55cf9cae0/images/qrcode.png --------------------------------------------------------------------------------