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:
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