IOS Companion for @LimesKey/Serviceberry (on github)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip

+2947 -43
+81
Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <!-- Required Bundle Keys --> 6 + <key>CFBundleDevelopmentRegion</key> 7 + <string>$(DEVELOPMENT_LANGUAGE)</string> 8 + <key>CFBundleExecutable</key> 9 + <string>$(EXECUTABLE_NAME)</string> 10 + <key>CFBundleIdentifier</key> 11 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 12 + <key>CFBundleInfoDictionaryVersion</key> 13 + <string>6.0</string> 14 + <key>CFBundleName</key> 15 + <string>$(PRODUCT_NAME)</string> 16 + <key>CFBundlePackageType</key> 17 + <string>APPL</string> 18 + <key>CFBundleShortVersionString</key> 19 + <string>$(MARKETING_VERSION)</string> 20 + <key>CFBundleVersion</key> 21 + <string>$(CURRENT_PROJECT_VERSION)</string> 22 + 23 + <!-- App Transport Security --> 24 + <key>NSAppTransportSecurity</key> 25 + <dict> 26 + <key>NSAllowsLocalNetworking</key> 27 + <true/> 28 + </dict> 29 + 30 + <!-- Background Modes --> 31 + <key>UIBackgroundModes</key> 32 + <array> 33 + <string>bluetooth-central</string> 34 + <string>location</string> 35 + </array> 36 + 37 + <!-- Bluetooth Usage Descriptions --> 38 + <key>NSBluetoothAlwaysUsageDescription</key> 39 + <string>Serviceberry uses Bluetooth to communicate with your server and send location data for geolocation database improvement.</string> 40 + <key>NSBluetoothPeripheralUsageDescription</key> 41 + <string>Serviceberry uses Bluetooth to communicate with your server.</string> 42 + 43 + <!-- Location Usage Descriptions --> 44 + <key>NSLocationWhenInUseUsageDescription</key> 45 + <string>Serviceberry needs your location to send to the server for geolocation database improvement.</string> 46 + <key>NSLocationAlwaysAndWhenInUseUsageDescription</key> 47 + <string>Serviceberry needs your location to send to the server for geolocation database improvement, even when the app is in the background.</string> 48 + 49 + <!-- Local Network Usage (iOS 14+) --> 50 + <key>NSLocalNetworkUsageDescription</key> 51 + <string>Serviceberry discovers servers on your local network to send location data.</string> 52 + 53 + <!-- Bonjour Services --> 54 + <key>NSBonjourServices</key> 55 + <array> 56 + <string>_serviceberry._tcp</string> 57 + </array> 58 + 59 + <!-- UI Configuration --> 60 + <key>UIApplicationSceneManifest</key> 61 + <dict> 62 + <key>UIApplicationSupportsMultipleScenes</key> 63 + <false/> 64 + </dict> 65 + <key>UILaunchScreen</key> 66 + <dict/> 67 + <key>UISupportedInterfaceOrientations</key> 68 + <array> 69 + <string>UIInterfaceOrientationPortrait</string> 70 + <string>UIInterfaceOrientationLandscapeLeft</string> 71 + <string>UIInterfaceOrientationLandscapeRight</string> 72 + </array> 73 + <key>UISupportedInterfaceOrientations~ipad</key> 74 + <array> 75 + <string>UIInterfaceOrientationPortrait</string> 76 + <string>UIInterfaceOrientationPortraitUpsideDown</string> 77 + <string>UIInterfaceOrientationLandscapeLeft</string> 78 + <string>UIInterfaceOrientationLandscapeRight</string> 79 + </array> 80 + </dict> 81 + </plist>
+10 -2
serviceberry.xcodeproj/project.pbxproj
··· 178 178 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 179 179 GCC_WARN_UNUSED_FUNCTION = YES; 180 180 GCC_WARN_UNUSED_VARIABLE = YES; 181 + INFOPLIST_FILE = Info.plist; 181 182 IPHONEOS_DEPLOYMENT_TARGET = 26.0; 182 183 LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 183 184 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; ··· 236 237 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 237 238 GCC_WARN_UNUSED_FUNCTION = YES; 238 239 GCC_WARN_UNUSED_VARIABLE = YES; 240 + INFOPLIST_FILE = Info.plist; 239 241 IPHONEOS_DEPLOYMENT_TARGET = 26.0; 240 242 LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 241 243 MTL_ENABLE_DEBUG_INFO = NO; ··· 251 253 buildSettings = { 252 254 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 253 255 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 256 + CODE_SIGN_IDENTITY = "Apple Development"; 254 257 CODE_SIGN_STYLE = Automatic; 255 258 CURRENT_PROJECT_VERSION = 1; 256 259 DEVELOPMENT_TEAM = M67B42LX8D; 257 260 ENABLE_PREVIEWS = YES; 258 - GENERATE_INFOPLIST_FILE = YES; 261 + GENERATE_INFOPLIST_FILE = NO; 262 + INFOPLIST_FILE = Info.plist; 259 263 INFOPLIST_KEY_CFBundleDisplayName = Serviceberry; 260 264 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 261 265 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; ··· 271 275 MARKETING_VERSION = 1.0; 272 276 PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.serviceberry; 273 277 PRODUCT_NAME = "$(TARGET_NAME)"; 278 + PROVISIONING_PROFILE_SPECIFIER = ""; 274 279 STRING_CATALOG_GENERATE_SYMBOLS = YES; 275 280 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 276 281 SUPPORTS_MACCATALYST = NO; ··· 290 295 buildSettings = { 291 296 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 292 297 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 298 + CODE_SIGN_IDENTITY = "Apple Development"; 293 299 CODE_SIGN_STYLE = Automatic; 294 300 CURRENT_PROJECT_VERSION = 1; 295 301 DEVELOPMENT_TEAM = M67B42LX8D; 296 302 ENABLE_PREVIEWS = YES; 297 - GENERATE_INFOPLIST_FILE = YES; 303 + GENERATE_INFOPLIST_FILE = NO; 304 + INFOPLIST_FILE = Info.plist; 298 305 INFOPLIST_KEY_CFBundleDisplayName = Serviceberry; 299 306 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 300 307 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; ··· 310 317 MARKETING_VERSION = 1.0; 311 318 PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.serviceberry; 312 319 PRODUCT_NAME = "$(TARGET_NAME)"; 320 + PROVISIONING_PROFILE_SPECIFIER = ""; 313 321 STRING_CATALOG_GENERATE_SYMBOLS = YES; 314 322 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 315 323 SUPPORTS_MACCATALYST = NO;
+31
serviceberry/App/AppDelegate.swift
··· 1 + import UIKit 2 + import CoreBluetooth 3 + 4 + /// App delegate for handling background launch and BLE state restoration 5 + class AppDelegate: NSObject, UIApplicationDelegate { 6 + 7 + func application(_ application: UIApplication, 8 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 9 + 10 + // Check if launched due to BLE state restoration 11 + if let bluetoothCentrals = launchOptions?[.bluetoothCentrals] as? [String] { 12 + print("App launched for BLE restoration: \(bluetoothCentrals)") 13 + // The BLETransport will handle restoration via willRestoreState 14 + } 15 + 16 + // Check if launched due to location event 17 + if let _ = launchOptions?[.location] { 18 + print("App launched for location event") 19 + } 20 + 21 + return true 22 + } 23 + 24 + func applicationDidEnterBackground(_ application: UIApplication) { 25 + print("App entered background") 26 + } 27 + 28 + func applicationWillEnterForeground(_ application: UIApplication) { 29 + print("App will enter foreground") 30 + } 31 + }
+90
serviceberry/App/AppState.swift
··· 1 + import Foundation 2 + import Combine 3 + 4 + /// Global application state 5 + @MainActor 6 + class AppState: ObservableObject { 7 + @Published var isOnboarded: Bool { 8 + didSet { 9 + UserDefaults.standard.set(isOnboarded, forKey: Constants.UserDefaultsKeys.isOnboarded) 10 + } 11 + } 12 + 13 + @Published var transportMode: TransportMode? { 14 + didSet { 15 + if let mode = transportMode { 16 + UserDefaults.standard.set(mode.rawValue, forKey: Constants.UserDefaultsKeys.transportMode) 17 + } else { 18 + UserDefaults.standard.removeObject(forKey: Constants.UserDefaultsKeys.transportMode) 19 + } 20 + } 21 + } 22 + 23 + @Published var serverInfo: ServerInfo? { 24 + didSet { 25 + if let info = serverInfo, 26 + let data = try? JSONEncoder().encode(info) { 27 + UserDefaults.standard.set(data, forKey: Constants.UserDefaultsKeys.serverInfo) 28 + } else { 29 + UserDefaults.standard.removeObject(forKey: Constants.UserDefaultsKeys.serverInfo) 30 + } 31 + } 32 + } 33 + 34 + let locationService: LocationService 35 + let transportManager: TransportManager 36 + 37 + init() { 38 + // Load persisted state 39 + self.isOnboarded = UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.isOnboarded) 40 + 41 + if let modeString = UserDefaults.standard.string(forKey: Constants.UserDefaultsKeys.transportMode) { 42 + self.transportMode = TransportMode(rawValue: modeString) 43 + } else { 44 + self.transportMode = nil 45 + } 46 + 47 + if let data = UserDefaults.standard.data(forKey: Constants.UserDefaultsKeys.serverInfo), 48 + let info = try? JSONDecoder().decode(ServerInfo.self, from: data) { 49 + self.serverInfo = info 50 + } else { 51 + self.serverInfo = nil 52 + } 53 + 54 + // Initialize services 55 + self.locationService = LocationService() 56 + self.transportManager = TransportManager(locationService: locationService) 57 + 58 + // Configure transport if already onboarded 59 + if isOnboarded, let mode = transportMode { 60 + transportManager.configure(mode: mode, serverInfo: serverInfo) 61 + } 62 + } 63 + 64 + /// Complete onboarding with selected mode and server info 65 + func completeOnboarding(mode: TransportMode, serverInfo: ServerInfo?) { 66 + self.transportMode = mode 67 + self.serverInfo = serverInfo 68 + self.isOnboarded = true 69 + 70 + transportManager.configure(mode: mode, serverInfo: serverInfo) 71 + } 72 + 73 + /// Reset to initial state (for re-running onboarding) 74 + func reset() { 75 + transportManager.disconnect() 76 + isOnboarded = false 77 + transportMode = nil 78 + serverInfo = nil 79 + } 80 + 81 + /// Connect to the server 82 + func connect() async throws { 83 + try await transportManager.connect() 84 + } 85 + 86 + /// Disconnect from the server 87 + func disconnect() { 88 + transportManager.disconnect() 89 + } 90 + }
+29
serviceberry/App/Constants.swift
··· 1 + import Foundation 2 + import CoreBluetooth 3 + 4 + enum Constants { 5 + // MARK: - BLE 6 + static let serviceUUID = CBUUID(string: "12345678-1234-5678-1234-56789abcdef0") 7 + static let characteristicUUID = CBUUID(string: "abcdef01-1234-5678-1234-56789abcdef0") 8 + static let peripheralName = "Serviceberry" 9 + 10 + // MARK: - mDNS 11 + static let bonjourServiceType = "_serviceberry._tcp" 12 + static let bonjourDomain = "local." 13 + 14 + // MARK: - Server 15 + static let serverPort: UInt16 = 8080 16 + static let submitPath = "/submit" 17 + static let requestPath = "/request" 18 + static let statusPath = "/status" 19 + 20 + // MARK: - Polling 21 + static let requestPollInterval: TimeInterval = 5.0 22 + 23 + // MARK: - UserDefaults Keys 24 + enum UserDefaultsKeys { 25 + static let isOnboarded = "isOnboarded" 26 + static let transportMode = "transportMode" 27 + static let serverInfo = "serverInfo" 28 + } 29 + }
+100 -16
serviceberry/Assets.xcassets/AppIcon.appiconset/Contents.json
··· 2 2 "images" : [ 3 3 { 4 4 "idiom" : "universal", 5 + "filename" : "ios_20pt@2x.png", 6 + "scale" : "2x", 5 7 "platform" : "ios", 6 - "size" : "1024x1024" 8 + "size" : "20x20" 9 + }, 10 + { 11 + "idiom" : "universal", 12 + "size" : "20x20", 13 + "scale" : "3x", 14 + "filename" : "ios_20pt@3x.png", 15 + "platform" : "ios" 16 + }, 17 + { 18 + "size" : "29x29", 19 + "idiom" : "universal", 20 + "filename" : "ios_29pt@2x.png", 21 + "scale" : "2x", 22 + "platform" : "ios" 7 23 }, 8 24 { 9 - "appearances" : [ 10 - { 11 - "appearance" : "luminosity", 12 - "value" : "dark" 13 - } 14 - ], 25 + "filename" : "ios_29pt@3x.png", 15 26 "idiom" : "universal", 16 27 "platform" : "ios", 17 - "size" : "1024x1024" 28 + "size" : "29x29", 29 + "scale" : "3x" 18 30 }, 19 31 { 20 - "appearances" : [ 21 - { 22 - "appearance" : "luminosity", 23 - "value" : "tinted" 24 - } 25 - ], 32 + "idiom" : "universal", 33 + "size" : "38x38", 34 + "platform" : "ios", 35 + "filename" : "ios_38pt@2x.png", 36 + "scale" : "2x" 37 + }, 38 + { 39 + "size" : "38x38", 40 + "idiom" : "universal", 41 + "scale" : "3x", 42 + "filename" : "ios_38pt@3x.png", 43 + "platform" : "ios" 44 + }, 45 + { 46 + "scale" : "2x", 47 + "size" : "40x40", 26 48 "idiom" : "universal", 49 + "filename" : "ios_40pt@2x.png", 50 + "platform" : "ios" 51 + }, 52 + { 53 + "idiom" : "universal", 54 + "filename" : "ios_40pt@3x.png", 55 + "size" : "40x40", 27 56 "platform" : "ios", 28 - "size" : "1024x1024" 57 + "scale" : "3x" 58 + }, 59 + { 60 + "idiom" : "universal", 61 + "scale" : "2x", 62 + "platform" : "ios", 63 + "size" : "60x60", 64 + "filename" : "ios_60pt@2x.png" 65 + }, 66 + { 67 + "platform" : "ios", 68 + "scale" : "3x", 69 + "idiom" : "universal", 70 + "filename" : "ios_60pt@3x.png", 71 + "size" : "60x60" 72 + }, 73 + { 74 + "idiom" : "universal", 75 + "filename" : "ios_64pt@2x.png", 76 + "platform" : "ios", 77 + "size" : "64x64", 78 + "scale" : "2x" 79 + }, 80 + { 81 + "idiom" : "universal", 82 + "size" : "64x64", 83 + "scale" : "3x", 84 + "filename" : "ios_64pt@3x.png", 85 + "platform" : "ios" 86 + }, 87 + { 88 + "scale" : "2x", 89 + "idiom" : "universal", 90 + "filename" : "ios_68pt@2x.png", 91 + "size" : "68x68", 92 + "platform" : "ios" 93 + }, 94 + { 95 + "filename" : "ios_76pt@2x.png", 96 + "size" : "76x76", 97 + "idiom" : "universal", 98 + "scale" : "2x", 99 + "platform" : "ios" 100 + }, 101 + { 102 + "idiom" : "universal", 103 + "platform" : "ios", 104 + "size" : "83.5x83.5", 105 + "scale" : "2x", 106 + "filename" : "ios_83.5pt@2x.png" 107 + }, 108 + { 109 + "filename" : "ios_1024pt@1x.png", 110 + "idiom" : "universal", 111 + "size" : "1024x1024", 112 + "platform" : "ios" 29 113 } 30 114 ], 31 115 "info" : { 32 116 "author" : "xcode", 33 117 "version" : 1 34 118 } 35 - } 119 + }
serviceberry/Assets.xcassets/AppIcon.appiconset/ios_1024pt@1x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_20pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_20pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_29pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_29pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_38pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_38pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_40pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_40pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_60pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_60pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_64pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_64pt@3x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_68pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_76pt@2x.png

This is a binary file and will not be displayed.

serviceberry/Assets.xcassets/AppIcon.appiconset/ios_83.5pt@2x.png

This is a binary file and will not be displayed.

-24
serviceberry/ContentView.swift
··· 1 - // 2 - // ContentView.swift 3 - // serviceberry 4 - // 5 - // Created by Jasper Mayone on 12/27/25. 6 - // 7 - 8 - import SwiftUI 9 - 10 - struct ContentView: View { 11 - var body: some View { 12 - VStack { 13 - Image(systemName: "globe") 14 - .imageScale(.large) 15 - .foregroundStyle(.tint) 16 - Text("Hello, world!") 17 - } 18 - .padding() 19 - } 20 - } 21 - 22 - #Preview { 23 - ContentView() 24 - }
+30
serviceberry/Models/LocationPayload.swift
··· 1 + import Foundation 2 + 3 + /// Payload sent to the server containing position and optional cell tower data 4 + struct LocationPayload: Codable { 5 + let position: Position 6 + let cell_towers: [CellTower]? 7 + 8 + init(position: Position, cellTowers: [CellTower]? = nil) { 9 + self.position = position 10 + self.cell_towers = cellTowers 11 + } 12 + } 13 + 14 + /// Radio type for cell towers 15 + enum RadioType: String, Codable { 16 + case gsm 17 + case wcdma 18 + case lte 19 + } 20 + 21 + /// Cell tower information (optional, iOS cannot easily access this) 22 + struct CellTower: Codable { 23 + let radioType: RadioType? 24 + let mobileCountryCode: UInt16 25 + let mobileNetworkCode: UInt16 26 + let locationAreaCode: UInt32 27 + let cellId: UInt32 28 + let age: UInt32? 29 + let asu: UInt8? 30 + }
+39
serviceberry/Models/Position.swift
··· 1 + import Foundation 2 + internal import CoreLocation 3 + 4 + /// Position data matching the server's expected format 5 + struct Position: Codable { 6 + let latitude: Double 7 + let longitude: Double 8 + let accuracy: Double 9 + let altitude: Double 10 + let altitudeAccuracy: Double 11 + let heading: Double 12 + let speed: Double 13 + let source: String 14 + 15 + /// Create Position from CLLocation 16 + init(from location: CLLocation) { 17 + self.latitude = location.coordinate.latitude 18 + self.longitude = location.coordinate.longitude 19 + self.accuracy = location.horizontalAccuracy 20 + self.altitude = location.altitude 21 + self.altitudeAccuracy = location.verticalAccuracy 22 + self.heading = location.course >= 0 ? location.course : 0 23 + self.speed = location.speed >= 0 ? location.speed : 0 24 + self.source = "gps" 25 + } 26 + 27 + /// Manual initializer for testing 28 + init(latitude: Double, longitude: Double, accuracy: Double, altitude: Double = 0, 29 + altitudeAccuracy: Double = 0, heading: Double = 0, speed: Double = 0, source: String = "gps") { 30 + self.latitude = latitude 31 + self.longitude = longitude 32 + self.accuracy = accuracy 33 + self.altitude = altitude 34 + self.altitudeAccuracy = altitudeAccuracy 35 + self.heading = heading 36 + self.speed = speed 37 + self.source = source 38 + } 39 + }
+46
serviceberry/Models/ServerInfo.swift
··· 1 + import Foundation 2 + 3 + /// Information about a discovered Serviceberry server 4 + struct ServerInfo: Codable, Identifiable, Hashable { 5 + let id: UUID 6 + let name: String 7 + let host: String 8 + let port: UInt16 9 + let certFingerprint: String 10 + let version: String 11 + let paths: [String] 12 + 13 + init(name: String, host: String, port: UInt16, certFingerprint: String, version: String, paths: [String]) { 14 + self.id = UUID() 15 + self.name = name 16 + self.host = host 17 + self.port = port 18 + self.certFingerprint = certFingerprint 19 + self.version = version 20 + self.paths = paths 21 + } 22 + 23 + /// Base URL for API requests 24 + var baseURL: URL? { 25 + URL(string: "https://\(host):\(port)") 26 + } 27 + 28 + /// Submit endpoint URL 29 + var submitURL: URL? { 30 + baseURL?.appendingPathComponent(Constants.submitPath) 31 + } 32 + 33 + /// Request endpoint URL 34 + var requestURL: URL? { 35 + baseURL?.appendingPathComponent(Constants.requestPath) 36 + } 37 + 38 + func hash(into hasher: inout Hasher) { 39 + hasher.combine(host) 40 + hasher.combine(port) 41 + } 42 + 43 + static func == (lhs: ServerInfo, rhs: ServerInfo) -> Bool { 44 + lhs.host == rhs.host && lhs.port == rhs.port 45 + } 46 + }
+34
serviceberry/Models/TransportMode.swift
··· 1 + import Foundation 2 + 3 + /// Transport method for communicating with the server 4 + enum TransportMode: String, Codable { 5 + case bluetooth 6 + case lan 7 + 8 + var displayName: String { 9 + switch self { 10 + case .bluetooth: 11 + return "Bluetooth" 12 + case .lan: 13 + return "Local Network" 14 + } 15 + } 16 + 17 + var icon: String { 18 + switch self { 19 + case .bluetooth: 20 + return "antenna.radiowaves.left.and.right" 21 + case .lan: 22 + return "wifi" 23 + } 24 + } 25 + 26 + var description: String { 27 + switch self { 28 + case .bluetooth: 29 + return "Connect directly via Bluetooth Low Energy. Best for when your phone is near the server." 30 + case .lan: 31 + return "Connect over your local WiFi network. Discovers the server automatically via mDNS." 32 + } 33 + } 34 + }
+225
serviceberry/Services/BLETransport.swift
··· 1 + import Foundation 2 + import CoreBluetooth 3 + import Combine 4 + 5 + /// BLE Central transport for communicating with Serviceberry server 6 + class BLETransport: NSObject, ObservableObject, TransportProtocol { 7 + private var centralManager: CBCentralManager! 8 + private var peripheral: CBPeripheral? 9 + private var characteristic: CBCharacteristic? 10 + 11 + private var connectionContinuation: CheckedContinuation<Void, Error>? 12 + private var writeContinuation: CheckedContinuation<Void, Error>? 13 + 14 + @Published private(set) var connectionState: ConnectionState = .disconnected 15 + @Published var discoveredPeripherals: [CBPeripheral] = [] 16 + 17 + var onLocationRequest: (() async -> Void)? 18 + 19 + private let connectionStateSubject = CurrentValueSubject<ConnectionState, Never>(.disconnected) 20 + var connectionStatePublisher: AnyPublisher<ConnectionState, Never> { 21 + connectionStateSubject.eraseToAnyPublisher() 22 + } 23 + 24 + override init() { 25 + super.init() 26 + centralManager = CBCentralManager( 27 + delegate: self, 28 + queue: nil, 29 + options: [CBCentralManagerOptionRestoreIdentifierKey: "serviceberry-central"] 30 + ) 31 + } 32 + 33 + /// Start scanning for Serviceberry peripherals 34 + func startScanning() { 35 + guard centralManager.state == .poweredOn else { return } 36 + discoveredPeripherals = [] 37 + centralManager.scanForPeripherals( 38 + withServices: [Constants.serviceUUID], 39 + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] 40 + ) 41 + } 42 + 43 + /// Stop scanning 44 + func stopScanning() { 45 + centralManager.stopScan() 46 + } 47 + 48 + /// Connect to a specific peripheral 49 + func connect(to peripheral: CBPeripheral) async throws { 50 + self.peripheral = peripheral 51 + try await connect() 52 + } 53 + 54 + func connect() async throws { 55 + guard let peripheral = peripheral else { 56 + throw TransportError.connectionFailed("No peripheral selected") 57 + } 58 + 59 + updateState(.connecting) 60 + 61 + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 62 + self.connectionContinuation = continuation 63 + self.centralManager.connect(peripheral, options: nil) 64 + } 65 + } 66 + 67 + func disconnect() { 68 + if let peripheral = peripheral { 69 + centralManager.cancelPeripheralConnection(peripheral) 70 + } 71 + peripheral = nil 72 + characteristic = nil 73 + updateState(.disconnected) 74 + } 75 + 76 + func sendLocation(_ payload: LocationPayload) async throws { 77 + guard let peripheral = peripheral, 78 + let characteristic = characteristic else { 79 + throw TransportError.notConnected 80 + } 81 + 82 + let encoder = JSONEncoder() 83 + var data = try encoder.encode(payload) 84 + // Append newline for server parsing 85 + data.append(0x0A) 86 + 87 + // Get max write length 88 + let maxLength = peripheral.maximumWriteValueLength(for: .withResponse) 89 + 90 + // Chunk if necessary 91 + var offset = 0 92 + while offset < data.count { 93 + let chunkSize = min(maxLength, data.count - offset) 94 + let chunk = data.subdata(in: offset..<offset + chunkSize) 95 + 96 + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 97 + self.writeContinuation = continuation 98 + peripheral.writeValue(chunk, for: characteristic, type: .withResponse) 99 + } 100 + 101 + offset += chunkSize 102 + } 103 + } 104 + 105 + private func updateState(_ state: ConnectionState) { 106 + connectionState = state 107 + connectionStateSubject.send(state) 108 + } 109 + } 110 + 111 + // MARK: - CBCentralManagerDelegate 112 + extension BLETransport: CBCentralManagerDelegate { 113 + func centralManagerDidUpdateState(_ central: CBCentralManager) { 114 + switch central.state { 115 + case .poweredOn: 116 + // Ready to scan 117 + break 118 + case .poweredOff: 119 + updateState(.error("Bluetooth is turned off")) 120 + case .unauthorized: 121 + updateState(.error("Bluetooth access not authorized")) 122 + case .unsupported: 123 + updateState(.error("Bluetooth not supported")) 124 + default: 125 + break 126 + } 127 + } 128 + 129 + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, 130 + advertisementData: [String: Any], rssi RSSI: NSNumber) { 131 + if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) { 132 + discoveredPeripherals.append(peripheral) 133 + } 134 + } 135 + 136 + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 137 + peripheral.delegate = self 138 + peripheral.discoverServices([Constants.serviceUUID]) 139 + } 140 + 141 + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 142 + updateState(.error(error?.localizedDescription ?? "Connection failed")) 143 + connectionContinuation?.resume(throwing: TransportError.connectionFailed(error?.localizedDescription ?? "Unknown error")) 144 + connectionContinuation = nil 145 + } 146 + 147 + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 148 + updateState(.disconnected) 149 + characteristic = nil 150 + } 151 + 152 + // State restoration 153 + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { 154 + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], 155 + let restoredPeripheral = peripherals.first { 156 + self.peripheral = restoredPeripheral 157 + restoredPeripheral.delegate = self 158 + if restoredPeripheral.state == .connected { 159 + restoredPeripheral.discoverServices([Constants.serviceUUID]) 160 + } 161 + } 162 + } 163 + } 164 + 165 + // MARK: - CBPeripheralDelegate 166 + extension BLETransport: CBPeripheralDelegate { 167 + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 168 + guard error == nil else { 169 + updateState(.error(error!.localizedDescription)) 170 + connectionContinuation?.resume(throwing: TransportError.connectionFailed(error!.localizedDescription)) 171 + connectionContinuation = nil 172 + return 173 + } 174 + 175 + if let service = peripheral.services?.first(where: { $0.uuid == Constants.serviceUUID }) { 176 + peripheral.discoverCharacteristics([Constants.characteristicUUID], for: service) 177 + } 178 + } 179 + 180 + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 181 + guard error == nil else { 182 + updateState(.error(error!.localizedDescription)) 183 + connectionContinuation?.resume(throwing: TransportError.connectionFailed(error!.localizedDescription)) 184 + connectionContinuation = nil 185 + return 186 + } 187 + 188 + if let char = service.characteristics?.first(where: { $0.uuid == Constants.characteristicUUID }) { 189 + self.characteristic = char 190 + // Subscribe to notifications 191 + peripheral.setNotifyValue(true, for: char) 192 + updateState(.connected) 193 + connectionContinuation?.resume() 194 + connectionContinuation = nil 195 + } 196 + } 197 + 198 + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { 199 + guard error == nil, let value = characteristic.value else { return } 200 + 201 + // Check if this is a location request from the server 202 + if let message = String(data: value, encoding: .utf8) { 203 + if message.lowercased().contains("request") || message == "GPS?" { 204 + Task { 205 + await onLocationRequest?() 206 + } 207 + } 208 + } 209 + } 210 + 211 + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { 212 + if let error = error { 213 + writeContinuation?.resume(throwing: TransportError.sendFailed(error.localizedDescription)) 214 + } else { 215 + writeContinuation?.resume() 216 + } 217 + writeContinuation = nil 218 + } 219 + 220 + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { 221 + if let error = error { 222 + print("Failed to subscribe to notifications: \(error.localizedDescription)") 223 + } 224 + } 225 + }
+164
serviceberry/Services/LANTransport.swift
··· 1 + import Foundation 2 + import Combine 3 + import CryptoKit 4 + 5 + /// LAN transport for communicating with Serviceberry server over HTTPS 6 + class LANTransport: NSObject, ObservableObject, TransportProtocol { 7 + private var session: URLSession! 8 + private let serverInfo: ServerInfo 9 + private var pollingTask: Task<Void, Never>? 10 + 11 + @Published private(set) var connectionState: ConnectionState = .disconnected 12 + 13 + var onLocationRequest: (() async -> Void)? 14 + 15 + private let connectionStateSubject = CurrentValueSubject<ConnectionState, Never>(.disconnected) 16 + var connectionStatePublisher: AnyPublisher<ConnectionState, Never> { 17 + connectionStateSubject.eraseToAnyPublisher() 18 + } 19 + 20 + init(serverInfo: ServerInfo) { 21 + self.serverInfo = serverInfo 22 + super.init() 23 + 24 + let config = URLSessionConfiguration.default 25 + config.timeoutIntervalForRequest = 30 26 + config.timeoutIntervalForResource = 60 27 + self.session = URLSession(configuration: config, delegate: self, delegateQueue: nil) 28 + } 29 + 30 + func connect() async throws { 31 + updateState(.connecting) 32 + 33 + // Test connection by hitting status endpoint 34 + guard let statusURL = serverInfo.baseURL?.appendingPathComponent(Constants.statusPath) else { 35 + throw TransportError.connectionFailed("Invalid server URL") 36 + } 37 + 38 + do { 39 + let (_, response) = try await session.data(from: statusURL) 40 + 41 + guard let httpResponse = response as? HTTPURLResponse, 42 + httpResponse.statusCode == 200 else { 43 + throw TransportError.connectionFailed("Server returned error") 44 + } 45 + 46 + updateState(.connected) 47 + startPolling() 48 + } catch { 49 + updateState(.error(error.localizedDescription)) 50 + throw error 51 + } 52 + } 53 + 54 + func disconnect() { 55 + pollingTask?.cancel() 56 + pollingTask = nil 57 + updateState(.disconnected) 58 + } 59 + 60 + func sendLocation(_ payload: LocationPayload) async throws { 61 + guard connectionState.isConnected else { 62 + throw TransportError.notConnected 63 + } 64 + 65 + guard let submitURL = serverInfo.submitURL else { 66 + throw TransportError.sendFailed("Invalid submit URL") 67 + } 68 + 69 + var request = URLRequest(url: submitURL) 70 + request.httpMethod = "POST" 71 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 72 + 73 + let encoder = JSONEncoder() 74 + request.httpBody = try encoder.encode(payload) 75 + 76 + let (_, response) = try await session.data(for: request) 77 + 78 + guard let httpResponse = response as? HTTPURLResponse, 79 + httpResponse.statusCode == 200 else { 80 + throw TransportError.sendFailed("Server returned error") 81 + } 82 + } 83 + 84 + private func startPolling() { 85 + pollingTask?.cancel() 86 + 87 + pollingTask = Task { [weak self] in 88 + while !Task.isCancelled { 89 + await self?.pollForRequest() 90 + try? await Task.sleep(nanoseconds: UInt64(Constants.requestPollInterval * 1_000_000_000)) 91 + } 92 + } 93 + } 94 + 95 + private func pollForRequest() async { 96 + guard let requestURL = serverInfo.requestURL else { return } 97 + 98 + do { 99 + let (data, response) = try await session.data(from: requestURL) 100 + 101 + guard let httpResponse = response as? HTTPURLResponse, 102 + httpResponse.statusCode == 200 else { return } 103 + 104 + // Check if server is requesting location 105 + if let responseText = String(data: data, encoding: .utf8), 106 + responseText.lowercased().contains("request") { 107 + await onLocationRequest?() 108 + } 109 + } catch { 110 + // Polling error - don't disconnect, just log 111 + print("Polling error: \(error.localizedDescription)") 112 + } 113 + } 114 + 115 + private func updateState(_ state: ConnectionState) { 116 + Task { @MainActor in 117 + connectionState = state 118 + connectionStateSubject.send(state) 119 + } 120 + } 121 + } 122 + 123 + // MARK: - URLSessionDelegate for Certificate Pinning 124 + extension LANTransport: URLSessionDelegate { 125 + func urlSession(_ session: URLSession, 126 + didReceive challenge: URLAuthenticationChallenge, 127 + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 128 + 129 + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, 130 + let serverTrust = challenge.protectionSpace.serverTrust else { 131 + completionHandler(.cancelAuthenticationChallenge, nil) 132 + return 133 + } 134 + 135 + // If no fingerprint provided, trust any certificate (for local network use) 136 + if serverInfo.certFingerprint.isEmpty { 137 + let credential = URLCredential(trust: serverTrust) 138 + completionHandler(.useCredential, credential) 139 + return 140 + } 141 + 142 + // Get the server certificate 143 + guard let certificateChain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], 144 + let serverCert = certificateChain.first else { 145 + completionHandler(.cancelAuthenticationChallenge, nil) 146 + return 147 + } 148 + 149 + // Get certificate data and compute fingerprint 150 + let certData = SecCertificateCopyData(serverCert) as Data 151 + let serverFingerprint = HexUtils.sha256Hex(of: certData) 152 + 153 + // Compare with expected fingerprint 154 + if HexUtils.fingerprintsMatch(serverFingerprint, serverInfo.certFingerprint) { 155 + let credential = URLCredential(trust: serverTrust) 156 + completionHandler(.useCredential, credential) 157 + } else { 158 + print("Certificate mismatch!") 159 + print("Expected: \(serverInfo.certFingerprint)") 160 + print("Got: \(serverFingerprint)") 161 + completionHandler(.cancelAuthenticationChallenge, nil) 162 + } 163 + } 164 + }
+104
serviceberry/Services/LocationService.swift
··· 1 + import Foundation 2 + internal import CoreLocation 3 + import Combine 4 + 5 + /// Service for managing location updates 6 + @MainActor 7 + class LocationService: NSObject, ObservableObject { 8 + private let locationManager = CLLocationManager() 9 + private var locationContinuation: CheckedContinuation<CLLocation, Error>? 10 + 11 + @Published var currentLocation: CLLocation? 12 + @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined 13 + @Published var lastError: Error? 14 + 15 + override init() { 16 + super.init() 17 + locationManager.delegate = self 18 + locationManager.desiredAccuracy = kCLLocationAccuracyBest 19 + authorizationStatus = locationManager.authorizationStatus 20 + } 21 + 22 + /// Request location authorization 23 + func requestAuthorization() { 24 + locationManager.requestWhenInUseAuthorization() 25 + } 26 + 27 + /// Request "Always" authorization for background updates 28 + func requestAlwaysAuthorization() { 29 + locationManager.requestAlwaysAuthorization() 30 + } 31 + 32 + /// Request a single location update 33 + func requestLocation() async throws -> Position { 34 + // Check authorization 35 + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { 36 + throw LocationError.notAuthorized 37 + } 38 + 39 + let location = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CLLocation, Error>) in 40 + self.locationContinuation = continuation 41 + self.locationManager.requestLocation() 42 + } 43 + 44 + return Position(from: location) 45 + } 46 + 47 + /// Start continuous location updates 48 + func startUpdatingLocation() { 49 + locationManager.startUpdatingLocation() 50 + } 51 + 52 + /// Stop continuous location updates 53 + func stopUpdatingLocation() { 54 + locationManager.stopUpdatingLocation() 55 + } 56 + } 57 + 58 + // MARK: - CLLocationManagerDelegate 59 + extension LocationService: CLLocationManagerDelegate { 60 + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 61 + guard let location = locations.last else { return } 62 + 63 + Task { @MainActor in 64 + self.currentLocation = location 65 + 66 + if let continuation = self.locationContinuation { 67 + self.locationContinuation = nil 68 + continuation.resume(returning: location) 69 + } 70 + } 71 + } 72 + 73 + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 74 + Task { @MainActor in 75 + self.lastError = error 76 + 77 + if let continuation = self.locationContinuation { 78 + self.locationContinuation = nil 79 + continuation.resume(throwing: error) 80 + } 81 + } 82 + } 83 + 84 + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 85 + Task { @MainActor in 86 + self.authorizationStatus = manager.authorizationStatus 87 + } 88 + } 89 + } 90 + 91 + // MARK: - Errors 92 + enum LocationError: LocalizedError { 93 + case notAuthorized 94 + case locationUnavailable 95 + 96 + var errorDescription: String? { 97 + switch self { 98 + case .notAuthorized: 99 + return "Location access not authorized" 100 + case .locationUnavailable: 101 + return "Unable to determine location" 102 + } 103 + } 104 + }
+201
serviceberry/Services/MDNSDiscovery.swift
··· 1 + import Foundation 2 + import Network 3 + import Combine 4 + 5 + /// Service for discovering Serviceberry servers via mDNS/Bonjour 6 + @MainActor 7 + class MDNSDiscovery: ObservableObject { 8 + private var browser: NWBrowser? 9 + private var connections: [NWConnection] = [] 10 + 11 + @Published var discoveredServers: [ServerInfo] = [] 12 + @Published var isSearching = false 13 + @Published var lastError: Error? 14 + 15 + /// Start browsing for Serviceberry servers 16 + func startBrowsing() { 17 + stopBrowsing() 18 + discoveredServers = [] 19 + isSearching = true 20 + 21 + let descriptor = NWBrowser.Descriptor.bonjour( 22 + type: Constants.bonjourServiceType, 23 + domain: Constants.bonjourDomain 24 + ) 25 + 26 + let parameters = NWParameters() 27 + parameters.includePeerToPeer = true 28 + 29 + browser = NWBrowser(for: descriptor, using: parameters) 30 + 31 + browser?.stateUpdateHandler = { [weak self] state in 32 + Task { @MainActor [weak self] in 33 + guard let self else { return } 34 + switch state { 35 + case .ready: 36 + break 37 + case .failed(let error): 38 + self.lastError = error 39 + self.isSearching = false 40 + case .cancelled: 41 + self.isSearching = false 42 + default: 43 + break 44 + } 45 + } 46 + } 47 + 48 + browser?.browseResultsChangedHandler = { [weak self] results, _ in 49 + Task { @MainActor [weak self] in 50 + guard let self else { return } 51 + self.processBrowseResults(results) 52 + } 53 + } 54 + 55 + browser?.start(queue: .main) 56 + } 57 + 58 + /// Stop browsing 59 + func stopBrowsing() { 60 + browser?.cancel() 61 + browser = nil 62 + for conn in connections { 63 + conn.cancel() 64 + } 65 + connections = [] 66 + isSearching = false 67 + } 68 + 69 + private func processBrowseResults(_ results: Set<NWBrowser.Result>) { 70 + for result in results { 71 + guard case .service(let name, let type, let domain, _) = result.endpoint else { continue } 72 + 73 + // Extract TXT records from metadata 74 + var version = "unknown" 75 + var paths: [String] = [] 76 + var certFingerprint = "" 77 + 78 + if case .bonjour(let txtRecord) = result.metadata { 79 + let dict = parseTXTRecord(txtRecord) 80 + version = dict["version"] ?? "unknown" 81 + certFingerprint = dict["cert_fingerprint"] ?? "" 82 + if let pathsStr = dict["paths"] { 83 + paths = pathsStr.components(separatedBy: ", ") 84 + } 85 + } 86 + 87 + // Resolve the service to get the actual host 88 + resolveService( 89 + name: name, 90 + type: type, 91 + domain: domain, 92 + version: version, 93 + paths: paths, 94 + certFingerprint: certFingerprint 95 + ) 96 + } 97 + } 98 + 99 + private func resolveService( 100 + name: String, 101 + type: String, 102 + domain: String, 103 + version: String, 104 + paths: [String], 105 + certFingerprint: String 106 + ) { 107 + // Create a connection to resolve the service endpoint 108 + let endpoint = NWEndpoint.service(name: name, type: type, domain: domain, interface: nil) 109 + let parameters = NWParameters.tcp 110 + let connection = NWConnection(to: endpoint, using: parameters) 111 + 112 + connection.stateUpdateHandler = { [weak self] state in 113 + Task { @MainActor [weak self] in 114 + guard let self else { return } 115 + 116 + switch state { 117 + case .ready: 118 + // Get the resolved endpoint 119 + if let resolvedEndpoint = connection.currentPath?.remoteEndpoint { 120 + self.handleResolvedEndpoint( 121 + resolvedEndpoint, 122 + serviceName: name, 123 + version: version, 124 + paths: paths, 125 + certFingerprint: certFingerprint 126 + ) 127 + } 128 + connection.cancel() 129 + 130 + case .failed, .cancelled: 131 + connection.cancel() 132 + 133 + default: 134 + break 135 + } 136 + } 137 + } 138 + 139 + connections.append(connection) 140 + connection.start(queue: .main) 141 + } 142 + 143 + private func handleResolvedEndpoint( 144 + _ endpoint: NWEndpoint, 145 + serviceName: String, 146 + version: String, 147 + paths: [String], 148 + certFingerprint: String 149 + ) { 150 + var host: String = "" 151 + var port: UInt16 = Constants.serverPort 152 + 153 + switch endpoint { 154 + case .hostPort(let h, let p): 155 + port = p.rawValue 156 + 157 + switch h { 158 + case .name(let hostname, _): 159 + // Use the resolved hostname (e.g., "turtle.local") 160 + host = hostname 161 + case .ipv4(let addr): 162 + // Use IPv4 address 163 + host = "\(addr)" 164 + case .ipv6(let addr): 165 + // Use IPv6 address 166 + host = "[\(addr)]" 167 + @unknown default: 168 + break 169 + } 170 + 171 + default: 172 + break 173 + } 174 + 175 + guard !host.isEmpty else { return } 176 + 177 + let serverInfo = ServerInfo( 178 + name: serviceName, 179 + host: host, 180 + port: port, 181 + certFingerprint: certFingerprint, 182 + version: version, 183 + paths: paths 184 + ) 185 + 186 + // Add if not already discovered (by host) 187 + if !discoveredServers.contains(where: { $0.host == host }) { 188 + discoveredServers.append(serverInfo) 189 + } 190 + } 191 + 192 + private func parseTXTRecord(_ record: NWTXTRecord) -> [String: String] { 193 + var dict: [String: String] = [:] 194 + for key in record.dictionary.keys { 195 + if let value = record.dictionary[key] { 196 + dict[key] = value 197 + } 198 + } 199 + return dict 200 + } 201 + }
+108
serviceberry/Services/TransportManager.swift
··· 1 + import Foundation 2 + import Combine 3 + 4 + /// Manages the active transport and coordinates location requests 5 + @MainActor 6 + class TransportManager: ObservableObject { 7 + @Published var connectionState: ConnectionState = .disconnected 8 + @Published var lastSubmissionTime: Date? 9 + @Published var submissionCount: Int = 0 10 + 11 + private var transport: TransportProtocol? 12 + private let locationService: LocationService 13 + private var cancellables = Set<AnyCancellable>() 14 + 15 + var mode: TransportMode? 16 + var serverInfo: ServerInfo? 17 + 18 + init(locationService: LocationService) { 19 + self.locationService = locationService 20 + } 21 + 22 + /// Configure transport based on mode 23 + func configure(mode: TransportMode, serverInfo: ServerInfo? = nil) { 24 + disconnect() 25 + 26 + self.mode = mode 27 + self.serverInfo = serverInfo 28 + 29 + switch mode { 30 + case .bluetooth: 31 + let ble = BLETransport() 32 + setupTransport(ble) 33 + transport = ble 34 + 35 + case .lan: 36 + guard let info = serverInfo else { 37 + connectionState = .error("No server info provided") 38 + return 39 + } 40 + let lan = LANTransport(serverInfo: info) 41 + setupTransport(lan) 42 + transport = lan 43 + } 44 + } 45 + 46 + private func setupTransport(_ transport: TransportProtocol) { 47 + transport.onLocationRequest = { [weak self] in 48 + await self?.handleLocationRequest() 49 + } 50 + 51 + transport.connectionStatePublisher 52 + .receive(on: DispatchQueue.main) 53 + .sink { [weak self] state in 54 + self?.connectionState = state 55 + } 56 + .store(in: &cancellables) 57 + } 58 + 59 + /// Connect to the server using current transport 60 + func connect() async throws { 61 + guard let transport = transport else { 62 + throw TransportError.notConnected 63 + } 64 + try await transport.connect() 65 + } 66 + 67 + /// Disconnect from the server 68 + func disconnect() { 69 + transport?.disconnect() 70 + transport = nil 71 + cancellables.removeAll() 72 + connectionState = .disconnected 73 + } 74 + 75 + /// Handle incoming location request from server 76 + private func handleLocationRequest() async { 77 + do { 78 + let position = try await locationService.requestLocation() 79 + let payload = LocationPayload(position: position) 80 + try await sendLocation(payload) 81 + } catch { 82 + print("Failed to handle location request: \(error.localizedDescription)") 83 + } 84 + } 85 + 86 + /// Manually send current location 87 + func sendCurrentLocation() async throws { 88 + let position = try await locationService.requestLocation() 89 + let payload = LocationPayload(position: position) 90 + try await sendLocation(payload) 91 + } 92 + 93 + /// Send location payload 94 + private func sendLocation(_ payload: LocationPayload) async throws { 95 + guard let transport = transport else { 96 + throw TransportError.notConnected 97 + } 98 + 99 + try await transport.sendLocation(payload) 100 + lastSubmissionTime = Date() 101 + submissionCount += 1 102 + } 103 + 104 + /// Get BLE transport for peripheral selection (only valid if mode is bluetooth) 105 + var bleTransport: BLETransport? { 106 + transport as? BLETransport 107 + } 108 + }
+99
serviceberry/Services/TransportProtocol.swift
··· 1 + import Foundation 2 + import Combine 3 + 4 + /// Connection state for transports 5 + enum ConnectionState: Equatable { 6 + case disconnected 7 + case connecting 8 + case connected 9 + case error(String) 10 + 11 + var isConnected: Bool { 12 + if case .connected = self { return true } 13 + return false 14 + } 15 + 16 + var displayText: String { 17 + switch self { 18 + case .disconnected: 19 + return "Disconnected" 20 + case .connecting: 21 + return "Connecting..." 22 + case .connected: 23 + return "Connected" 24 + case .error(let message): 25 + return "Error: \(message)" 26 + } 27 + } 28 + 29 + var iconName: String { 30 + switch self { 31 + case .disconnected: 32 + return "circle" 33 + case .connecting: 34 + return "circle.dotted" 35 + case .connected: 36 + return "checkmark.circle.fill" 37 + case .error: 38 + return "exclamationmark.circle.fill" 39 + } 40 + } 41 + 42 + var iconColor: String { 43 + switch self { 44 + case .disconnected: 45 + return "secondary" 46 + case .connecting: 47 + return "orange" 48 + case .connected: 49 + return "green" 50 + case .error: 51 + return "red" 52 + } 53 + } 54 + } 55 + 56 + /// Protocol for transport implementations (BLE and LAN) 57 + protocol TransportProtocol: AnyObject { 58 + /// Current connection state 59 + var connectionState: ConnectionState { get } 60 + 61 + /// Publisher for connection state changes 62 + var connectionStatePublisher: AnyPublisher<ConnectionState, Never> { get } 63 + 64 + /// Called when the server requests a location update 65 + var onLocationRequest: (() async -> Void)? { get set } 66 + 67 + /// Connect to the server 68 + func connect() async throws 69 + 70 + /// Disconnect from the server 71 + func disconnect() 72 + 73 + /// Send location payload to the server 74 + func sendLocation(_ payload: LocationPayload) async throws 75 + } 76 + 77 + /// Errors that can occur during transport operations 78 + enum TransportError: LocalizedError { 79 + case notConnected 80 + case connectionFailed(String) 81 + case sendFailed(String) 82 + case invalidResponse 83 + case certificateMismatch 84 + 85 + var errorDescription: String? { 86 + switch self { 87 + case .notConnected: 88 + return "Not connected to server" 89 + case .connectionFailed(let reason): 90 + return "Connection failed: \(reason)" 91 + case .sendFailed(let reason): 92 + return "Send failed: \(reason)" 93 + case .invalidResponse: 94 + return "Invalid response from server" 95 + case .certificateMismatch: 96 + return "Server certificate does not match expected fingerprint" 97 + } 98 + } 99 + }
+20
serviceberry/Utilities/HexUtils.swift
··· 1 + import Foundation 2 + import CryptoKit 3 + 4 + enum HexUtils { 5 + /// Convert Data to lowercase hex string 6 + static func hexString(from data: Data) -> String { 7 + data.map { String(format: "%02x", $0) }.joined() 8 + } 9 + 10 + /// Compute SHA256 hash of data and return as hex string 11 + static func sha256Hex(of data: Data) -> String { 12 + let hash = SHA256.hash(data: data) 13 + return hash.compactMap { String(format: "%02x", $0) }.joined() 14 + } 15 + 16 + /// Compare two hex fingerprints (case-insensitive) 17 + static func fingerprintsMatch(_ a: String, _ b: String) -> Bool { 18 + a.lowercased() == b.lowercased() 19 + } 20 + }
+287
serviceberry/Views/Main/DashboardView.swift
··· 1 + import SwiftUI 2 + 3 + /// Main dashboard view showing connection status and controls 4 + struct DashboardView: View { 5 + @EnvironmentObject var appState: AppState 6 + @State private var showSettings = false 7 + @State private var isSending = false 8 + @State private var lastError: String? 9 + 10 + var body: some View { 11 + NavigationStack { 12 + ScrollView { 13 + VStack(spacing: 24) { 14 + // Connection status card 15 + connectionStatusCard 16 + 17 + // Stats card 18 + statsCard 19 + 20 + // Manual send button 21 + manualSendSection 22 + 23 + // Server info (if LAN mode) 24 + if let server = appState.serverInfo { 25 + serverInfoCard(server) 26 + } 27 + } 28 + .padding() 29 + } 30 + .navigationTitle("Serviceberry") 31 + .toolbar { 32 + ToolbarItem(placement: .navigationBarTrailing) { 33 + Button(action: { showSettings = true }) { 34 + Image(systemName: "gear") 35 + } 36 + } 37 + } 38 + .sheet(isPresented: $showSettings) { 39 + SettingsView() 40 + } 41 + .onAppear { 42 + connectIfNeeded() 43 + } 44 + } 45 + } 46 + 47 + // MARK: - Connection Status Card 48 + 49 + private var connectionStatusCard: some View { 50 + VStack(spacing: 16) { 51 + HStack { 52 + Image(systemName: appState.transportManager.connectionState.iconName) 53 + .font(.title) 54 + .foregroundStyle(connectionColor) 55 + 56 + VStack(alignment: .leading, spacing: 4) { 57 + Text("Connection Status") 58 + .font(.headline) 59 + Text(appState.transportManager.connectionState.displayText) 60 + .font(.subheadline) 61 + .foregroundStyle(.secondary) 62 + } 63 + 64 + Spacer() 65 + 66 + // Connection toggle 67 + Button(action: toggleConnection) { 68 + Text(appState.transportManager.connectionState.isConnected ? "Disconnect" : "Connect") 69 + .font(.subheadline) 70 + } 71 + .buttonStyle(.bordered) 72 + } 73 + 74 + // Transport mode indicator 75 + HStack { 76 + Image(systemName: appState.transportMode?.icon ?? "questionmark") 77 + .foregroundStyle(.secondary) 78 + Text(appState.transportMode?.displayName ?? "Not configured") 79 + .font(.caption) 80 + .foregroundStyle(.secondary) 81 + Spacer() 82 + } 83 + } 84 + .padding() 85 + .background( 86 + RoundedRectangle(cornerRadius: 16) 87 + .fill(Color(.systemGray6)) 88 + ) 89 + } 90 + 91 + private var connectionColor: Color { 92 + switch appState.transportManager.connectionState { 93 + case .connected: 94 + return .green 95 + case .connecting: 96 + return .orange 97 + case .error: 98 + return .red 99 + case .disconnected: 100 + return .gray 101 + } 102 + } 103 + 104 + // MARK: - Stats Card 105 + 106 + private var statsCard: some View { 107 + VStack(spacing: 16) { 108 + HStack { 109 + Text("Activity") 110 + .font(.headline) 111 + Spacer() 112 + } 113 + 114 + HStack(spacing: 24) { 115 + StatItem( 116 + icon: "arrow.up.circle.fill", 117 + title: "Submissions", 118 + value: "\(appState.transportManager.submissionCount)" 119 + ) 120 + 121 + if let lastTime = appState.transportManager.lastSubmissionTime { 122 + StatItem( 123 + icon: "clock.fill", 124 + title: "Last Sent", 125 + value: lastTime.formatted(date: .omitted, time: .shortened) 126 + ) 127 + } else { 128 + StatItem( 129 + icon: "clock.fill", 130 + title: "Last Sent", 131 + value: "Never" 132 + ) 133 + } 134 + } 135 + } 136 + .padding() 137 + .background( 138 + RoundedRectangle(cornerRadius: 16) 139 + .fill(Color(.systemGray6)) 140 + ) 141 + } 142 + 143 + // MARK: - Manual Send Section 144 + 145 + private var manualSendSection: some View { 146 + VStack(spacing: 12) { 147 + Button(action: sendLocation) { 148 + HStack { 149 + if isSending { 150 + ProgressView() 151 + .progressViewStyle(CircularProgressViewStyle(tint: .white)) 152 + } else { 153 + Image(systemName: "location.fill") 154 + } 155 + Text("Send Location Now") 156 + } 157 + .font(.headline) 158 + .frame(maxWidth: .infinity) 159 + .padding() 160 + .background(appState.transportManager.connectionState.isConnected ? Color.blue : Color.gray) 161 + .foregroundColor(.white) 162 + .cornerRadius(12) 163 + } 164 + .disabled(!appState.transportManager.connectionState.isConnected || isSending) 165 + 166 + if let error = lastError { 167 + Text(error) 168 + .font(.caption) 169 + .foregroundStyle(.red) 170 + } 171 + 172 + Text("Your server will automatically request location updates when needed.") 173 + .font(.caption) 174 + .foregroundStyle(.secondary) 175 + .multilineTextAlignment(.center) 176 + } 177 + } 178 + 179 + // MARK: - Server Info Card 180 + 181 + private func serverInfoCard(_ server: ServerInfo) -> some View { 182 + VStack(alignment: .leading, spacing: 12) { 183 + HStack { 184 + Text("Server") 185 + .font(.headline) 186 + Spacer() 187 + } 188 + 189 + VStack(alignment: .leading, spacing: 8) { 190 + InfoRow(label: "Name", value: server.name) 191 + InfoRow(label: "Address", value: "\(server.host):\(server.port)") 192 + if !server.version.isEmpty && server.version != "unknown" { 193 + InfoRow(label: "Version", value: server.version) 194 + } 195 + } 196 + } 197 + .padding() 198 + .background( 199 + RoundedRectangle(cornerRadius: 16) 200 + .fill(Color(.systemGray6)) 201 + ) 202 + } 203 + 204 + // MARK: - Actions 205 + 206 + private func connectIfNeeded() { 207 + guard !appState.transportManager.connectionState.isConnected else { return } 208 + 209 + Task { 210 + do { 211 + try await appState.connect() 212 + } catch { 213 + // Connection failed, will show in UI 214 + } 215 + } 216 + } 217 + 218 + private func toggleConnection() { 219 + if appState.transportManager.connectionState.isConnected { 220 + appState.disconnect() 221 + } else { 222 + Task { 223 + try? await appState.connect() 224 + } 225 + } 226 + } 227 + 228 + private func sendLocation() { 229 + isSending = true 230 + lastError = nil 231 + 232 + Task { 233 + do { 234 + try await appState.transportManager.sendCurrentLocation() 235 + } catch { 236 + lastError = error.localizedDescription 237 + } 238 + isSending = false 239 + } 240 + } 241 + } 242 + 243 + // MARK: - Supporting Views 244 + 245 + struct StatItem: View { 246 + let icon: String 247 + let title: String 248 + let value: String 249 + 250 + var body: some View { 251 + VStack(spacing: 8) { 252 + Image(systemName: icon) 253 + .font(.title2) 254 + .foregroundStyle(.blue) 255 + 256 + Text(value) 257 + .font(.title3) 258 + .fontWeight(.semibold) 259 + 260 + Text(title) 261 + .font(.caption) 262 + .foregroundStyle(.secondary) 263 + } 264 + .frame(maxWidth: .infinity) 265 + } 266 + } 267 + 268 + struct InfoRow: View { 269 + let label: String 270 + let value: String 271 + 272 + var body: some View { 273 + HStack { 274 + Text(label) 275 + .foregroundStyle(.secondary) 276 + Spacer() 277 + Text(value) 278 + .fontWeight(.medium) 279 + } 280 + .font(.subheadline) 281 + } 282 + } 283 + 284 + #Preview { 285 + DashboardView() 286 + .environmentObject(AppState()) 287 + }
+156
serviceberry/Views/Main/SettingsView.swift
··· 1 + import SwiftUI 2 + internal import CoreLocation 3 + 4 + /// Settings view for app configuration 5 + struct SettingsView: View { 6 + @EnvironmentObject var appState: AppState 7 + @Environment(\.dismiss) private var dismiss 8 + 9 + @State private var showResetConfirmation = false 10 + 11 + var body: some View { 12 + NavigationStack { 13 + List { 14 + // Connection section 15 + Section("Connection") { 16 + HStack { 17 + Label("Mode", systemImage: appState.transportMode?.icon ?? "questionmark") 18 + Spacer() 19 + Text(appState.transportMode?.displayName ?? "Not set") 20 + .foregroundStyle(.secondary) 21 + } 22 + 23 + if let server = appState.serverInfo { 24 + HStack { 25 + Label("Server", systemImage: "desktopcomputer") 26 + Spacer() 27 + Text(server.name) 28 + .foregroundStyle(.secondary) 29 + } 30 + 31 + HStack { 32 + Label("Address", systemImage: "network") 33 + Spacer() 34 + Text("\(server.host):\(server.port)") 35 + .foregroundStyle(.secondary) 36 + .font(.caption) 37 + } 38 + } 39 + } 40 + 41 + // Status section 42 + Section("Status") { 43 + HStack { 44 + Label("Connection", systemImage: appState.transportManager.connectionState.iconName) 45 + Spacer() 46 + Text(appState.transportManager.connectionState.displayText) 47 + .foregroundStyle(.secondary) 48 + } 49 + 50 + HStack { 51 + Label("Submissions", systemImage: "arrow.up.circle") 52 + Spacer() 53 + Text("\(appState.transportManager.submissionCount)") 54 + .foregroundStyle(.secondary) 55 + } 56 + } 57 + 58 + // Location section 59 + Section("Location") { 60 + HStack { 61 + Label("Authorization", systemImage: "location") 62 + Spacer() 63 + Text(authorizationText) 64 + .foregroundStyle(.secondary) 65 + } 66 + 67 + if appState.locationService.authorizationStatus == .denied || 68 + appState.locationService.authorizationStatus == .restricted { 69 + Button("Open Settings") { 70 + openSettings() 71 + } 72 + } 73 + } 74 + 75 + // About section 76 + Section("About") { 77 + HStack { 78 + Label("Version", systemImage: "info.circle") 79 + Spacer() 80 + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") 81 + .foregroundStyle(.secondary) 82 + } 83 + 84 + Link(destination: URL(string: "https://beacondb.net")!) { 85 + Label("About BeaconDB", systemImage: "globe") 86 + } 87 + } 88 + 89 + // Reset section 90 + Section { 91 + Button(role: .destructive) { 92 + showResetConfirmation = true 93 + } label: { 94 + Label("Reset Setup", systemImage: "arrow.counterclockwise") 95 + } 96 + } footer: { 97 + Text("This will disconnect and return to the setup wizard.") 98 + } 99 + } 100 + .navigationTitle("Settings") 101 + .navigationBarTitleDisplayMode(.inline) 102 + .toolbar { 103 + ToolbarItem(placement: .confirmationAction) { 104 + Button("Done") { 105 + dismiss() 106 + } 107 + } 108 + } 109 + .confirmationDialog( 110 + "Reset Setup?", 111 + isPresented: $showResetConfirmation, 112 + titleVisibility: .visible 113 + ) { 114 + Button("Reset", role: .destructive) { 115 + resetSetup() 116 + } 117 + Button("Cancel", role: .cancel) { } 118 + } message: { 119 + Text("This will disconnect from the server and return to the setup wizard.") 120 + } 121 + } 122 + } 123 + 124 + private var authorizationText: String { 125 + switch appState.locationService.authorizationStatus { 126 + case .notDetermined: 127 + return "Not Determined" 128 + case .restricted: 129 + return "Restricted" 130 + case .denied: 131 + return "Denied" 132 + case .authorizedAlways: 133 + return "Always" 134 + case .authorizedWhenInUse: 135 + return "When In Use" 136 + @unknown default: 137 + return "Unknown" 138 + } 139 + } 140 + 141 + private func openSettings() { 142 + if let url = URL(string: UIApplication.openSettingsURLString) { 143 + UIApplication.shared.open(url) 144 + } 145 + } 146 + 147 + private func resetSetup() { 148 + appState.reset() 149 + dismiss() 150 + } 151 + } 152 + 153 + #Preview { 154 + SettingsView() 155 + .environmentObject(AppState()) 156 + }
+166
serviceberry/Views/Onboarding/BluetoothSetupView.swift
··· 1 + import SwiftUI 2 + import CoreBluetooth 3 + 4 + /// Bluetooth device setup screen 5 + struct BluetoothSetupView: View { 6 + @EnvironmentObject var appState: AppState 7 + @EnvironmentObject var viewModel: OnboardingViewModel 8 + @StateObject private var bleTransport = BLETransport() 9 + 10 + @State private var isScanning = false 11 + @State private var connectionError: String? 12 + @State private var isConnecting = false 13 + 14 + var body: some View { 15 + VStack(spacing: 24) { 16 + // Header 17 + VStack(spacing: 8) { 18 + Text("Find Your Server") 19 + .font(.title2) 20 + .fontWeight(.bold) 21 + 22 + Text("Make sure your Serviceberry server is running and nearby.") 23 + .font(.body) 24 + .foregroundStyle(.secondary) 25 + .multilineTextAlignment(.center) 26 + } 27 + .padding(.top) 28 + 29 + // Scanning indicator or device list 30 + if bleTransport.discoveredPeripherals.isEmpty { 31 + Spacer() 32 + 33 + if isScanning { 34 + VStack(spacing: 16) { 35 + ProgressView() 36 + .scaleEffect(1.5) 37 + Text("Scanning for devices...") 38 + .foregroundStyle(.secondary) 39 + } 40 + } else { 41 + VStack(spacing: 16) { 42 + Image(systemName: "antenna.radiowaves.left.and.right") 43 + .font(.system(size: 60)) 44 + .foregroundStyle(.secondary) 45 + 46 + Text("No devices found") 47 + .font(.headline) 48 + .foregroundStyle(.secondary) 49 + 50 + Button("Start Scanning") { 51 + startScanning() 52 + } 53 + .buttonStyle(.borderedProminent) 54 + } 55 + } 56 + 57 + Spacer() 58 + } else { 59 + // Device list 60 + List { 61 + ForEach(bleTransport.discoveredPeripherals, id: \.identifier) { peripheral in 62 + Button(action: { 63 + selectPeripheral(peripheral) 64 + }) { 65 + HStack { 66 + Image(systemName: "desktopcomputer") 67 + .foregroundStyle(.blue) 68 + 69 + VStack(alignment: .leading) { 70 + Text(peripheral.name ?? "Unknown Device") 71 + .font(.headline) 72 + Text(peripheral.identifier.uuidString) 73 + .font(.caption) 74 + .foregroundStyle(.secondary) 75 + } 76 + 77 + Spacer() 78 + 79 + if isConnecting && viewModel.selectedPeripheral?.id == peripheral.identifier { 80 + ProgressView() 81 + } 82 + } 83 + } 84 + .disabled(isConnecting) 85 + } 86 + } 87 + .listStyle(.insetGrouped) 88 + } 89 + 90 + // Error message 91 + if let error = connectionError { 92 + Text(error) 93 + .font(.caption) 94 + .foregroundStyle(.red) 95 + .padding(.horizontal) 96 + } 97 + 98 + // Scan button (when devices are shown) 99 + if !bleTransport.discoveredPeripherals.isEmpty && !isConnecting { 100 + Button(isScanning ? "Stop Scanning" : "Scan Again") { 101 + if isScanning { 102 + stopScanning() 103 + } else { 104 + startScanning() 105 + } 106 + } 107 + .buttonStyle(.bordered) 108 + } 109 + } 110 + .navigationTitle("Bluetooth Setup") 111 + .navigationBarTitleDisplayMode(.inline) 112 + .onAppear { 113 + startScanning() 114 + } 115 + .onDisappear { 116 + stopScanning() 117 + } 118 + } 119 + 120 + private func startScanning() { 121 + isScanning = true 122 + connectionError = nil 123 + bleTransport.startScanning() 124 + 125 + // Auto-stop after 30 seconds 126 + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { 127 + if isScanning { 128 + stopScanning() 129 + } 130 + } 131 + } 132 + 133 + private func stopScanning() { 134 + isScanning = false 135 + bleTransport.stopScanning() 136 + } 137 + 138 + private func selectPeripheral(_ peripheral: CBPeripheral) { 139 + viewModel.selectedPeripheral = CBPeripheralWrapper( 140 + id: peripheral.identifier, 141 + name: peripheral.name ?? "Unknown" 142 + ) 143 + 144 + isConnecting = true 145 + connectionError = nil 146 + 147 + Task { 148 + do { 149 + try await bleTransport.connect(to: peripheral) 150 + // Connection successful, proceed to permissions 151 + viewModel.navigateTo(.permissions) 152 + } catch { 153 + connectionError = error.localizedDescription 154 + } 155 + isConnecting = false 156 + } 157 + } 158 + } 159 + 160 + #Preview { 161 + NavigationStack { 162 + BluetoothSetupView() 163 + } 164 + .environmentObject(AppState()) 165 + .environmentObject(OnboardingViewModel()) 166 + }
+155
serviceberry/Views/Onboarding/CompletionView.swift
··· 1 + import SwiftUI 2 + 3 + /// Setup completion screen 4 + struct CompletionView: View { 5 + @EnvironmentObject var appState: AppState 6 + @EnvironmentObject var viewModel: OnboardingViewModel 7 + 8 + @State private var isConnecting = false 9 + @State private var connectionError: String? 10 + 11 + var body: some View { 12 + VStack(spacing: 32) { 13 + Spacer() 14 + 15 + // Success icon 16 + Image(systemName: "checkmark.circle.fill") 17 + .font(.system(size: 80)) 18 + .foregroundStyle(.green) 19 + 20 + // Header 21 + VStack(spacing: 12) { 22 + Text("You're All Set!") 23 + .font(.title) 24 + .fontWeight(.bold) 25 + 26 + Text("Serviceberry is configured and ready to go.") 27 + .font(.body) 28 + .foregroundStyle(.secondary) 29 + .multilineTextAlignment(.center) 30 + } 31 + 32 + // Configuration summary 33 + VStack(spacing: 16) { 34 + SummaryRow( 35 + icon: viewModel.selectedMode?.icon ?? "questionmark", 36 + title: "Connection", 37 + value: viewModel.selectedMode?.displayName ?? "Not set" 38 + ) 39 + 40 + if let server = viewModel.selectedServer { 41 + SummaryRow( 42 + icon: "desktopcomputer", 43 + title: "Server", 44 + value: "\(server.name) (\(server.host))" 45 + ) 46 + } 47 + } 48 + .padding() 49 + .background( 50 + RoundedRectangle(cornerRadius: 16) 51 + .fill(Color(.systemGray6)) 52 + ) 53 + .padding(.horizontal) 54 + 55 + // Error message 56 + if let error = connectionError { 57 + Text(error) 58 + .font(.caption) 59 + .foregroundStyle(.red) 60 + .padding(.horizontal) 61 + } 62 + 63 + Spacer() 64 + 65 + // Complete button 66 + Button(action: completeSetup) { 67 + if isConnecting { 68 + ProgressView() 69 + .progressViewStyle(CircularProgressViewStyle(tint: .white)) 70 + .frame(maxWidth: .infinity) 71 + .padding() 72 + .background(Color.blue) 73 + .cornerRadius(12) 74 + } else { 75 + Text("Start Using Serviceberry") 76 + .font(.headline) 77 + .frame(maxWidth: .infinity) 78 + .padding() 79 + .background(Color.blue) 80 + .foregroundColor(.white) 81 + .cornerRadius(12) 82 + } 83 + } 84 + .disabled(isConnecting) 85 + .padding(.horizontal) 86 + .padding(.bottom) 87 + } 88 + .navigationTitle("Complete") 89 + .navigationBarTitleDisplayMode(.inline) 90 + .navigationBarBackButtonHidden(true) 91 + } 92 + 93 + private func completeSetup() { 94 + guard let mode = viewModel.selectedMode else { return } 95 + 96 + isConnecting = true 97 + connectionError = nil 98 + 99 + // Save configuration and complete onboarding 100 + appState.completeOnboarding(mode: mode, serverInfo: viewModel.selectedServer) 101 + 102 + // Try to connect 103 + Task { 104 + do { 105 + try await appState.connect() 106 + } catch { 107 + connectionError = "Connected but couldn't verify: \(error.localizedDescription)" 108 + } 109 + isConnecting = false 110 + } 111 + } 112 + } 113 + 114 + /// Summary row component 115 + struct SummaryRow: View { 116 + let icon: String 117 + let title: String 118 + let value: String 119 + 120 + var body: some View { 121 + HStack { 122 + Image(systemName: icon) 123 + .foregroundStyle(.blue) 124 + .frame(width: 30) 125 + 126 + Text(title) 127 + .foregroundStyle(.secondary) 128 + 129 + Spacer() 130 + 131 + Text(value) 132 + .fontWeight(.medium) 133 + } 134 + } 135 + } 136 + 137 + #Preview { 138 + NavigationStack { 139 + CompletionView() 140 + } 141 + .environmentObject(AppState()) 142 + .environmentObject({ 143 + let vm = OnboardingViewModel() 144 + vm.selectedMode = .lan 145 + vm.selectedServer = ServerInfo( 146 + name: "Home Server", 147 + host: "192.168.1.100", 148 + port: 8080, 149 + certFingerprint: "abc123", 150 + version: "0.1.0", 151 + paths: [] 152 + ) 153 + return vm 154 + }()) 155 + }
+272
serviceberry/Views/Onboarding/LANSetupView.swift
··· 1 + import SwiftUI 2 + 3 + /// LAN/mDNS server discovery screen 4 + struct LANSetupView: View { 5 + @EnvironmentObject var appState: AppState 6 + @EnvironmentObject var viewModel: OnboardingViewModel 7 + @StateObject private var discovery = MDNSDiscovery() 8 + 9 + @State private var selectedServer: ServerInfo? 10 + @State private var showManualEntry = false 11 + @State private var manualHost = "" 12 + @State private var manualFingerprint = "" 13 + 14 + var body: some View { 15 + VStack(spacing: 24) { 16 + // Header 17 + VStack(spacing: 8) { 18 + Text("Find Your Server") 19 + .font(.title2) 20 + .fontWeight(.bold) 21 + 22 + Text("Searching for Serviceberry servers on your local network...") 23 + .font(.body) 24 + .foregroundStyle(.secondary) 25 + .multilineTextAlignment(.center) 26 + } 27 + .padding(.top) 28 + 29 + // Server list or searching indicator 30 + if discovery.discoveredServers.isEmpty { 31 + Spacer() 32 + 33 + if discovery.isSearching { 34 + VStack(spacing: 16) { 35 + ProgressView() 36 + .scaleEffect(1.5) 37 + Text("Searching...") 38 + .foregroundStyle(.secondary) 39 + } 40 + } else { 41 + VStack(spacing: 16) { 42 + Image(systemName: "wifi.exclamationmark") 43 + .font(.system(size: 60)) 44 + .foregroundStyle(.secondary) 45 + 46 + Text("No servers found") 47 + .font(.headline) 48 + .foregroundStyle(.secondary) 49 + 50 + Text("Make sure your server is running and connected to the same network.") 51 + .font(.caption) 52 + .foregroundStyle(.secondary) 53 + .multilineTextAlignment(.center) 54 + .padding(.horizontal) 55 + 56 + Button("Search Again") { 57 + discovery.startBrowsing() 58 + } 59 + .buttonStyle(.borderedProminent) 60 + 61 + Button("Enter Manually") { 62 + showManualEntry = true 63 + } 64 + .buttonStyle(.bordered) 65 + } 66 + } 67 + 68 + Spacer() 69 + } else { 70 + // Server list 71 + List { 72 + ForEach(discovery.discoveredServers) { server in 73 + ServerRowView( 74 + server: server, 75 + isSelected: selectedServer?.id == server.id, 76 + onSelect: { selectedServer = server } 77 + ) 78 + } 79 + 80 + Section { 81 + Button("Enter Manually") { 82 + showManualEntry = true 83 + } 84 + } 85 + } 86 + .listStyle(.insetGrouped) 87 + } 88 + 89 + // Continue button 90 + if selectedServer != nil { 91 + Button(action: { 92 + viewModel.selectedServer = selectedServer 93 + viewModel.navigateTo(.permissions) 94 + }) { 95 + Text("Continue") 96 + .font(.headline) 97 + .frame(maxWidth: .infinity) 98 + .padding() 99 + .background(Color.blue) 100 + .foregroundColor(.white) 101 + .cornerRadius(12) 102 + } 103 + .padding(.horizontal) 104 + .padding(.bottom) 105 + } 106 + } 107 + .navigationTitle("Network Setup") 108 + .navigationBarTitleDisplayMode(.inline) 109 + .onAppear { 110 + discovery.startBrowsing() 111 + } 112 + .onDisappear { 113 + discovery.stopBrowsing() 114 + } 115 + .sheet(isPresented: $showManualEntry) { 116 + ManualServerEntryView( 117 + host: $manualHost, 118 + fingerprint: $manualFingerprint, 119 + onSave: { host, fingerprint in 120 + let server = ServerInfo( 121 + name: "Manual Server", 122 + host: host, 123 + port: Constants.serverPort, 124 + certFingerprint: fingerprint, 125 + version: "unknown", 126 + paths: [Constants.submitPath, Constants.statusPath, Constants.requestPath] 127 + ) 128 + selectedServer = server 129 + showManualEntry = false 130 + } 131 + ) 132 + } 133 + } 134 + } 135 + 136 + /// Row view for a discovered server 137 + struct ServerRowView: View { 138 + let server: ServerInfo 139 + let isSelected: Bool 140 + let onSelect: () -> Void 141 + 142 + @State private var showFingerprint = false 143 + 144 + var body: some View { 145 + VStack(alignment: .leading, spacing: 8) { 146 + Button(action: onSelect) { 147 + HStack { 148 + Image(systemName: "desktopcomputer") 149 + .foregroundStyle(.blue) 150 + 151 + VStack(alignment: .leading, spacing: 4) { 152 + Text(server.name) 153 + .font(.headline) 154 + Text(server.host) 155 + .font(.caption) 156 + .foregroundStyle(.secondary) 157 + if !server.version.isEmpty && server.version != "unknown" { 158 + Text("v\(server.version)") 159 + .font(.caption2) 160 + .foregroundStyle(.secondary) 161 + } 162 + } 163 + 164 + Spacer() 165 + 166 + if isSelected { 167 + Image(systemName: "checkmark.circle.fill") 168 + .foregroundStyle(.blue) 169 + } 170 + } 171 + } 172 + .buttonStyle(.plain) 173 + 174 + // Show fingerprint when selected 175 + if isSelected && !server.certFingerprint.isEmpty { 176 + VStack(alignment: .leading, spacing: 4) { 177 + Button(action: { showFingerprint.toggle() }) { 178 + HStack { 179 + Image(systemName: "lock.shield") 180 + .foregroundStyle(.green) 181 + Text(showFingerprint ? "Hide Certificate" : "Verify Certificate") 182 + .font(.caption) 183 + .foregroundStyle(.blue) 184 + } 185 + } 186 + 187 + if showFingerprint { 188 + Text("Verify this matches your server:") 189 + .font(.caption2) 190 + .foregroundStyle(.secondary) 191 + 192 + Text(formatFingerprint(server.certFingerprint)) 193 + .font(.system(.caption2, design: .monospaced)) 194 + .foregroundStyle(.primary) 195 + .padding(8) 196 + .background(Color(.systemGray6)) 197 + .cornerRadius(6) 198 + .textSelection(.enabled) 199 + } 200 + } 201 + .padding(.top, 4) 202 + } 203 + } 204 + } 205 + 206 + private func formatFingerprint(_ fingerprint: String) -> String { 207 + // Format as XX:XX:XX:XX... for readability 208 + var formatted = "" 209 + for (index, char) in fingerprint.uppercased().enumerated() { 210 + if index > 0 && index % 2 == 0 { 211 + formatted += ":" 212 + } 213 + formatted.append(char) 214 + } 215 + return formatted 216 + } 217 + } 218 + 219 + /// Manual server entry sheet 220 + struct ManualServerEntryView: View { 221 + @Binding var host: String 222 + @Binding var fingerprint: String 223 + let onSave: (String, String) -> Void 224 + 225 + @Environment(\.dismiss) private var dismiss 226 + 227 + var body: some View { 228 + NavigationStack { 229 + Form { 230 + Section("Server Address") { 231 + TextField("Hostname (e.g., myserver.local)", text: $host) 232 + .textContentType(.URL) 233 + .autocapitalization(.none) 234 + .keyboardType(.URL) 235 + } 236 + 237 + Section { 238 + TextField("Certificate Fingerprint (optional)", text: $fingerprint) 239 + .autocapitalization(.none) 240 + .font(.system(.body, design: .monospaced)) 241 + } header: { 242 + Text("TLS Certificate") 243 + } footer: { 244 + Text("Enter the SHA256 fingerprint shown by your server to verify secure connection. You can skip this if connecting on a trusted network.") 245 + } 246 + } 247 + .navigationTitle("Manual Entry") 248 + .navigationBarTitleDisplayMode(.inline) 249 + .toolbar { 250 + ToolbarItem(placement: .cancellationAction) { 251 + Button("Cancel") { 252 + dismiss() 253 + } 254 + } 255 + ToolbarItem(placement: .confirmationAction) { 256 + Button("Save") { 257 + onSave(host, fingerprint) 258 + } 259 + .disabled(host.isEmpty) 260 + } 261 + } 262 + } 263 + } 264 + } 265 + 266 + #Preview { 267 + NavigationStack { 268 + LANSetupView() 269 + } 270 + .environmentObject(AppState()) 271 + .environmentObject(OnboardingViewModel()) 272 + }
+84
serviceberry/Views/Onboarding/OnboardingContainerView.swift
··· 1 + import SwiftUI 2 + import Combine 3 + 4 + /// Onboarding step enumeration 5 + enum OnboardingStep: Hashable { 6 + case welcome 7 + case transportChoice 8 + case bluetoothSetup 9 + case lanSetup 10 + case permissions 11 + case completion 12 + } 13 + 14 + /// Container view for the onboarding wizard 15 + struct OnboardingContainerView: View { 16 + @EnvironmentObject var appState: AppState 17 + @StateObject private var viewModel = OnboardingViewModel() 18 + 19 + var body: some View { 20 + NavigationStack(path: $viewModel.navigationPath) { 21 + WelcomeStepView() 22 + .navigationDestination(for: OnboardingStep.self) { step in 23 + destinationView(for: step) 24 + } 25 + } 26 + .environmentObject(viewModel) 27 + } 28 + 29 + @ViewBuilder 30 + private func destinationView(for step: OnboardingStep) -> some View { 31 + switch step { 32 + case .welcome: 33 + WelcomeStepView() 34 + case .transportChoice: 35 + TransportChoiceView() 36 + case .bluetoothSetup: 37 + BluetoothSetupView() 38 + case .lanSetup: 39 + LANSetupView() 40 + case .permissions: 41 + PermissionsRequestView() 42 + case .completion: 43 + CompletionView() 44 + } 45 + } 46 + } 47 + 48 + /// View model for managing onboarding state 49 + @MainActor 50 + class OnboardingViewModel: ObservableObject { 51 + @Published var navigationPath = NavigationPath() 52 + @Published var selectedMode: TransportMode? 53 + @Published var selectedServer: ServerInfo? 54 + @Published var selectedPeripheral: CBPeripheralWrapper? 55 + 56 + func navigateTo(_ step: OnboardingStep) { 57 + navigationPath.append(step) 58 + } 59 + 60 + func goBack() { 61 + if !navigationPath.isEmpty { 62 + navigationPath.removeLast() 63 + } 64 + } 65 + } 66 + 67 + /// Wrapper for CBPeripheral to make it Hashable 68 + struct CBPeripheralWrapper: Hashable, Identifiable { 69 + let id: UUID 70 + let name: String 71 + 72 + func hash(into hasher: inout Hasher) { 73 + hasher.combine(id) 74 + } 75 + 76 + static func == (lhs: CBPeripheralWrapper, rhs: CBPeripheralWrapper) -> Bool { 77 + lhs.id == rhs.id 78 + } 79 + } 80 + 81 + #Preview { 82 + OnboardingContainerView() 83 + .environmentObject(AppState()) 84 + }
+182
serviceberry/Views/Onboarding/PermissionsRequestView.swift
··· 1 + import SwiftUI 2 + internal import CoreLocation 3 + 4 + /// Permissions request screen 5 + struct PermissionsRequestView: View { 6 + @EnvironmentObject var appState: AppState 7 + @EnvironmentObject var viewModel: OnboardingViewModel 8 + 9 + @State private var locationStatus: CLAuthorizationStatus = .notDetermined 10 + 11 + var body: some View { 12 + VStack(spacing: 32) { 13 + Spacer() 14 + 15 + // Icon 16 + Image(systemName: "location.circle.fill") 17 + .font(.system(size: 80)) 18 + .foregroundStyle(.blue) 19 + 20 + // Header 21 + VStack(spacing: 12) { 22 + Text("Location Permission") 23 + .font(.title2) 24 + .fontWeight(.bold) 25 + 26 + Text("Serviceberry needs access to your location to send coordinates to your server for geolocation database improvement.") 27 + .font(.body) 28 + .foregroundStyle(.secondary) 29 + .multilineTextAlignment(.center) 30 + .padding(.horizontal) 31 + } 32 + 33 + // Status indicator 34 + statusView 35 + 36 + Spacer() 37 + 38 + // Action buttons 39 + VStack(spacing: 12) { 40 + if locationStatus == .notDetermined { 41 + Button(action: requestPermission) { 42 + Text("Allow Location Access") 43 + .font(.headline) 44 + .frame(maxWidth: .infinity) 45 + .padding() 46 + .background(Color.blue) 47 + .foregroundColor(.white) 48 + .cornerRadius(12) 49 + } 50 + } else if locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways { 51 + Button(action: proceed) { 52 + Text("Continue") 53 + .font(.headline) 54 + .frame(maxWidth: .infinity) 55 + .padding() 56 + .background(Color.blue) 57 + .foregroundColor(.white) 58 + .cornerRadius(12) 59 + } 60 + } else { 61 + Button(action: openSettings) { 62 + Text("Open Settings") 63 + .font(.headline) 64 + .frame(maxWidth: .infinity) 65 + .padding() 66 + .background(Color.blue) 67 + .foregroundColor(.white) 68 + .cornerRadius(12) 69 + } 70 + 71 + Button(action: proceed) { 72 + Text("Skip for Now") 73 + .font(.subheadline) 74 + .foregroundStyle(.secondary) 75 + } 76 + } 77 + } 78 + .padding(.horizontal) 79 + .padding(.bottom) 80 + } 81 + .navigationTitle("Permissions") 82 + .navigationBarTitleDisplayMode(.inline) 83 + .onAppear { 84 + updateStatus() 85 + } 86 + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in 87 + updateStatus() 88 + } 89 + } 90 + 91 + @ViewBuilder 92 + private var statusView: some View { 93 + HStack(spacing: 12) { 94 + Image(systemName: statusIcon) 95 + .foregroundStyle(statusColor) 96 + 97 + Text(statusText) 98 + .font(.subheadline) 99 + .foregroundStyle(.secondary) 100 + } 101 + .padding() 102 + .background( 103 + RoundedRectangle(cornerRadius: 12) 104 + .fill(statusColor.opacity(0.1)) 105 + ) 106 + } 107 + 108 + private var statusIcon: String { 109 + switch locationStatus { 110 + case .notDetermined: 111 + return "questionmark.circle" 112 + case .authorizedWhenInUse, .authorizedAlways: 113 + return "checkmark.circle.fill" 114 + case .denied, .restricted: 115 + return "xmark.circle.fill" 116 + @unknown default: 117 + return "questionmark.circle" 118 + } 119 + } 120 + 121 + private var statusColor: Color { 122 + switch locationStatus { 123 + case .notDetermined: 124 + return .orange 125 + case .authorizedWhenInUse, .authorizedAlways: 126 + return .green 127 + case .denied, .restricted: 128 + return .red 129 + @unknown default: 130 + return .gray 131 + } 132 + } 133 + 134 + private var statusText: String { 135 + switch locationStatus { 136 + case .notDetermined: 137 + return "Permission not yet requested" 138 + case .authorizedWhenInUse: 139 + return "Location access granted (when in use)" 140 + case .authorizedAlways: 141 + return "Location access granted (always)" 142 + case .denied: 143 + return "Location access denied" 144 + case .restricted: 145 + return "Location access restricted" 146 + @unknown default: 147 + return "Unknown status" 148 + } 149 + } 150 + 151 + private func updateStatus() { 152 + locationStatus = appState.locationService.authorizationStatus 153 + } 154 + 155 + private func requestPermission() { 156 + appState.locationService.requestAuthorization() 157 + 158 + // Monitor for changes 159 + Task { 160 + try? await Task.sleep(nanoseconds: 500_000_000) 161 + updateStatus() 162 + } 163 + } 164 + 165 + private func openSettings() { 166 + if let url = URL(string: UIApplication.openSettingsURLString) { 167 + UIApplication.shared.open(url) 168 + } 169 + } 170 + 171 + private func proceed() { 172 + viewModel.navigateTo(.completion) 173 + } 174 + } 175 + 176 + #Preview { 177 + NavigationStack { 178 + PermissionsRequestView() 179 + } 180 + .environmentObject(AppState()) 181 + .environmentObject(OnboardingViewModel()) 182 + }
+123
serviceberry/Views/Onboarding/TransportChoiceView.swift
··· 1 + import SwiftUI 2 + 3 + /// Transport selection screen 4 + struct TransportChoiceView: View { 5 + @EnvironmentObject var viewModel: OnboardingViewModel 6 + 7 + var body: some View { 8 + VStack(spacing: 24) { 9 + // Header 10 + VStack(spacing: 8) { 11 + Text("Choose Connection Method") 12 + .font(.title2) 13 + .fontWeight(.bold) 14 + 15 + Text("Select how you want to connect to your Serviceberry server.") 16 + .font(.body) 17 + .foregroundStyle(.secondary) 18 + .multilineTextAlignment(.center) 19 + } 20 + .padding(.top) 21 + 22 + Spacer() 23 + 24 + // Transport options 25 + VStack(spacing: 16) { 26 + TransportOptionCard( 27 + mode: .bluetooth, 28 + isSelected: viewModel.selectedMode == .bluetooth 29 + ) { 30 + viewModel.selectedMode = .bluetooth 31 + } 32 + 33 + TransportOptionCard( 34 + mode: .lan, 35 + isSelected: viewModel.selectedMode == .lan 36 + ) { 37 + viewModel.selectedMode = .lan 38 + } 39 + } 40 + .padding(.horizontal) 41 + 42 + Spacer() 43 + 44 + // Continue button 45 + Button(action: { 46 + if viewModel.selectedMode == .bluetooth { 47 + viewModel.navigateTo(.bluetoothSetup) 48 + } else { 49 + viewModel.navigateTo(.lanSetup) 50 + } 51 + }) { 52 + Text("Continue") 53 + .font(.headline) 54 + .frame(maxWidth: .infinity) 55 + .padding() 56 + .background(viewModel.selectedMode != nil ? Color.blue : Color.gray) 57 + .foregroundColor(.white) 58 + .cornerRadius(12) 59 + } 60 + .disabled(viewModel.selectedMode == nil) 61 + .padding(.horizontal) 62 + .padding(.bottom) 63 + } 64 + .navigationTitle("Connection") 65 + .navigationBarTitleDisplayMode(.inline) 66 + } 67 + } 68 + 69 + /// Card component for transport option 70 + struct TransportOptionCard: View { 71 + let mode: TransportMode 72 + let isSelected: Bool 73 + let action: () -> Void 74 + 75 + var body: some View { 76 + Button(action: action) { 77 + HStack(spacing: 16) { 78 + Image(systemName: mode.icon) 79 + .font(.title) 80 + .foregroundStyle(isSelected ? .white : .blue) 81 + .frame(width: 50, height: 50) 82 + .background(isSelected ? Color.blue : Color.blue.opacity(0.1)) 83 + .cornerRadius(12) 84 + 85 + VStack(alignment: .leading, spacing: 4) { 86 + Text(mode.displayName) 87 + .font(.headline) 88 + .foregroundStyle(.primary) 89 + 90 + Text(mode.description) 91 + .font(.caption) 92 + .foregroundStyle(.secondary) 93 + .lineLimit(2) 94 + } 95 + 96 + Spacer() 97 + 98 + if isSelected { 99 + Image(systemName: "checkmark.circle.fill") 100 + .foregroundStyle(.blue) 101 + .font(.title2) 102 + } 103 + } 104 + .padding() 105 + .background( 106 + RoundedRectangle(cornerRadius: 16) 107 + .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) 108 + .background( 109 + RoundedRectangle(cornerRadius: 16) 110 + .fill(isSelected ? Color.blue.opacity(0.05) : Color.clear) 111 + ) 112 + ) 113 + } 114 + .buttonStyle(PlainButtonStyle()) 115 + } 116 + } 117 + 118 + #Preview { 119 + NavigationStack { 120 + TransportChoiceView() 121 + } 122 + .environmentObject(OnboardingViewModel()) 123 + }
+101
serviceberry/Views/Onboarding/WelcomeStepView.swift
··· 1 + import SwiftUI 2 + 3 + /// Welcome screen - first step of onboarding 4 + struct WelcomeStepView: View { 5 + @EnvironmentObject var viewModel: OnboardingViewModel 6 + 7 + var body: some View { 8 + VStack(spacing: 32) { 9 + Spacer() 10 + 11 + // App icon/logo 12 + Image(systemName: "location.north.circle.fill") 13 + .font(.system(size: 80)) 14 + .foregroundStyle(.blue) 15 + 16 + VStack(spacing: 12) { 17 + Text("Welcome to Serviceberry") 18 + .font(.largeTitle) 19 + .fontWeight(.bold) 20 + 21 + Text("Help improve geolocation databases by sharing anonymous location data with your server.") 22 + .font(.body) 23 + .foregroundStyle(.secondary) 24 + .multilineTextAlignment(.center) 25 + .padding(.horizontal) 26 + } 27 + 28 + Spacer() 29 + 30 + // Features list 31 + VStack(alignment: .leading, spacing: 16) { 32 + FeatureRow( 33 + icon: "shield.checkered", 34 + title: "Privacy First", 35 + description: "Data stays on your local network" 36 + ) 37 + 38 + FeatureRow( 39 + icon: "bolt.fill", 40 + title: "Automatic", 41 + description: "Server requests location when needed" 42 + ) 43 + 44 + FeatureRow( 45 + icon: "antenna.radiowaves.left.and.right", 46 + title: "Flexible", 47 + description: "Connect via Bluetooth or WiFi" 48 + ) 49 + } 50 + .padding(.horizontal) 51 + 52 + Spacer() 53 + 54 + Button(action: { 55 + viewModel.navigateTo(.transportChoice) 56 + }) { 57 + Text("Get Started") 58 + .font(.headline) 59 + .frame(maxWidth: .infinity) 60 + .padding() 61 + .background(Color.blue) 62 + .foregroundColor(.white) 63 + .cornerRadius(12) 64 + } 65 + .padding(.horizontal) 66 + .padding(.bottom) 67 + } 68 + .navigationBarHidden(true) 69 + } 70 + } 71 + 72 + /// Feature row component 73 + struct FeatureRow: View { 74 + let icon: String 75 + let title: String 76 + let description: String 77 + 78 + var body: some View { 79 + HStack(spacing: 16) { 80 + Image(systemName: icon) 81 + .font(.title2) 82 + .foregroundStyle(.blue) 83 + .frame(width: 40) 84 + 85 + VStack(alignment: .leading, spacing: 2) { 86 + Text(title) 87 + .font(.headline) 88 + Text(description) 89 + .font(.subheadline) 90 + .foregroundStyle(.secondary) 91 + } 92 + } 93 + } 94 + } 95 + 96 + #Preview { 97 + NavigationStack { 98 + WelcomeStepView() 99 + } 100 + .environmentObject(OnboardingViewModel()) 101 + }
+10 -1
serviceberry/serviceberryApp.swift
··· 9 9 10 10 @main 11 11 struct serviceberryApp: App { 12 + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 13 + @StateObject private var appState = AppState() 14 + 12 15 var body: some Scene { 13 16 WindowGroup { 14 - ContentView() 17 + if appState.isOnboarded { 18 + DashboardView() 19 + .environmentObject(appState) 20 + } else { 21 + OnboardingContainerView() 22 + .environmentObject(appState) 23 + } 15 24 } 16 25 } 17 26 }