├── .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
--------------------------------------------------------------------------------