Use Kotlin multiplatform library for data layer
This commit is contained in:
parent
dcba7c3979
commit
811341b139
15 changed files with 285 additions and 853 deletions
|
@ -17,9 +17,6 @@
|
|||
2821267C235926EB00072D52 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2821267A235926EB00072D52 /* LaunchScreen.storyboard */; };
|
||||
28212687235926EB00072D52 /* Pi_HelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28212686235926EB00072D52 /* Pi_HelperTests.swift */; };
|
||||
28212692235926EB00072D52 /* Pi_HelperUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28212691235926EB00072D52 /* Pi_HelperUITests.swift */; };
|
||||
282126A6235BE6EE00072D52 /* PiHoleDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */; };
|
||||
282126A8235BE72800072D52 /* PiHole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A7235BE72800072D52 /* PiHole.swift */; };
|
||||
282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A9235BEE0F00072D52 /* PiHoleApiService.swift */; };
|
||||
282126AC235BF09800072D52 /* PiHoleDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AB235BF09800072D52 /* PiHoleDetailsView.swift */; };
|
||||
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AD235BF21D00072D52 /* AddPiHoleView.swift */; };
|
||||
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */; };
|
||||
|
@ -32,6 +29,7 @@
|
|||
28EC1E3C23A448940088BA26 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3B23A448940088BA26 /* Styles.swift */; };
|
||||
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */; };
|
||||
80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A8CF842782190400CCCEC5 /* AsyncData.swift */; };
|
||||
80B3226928FE010E00CB33B5 /* PihelperStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B3226828FE010E00CB33B5 /* PihelperStore.swift */; };
|
||||
80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E91FA623DE160100B7FB55 /* AboutView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
@ -69,9 +67,6 @@
|
|||
2821268D235926EB00072D52 /* Pi-helperUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Pi-helperUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
28212691235926EB00072D52 /* Pi_HelperUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pi_HelperUITests.swift; sourceTree = "<group>"; };
|
||||
28212693235926EB00072D52 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleDataStore.swift; sourceTree = "<group>"; };
|
||||
282126A7235BE72800072D52 /* PiHole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHole.swift; sourceTree = "<group>"; };
|
||||
282126A9235BEE0F00072D52 /* PiHoleApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleApiService.swift; sourceTree = "<group>"; };
|
||||
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleDetailsView.swift; sourceTree = "<group>"; };
|
||||
282126AD235BF21D00072D52 /* AddPiHoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPiHoleView.swift; sourceTree = "<group>"; };
|
||||
282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
|
||||
|
@ -91,6 +86,7 @@
|
|||
805148A927D1B2C7008FF0A4 /* Pihelper.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pihelper.framework; path = "../pihelper-android/shared/build/fat-framework/debug/Pihelper.framework"; sourceTree = "<group>"; };
|
||||
80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableCustomTimeView.swift; sourceTree = "<group>"; };
|
||||
80A8CF842782190400CCCEC5 /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
|
||||
80B3226828FE010E00CB33B5 /* PihelperStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PihelperStore.swift; sourceTree = "<group>"; };
|
||||
80E91FA623DE160100B7FB55 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
@ -158,9 +154,6 @@
|
|||
28D7205823808E8C0038D439 /* Extensions.swift */,
|
||||
280F0D6223DA9B1800D341B7 /* KeyboardHelper.swift */,
|
||||
28EC1E3923A441F30088BA26 /* OrDivider.swift */,
|
||||
282126A7235BE72800072D52 /* PiHole.swift */,
|
||||
282126A9235BEE0F00072D52 /* PiHoleApiService.swift */,
|
||||
282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */,
|
||||
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */,
|
||||
280AA3EA2390B302007841F1 /* RetrieveApiKeyView.swift */,
|
||||
28D2125B23A59057003B33F2 /* ScanningView.swift */,
|
||||
|
@ -171,6 +164,7 @@
|
|||
282126B6235C0F5400072D52 /* Localizable.strings */,
|
||||
28212677235926EB00072D52 /* Preview Content */,
|
||||
80A8CF842782190400CCCEC5 /* AsyncData.swift */,
|
||||
80B3226828FE010E00CB33B5 /* PihelperStore.swift */,
|
||||
);
|
||||
path = "Pi-helper";
|
||||
sourceTree = "<group>";
|
||||
|
@ -368,11 +362,10 @@
|
|||
files = (
|
||||
280F0D6323DA9B1800D341B7 /* KeyboardHelper.swift in Sources */,
|
||||
28D2125C23A59057003B33F2 /* ScanningView.swift in Sources */,
|
||||
80B3226928FE010E00CB33B5 /* PihelperStore.swift in Sources */,
|
||||
80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */,
|
||||
282126AC235BF09800072D52 /* PiHoleDetailsView.swift in Sources */,
|
||||
28EC1E3C23A448940088BA26 /* Styles.swift in Sources */,
|
||||
282126A6235BE6EE00072D52 /* PiHoleDataStore.swift in Sources */,
|
||||
282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */,
|
||||
280AA3EB2390B302007841F1 /* RetrieveApiKeyView.swift in Sources */,
|
||||
28EC1E3A23A441F30088BA26 /* OrDivider.swift in Sources */,
|
||||
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */,
|
||||
|
@ -382,7 +375,6 @@
|
|||
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */,
|
||||
28212674235926E700072D52 /* ContentView.swift in Sources */,
|
||||
28D7205923808E8C0038D439 /* Extensions.swift in Sources */,
|
||||
282126A8235BE72800072D52 /* PiHole.swift in Sources */,
|
||||
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */,
|
||||
80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */,
|
||||
);
|
||||
|
|
|
@ -6,9 +6,12 @@
|
|||
// Copyright © 2020 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
var version: String? {
|
||||
get {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
|
@ -30,20 +33,18 @@ struct AboutView: View {
|
|||
.font(.subheadline)
|
||||
.padding(.bottom)
|
||||
Button(action: {
|
||||
self.dataStore.forgetPihole()
|
||||
self.store.dispatch(ActionForget())
|
||||
}, label: { Text("forget_pihole") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
}.padding()
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
init(_ dataStore: PiHoleDataStore) {
|
||||
self.dataStore = dataStore
|
||||
.onDisappear {
|
||||
self.store.dispatch(ActionBack())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AboutView(PiHoleDataStore())
|
||||
AboutView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,17 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct AddPiHoleView: View {
|
||||
@State var ipAddress: String = ""
|
||||
@State var showScanFailed = false
|
||||
@State var showConnectFailed = false
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
@SwiftUI.State var ipAddress: String = ""
|
||||
@SwiftUI.State var showScanFailed = false
|
||||
@SwiftUI.State var showConnectFailed = false
|
||||
var hideNavigationBar: Bool {
|
||||
get {
|
||||
if case .scanning(_) = self.dataStore.pihole {
|
||||
if case .scan = self.store.state.route {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
@ -27,10 +29,10 @@ struct AddPiHoleView: View {
|
|||
ScrollView {
|
||||
VStack(spacing: 30) {
|
||||
Image("logo")
|
||||
ScanButton(dataStore: self.dataStore)
|
||||
ScanButton()
|
||||
.alert(isPresented: self.$showScanFailed, content: {
|
||||
Alert(title: Text("scan_failed"), message: Text("try_direct_connection"), dismissButton: .default(Text("OK"), action: {
|
||||
self.dataStore.pihole = .missingIpAddress
|
||||
self.store.sideEffect = nil
|
||||
}))
|
||||
})
|
||||
OrDivider()
|
||||
|
@ -42,17 +44,14 @@ struct AddPiHoleView: View {
|
|||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await self.dataStore.connect(self.ipAddress)
|
||||
}
|
||||
self.store.dispatch(ActionScan(deviceIp: self.ipAddress))
|
||||
}
|
||||
Button(action: { Task {
|
||||
await self.dataStore.connect(self.ipAddress)
|
||||
} }, label: { Text("connect") })
|
||||
Button(action: { self.store.dispatch(ActionConnect(host: ipAddress))
|
||||
}, label: { Text("connect") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.alert(isPresented: self.$showConnectFailed, content: {
|
||||
Alert(title: Text("connection_failed"), message: Text("verify_ip_address"), dismissButton: .default(Text("OK"), action: {
|
||||
self.dataStore.pihole = .missingIpAddress
|
||||
self.store.sideEffect = nil
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
@ -63,52 +62,42 @@ struct AddPiHoleView: View {
|
|||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
init(_ dataStore: PiHoleDataStore) {
|
||||
self.dataStore = dataStore
|
||||
}
|
||||
}
|
||||
|
||||
struct ScanButton: View {
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
|
||||
var statefulContent: AnyView {
|
||||
guard let deviceIpAddress = resolver_get_device_ip() else {
|
||||
return Text("no_wireless_connection")
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if let deviceIpAddress = resolver_get_device_ip() {
|
||||
let ipAddress = String(cString: deviceIpAddress)
|
||||
VStack(spacing: 30) {
|
||||
Text("scan_for_pihole")
|
||||
.multilineTextAlignment(.center)
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionScan(deviceIp: ipAddress))
|
||||
}, label: { Text("scan") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
NavigationLink(
|
||||
destination: ScanningView(),
|
||||
isActive: .constant(store.state.route == Route.scan),
|
||||
label: { EmptyView() }
|
||||
)
|
||||
NavigationLink(
|
||||
destination: RetrieveApiKeyView(),
|
||||
isActive: .constant(store.state.route == Route.auth),
|
||||
label: { EmptyView() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text("no_wireless_connection")
|
||||
.multilineTextAlignment(.center)
|
||||
.toAnyView()
|
||||
}
|
||||
|
||||
let ipAddress = String(cString: deviceIpAddress)
|
||||
|
||||
return VStack(spacing: 30) {
|
||||
Text("scan_for_pihole")
|
||||
.multilineTextAlignment(.center)
|
||||
Button(action: {
|
||||
self.dataStore.scanTask = Task {
|
||||
await self.dataStore.beginScanning(ipAddress)
|
||||
}
|
||||
}, label: { Text("scan") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
NavigationLink(
|
||||
destination: ScanningView(dataStore: self.dataStore),
|
||||
isActive: $dataStore.isScanning,
|
||||
label: { EmptyView() }
|
||||
)
|
||||
NavigationLink(
|
||||
destination: RetrieveApiKeyView(dataStore: self.dataStore),
|
||||
isActive: .constant(self.dataStore.pihole == .missingApiKey),
|
||||
label: { EmptyView() }
|
||||
)
|
||||
}.toAnyView()
|
||||
}
|
||||
|
||||
var body: some View { statefulContent }
|
||||
}
|
||||
|
||||
struct AddPiHoleView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddPiHoleView(PiHoleDataStore())
|
||||
AddPiHoleView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,29 +6,24 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
stateContent
|
||||
}
|
||||
|
||||
var stateContent: AnyView {
|
||||
if self.dataStore.baseUrl?.isEmpty ?? true || self.dataStore.apiKey?.isEmpty ?? true {
|
||||
return AddPiHoleView(self.dataStore).toAnyView()
|
||||
if [Route.home, Route.about].contains(self.store.state.route) {
|
||||
PiHoleDetailsView()
|
||||
} else {
|
||||
return PiHoleDetailsView(self.dataStore).toAnyView()
|
||||
AddPiHoleView()
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
init(_ dataStore: PiHoleDataStore) {
|
||||
self.dataStore = dataStore
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView(PiHoleDataStore())
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,11 +6,14 @@
|
|||
// Copyright © 2020 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct DisableCustomTimeView: View {
|
||||
@State var duration: String = ""
|
||||
@State var unit: Int = 0
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
@Binding var showCustom: Bool
|
||||
@SwiftUI.State var duration: String = ""
|
||||
@SwiftUI.State var unit: Int = 0
|
||||
private let units = ["seconds", "minutes", "hours"]
|
||||
|
||||
var body: some View {
|
||||
|
@ -20,36 +23,25 @@ struct DisableCustomTimeView: View {
|
|||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.keyboardType(.numberPad)
|
||||
Picker("", selection: $unit) {
|
||||
ForEach(0 ..< units.count) {
|
||||
ForEach(0 ..< 3) {
|
||||
Text(LocalizedStringKey(self.units[$0])).tag($0)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
Button(action: { Task {
|
||||
await self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit)
|
||||
} }, label: { Text(LocalizedStringKey("disable")) })
|
||||
Button(action: {
|
||||
let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue
|
||||
self.store.dispatch(ActionDisable(duration: KotlinLong(value: Int64((Int(duration) ?? 0) * multiplier))))
|
||||
self.showCustom = false
|
||||
}, label: { Text(LocalizedStringKey("disable")) })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
}
|
||||
.padding()
|
||||
.keyboardAwarePadding()
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
init(_ dataStore: PiHoleDataStore) {
|
||||
self.dataStore = dataStore
|
||||
}
|
||||
}
|
||||
|
||||
struct DisableCustomTimeView_Previews: PreviewProvider {
|
||||
static var dataStore: PiHoleDataStore {
|
||||
get {
|
||||
let _dataStore = PiHoleDataStore()
|
||||
_dataStore.pihole = .success(.enabled)
|
||||
return _dataStore
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
DisableCustomTimeView(self.dataStore)
|
||||
DisableCustomTimeView(showCustom: .constant(true))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,15 +7,10 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
extension View {
|
||||
func toAnyView() -> AnyView {
|
||||
return AnyView(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIApplication {
|
||||
func endEditing() {
|
||||
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
@ -42,3 +37,56 @@ extension Digest {
|
|||
.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
var localizedStringKey: LocalizedStringKey {
|
||||
var key: String
|
||||
switch self {
|
||||
case .enabled:
|
||||
key = "enabled"
|
||||
case .disabled:
|
||||
key = "disabled"
|
||||
default:
|
||||
key = "unknown"
|
||||
}
|
||||
return LocalizedStringKey(key)
|
||||
}
|
||||
|
||||
var foregroundColor: Color {
|
||||
switch self {
|
||||
case .enabled:
|
||||
return .green
|
||||
case .disabled:
|
||||
return .red
|
||||
default:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UInt {
|
||||
func toDurationString() -> String {
|
||||
// I add one to the timestamp to prevent showing 0 seconds remaining
|
||||
if (self < 60) {
|
||||
return String(self + 1)
|
||||
}
|
||||
|
||||
var seconds: UInt = self + 1
|
||||
var hours: UInt = 0
|
||||
if (seconds >= 3600) {
|
||||
hours = seconds / 3600
|
||||
seconds -= hours * 3600
|
||||
}
|
||||
|
||||
var minutes: UInt = 0
|
||||
if (seconds >= 60) {
|
||||
minutes = seconds / 60
|
||||
seconds -= minutes * 60
|
||||
}
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,23 +11,24 @@ import SwiftUI
|
|||
struct OrDivider: View {
|
||||
let orientation: Orientation
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if orientation == .horizontal {
|
||||
return HStack {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.frame(height: 2)
|
||||
Text("or")
|
||||
Rectangle()
|
||||
.frame(height: 2)
|
||||
}.toAnyView()
|
||||
}
|
||||
} else {
|
||||
return VStack {
|
||||
VStack {
|
||||
Rectangle()
|
||||
.frame(width: 2)
|
||||
Text("or")
|
||||
Rectangle()
|
||||
.frame(width: 2)
|
||||
}.toAnyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
//
|
||||
// PiHole.swift
|
||||
// Pi-Helper
|
||||
//
|
||||
// Created by Billy Brawner on 10/19/19.
|
||||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PiHole: Codable, Equatable {
|
||||
let domainsBeingBlocked: Int
|
||||
let dnsQueriesToday: Int
|
||||
let adsBlockedToday: Int
|
||||
let adsPercentageToday: Float
|
||||
let uniqueDomains: Int
|
||||
let queriesForwarded: Int
|
||||
let clientsEverSeen: Int
|
||||
let uniqueClients: Int
|
||||
let dnsQueriesAllTypes: Int
|
||||
let queriesCached: Int
|
||||
let noDataReplies: Int
|
||||
let nxDomainReplies: Int
|
||||
let cnameReplies: Int
|
||||
let ipReplies: Int
|
||||
let privacyLevel: Int
|
||||
let status: String
|
||||
let gravity: Gravity
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case domainsBeingBlocked = "domains_being_blocked"
|
||||
case dnsQueriesToday = "dns_queries_today"
|
||||
case adsBlockedToday = "ads_blocked_today"
|
||||
case adsPercentageToday = "ads_percentage_today"
|
||||
case uniqueDomains = "unique_domains"
|
||||
case queriesForwarded = "queries_forwarded"
|
||||
case queriesCached = "queries_cached"
|
||||
case clientsEverSeen = "clients_ever_seen"
|
||||
case uniqueClients = "unique_clients"
|
||||
case dnsQueriesAllTypes = "dns_queries_all_types"
|
||||
case noDataReplies = "reply_NODATA"
|
||||
case nxDomainReplies = "reply_NXDOMAIN"
|
||||
case cnameReplies = "reply_CNAME"
|
||||
case ipReplies = "reply_IP"
|
||||
case privacyLevel = "privacy_level"
|
||||
case status
|
||||
case gravity = "gravity_last_updated"
|
||||
}
|
||||
|
||||
func copy(
|
||||
domainsBeingBlocked: Int? = nil,
|
||||
dnsQueriesToday: Int? = nil,
|
||||
adsBlockedToday: Int? = nil,
|
||||
adsPercentageToday: Float? = nil,
|
||||
uniqueDomains: Int? = nil,
|
||||
queriesForwarded: Int? = nil,
|
||||
clientsEverSeen: Int? = nil,
|
||||
uniqueClients: Int? = nil,
|
||||
dnsQueriesAllTypes: Int? = nil,
|
||||
queriesCached: Int? = nil,
|
||||
noDataReplies: Int? = nil,
|
||||
nxDomainReplies: Int? = nil,
|
||||
cnameReplies: Int? = nil,
|
||||
ipReplies: Int? = nil,
|
||||
privacyLevel: Int? = nil,
|
||||
status: String? = nil,
|
||||
gravity: Gravity? = nil
|
||||
) -> PiHole {
|
||||
return PiHole(
|
||||
domainsBeingBlocked: domainsBeingBlocked ?? self.domainsBeingBlocked,
|
||||
dnsQueriesToday: dnsQueriesToday ?? self.dnsQueriesToday,
|
||||
adsBlockedToday: adsBlockedToday ?? self.adsBlockedToday,
|
||||
adsPercentageToday: adsPercentageToday ?? self.adsPercentageToday,
|
||||
uniqueDomains: uniqueDomains ?? self.uniqueDomains,
|
||||
queriesForwarded: queriesForwarded ?? self.queriesForwarded,
|
||||
clientsEverSeen: clientsEverSeen ?? self.clientsEverSeen,
|
||||
uniqueClients: uniqueClients ?? self.uniqueClients,
|
||||
dnsQueriesAllTypes: dnsQueriesAllTypes ?? self.dnsQueriesAllTypes,
|
||||
queriesCached: queriesCached ?? self.queriesCached,
|
||||
noDataReplies: noDataReplies ?? self.noDataReplies,
|
||||
nxDomainReplies: nxDomainReplies ?? self.nxDomainReplies,
|
||||
cnameReplies: cnameReplies ?? self.cnameReplies,
|
||||
ipReplies: ipReplies ?? self.ipReplies,
|
||||
privacyLevel: privacyLevel ?? self.privacyLevel,
|
||||
status: status ?? self.status,
|
||||
gravity: gravity ?? self.gravity
|
||||
)
|
||||
}
|
||||
|
||||
static func == (lhs: PiHole, rhs: PiHole) -> Bool {
|
||||
return lhs.domainsBeingBlocked == rhs.domainsBeingBlocked
|
||||
&& lhs.dnsQueriesToday == rhs.dnsQueriesToday
|
||||
&& lhs.adsBlockedToday == rhs.adsBlockedToday
|
||||
&& lhs.adsPercentageToday == rhs.adsPercentageToday
|
||||
&& lhs.uniqueDomains == rhs.uniqueDomains
|
||||
&& lhs.queriesForwarded == rhs.queriesForwarded
|
||||
&& lhs.clientsEverSeen == rhs.clientsEverSeen
|
||||
&& lhs.uniqueClients == rhs.uniqueClients
|
||||
&& lhs.dnsQueriesAllTypes == rhs.dnsQueriesAllTypes
|
||||
&& lhs.queriesCached == rhs.queriesCached
|
||||
&& lhs.noDataReplies == rhs.noDataReplies
|
||||
&& lhs.nxDomainReplies == rhs.nxDomainReplies
|
||||
&& lhs.cnameReplies == rhs.cnameReplies
|
||||
&& lhs.ipReplies == rhs.ipReplies
|
||||
&& lhs.privacyLevel == rhs.privacyLevel
|
||||
&& lhs.status == rhs.status
|
||||
&& lhs.gravity == rhs.gravity
|
||||
}
|
||||
}
|
||||
|
||||
struct Gravity: Codable, Equatable {
|
||||
let fileExists: Bool
|
||||
let absolute: Int
|
||||
let relative: Relative
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileExists = "file_exists"
|
||||
case absolute
|
||||
case relative
|
||||
}
|
||||
|
||||
static func == (lhs: Gravity, rhs: Gravity) -> Bool {
|
||||
return lhs.fileExists == rhs.fileExists
|
||||
&& lhs.absolute == rhs.absolute
|
||||
&& lhs.relative == rhs.relative
|
||||
}
|
||||
}
|
||||
|
||||
struct Relative: Codable, Equatable {
|
||||
let days: Int
|
||||
let hours: Int
|
||||
let minutes: Int
|
||||
|
||||
static func == (lhs: Relative, rhs: Relative) -> Bool {
|
||||
return lhs.days == rhs.days
|
||||
&& lhs.hours == rhs.hours
|
||||
&& lhs.minutes == rhs.minutes
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusUpdate: Codable {
|
||||
let status: String
|
||||
}
|
||||
|
||||
struct VersionResponse: Codable {
|
||||
let version: Int
|
||||
}
|
||||
|
||||
struct TopItemsResponse: Codable {
|
||||
let topQueries: [String:Int]
|
||||
let topAds: [String:Int]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case topQueries = "top_queries"
|
||||
case topAds = "top_ads"
|
||||
}
|
||||
}
|
||||
|
||||
enum Status: Equatable {
|
||||
case enabled
|
||||
case disabled(_ duration: String? = nil)
|
||||
case unknown
|
||||
|
||||
var localizedStringKey: LocalizedStringKey {
|
||||
var key: String
|
||||
switch self {
|
||||
case .enabled:
|
||||
key = "enabled"
|
||||
case .disabled:
|
||||
key = "disabled"
|
||||
default:
|
||||
key = "unknown"
|
||||
}
|
||||
return LocalizedStringKey(key)
|
||||
}
|
||||
|
||||
var foregroundColor: Color {
|
||||
switch self {
|
||||
case .enabled:
|
||||
return .green
|
||||
case .disabled:
|
||||
return .red
|
||||
default:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
//
|
||||
// PiHoleApiService.swift
|
||||
// Pi-Helper
|
||||
//
|
||||
// Created by Billy Brawner on 10/19/19.
|
||||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NetworkError: Error, Equatable {
|
||||
case loading
|
||||
case cancelled
|
||||
case badRequest
|
||||
case notFound
|
||||
case unauthorized
|
||||
case unknown(Error?)
|
||||
case invalidUrl
|
||||
case jsonParsingFailed(Error)
|
||||
|
||||
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.cancelled, .cancelled):
|
||||
return true
|
||||
case (.badRequest, .badRequest):
|
||||
return true
|
||||
case (.notFound, .notFound):
|
||||
return true
|
||||
case (.unauthorized, .unauthorized):
|
||||
return true
|
||||
case (.unknown(let error1), .unknown(let error2)):
|
||||
return error1?.localizedDescription == error2?.localizedDescription
|
||||
case (.invalidUrl, .invalidUrl):
|
||||
return true
|
||||
case (.jsonParsingFailed(let error1), .jsonParsingFailed(let error2)):
|
||||
return error1.localizedDescription == error2.localizedDescription
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,352 +0,0 @@
|
|||
//
|
||||
// PiHoleDataStore.swift
|
||||
// Pi-Helper
|
||||
//
|
||||
// Created by Billy Brawner on 10/19/19.
|
||||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
import Pihelper
|
||||
|
||||
@MainActor
|
||||
class PiHoleDataStore: ObservableObject {
|
||||
private let IP_MIN = 0
|
||||
private let IP_MAX = 255
|
||||
var isScanning: Bool {
|
||||
get {
|
||||
if case .scanning(_) = self.pihole {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
set {
|
||||
// No op
|
||||
}
|
||||
}
|
||||
@Published var showCustomDisableView = false
|
||||
@Published var pihole: PiholeStatus = .empty
|
||||
@Published var error: PiHoleError? = nil
|
||||
@Published var apiKey: String? = nil {
|
||||
didSet {
|
||||
UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY)
|
||||
apiService.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
@Published var baseUrl: String? = nil {
|
||||
didSet {
|
||||
UserDefaults.standard.set(baseUrl, forKey: PiHoleDataStore.HOST_KEY)
|
||||
apiService.baseUrl = baseUrl
|
||||
}
|
||||
}
|
||||
private var shouldMonitorStatus = false
|
||||
var scanTask: Task<Any, Error>? = nil
|
||||
|
||||
func monitorStatus() async {
|
||||
while apiKey != nil && baseUrl != nil && !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
await loadSummary()
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beginScanning(_ ipAddress: String) async {
|
||||
var addressParts = ipAddress.split(separator: ".")
|
||||
let lastOctal = Int(addressParts[3])
|
||||
for octal in 0..<IP_MAX {
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
if octal == lastOctal {
|
||||
// Don't scan the current device
|
||||
continue
|
||||
}
|
||||
addressParts[3] = Substring(String(octal))
|
||||
if await scan(addressParts.joined(separator: ".")) {
|
||||
return
|
||||
}
|
||||
}
|
||||
self.error = .scanFailed
|
||||
}
|
||||
|
||||
func cancelScanning() {
|
||||
scanTask?.cancel()
|
||||
scanTask = nil
|
||||
self.pihole = .missingIpAddress
|
||||
}
|
||||
|
||||
func connect(_ ipAddress: String) async {
|
||||
self.apiService.baseUrl = ipAddress
|
||||
do {
|
||||
_ = try await self.apiService.getVersion()
|
||||
self.baseUrl = ipAddress
|
||||
self.pihole = .missingApiKey
|
||||
} catch {
|
||||
self.error = .connectionFailed(ipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func scan(_ ipAddress: String) async -> Bool {
|
||||
self.pihole = PiholeStatus.scanning(ipAddress)
|
||||
do {
|
||||
self.apiService.baseUrl = ipAddress
|
||||
_ = try await self.apiService.getVersion()
|
||||
self.baseUrl = self.apiService.baseUrl
|
||||
self.pihole = PiholeStatus.missingApiKey
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func forgetPihole() {
|
||||
self.baseUrl = nil
|
||||
self.apiKey = nil
|
||||
self.pihole = PiholeStatus.missingIpAddress
|
||||
}
|
||||
|
||||
func connectWithPassword(_ password: String) async {
|
||||
if let hash = password.sha256Hash()?.sha256Hash() {
|
||||
await connectWithApiKey(hash)
|
||||
} else {
|
||||
self.error = .invalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
func connectWithApiKey(_ apiToken: String) async {
|
||||
self.apiService.apiKey = apiToken
|
||||
do {
|
||||
_ = try await self.apiService.getTopItems()
|
||||
self.apiKey = apiToken
|
||||
} catch {
|
||||
self.error = .invalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
func loadSummary() async {
|
||||
var loadingTask: Task<Void, Error>? = nil
|
||||
if case let .success(previousState) = self.pihole {
|
||||
// Avoid showing the loading spinner immediately
|
||||
loadingTask = Task {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
self.pihole = .loading(previousState)
|
||||
}
|
||||
} else {
|
||||
self.pihole = .loading()
|
||||
}
|
||||
do {
|
||||
let pihole = try await apiService.getSummary()
|
||||
await MainActor.run {
|
||||
UIApplication.shared.shortcutItems = [
|
||||
UIApplicationShortcutItem(
|
||||
type: ShortcutAction.enable.rawValue,
|
||||
localizedTitle: Bundle.main.localizedString(forKey: "enable", value: "Enable", table: nil),
|
||||
localizedSubtitle: nil,
|
||||
icon: UIApplicationShortcutIcon(type: .play),
|
||||
userInfo: nil
|
||||
),
|
||||
UIApplicationShortcutItem(
|
||||
type: ShortcutAction.disable.rawValue,
|
||||
localizedTitle: Bundle.main.localizedString(forKey: "disable_10_sec", value: "Disable 10 Secs", table: nil),
|
||||
localizedSubtitle: nil,
|
||||
icon: UIApplicationShortcutIcon(type: .pause),
|
||||
userInfo: ["forSeconds": 10 as NSSecureCoding]
|
||||
),
|
||||
UIApplicationShortcutItem(
|
||||
type: ShortcutAction.disable.rawValue,
|
||||
localizedTitle: Bundle.main.localizedString(forKey: "disable_30_sec", value: "Disable 30 Secs", table: nil),
|
||||
localizedSubtitle: nil,
|
||||
icon: UIApplicationShortcutIcon(type: .pause),
|
||||
userInfo: ["forSeconds": 30 as NSSecureCoding]
|
||||
),
|
||||
UIApplicationShortcutItem(
|
||||
type: ShortcutAction.disable.rawValue,
|
||||
localizedTitle: Bundle.main.localizedString(forKey: "disable_5_min", value: "Disable 5 Min", table: nil),
|
||||
localizedSubtitle: nil,
|
||||
icon: UIApplicationShortcutIcon(type: .pause),
|
||||
userInfo: ["forSeconds": 300 as NSSecureCoding]
|
||||
)
|
||||
]
|
||||
}
|
||||
await self.updateStatus(pihole.status.name)
|
||||
loadingTask?.cancel()
|
||||
} catch {
|
||||
if let error = error as? NetworkError {
|
||||
self.error = .networkError(error)
|
||||
} else {
|
||||
print("Unhandled error! \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enable() async {
|
||||
var previousStatus: Status? = nil
|
||||
if case let .success(status) = self.pihole {
|
||||
previousStatus = status
|
||||
}
|
||||
self.pihole = .loading(previousStatus)
|
||||
do {
|
||||
let status = try await self.apiService.enable()
|
||||
await self.updateStatus(status.status.name)
|
||||
} catch {
|
||||
self.error = .networkError(error as! NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
func disable(_ forDuration: Int, unit: Int) async {
|
||||
let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue
|
||||
await disable(forDuration * multiplier)
|
||||
}
|
||||
|
||||
func disable(_ forSeconds: Int? = nil) async {
|
||||
var previousStatus: Status? = nil
|
||||
if case let .success(status) = self.pihole {
|
||||
previousStatus = status
|
||||
}
|
||||
self.pihole = .loading(previousStatus)
|
||||
do {
|
||||
var duration: KotlinLong? = nil
|
||||
if let seconds = forSeconds {
|
||||
duration = KotlinLong(integerLiteral: seconds)
|
||||
}
|
||||
let status = try await self.apiService.disable(duration: duration)
|
||||
await self.updateStatus(status.status.name)
|
||||
} catch {
|
||||
if let error = error as? NetworkError {
|
||||
self.error = .networkError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatus(_ status: String) async {
|
||||
switch status {
|
||||
case "disabled":
|
||||
await self.getDisabledDuration()
|
||||
default:
|
||||
self.pihole = .success(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var customDisableTimeRequest: AnyCancellable? = nil
|
||||
|
||||
private func getDisabledDuration() async {
|
||||
self.pihole = .success(.disabled())
|
||||
// do {
|
||||
|
||||
// let timestamp = try await self.apiService.getCustomDisableTimer()
|
||||
// let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0))
|
||||
// let now = Date().timeIntervalSince1970
|
||||
// if now > disabledUntil {
|
||||
// self.pihole = .success(.disabled())
|
||||
// } else {
|
||||
// self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString()))
|
||||
// }
|
||||
// self.customDisableTimeRequest = nil
|
||||
// } catch {
|
||||
// self.pihole = .success(.disabled())
|
||||
// }
|
||||
}
|
||||
|
||||
let apiService = PiholeAPIService.companion.create()
|
||||
static let HOST_KEY = "host"
|
||||
static let API_KEY = "apiKey"
|
||||
|
||||
init(baseUrl: String? = nil, apiKey: String? = nil) {
|
||||
self.baseUrl = baseUrl
|
||||
self.apiKey = apiKey
|
||||
if baseUrl == nil {
|
||||
self.pihole = .missingIpAddress
|
||||
} else if apiKey == nil {
|
||||
self.pihole = .missingApiKey
|
||||
} else {
|
||||
self.pihole = .loading(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ShortcutAction: String {
|
||||
case enable = "EnableAction"
|
||||
case disable = "DisableAction"
|
||||
}
|
||||
|
||||
enum PiholeStatus: Equatable {
|
||||
case empty
|
||||
case loading(_ previousStatus: Status? = nil)
|
||||
case error(_ error: Error)
|
||||
case scanning(_ ipAddress: String)
|
||||
case missingIpAddress
|
||||
case missingApiKey
|
||||
case success(_ status: Status)
|
||||
|
||||
static func == (lhs: PiholeStatus, rhs: PiholeStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.loading(let left), .loading(let right)):
|
||||
return left == right
|
||||
case (.scanning(let left), .scanning(let right)):
|
||||
return left == right
|
||||
case (.missingIpAddress, .missingIpAddress):
|
||||
return true
|
||||
case (.missingApiKey, .missingApiKey):
|
||||
return true
|
||||
case (.success(let left), .success(let right)):
|
||||
return left == right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PiHoleError : Error, Equatable {
|
||||
case networkError(_ error: NetworkError)
|
||||
case invalidCredentials
|
||||
case scanFailed
|
||||
case connectionFailed(_ host: String)
|
||||
|
||||
static func == (lhs: PiHoleError, rhs: PiHoleError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.networkError(let error1), .networkError(let error2)):
|
||||
return error1 == error2
|
||||
case (.invalidCredentials, .invalidCredentials):
|
||||
return true
|
||||
case (.scanFailed, .scanFailed):
|
||||
return true
|
||||
case (.connectionFailed(let lIp), .connectionFailed(let rIp)):
|
||||
return lIp == rIp
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UInt {
|
||||
func toDurationString() -> String {
|
||||
// I add one to the timestamp to prevent showing 0 seconds remaining
|
||||
if (self < 60) {
|
||||
return String(self + 1)
|
||||
}
|
||||
|
||||
var seconds: UInt = self + 1
|
||||
var hours: UInt = 0
|
||||
if (seconds >= 3600) {
|
||||
hours = seconds / 3600
|
||||
seconds -= hours * 3600
|
||||
}
|
||||
|
||||
var minutes: UInt = 0
|
||||
if (seconds >= 60) {
|
||||
minutes = seconds / 60
|
||||
seconds -= minutes * 60
|
||||
}
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
|
@ -6,51 +6,32 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct PiHoleDetailsView: View {
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
@ViewBuilder
|
||||
func stateContent() -> some View {
|
||||
switch self.dataStore.pihole {
|
||||
case .success(let pihole):
|
||||
PiHoleActionsView(dataStore: self.dataStore, status: pihole)
|
||||
case .loading(let previousStatus):
|
||||
if let status = previousStatus {
|
||||
PiHoleActionsView(dataStore: self.dataStore, status: status)
|
||||
} else {
|
||||
ActivityIndicatorView()
|
||||
}
|
||||
case .error(let error):
|
||||
switch (error) {
|
||||
default:
|
||||
PiHoleActionsView(dataStore: self.dataStore, status: .unknown)
|
||||
}
|
||||
default:
|
||||
var stateContent: some View {
|
||||
if self.store.state.loading {
|
||||
ActivityIndicatorView()
|
||||
} else {
|
||||
PiHoleActionsView()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
stateContent()
|
||||
.animation(.default, value: self.dataStore.pihole)
|
||||
stateContent
|
||||
.animation(.default, value: self.store.state.status)
|
||||
.navigationBarTitle("Pi-helper")
|
||||
.navigationBarItems(trailing: NavigationLink(destination: AboutView(self.dataStore), label: {
|
||||
.navigationBarItems(trailing: NavigationLink(destination: AboutView(), label: {
|
||||
Image(systemName: "info.circle")
|
||||
.padding()
|
||||
}))
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
Task {
|
||||
await self.dataStore.monitorStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
init(_ dataStore: PiHoleDataStore) {
|
||||
self.dataStore = dataStore
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,107 +40,90 @@ struct PiHoleActionsView: View {
|
|||
VStack {
|
||||
HStack {
|
||||
Text("status")
|
||||
PiholeStatusView(status)
|
||||
PiholeStatusView()
|
||||
}
|
||||
PiHoleActions(self.dataStore, status: status)
|
||||
PiHoleActions()
|
||||
}
|
||||
}
|
||||
|
||||
let dataStore: PiHoleDataStore
|
||||
let status: Status
|
||||
}
|
||||
|
||||
struct PiholeStatusView: View {
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(status.localizedStringKey)
|
||||
.foregroundColor(status.foregroundColor)
|
||||
if case let .disabled(duration) = status, let durationString = duration {
|
||||
Text("(\(durationString))")
|
||||
.monospacedDigit()
|
||||
if let status = store.state.status {
|
||||
Text(status.localizedStringKey)
|
||||
.foregroundColor(status.foregroundColor)
|
||||
}
|
||||
// if case let .disabled(duration) = store.state.status, let durationString = duration {
|
||||
// Text("(\(durationString))")
|
||||
// .monospacedDigit()
|
||||
// .foregroundColor(store.state.status.foregroundColor)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
let status: Status
|
||||
init(_ status: Status) {
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
struct PiHoleActions: View {
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
@SwiftUI.State var showCustomDisable: Bool = false
|
||||
|
||||
var body: some View {
|
||||
stateContent.padding()
|
||||
}
|
||||
|
||||
var stateContent: AnyView {
|
||||
switch status {
|
||||
case .disabled:
|
||||
return Button(action: { Task {
|
||||
await self.dataStore.enable()
|
||||
} }, label: { Text("enable") })
|
||||
|
||||
@ViewBuilder
|
||||
var stateContent: some View {
|
||||
switch self.store.state.status {
|
||||
case Pihelper.Status.disabled:
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionEnable())
|
||||
}, label: { Text("enable") })
|
||||
.buttonStyle(PiHelperButtonStyle(.green))
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
.toAnyView()
|
||||
case .enabled:
|
||||
return VStack {
|
||||
Button(action: { Task {
|
||||
await self.dataStore.disable(10)
|
||||
} }, label: { Text("disable_10_sec") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: { Task{
|
||||
await self.dataStore.disable(30)
|
||||
} }, label: { Text("disable_30_sec") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: { Task {
|
||||
await self.dataStore.disable(300)
|
||||
} }, label: { Text("disable_5_min") })
|
||||
case Pihelper.Status.enabled:
|
||||
VStack {
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionDisable(duration: 10))
|
||||
}, label: { Text("disable_10_sec") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: {
|
||||
self.dataStore.showCustomDisableView = true
|
||||
self.store.dispatch(ActionDisable(duration: 30))
|
||||
}, label: { Text("disable_30_sec") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionDisable(duration: 300))
|
||||
}, label: { Text("disable_5_min") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: {
|
||||
self.showCustomDisable = true
|
||||
}, label: { Text("disable_custom") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
Button(action: { Task {
|
||||
await self.dataStore.disable()
|
||||
} }, label: { Text("disable_permanent") })
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionDisable(duration: nil))
|
||||
}, label: { Text("disable_permanent") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
|
||||
NavigationLink(
|
||||
destination: DisableCustomTimeView(self.dataStore),
|
||||
isActive: self.$dataStore.showCustomDisableView,
|
||||
destination: DisableCustomTimeView(showCustom: self.$showCustomDisable),
|
||||
isActive: self.$showCustomDisable,
|
||||
label: { EmptyView() }
|
||||
)
|
||||
}.toAnyView()
|
||||
}
|
||||
default:
|
||||
return Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.")
|
||||
.toAnyView()
|
||||
Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.")
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
let status: Status
|
||||
init(_ dataStore: PiHoleDataStore, status: Status) {
|
||||
self.dataStore = dataStore
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
struct PiHoleDetailsView_Previews: PreviewProvider {
|
||||
static var dataStore: PiHoleDataStore {
|
||||
get {
|
||||
let _dataStore = PiHoleDataStore()
|
||||
_dataStore.pihole = PiholeStatus.success(Status.disabled("20"))
|
||||
return _dataStore
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
PiHoleDetailsView(self.dataStore)
|
||||
PiHoleDetailsView()
|
||||
}
|
||||
}
|
||||
|
|
44
Pi-helper/PihelperStore.swift
Normal file
44
Pi-helper/PihelperStore.swift
Normal file
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// PihelperDataStore.swift
|
||||
// Pi-helper
|
||||
//
|
||||
// Created by William Brawner on 10/17/22.
|
||||
// Copyright © 2022 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
class PihelperStore: ObservableObject {
|
||||
@Published public var state: Pihelper.State = State(apiKey: nil, host: nil, status: nil, scanning: nil, loading: false, route: Route.connect, initialRoute: Route.connect)
|
||||
@Published public var sideEffect: Effect? {
|
||||
didSet {
|
||||
print("sideEffect: \(sideEffect)")
|
||||
}
|
||||
}
|
||||
|
||||
let store: Store
|
||||
|
||||
var stateWatcher : Closeable?
|
||||
var sideEffectWatcher : Closeable?
|
||||
|
||||
init(store: Store) {
|
||||
self.store = store
|
||||
stateWatcher = self.store.watchState { [weak self] state in
|
||||
self?.state = state
|
||||
}
|
||||
sideEffectWatcher = self.store.watchEffects { [weak self] effect in
|
||||
self?.sideEffect = effect
|
||||
}
|
||||
}
|
||||
|
||||
public func dispatch(_ action: Action) {
|
||||
store.dispatch(action: action)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stateWatcher?.close()
|
||||
sideEffectWatcher?.close()
|
||||
}
|
||||
}
|
|
@ -6,14 +6,17 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct RetrieveApiKeyView: View {
|
||||
@State var apiKey: String = ""
|
||||
@State var password: String = ""
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
@SwiftUI.State var apiKey: String = ""
|
||||
@SwiftUI.State var password: String = ""
|
||||
|
||||
var showAlert: Bool {
|
||||
get {
|
||||
if case .invalidCredentials = self.dataStore.error {
|
||||
if let error = self.store.sideEffect, error is EffectError {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
@ -32,49 +35,47 @@ struct RetrieveApiKeyView: View {
|
|||
SecureField("prompt_password", text: self.$password)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
Task {
|
||||
await self.dataStore.connectWithPassword(self.password)
|
||||
}
|
||||
// self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringPassword(value: password)))
|
||||
// TODO: Fix shared implementation and then use that instead
|
||||
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: password.sha256Hash()?.sha256Hash() ?? "")))
|
||||
}
|
||||
Button(action: { Task {
|
||||
await self.dataStore.connectWithPassword(self.password)
|
||||
} }, label: {
|
||||
Button(action: {
|
||||
// self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringPassword(value: password)))
|
||||
// TODO: Fix shared implementation and then use that instead
|
||||
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: password.sha256Hash()?.sha256Hash() ?? "")))
|
||||
}, label: {
|
||||
Text("connect_with_password")
|
||||
})
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
OrDivider()
|
||||
SecureField("prompt_api_key", text: self.$apiKey)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
Task {
|
||||
await self.dataStore.connectWithApiKey(self.apiKey)
|
||||
}
|
||||
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: apiKey)))
|
||||
}
|
||||
Button(action: { Task {
|
||||
await self.dataStore.connectWithApiKey(self.apiKey)
|
||||
} }, label: {
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: apiKey)))
|
||||
}, label: {
|
||||
Text("connect_with_api_key")
|
||||
})
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
}
|
||||
.padding()
|
||||
.keyboardAwarePadding()
|
||||
.alert(isPresented: .constant(showAlert), content: {
|
||||
Alert(title: Text("connection_failed"), message: Text("verify_credentials"), dismissButton: .default(Text("OK"), action: {
|
||||
self.dataStore.pihole = .missingApiKey
|
||||
self.store.sideEffect = nil
|
||||
}))
|
||||
})
|
||||
}
|
||||
.onDisappear {
|
||||
self.dataStore.pihole = .missingIpAddress
|
||||
self.store.dispatch(ActionBack())
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
}
|
||||
|
||||
struct RetrieveApiKeyView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RetrieveApiKeyView(dataStore: PiHoleDataStore())
|
||||
RetrieveApiKeyView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,45 +6,36 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
struct ScanningView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@ObservedObject var dataStore: PiHoleDataStore
|
||||
@EnvironmentObject var store: PihelperStore
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch dataStore.pihole {
|
||||
case .scanning(let ipAddress):
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ActivityIndicatorView()
|
||||
Text("scanning_ip_address")
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ActivityIndicatorView()
|
||||
Text("scanning_ip_address")
|
||||
if let ipAddress = store.state.scanning {
|
||||
Text(verbatim: ipAddress)
|
||||
Button(action: {
|
||||
self.dataStore.cancelScanning()
|
||||
}, label: { Text("cancel") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
}.padding()
|
||||
}
|
||||
default:
|
||||
EmptyView().onAppear {
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
self.store.dispatch(ActionBack())
|
||||
}, label: { Text("cancel") })
|
||||
.buttonStyle(PiHelperButtonStyle())
|
||||
}.padding()
|
||||
}
|
||||
.onDisappear {
|
||||
store.dispatch(ActionBack())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScanningView_Previews: PreviewProvider {
|
||||
static var dataStore: PiHoleDataStore {
|
||||
get {
|
||||
let dataStore = PiHoleDataStore()
|
||||
dataStore.pihole = .scanning("127.0.0.1")
|
||||
return dataStore
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
ScanningView(dataStore: self.dataStore)
|
||||
ScanningView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,30 +7,22 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Pihelper
|
||||
import SwiftUI
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
var dataStore: PiHoleDataStore?
|
||||
var store: PihelperStore?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||
if let host = UserDefaults.standard.string(forKey: PiHoleDataStore.HOST_KEY) {
|
||||
// If we already have the address of a Pi we've previously connected to, try that
|
||||
let apiKey = UserDefaults.standard.string(forKey: PiHoleDataStore.API_KEY)
|
||||
self.dataStore = PiHoleDataStore(baseUrl: host, apiKey: apiKey)
|
||||
} else {
|
||||
self.dataStore = PiHoleDataStore()
|
||||
Task {
|
||||
await self.dataStore?.scan("pi.hole")
|
||||
}
|
||||
}
|
||||
|
||||
let store = PihelperStore(store: Pihelper.Store.companion.create())
|
||||
// Create the SwiftUI view that provides the window contents.
|
||||
let contentView = ContentView(self.dataStore!)
|
||||
let contentView = ContentView()
|
||||
.environmentObject(store)
|
||||
|
||||
// Use a UIHostingController as window root view controller.
|
||||
if let windowScene = scene as? UIWindowScene {
|
||||
|
@ -55,14 +47,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// Is there a shortcut item that has not yet been processed?
|
||||
if let shortcutItem = (UIApplication.shared.delegate as! AppDelegate).shortcutItemToProcess {
|
||||
if shortcutItem.type == ShortcutAction.enable.rawValue {
|
||||
Task {
|
||||
await self.dataStore?.enable()
|
||||
}
|
||||
self.store?.dispatch(ActionEnable())
|
||||
} else {
|
||||
let amount = shortcutItem.userInfo?["forSeconds"] as? Int
|
||||
Task {
|
||||
await self.dataStore?.disable(amount)
|
||||
var kAmount: KotlinLong? = nil
|
||||
if let amount = shortcutItem.userInfo?["forSeconds"] as? Int {
|
||||
kAmount = KotlinLong(value: Int64(amount))
|
||||
}
|
||||
self.store?.dispatch(ActionDisable(duration: kAmount))
|
||||
}
|
||||
|
||||
// Reset the shorcut item so it's never processed twice.
|
||||
|
@ -92,3 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
enum ShortcutAction: String {
|
||||
case enable = "EnableAction"
|
||||
case disable = "DisableAction"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue