field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 362996b388032132641554e947f2e0e87ad5fc72
parent 61a74b5814e2c80169b1ad0608512cc8ee9045f9
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 00:39:47 +0000

feat(ios): install the two-tab product shell

- Replace the four-tab field prototype with Today and Add bottom tabs in order.
- Fail closed when restoration or deep links name any unsupported root route.
- Keep Search and account status reachable as Today-level supporting sheets.
- Add unit and simulator UI tests for exact tab count, order, navigation, and exclusions.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme | 11+++++++++++
MRadroots/App/AppEntry.swift | 20+++++++++++++-------
MRadroots/App/RadrootsAppModel.swift | 33+++++++++++++++++++++++++++++++++
ARadroots/App/RadrootsRootShell.swift | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DRadroots/Views/HomeView.swift | 627-------------------------------------------------------------------------------
ARadrootsTests/RadrootsRootShellTests.swift | 33+++++++++++++++++++++++++++++++++
ARadrootsUITests/RadrootsRootShellUITests.swift | 24++++++++++++++++++++++++
Mproject.yml | 16++++++++++++++++
9 files changed, 401 insertions(+), 634 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -10,11 +10,14 @@ 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; }; 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */; }; 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */; }; + 509FF909EFFD7FD9BAEC537C /* RadrootsRootShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF529B919921D63157110E44 /* RadrootsRootShell.swift */; }; 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */; }; 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */; }; 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; }; 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F432D5450B21DFADF99FACD1 /* Localizable.strings */; }; + 75C044403AF08F7230DE9485 /* RadrootsRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */; }; + 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */; }; AA7D80A73CD6C22ED5080781 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */; }; B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */; }; C356F2A068722087ED91256C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 298DA81B0A000E307098C840 /* Assets.xcassets */; }; @@ -44,6 +47,13 @@ remoteGlobalIDString = 469EC50C49E7266DF1C2000B; remoteInfo = Radroots; }; + F2CB260E3681C202D1B6B84D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 572C41532B5066EC7641561C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 469EC50C49E7266DF1C2000B; + remoteInfo = Radroots; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -78,8 +88,12 @@ 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9469790A906080D90F8735CB /* RuntimeStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusView.swift; sourceTree = "<group>"; }; + B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShellTests.swift; sourceTree = "<group>"; }; BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = RadrootsFFI.xcframework; path = Radroots/Frameworks/RadrootsFFI.xcframework; sourceTree = "<group>"; }; C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsStateMigrationTests.swift; sourceTree = "<group>"; }; + CAD74320A58874A7F7370505 /* RadrootsUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShellUITests.swift; sourceTree = "<group>"; }; + CF529B919921D63157110E44 /* RadrootsRootShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShell.swift; sourceTree = "<group>"; }; D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsConfigurationStore.swift; sourceTree = "<group>"; }; FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RadrootsKitBindings.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -109,6 +123,7 @@ 31C88176C5674CFF5F9CFBEA /* RadrootsTests */ = { isa = PBXGroup; children = ( + B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */, D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */, C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */, ); @@ -131,6 +146,7 @@ 93AA285819DD1269C3EAD80A /* Radroots.app */, FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */, 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */, + CAD74320A58874A7F7370505 /* RadrootsUITests.xctest */, ); name = Products; sourceTree = "<group>"; @@ -182,6 +198,7 @@ 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */, 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */, 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */, + CF529B919921D63157110E44 /* RadrootsRootShell.swift */, ); name = App; path = Radroots/App; @@ -194,6 +211,7 @@ 401A3C62BEECB8BD5C500756 /* Config */, D17ED36BEBE689794F31C2BB /* Generated */, 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, + FD341F0384225E202A0845B1 /* RadrootsUITests */, 94F94915631E6DC54AAB0B89 /* Resources */, 64C889EFCEB42611797110F7 /* Runtime */, A2DE5BE1D645F19DA46B3FC5 /* State */, @@ -229,6 +247,14 @@ path = Radroots/Views; sourceTree = "<group>"; }; + FD341F0384225E202A0845B1 /* RadrootsUITests */ = { + isa = PBXGroup; + children = ( + CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */, + ); + path = RadrootsUITests; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -291,6 +317,24 @@ productReference = 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + D87D8771FD63904A45FAFB4D /* RadrootsUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B3DDD079CA522503021BDB85 /* Build configuration list for PBXNativeTarget "RadrootsUITests" */; + buildPhases = ( + 1A10809E12A270635124EDD4 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 54F8C314CE8A0D06346F56DE /* PBXTargetDependency */, + ); + name = RadrootsUITests; + packageProductDependencies = ( + ); + productName = RadrootsUITests; + productReference = CAD74320A58874A7F7370505 /* RadrootsUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -300,6 +344,9 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; TargetAttributes = { + D87D8771FD63904A45FAFB4D = { + TestTargetID = 469EC50C49E7266DF1C2000B; + }; }; }; buildConfigurationList = D17883D6A48F29A2A4DFE795 /* Build configuration list for PBXProject "Radroots" */; @@ -322,6 +369,7 @@ 1C22115E511334B291A71594 /* RadrootsKitBindings */, 469EC50C49E7266DF1C2000B /* Radroots */, 67B2EF8D69F6CEA9BE5A8B04 /* RadrootsTests */, + D87D8771FD63904A45FAFB4D /* RadrootsUITests */, ); }; /* End PBXProject section */ @@ -361,6 +409,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 1A10809E12A270635124EDD4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 75C044403AF08F7230DE9485 /* RadrootsRootShellUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 21C962D4AD2665D5ED66E70F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -373,6 +429,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */, CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */, 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */, ); @@ -389,6 +446,7 @@ 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */, F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */, 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */, + 509FF909EFFD7FD9BAEC537C /* RadrootsRootShell.swift in Sources */, F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */, B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */, 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */, @@ -404,6 +462,11 @@ target = 1C22115E511334B291A71594 /* RadrootsKitBindings */; targetProxy = 51F4BD021D143679140FF35F /* PBXContainerItemProxy */; }; + 54F8C314CE8A0D06346F56DE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 469EC50C49E7266DF1C2000B /* Radroots */; + targetProxy = F2CB260E3681C202D1B6B84D /* PBXContainerItemProxy */; + }; C4ECFE4B8C347EE1F784B9BA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 469EC50C49E7266DF1C2000B /* Radroots */; @@ -523,6 +586,44 @@ }; name = Release; }; + A431C09D041E1479F6044316 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Radroots; + }; + name = Release; + }; + CCF6247EBAF64D12A58E70D2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Radroots; + }; + name = Debug; + }; D930E9391B5B63DB518922D2 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 43B0EA447E213CD1B96846D6 /* Release.xcconfig */; @@ -701,6 +802,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + B3DDD079CA522503021BDB85 /* Build configuration list for PBXNativeTarget "RadrootsUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CCF6247EBAF64D12A58E70D2 /* Debug */, + A431C09D041E1479F6044316 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; D17883D6A48F29A2A4DFE795 /* Build configuration list for PBXProject "Radroots" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Radroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme b/Radroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme @@ -50,6 +50,17 @@ ReferencedContainer = "container:Radroots.xcodeproj"> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO" + parallelizable = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "D87D8771FD63904A45FAFB4D" + BuildableName = "RadrootsUITests.xctest" + BlueprintName = "RadrootsUITests" + ReferencedContainer = "container:Radroots.xcodeproj"> + </BuildableReference> + </TestableReference> </Testables> <CommandLineArguments> </CommandLineArguments> diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -4,13 +4,19 @@ struct AppEntry: View { @EnvironmentObject private var appModel: RadrootsAppModel var body: some View { - RuntimeStatusView( - phase: appModel.phase, - retry: { Task { await appModel.retry() } }, - createIdentity: { Task { await appModel.createIdentity() } }, - unlockIdentity: { Task { await appModel.unlockIdentity() } }, - recoverIdentity: { Task { await appModel.recoverIdentity() } } - ) + Group { + if case let .running(snapshot) = appModel.phase { + RadrootsRootShell(snapshot: snapshot) + } else { + RuntimeStatusView( + phase: appModel.phase, + retry: { Task { await appModel.retry() } }, + createIdentity: { Task { await appModel.createIdentity() } }, + unlockIdentity: { Task { await appModel.unlockIdentity() } }, + recoverIdentity: { Task { await appModel.recoverIdentity() } } + ) + } + } .accessibilityIdentifier("radroots.app_entry") } } diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -9,8 +9,19 @@ final class RadrootsAppModel: ObservableObject { private let sessionStore: RadrootsSessionStore? private let bootstrapFailure: RadrootsRuntimeFailure? private var generation: UInt64 = 0 + private let isShellUITest: Bool init(sessionStore: RadrootsSessionStore? = nil) { + #if DEBUG + if ProcessInfo.processInfo.environment["RADROOTS_IOS_UI_TEST_SHELL"] == "1" { + self.sessionStore = nil + bootstrapFailure = nil + isShellUITest = true + phase = .running(.shellUITest) + return + } + #endif + isShellUITest = false if let sessionStore { self.sessionStore = sessionStore bootstrapFailure = nil @@ -63,6 +74,7 @@ final class RadrootsAppModel: ObservableObject { private func run( _ operation: @escaping @Sendable (RadrootsSessionStore) async -> Phase ) async { + guard !isShellUITest else { return } generation &+= 1 let requestedGeneration = generation guard let sessionStore else { @@ -80,3 +92,24 @@ final class RadrootsAppModel: ObservableObject { phase = result } } + +#if DEBUG + private extension RadrootsRuntimeSnapshot { + static let shellUITest = RadrootsRuntimeSnapshot( + identity: RadrootsRuntimeIdentity( + publicKeyHex: String(repeating: "ab", count: 32), + hostSignerConfigured: true + ), + relay: RadrootsRelayStatus( + profile: "simulator", + state: "configured", + readAvailability: "unobserved", + writeAvailability: "unobserved", + relays: [] + ), + crateName: "radroots_mobile_ffi", + crateVersion: "0.1.0-alpha", + isClosed: false + ) + } +#endif diff --git a/Radroots/App/RadrootsRootShell.swift b/Radroots/App/RadrootsRootShell.swift @@ -0,0 +1,161 @@ +import SwiftUI + +enum RadrootsRootTab: String, CaseIterable, Sendable { + case today + case add + + static func resolve(_ rawValue: String?) -> Self { + guard let rawValue, let tab = Self(rawValue: rawValue) else { return .today } + return tab + } + + static func resolve(url: URL) -> Self? { + guard url.scheme?.lowercased() == "radroots", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil + else { + return nil + } + let candidate = url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return Self(rawValue: candidate.lowercased()) + } +} + +struct RadrootsRootShell: View { + let snapshot: RadrootsRuntimeSnapshot + @SceneStorage("radroots.selected_root_tab") private var storedSelection = RadrootsRootTab.today.rawValue + + var body: some View { + TabView(selection: selection) { + NavigationStack { + RadrootsTodayLanding(snapshot: snapshot) + } + .tabItem { Label("Today", systemImage: "sun.max.fill") } + .tag(RadrootsRootTab.today) + .accessibilityIdentifier("radroots.tab.today") + + NavigationStack { + RadrootsAddLanding() + } + .tabItem { Label("Add", systemImage: "plus.circle.fill") } + .tag(RadrootsRootTab.add) + .accessibilityIdentifier("radroots.tab.add") + } + .accessibilityIdentifier("radroots.root.tabs") + .onOpenURL { url in + guard let tab = RadrootsRootTab.resolve(url: url) else { return } + storedSelection = tab.rawValue + } + } + + private var selection: Binding<RadrootsRootTab> { + Binding( + get: { RadrootsRootTab.resolve(storedSelection) }, + set: { storedSelection = $0.rawValue } + ) + } +} + +private struct RadrootsTodayLanding: View { + let snapshot: RadrootsRuntimeSnapshot + @State private var showsAccount = false + @State private var showsSearch = false + + var body: some View { + ContentUnavailableView { + Label("Today", systemImage: "leaf.fill") + } description: { + Text("Your local food network is ready for its first refresh.") + } + .navigationTitle("Today") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showsSearch = true + } label: { + Label("Search", systemImage: "magnifyingglass") + } + .accessibilityIdentifier("radroots.support.search") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showsAccount = true + } label: { + Label("Account", systemImage: "person.crop.circle") + } + .accessibilityIdentifier("radroots.support.account") + } + } + .sheet(isPresented: $showsAccount) { + RadrootsAccountSheet(snapshot: snapshot) + } + .sheet(isPresented: $showsSearch) { + RadrootsSearchSheet() + } + .accessibilityIdentifier("radroots.today.root") + } +} + +private struct RadrootsSearchSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ContentUnavailableView.search + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .accessibilityIdentifier("radroots.support.search.sheet") + } +} + +private struct RadrootsAccountSheet: View { + let snapshot: RadrootsRuntimeSnapshot + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Me") { + LabeledContent("Public key", value: abbreviatedPublicKey) + } + Section("Connection") { + LabeledContent("Runtime", value: snapshot.crateVersion) + LabeledContent("Relay", value: snapshot.relay?.state ?? "Not configured") + } + } + .navigationTitle("Account") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .accessibilityIdentifier("radroots.support.account.sheet") + } + + private var abbreviatedPublicKey: String { + let key = snapshot.identity.publicKeyHex + guard key.count > 16 else { return key } + return "\(key.prefix(8))…\(key.suffix(8))" + } +} + +private struct RadrootsAddLanding: View { + var body: some View { + ContentUnavailableView { + Label("Add", systemImage: "plus.circle.fill") + } description: { + Text("Choose what to share with your local food network.") + } + .navigationTitle("Add") + .accessibilityIdentifier("radroots.add.root") + } +} diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -1,627 +0,0 @@ -import RadrootsKit -import SwiftUI - -private enum HomeTab: String, Hashable { - case today - case capture - case activity - case settings -} - -struct HomeView: View { - @AppStorage("field_ios.selected_tab") private var selection = HomeTab.today.rawValue - - var body: some View { - TabView(selection: $selection) { - NavigationStack { - TodayView() - } - .tabItem { Label("Today", systemImage: "sun.max.fill") } - .tag(HomeTab.today.rawValue) - .accessibilityIdentifier("field_ios.today.tab") - - NavigationStack { - CaptureView() - } - .tabItem { Label("Capture", systemImage: "camera.viewfinder") } - .tag(HomeTab.capture.rawValue) - .accessibilityIdentifier("field_ios.capture.tab") - - NavigationStack { - ActivityView() - } - .tabItem { Label("Activity", systemImage: "list.bullet.clipboard.fill") } - .tag(HomeTab.activity.rawValue) - .accessibilityIdentifier("field_ios.activity.tab") - - NavigationStack { - SettingsView() - } - .tabItem { Label("Settings", systemImage: "gearshape.fill") } - .tag(HomeTab.settings.rawValue) - .accessibilityIdentifier("field_ios.settings.tab") - } - .accessibilityIdentifier("field_ios.home.tabs") - } -} - -private struct TodayView: View { - @EnvironmentObject private var app: AppState - - var body: some View { - List { - Section { - VStack(alignment: .leading, spacing: 12) { - Text("Today") - .font(.largeTitle.weight(.bold)) - Text(app.identityDisplayName) - .font(.title3.weight(.semibold)) - HStack(spacing: 10) { - Label(syncLabel, systemImage: syncImage) - .font(.subheadline.weight(.semibold)) - Spacer() - RelayPill(available: app.relaySourceAvailable && app.relaySinkAvailable) - } - } - .padding(.vertical, 4) - } - - Section("Next Actions") { - FieldActionRow(title: "Photo Evidence", subtitle: "Document a crop, delivery, or field condition.", systemImage: "camera.fill") - LocationCheckInRow( - accessibilityIDPrefix: "field_ios.today.location_check_in" - ) - FieldActionRow(title: "Status Log", subtitle: "Capture a short operational update.", systemImage: "text.badge.checkmark") - FieldActionRow(title: "Compliance Note", subtitle: "Reserve audit-ready notes for the current workflow.", systemImage: "checkmark.seal.fill") - } - - Section("Relay") { - RelayMetricRow(label: "Read operations", systemImage: "arrow.down.circle", value: app.relaySourceAvailable ? "Available" : "Unavailable") - RelayMetricRow(label: "Write operations", systemImage: "arrow.up.circle", value: app.relaySinkAvailable ? "Available" : "Unavailable") - if let last = app.relayLastError { - Text(last) - .foregroundStyle(.red) - .font(.footnote) - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Today") - .accessibilityIdentifier("field_ios.today") - } - - private var syncLabel: String { - app.relaySourceAvailable && app.relaySinkAvailable ? "Relay ready" : "Waiting for relay" - } - - private var syncImage: String { - app.relaySourceAvailable && app.relaySinkAvailable ? "checkmark.icloud.fill" : "icloud.slash.fill" - } -} - -private struct CaptureView: View { - @EnvironmentObject private var app: AppState - - var body: some View { - List { - Section("Capture Intake") { - CaptureIntakeStatusRow() - CaptureIntakeActionButton( - title: "Import Photo", - subtitle: "Attach visual proof from local media.", - systemImage: "photo.on.rectangle", - accessibilityID: "field_ios.capture_intake.import_photo", - isWorking: app.captureIntakeState.operation == .importingPhoto, - isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.photoImportAvailable - ) { - await app.importPhotoEvidence() - } - CaptureIntakeActionButton( - title: "Take Photo", - subtitle: "Capture a new field photo.", - systemImage: "camera.fill", - accessibilityID: "field_ios.capture_intake.capture_photo", - isWorking: app.captureIntakeState.operation == .capturingPhoto, - isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.cameraPhotoAvailable - ) { - await app.capturePhotoEvidence() - } - CaptureIntakeActionButton( - title: "Scan Document", - subtitle: "Create a local PDF scan.", - systemImage: "doc.viewfinder", - accessibilityID: "field_ios.capture_intake.scan_document", - isWorking: app.captureIntakeState.operation == .scanningDocument, - isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.documentScannerAvailable - ) { - await app.scanDocumentEvidence() - } - } - - Section("Latest Capture") { - if let latest = app.captureIntakeState.latestRecord { - CaptureRecordRow(record: latest) - .accessibilityIdentifier("field_ios.capture_intake.latest") - } else { - Text("No capture records yet") - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.capture_intake.empty") - } - Text("\(app.captureIntakeState.records.count) local records") - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.capture_intake.count") - } - - if let lastError = app.captureIntakeState.lastError { - Section("Capture Error") { - Text(lastError) - .font(.footnote) - .foregroundStyle(.red) - .accessibilityIdentifier("field_ios.capture_intake.error") - if app.captureIntakeState.recoveryAction == .appSettings { - ExternalActionButton( - title: "Open Settings", - systemImage: "gearshape.fill", - accessibilityID: "field_ios.capture_intake.open_settings" - ) { - await app.openAppSettingsRecovery() - } - if let status = app.externalActionStatus { - ExternalActionStatusText(status: status) - } - } - } - } - - Section("Field Context") { - LocationCheckInRow( - accessibilityIDPrefix: "field_ios.location_check_in" - ) - if app.locationCheckInState.showsAppSettingsRecovery { - ExternalActionButton( - title: "Open Settings", - systemImage: "gearshape.fill", - accessibilityID: "field_ios.location_check_in.open_settings" - ) { - await app.openAppSettingsRecovery() - } - if let status = app.externalActionStatus { - ExternalActionStatusText(status: status) - } - } - FieldActionRow(title: "Status Log", subtitle: "Record observations from the field.", systemImage: "square.and.pencil") - FieldActionRow(title: "Compliance Note", subtitle: "Prepare traceability notes for review.", systemImage: "checkmark.seal.fill") - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Capture") - .accessibilityIdentifier("field_ios.capture") - .task { - await app.refreshCaptureIntakeState() - } - } -} - -private struct CaptureIntakeStatusRow: View { - @EnvironmentObject private var app: AppState - - var body: some View { - HStack(alignment: .top, spacing: 14) { - Image(systemName: statusImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(statusColor) - .frame(width: 34, height: 34) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text("Capture Ready") - .font(.headline) - Text(statusText) - .font(.subheadline) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.capture_intake.status") - } - Spacer() - if app.captureIntakeState.operation != .idle { - ProgressView() - .accessibilityIdentifier("field_ios.capture_intake.progress") - } - } - .padding(.vertical, 4) - } - - private var supportedCount: Int { - [ - app.captureIntakeState.support.photoImportAvailable, - app.captureIntakeState.support.cameraPhotoAvailable, - app.captureIntakeState.support.documentScannerAvailable - ].filter { $0 }.count - } - - private var statusText: String { - switch app.captureIntakeState.operation { - case .refreshing: - "Checking capture support..." - case .importingPhoto: - "Importing photo..." - case .capturingPhoto: - "Taking photo..." - case .scanningDocument: - "Scanning document..." - case .idle: - supportedCount == 0 ? "Capture is unavailable on this device." : "\(supportedCount) capture options available." - } - } - - private var statusImage: String { - supportedCount == 0 ? "camera.viewfinder" : "checkmark.circle.fill" - } - - private var statusColor: Color { - supportedCount == 0 ? .secondary : .green - } -} - -private struct CaptureIntakeActionButton: View { - let title: String - let subtitle: String - let systemImage: String - let accessibilityID: String - let isWorking: Bool - let isDisabled: Bool - let action: () async -> Void - - var body: some View { - Button { - Task { - await action() - } - } label: { - HStack(spacing: 14) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .frame(width: 34, height: 34) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - if isWorking { - ProgressView() - } else { - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - } - .padding(.vertical, 4) - } - .disabled(isDisabled) - .accessibilityIdentifier(accessibilityID) - } -} - -private struct CaptureRecordRow: View { - let record: FieldCaptureRecord - - var body: some View { - Label { - VStack(alignment: .leading, spacing: 4) { - Text(record.source.displayName) - .font(.headline) - Text(record.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - Text("\(record.sizeBytes) bytes") - .font(.footnote) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: record.kind == .pdf ? "doc.richtext.fill" : "photo.fill") - .foregroundStyle(.green) - } - .padding(.vertical, 4) - } -} - -private struct ActivityView: View { - @EnvironmentObject private var app: AppState - - var body: some View { - List { - Section("Recent Activity") { - ActivityRow(title: "Identity ready", detail: app.npub.map(shortNpub) ?? "Local key selected", systemImage: "person.crop.circle.badge.checkmark") - ActivityRow(title: "Relay posture", detail: app.relaySourceAvailable && app.relaySinkAvailable ? "Read and write operations available" : "Relay operations unavailable", systemImage: "dot.radiowaves.left.and.right") - ActivityRow(title: "Draft queue", detail: "No local drafts", systemImage: "tray") - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Activity") - .accessibilityIdentifier("field_ios.activity") - } - - private func shortNpub(_ value: String) -> String { - guard value.count > 18 else { return value } - return "\(value.prefix(12))...\(value.suffix(6))" - } -} - -private struct FieldActionRow: View { - let title: String - let subtitle: String - let systemImage: String - - var body: some View { - HStack(spacing: 14) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(.green) - .frame(width: 34, height: 34) - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - } -} - -private struct LocationCheckInRow: View { - @EnvironmentObject private var app: AppState - let accessibilityIDPrefix: String - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 14) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(systemColor) - .frame(width: 34, height: 34) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text("Location Check-in") - .font(.headline) - .accessibilityIdentifier("\(accessibilityIDPrefix).card") - Text(statusText) - .font(.subheadline) - .foregroundStyle(.secondary) - .accessibilityIdentifier("\(accessibilityIDPrefix).status") - if let detailText { - Text(detailText) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("\(accessibilityIDPrefix).detail") - } - } - Spacer() - if isChecking { - ProgressView() - .accessibilityIdentifier("\(accessibilityIDPrefix).progress") - } - } - - Button { - Task { - await app.performLocationCheckIn() - } - } label: { - HStack(spacing: 14) { - Image(systemName: "location.fill") - .font(.title3.weight(.semibold)) - .frame(width: 34, height: 34) - .accessibilityHidden(true) - Text(actionTitle) - .font(.headline) - Spacer() - if isChecking { - ProgressView() - } else { - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - } - .padding(.vertical, 4) - } - .disabled(isChecking) - .accessibilityIdentifier("\(accessibilityIDPrefix).action") - } - .padding(.vertical, 4) - .task { - await app.refreshLocationCheckInStatus() - } - } - - private var isChecking: Bool { - if case .checking = app.locationCheckInState { - return true - } - return false - } - - private var systemImage: String { - switch app.locationCheckInState { - case .checkedIn: - "location.circle.fill" - case .failed: - "location.slash.fill" - case .checking: - "location.fill" - case .idle(let availability): - availability.canRequestCurrentLocation ? "location.circle.fill" : "location.fill" - } - } - - private var systemColor: Color { - switch app.locationCheckInState { - case .checkedIn: - .green - case .failed: - .red - case .checking: - .green - case .idle(let availability): - availability.canRequestCurrentLocation ? .green : .secondary - } - } - - private var actionTitle: String { - isChecking ? "Checking In" : "Check In" - } - - private var statusText: String { - switch app.locationCheckInState { - case .idle(let availability): - statusText(for: availability) - case .checking: - "Checking current location..." - case .checkedIn(let reading): - "Checked in at \(reading.coordinateSummary)" - case .failed(_, let message): - "Check-in unavailable: \(message)" - } - } - - private var detailText: String? { - switch app.locationCheckInState { - case .checkedIn(let reading): - reading.accuracySummary - case .idle(let availability): - availability.authorization == .notDetermined ? "Permission will be requested when you check in." : nil - case .checking, .failed: - nil - } - } - - private func statusText(for availability: RadrootsLocationServicesAvailability) -> String { - guard availability.locationServicesEnabled else { - return "Location Services are disabled." - } - switch availability.authorization { - case .notDetermined: - return "Ready to request location permission." - case .authorizedWhenInUse, .authorizedAlways: - return "Ready to record the current site." - case .denied: - return "Location permission is denied." - case .restricted: - return "Location permission is restricted." - case .unavailable: - return "Location Services are unavailable." - case .unsupported: - return "Location Services are unsupported." - } - } -} - -private extension FieldLocationCheckInState { - var showsAppSettingsRecovery: Bool { - if case .checking = self { - return false - } - guard let availability else { - return false - } - guard availability.locationServicesEnabled else { - return true - } - switch availability.authorization { - case .denied, .restricted, .unavailable: - return true - case .notDetermined, .authorizedWhenInUse, .authorizedAlways, .unsupported: - return false - } - } -} - -private struct ExternalActionButton: View { - let title: String - let systemImage: String - let accessibilityID: String - let action: () async -> Void - - var body: some View { - Button { - Task { - await action() - } - } label: { - Label(title, systemImage: systemImage) - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityIdentifier(accessibilityID) - } -} - -private struct ExternalActionStatusText: View { - let status: String - - var body: some View { - Text(status) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.external_actions.status") - } -} - -private struct ActivityRow: View { - let title: String - let detail: String - let systemImage: String - - var body: some View { - Label { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: systemImage) - .foregroundStyle(.green) - } - .padding(.vertical, 4) - } -} - -private struct RelayPill: View { - let available: Bool - - var body: some View { - Text(available ? "relay ready" : "relay unavailable") - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .foregroundStyle(available ? .green : .secondary) - .background(.thinMaterial, in: Capsule()) - } -} - -private struct RelayMetricRow: View { - let label: String - let systemImage: String - let value: String - - var body: some View { - HStack { - Label(label, systemImage: systemImage) - Spacer() - Text(value) - .foregroundStyle(.secondary) - } - } -} diff --git a/RadrootsTests/RadrootsRootShellTests.swift b/RadrootsTests/RadrootsRootShellTests.swift @@ -0,0 +1,33 @@ +@testable import Radroots +import XCTest + +final class RadrootsRootShellTests: XCTestCase { + func testRootInventoryIsExactlyTodayThenAdd() { + XCTAssertEqual(RadrootsRootTab.allCases.map(\.rawValue), ["today", "add"]) + } + + func testRestorationFailsClosedToToday() { + XCTAssertEqual(RadrootsRootTab.resolve(nil), .today) + XCTAssertEqual(RadrootsRootTab.resolve("capture"), .today) + XCTAssertEqual(RadrootsRootTab.resolve("activity"), .today) + XCTAssertEqual(RadrootsRootTab.resolve("settings"), .today) + } + + func testDeepLinksCanSelectOnlyRootTabs() throws { + XCTAssertEqual( + try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: "radroots://today"))), + .today + ) + XCTAssertEqual( + try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: "radroots://add"))), + .add + ) + for removed in ["capture", "activity", "settings", "search", "me"] { + XCTAssertNil( + try RadrootsRootTab.resolve( + url: XCTUnwrap(URL(string: "radroots://\(removed)")) + ) + ) + } + } +} diff --git a/RadrootsUITests/RadrootsRootShellUITests.swift b/RadrootsUITests/RadrootsRootShellUITests.swift @@ -0,0 +1,24 @@ +import XCTest + +final class RadrootsRootShellUITests: XCTestCase { + @MainActor + func testShellExposesExactlyTodayAndAddBottomTabs() { + let app = XCUIApplication() + app.launchEnvironment["RADROOTS_IOS_UI_TEST_SHELL"] = "1" + app.launch() + + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + XCTAssertEqual(tabBar.buttons.count, 2) + XCTAssertTrue(tabBar.buttons["Today"].exists) + XCTAssertTrue(tabBar.buttons["Add"].exists) + XCTAssertFalse(tabBar.buttons["Capture"].exists) + XCTAssertFalse(tabBar.buttons["Activity"].exists) + XCTAssertFalse(tabBar.buttons["Settings"].exists) + + tabBar.buttons["Add"].tap() + XCTAssertTrue(app.otherElements["radroots.add.root"].waitForExistence(timeout: 2)) + tabBar.buttons["Today"].tap() + XCTAssertTrue(app.otherElements["radroots.today.root"].waitForExistence(timeout: 2)) + } +} diff --git a/project.yml b/project.yml @@ -39,6 +39,7 @@ targets: - path: Radroots/App/AppEntry.swift - path: Radroots/App/RadrootsAppModel.swift - path: Radroots/App/RadrootsProvider.swift + - path: Radroots/App/RadrootsRootShell.swift - path: Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift - path: Radroots/Runtime/RadrootsRuntimeClient.swift - path: Radroots/Runtime/RadrootsRuntimeModels.swift @@ -71,6 +72,7 @@ targets: deploymentTarget: "18.0" sources: - path: RadrootsTests/RadrootsRuntimeClientTests.swift + - path: RadrootsTests/RadrootsRootShellTests.swift - path: RadrootsTests/RadrootsStateMigrationTests.swift settings: base: @@ -79,6 +81,19 @@ targets: GENERATE_INFOPLIST_FILE: true dependencies: - target: Radroots + RadrootsUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "18.0" + sources: + - path: RadrootsUITests/RadrootsRootShellUITests.swift + settings: + base: + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 + SWIFT_VERSION: 6.0 + GENERATE_INFOPLIST_FILE: true + dependencies: + - target: Radroots schemes: Radroots: build: @@ -87,3 +102,4 @@ schemes: test: targets: - RadrootsTests + - RadrootsUITests