diff --git a/README.md b/README.md index 3051ac0..ce483dd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Test workspace for [FlutterProbe](https://github.com/AlphaWaveSystems/flutter-pr ## Structure ``` -mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests +mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests +native/ios-swift/ Native SwiftUI twin of the Flutter app — same 11 screens, 1:1 accessibilityIdentifiers ``` ## Mobile App @@ -28,6 +29,16 @@ The `BiometricLoginPage` exercises the new `enroll biometric` / `biometric match - Android: emulator with fingerprint ID `1` pre-enrolled in Settings → Security - Physical devices skip these steps with a warning (`set location`-style behavior) +## Native iOS Twin + +[`native/ios-swift/`](native/ios-swift/) is a hand-built SwiftUI app that replicates all 11 `mobile/` screens with real (not stubbed) behavior — same navigation shape, same state transitions, same assertable text. Every interactive/assertable view carries an `.accessibilityIdentifier` that mirrors the Flutter app's `ValueKey` 1:1 (e.g. `tab_home`, `nav_login`, `sign_in_button`), so the same test scenarios can eventually run against both frameworks with only the selector syntax changing. + +- Bundle id `com.alphawavesystems.probeTestApp.native` (distinct from the Flutter app's `com.alphawavesystems.probeTestApp`, so both can be installed on one simulator at once). +- Biometric Login uses real `LocalAuthentication` (Face ID/Touch ID), matching the Flutter screen's enrollment/match/no-match flow. +- Built via `xcodegen` + `xcodebuild`, same pattern as `flutter-probe/native-test-apps/ios/`. See `native/ios-swift/project.yml`. + +**Not yet runnable end-to-end via ProbeScript.** FlutterProbe drives apps through an embedded Flutter agent package that only exists inside Flutter apps; iOS support for driving arbitrary native UI (WebDriverAgent/XCTest-based) is tracked as proposal **N-2** in the flutter-probe repo (`docs/proposals/n2-ios-native-ui-bridging.md`) and is **not yet implemented** as of this writing. `native/ios-swift/probe-tests/native_suite.probe` records the intended test scenarios, clearly marked pending at the top of the file, ready to run once N-2 lands. In the meantime, `native/ios-swift/UITests/ProbeNativeTwinUITests.swift` is a standalone XCUITest smoke suite that verifies the key accessibilityIdentifiers actually resolve at runtime. + ## Running Tests ```bash diff --git a/native/ios-swift/.gitignore b/native/ios-swift/.gitignore new file mode 100644 index 0000000..a80d1b8 --- /dev/null +++ b/native/ios-swift/.gitignore @@ -0,0 +1,3 @@ +build/ +*.xcodeproj/ +Sources/Info.plist diff --git a/native/ios-swift/Sources/App/AppRoute.swift b/native/ios-swift/Sources/App/AppRoute.swift new file mode 100644 index 0000000..af921a8 --- /dev/null +++ b/native/ios-swift/Sources/App/AppRoute.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Mirrors the Flutter app's named routes (lib/navigation/routes.dart). +enum AppRoute: Hashable { + case login + case biometric + case dashboard + case settings + case items + case gestures + case api + case device + case visual + case dynamicScreen + case signal +} + +/// Replicates the two navigation patterns the Flutter app uses: +/// - `Navigator.pushNamed` (push) +/// - `Navigator.pushNamedAndRemoveUntil(route, (r) => r.isFirst)` after +/// login / biometric success -> stack becomes [Home, Dashboard] +/// - `Navigator.pushNamedAndRemoveUntil(route, (r) => false)` after logout +/// -> stack becomes [Home] +final class AppRouter: ObservableObject { + @Published var path: [AppRoute] = [] + + func push(_ route: AppRoute) { + path.append(route) + } + + /// Equivalent of pushNamedAndRemoveUntil(Dashboard, (r) => r.isFirst). + func replaceStackWithDashboard() { + path = [.dashboard] + } + + /// Equivalent of pushNamedAndRemoveUntil(Home, (r) => false). + func popToHome() { + path = [] + } +} diff --git a/native/ios-swift/Sources/App/ProbeNativeTwinApp.swift b/native/ios-swift/Sources/App/ProbeNativeTwinApp.swift new file mode 100644 index 0000000..67da117 --- /dev/null +++ b/native/ios-swift/Sources/App/ProbeNativeTwinApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ProbeNativeTwinApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/native/ios-swift/Sources/App/RootView.swift b/native/ios-swift/Sources/App/RootView.swift new file mode 100644 index 0000000..c99adf8 --- /dev/null +++ b/native/ios-swift/Sources/App/RootView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct RootView: View { + @StateObject private var router = AppRouter() + @StateObject private var auth = AuthStore() + @StateObject private var settings = SettingsStore() + @StateObject private var items = ItemsStore() + @StateObject private var signal = SignalStore() + @StateObject private var snackbar = SnackbarCenter() + + var body: some View { + ZStack { + NavigationStack(path: $router.path) { + HomePage() + .navigationDestination(for: AppRoute.self) { route in + destination(for: route) + } + } + SnackbarOverlay() + } + .environmentObject(router) + .environmentObject(auth) + .environmentObject(settings) + .environmentObject(items) + .environmentObject(signal) + .environmentObject(snackbar) + } + + @ViewBuilder + private func destination(for route: AppRoute) -> some View { + switch route { + case .login: LoginPage() + case .biometric: BiometricLoginPage() + case .dashboard: DashboardPage() + case .settings: SettingsPage() + case .items: ItemsPage() + case .gestures: GesturesPage() + case .api: ApiDemoPage() + case .device: DevicePage() + case .visual: VisualPage() + case .dynamicScreen: DynamicPage() + case .signal: SignalDemoPage() + } + } +} + +#Preview { + RootView() +} diff --git a/native/ios-swift/Sources/App/Stores.swift b/native/ios-swift/Sources/App/Stores.swift new file mode 100644 index 0000000..7527b1d --- /dev/null +++ b/native/ios-swift/Sources/App/Stores.swift @@ -0,0 +1,208 @@ +import Foundation +import SwiftUI + +// MARK: - Auth (mirrors lib/features/auth/presentation/providers/auth_provider.dart +// and lib/features/auth/data/datasources/auth_remote_datasource.dart) + +struct AppUser: Equatable { + let id: Int + let name: String + let email: String +} + +@MainActor +final class AuthStore: ObservableObject { + @Published private(set) var currentUser: AppUser? = nil + @Published private(set) var isLoading: Bool = false + @Published private(set) var error: String? = nil + + var isAuthenticated: Bool { currentUser != nil } + + /// Exact-match, case-sensitive credentials from auth_remote_datasource.dart. + private static let validCredentials: [(email: String, password: String, user: AppUser)] = [ + ("test@test.com", "password", AppUser(id: 1, name: "Test User", email: "test@test.com")), + ("admin@test.com", "admin123", AppUser(id: 2, name: "Admin User", email: "admin@test.com")), + ] + + /// Mirrors the 1000ms Future.delayed in AuthRemoteDatasource.login. + func login(email: String, password: String) async -> Bool { + isLoading = true + error = nil + try? await Task.sleep(nanoseconds: 1_000_000_000) + if let match = Self.validCredentials.first(where: { $0.email == email && $0.password == password }) { + currentUser = match.user + isLoading = false + return true + } else { + error = "Invalid email or password" + isLoading = false + return false + } + } + + /// Mirrors AuthProvider.loginWithBiometrics() — synthetic user, no delay. + func loginWithBiometrics() { + currentUser = AppUser(id: -1, name: "Biometric User", email: "biometric@local") + error = nil + } + + func logout() { + currentUser = nil + error = nil + } +} + +// MARK: - Settings (mirrors lib/features/settings/presentation/providers/settings_provider.dart) + +@MainActor +final class SettingsStore: ObservableObject { + static let languages = ["English", "Spanish", "French", "German", "Japanese"] + + @Published var darkMode: Bool = false + @Published var notifications: Bool = true + @Published var termsAccepted: Bool = false + @Published var language: String = "English" + + /// Mirrors SettingsProvider.savePreferences() — a no-op round trip in the + /// Flutter app (no disk persistence despite shared_preferences dependency). + func savePreferences() { + // Intentionally a no-op, matching the Flutter twin's behavior. + } +} + +// MARK: - Snackbar (mirrors Flutter's ScaffoldMessenger.showSnackBar) + +@MainActor +final class SnackbarCenter: ObservableObject { + @Published private(set) var message: String? = nil + private var dismissTask: Task? + + func show(_ text: String) { + dismissTask?.cancel() + message = text + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + if !Task.isCancelled { + message = nil + } + } + } +} + +struct SnackbarOverlay: View { + @EnvironmentObject var center: SnackbarCenter + + var body: some View { + VStack { + Spacer() + if let message = center.message { + Text(message) + .padding() + .background(Color.black.opacity(0.85)) + .foregroundColor(.white) + .cornerRadius(8) + .padding(.bottom, 24) + .accessibilityIdentifier("snackbar_message") + .transition(.move(edge: .bottom).combined(with: .opacity)) + .allowsHitTesting(false) + } + } + .animation(.easeInOut, value: center.message) + } +} + +// MARK: - Items (mirrors lib/features/items/presentation/providers/item_provider.dart) + +struct AppItem: Identifiable, Equatable { + let id: Int + let title: String + let description: String +} + +@MainActor +final class ItemsStore: ObservableObject { + @Published private(set) var allItems: [AppItem] + @Published private(set) var filteredItems: [AppItem] + @Published private(set) var isLoading: Bool = false + @Published private(set) var searchQuery: String = "" + + init() { + let generated = Self.generateItems() + allItems = generated + filteredItems = generated + } + + private static func generateItems() -> [AppItem] { + (0..<50).map { AppItem(id: $0, title: "Item \($0)", description: "Description for item \($0)") } + } + + /// Mirrors ItemProvider.fetchItems() — 300ms delayed regeneration. + /// Nothing in the current UI calls this (no pull-to-refresh), kept for parity. + func fetchItems() async { + isLoading = true + try? await Task.sleep(nanoseconds: 300_000_000) + allItems = Self.generateItems() + applySearch() + isLoading = false + } + + func search(_ query: String) { + searchQuery = query + applySearch() + } + + private func applySearch() { + if searchQuery.isEmpty { + filteredItems = allItems + } else { + let needle = searchQuery.lowercased() + filteredItems = allItems.filter { $0.title.lowercased().contains(needle) } + } + } + + func deleteItem(id: Int) { + allItems.removeAll { $0.id == id } + applySearch() + } +} + +// MARK: - Signal demo (mirrors lib/features/signal/presentation/pages/signal_demo_page.dart) +// +// The Flutter screen branches on `bool.fromEnvironment('PROBE_AGENT')`: under the +// probe agent it awaits an RPC-delivered signal; otherwise it falls back to a fixed +// delay + happy-path result. This native twin has no probe-agent socket bridge yet +// (iOS N-2 XCTest bridging is unimplemented — see native_suite.probe), so it always +// takes the non-agent fallback branch, matching Flutter's non-agent behavior exactly. + +@MainActor +final class SignalStore: ObservableObject { + @Published private(set) var busy: Bool = false + @Published private(set) var status: String = "" + + func requestPushPermission() async { + guard !busy else { return } + busy = true + status = "Waiting for permission\u{2026}" + try? await Task.sleep(nanoseconds: 1_000_000_000) + status = "Notifications enabled" + busy = false + } + + func startPayment() async { + guard !busy else { return } + busy = true + status = "Payment in progress\u{2026}" + try? await Task.sleep(nanoseconds: 1_000_000_000) + status = "Payment confirmed" + busy = false + } + + func openDeepLink() async { + guard !busy else { return } + busy = true + status = "Opening link\u{2026}" + try? await Task.sleep(nanoseconds: 1_000_000_000) + status = "Opened: https://example.com" + busy = false + } +} diff --git a/native/ios-swift/Sources/Screens/ApiDemoPage.swift b/native/ios-swift/Sources/Screens/ApiDemoPage.swift new file mode 100644 index 0000000..d00aaf7 --- /dev/null +++ b/native/ios-swift/Sources/Screens/ApiDemoPage.swift @@ -0,0 +1,84 @@ +import SwiftUI + +/// Mirrors the inline `_ApiPage` in app.dart, reached via the "API Tests" home +/// tile. AppBar/nav title is "API Demo" — note this differs from the route's +/// nav-tile label "API Tests", matching the Flutter twin exactly. +struct ApiDemoPage: View { + @State private var loading = false + @State private var error: String? = nil + @State private var users: [String] = [] + @State private var postResult: String? = nil + + var body: some View { + ScrollView { + VStack(spacing: 8) { + Button("Fetch Users") { + Task { await fetchUsers() } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(loading) + .accessibilityIdentifier("fetch_users_button") + + Button("Create Post") { + Task { await createPost() } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(loading) + .accessibilityIdentifier("create_post_button") + + if loading { + ProgressView() + .padding(.top, 16) + .accessibilityIdentifier("api_loading") + } + + if let error = error { + Text(error) + .foregroundColor(.red) + .padding(.top, 16) + .accessibilityIdentifier("api_error") + } + + if !users.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Users:").font(.system(size: 16, weight: .bold)) + ForEach(Array(users.enumerated()), id: \.offset) { index, name in + Text(name) + .accessibilityIdentifier("user_\(index)") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } + + if let postResult = postResult { + Text(postResult) + .foregroundColor(.green) + .padding(.top, 16) + .accessibilityIdentifier("post_result") + } + } + .padding(16) + } + .navigationTitle("API Demo") + .navigationBarTitleDisplayMode(.inline) + } + + private func fetchUsers() async { + loading = true + error = nil + try? await Task.sleep(nanoseconds: 1_000_000_000) + users = ["Leanne Graham", "Ervin Howell", "Clementine Bauch"] + loading = false + } + + private func createPost() async { + loading = true + error = nil + try? await Task.sleep(nanoseconds: 500_000_000) + postResult = "Post created with id: 101" + loading = false + } +} diff --git a/native/ios-swift/Sources/Screens/BiometricLoginPage.swift b/native/ios-swift/Sources/Screens/BiometricLoginPage.swift new file mode 100644 index 0000000..6f7fbc2 --- /dev/null +++ b/native/ios-swift/Sources/Screens/BiometricLoginPage.swift @@ -0,0 +1,99 @@ +import SwiftUI +import LocalAuthentication + +/// Mirrors lib/features/auth/presentation/pages/biometric_login_page.dart. +/// +/// Unlike the Flutter twin's PROBE_AGENT branch (which awaits an RPC-delivered +/// bool from flutter-probe's `biometric match` / `biometric no match` steps), +/// this native screen always uses real LocalAuthentication as instructed — +/// flutter-probe drives Face ID/Touch ID match outcomes directly at the iOS +/// Simulator level via `xcrun simctl ... notifyutil`, independent of the app, +/// so real LAContext usage is compatible with those verbs once the N-2 XCTest +/// bridge lands and can drive taps/waits against this screen. +struct BiometricLoginPage: View { + @EnvironmentObject var auth: AuthStore + @EnvironmentObject var router: AppRouter + + @State private var busy = false + @State private var error: String? = nil + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Spacer().frame(height: 48) + + Image(systemName: "faceid") + .font(.system(size: 96)) + + Text("Use your face or fingerprint to sign in.") + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .accessibilityIdentifier("biometric_prompt_intro") + + Button(action: signIn) { + Label("Sign in with Face ID", systemImage: "faceid") + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + .buttonStyle(.borderedProminent) + .disabled(busy) + .accessibilityIdentifier("sign_in_with_face_id") + + if busy { + ProgressView() + .padding(.top, 8) + .accessibilityIdentifier("biometric_progress") + } + + if let error = error { + Text(error) + .foregroundColor(Color(red: 0.6, green: 0, blue: 0)) + .padding(12) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.red.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + ) + .padding(.horizontal, 24) + .accessibilityIdentifier("biometric_error_banner") + } + + Spacer() + } + } + .navigationTitle("Biometric Login") + .navigationBarTitleDisplayMode(.inline) + } + + private func signIn() { + busy = true + error = nil + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &policyError) else { + busy = false + self.error = "Authentication failed" + return + } + + context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Sign in to your account" + ) { success, _ in + DispatchQueue.main.async { + if success { + auth.loginWithBiometrics() + router.replaceStackWithDashboard() + } else { + busy = false + error = "Authentication failed" + } + } + } + } +} diff --git a/native/ios-swift/Sources/Screens/DashboardPage.swift b/native/ios-swift/Sources/Screens/DashboardPage.swift new file mode 100644 index 0000000..fdf0463 --- /dev/null +++ b/native/ios-swift/Sources/Screens/DashboardPage.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Mirrors lib/features/auth/presentation/pages/dashboard_page.dart. +struct DashboardPage: View { + @EnvironmentObject var auth: AuthStore + @EnvironmentObject var router: AppRouter + @EnvironmentObject var snackbar: SnackbarCenter + + private var userName: String { auth.currentUser?.name ?? "Guest" } + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 4) { + Text("Welcome, \(userName)") + .font(.title3.bold()) + Text("Here is your dashboard overview") + .font(.subheadline) + .opacity(0.7) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.15)) + .cornerRadius(12) + .accessibilityIdentifier("welcome_banner") + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + HStack(spacing: 8) { + statCard(key: "stat_card_1", value: "42", title: "Tests Run", icon: "checkmark.circle.fill") + statCard(key: "stat_card_2", value: "38", title: "Passed", icon: "hand.thumbsup.fill") + statCard(key: "stat_card_3", value: "4", title: "Failed", icon: "exclamationmark.circle") + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + Text("Recent Items") + .font(.headline) + .padding(.top, 24) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + + ForEach(0..<10, id: \.self) { index in + Button { + snackbar.show("Tapped item \(index + 1)") + } label: { + HStack { + Text("\(index + 1)") + .frame(width: 32, height: 32) + .background(Circle().fill(Color.accentColor.opacity(0.2))) + VStack(alignment: .leading) { + Text("Item \(index + 1)") + Text("Description for item \(index + 1)") + .font(.caption) + .foregroundColor(.gray) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("item_\(index)") + } + } + .listStyle(.plain) + .refreshable { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + .navigationTitle("Dashboard") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + snackbar.show("Refreshed") + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityIdentifier("refresh_button") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + auth.logout() + router.popToHome() + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + .accessibilityIdentifier("logout_button") + } + } + } + + private func statCard(key: String, value: String, title: String, icon: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(.accentColor) + Text(value) + .font(.title2.bold()) + Text(title) + .font(.caption) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .accessibilityIdentifier(key) + } +} diff --git a/native/ios-swift/Sources/Screens/DevicePage.swift b/native/ios-swift/Sources/Screens/DevicePage.swift new file mode 100644 index 0000000..580c87b --- /dev/null +++ b/native/ios-swift/Sources/Screens/DevicePage.swift @@ -0,0 +1,89 @@ +import SwiftUI +import UIKit + +/// Mirrors lib/features/device/presentation/pages/device_page.dart. +/// All permission simulation is UI-only, matching the Flutter twin — no real +/// permission APIs are invoked (the Flutter app doesn't use one either). +struct DevicePage: View { + @EnvironmentObject var snackbar: SnackbarCenter + + @State private var cameraStatus = "Not requested" + @State private var locationStatus = "Not requested" + @State private var gpsDisplay = "Location: --" + @State private var copyText = "Copy this text" + @State private var pastedText = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Permissions").font(.title3.bold()) + + Button { + cameraStatus = "Granted" + } label: { + Label("Request Camera", systemImage: "camera.fill") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("request_camera") + Text("Camera: \(cameraStatus)") + .accessibilityIdentifier("camera_status") + .padding(.bottom, 12) + + Button { + locationStatus = "Granted" + gpsDisplay = "Location: 37.7749, -122.4194" + } label: { + Label("Request Location", systemImage: "location.fill") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("request_location") + Text("Location: \(locationStatus)") + .accessibilityIdentifier("location_status") + .padding(.bottom, 12) + + Button { + snackbar.show("Notification permission requested") + } label: { + Label("Request Notifications", systemImage: "bell.fill") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("request_notifications") + + Text("GPS").font(.title3.bold()).padding(.top, 24) + Text(gpsDisplay) + .accessibilityIdentifier("gps_display") + + Text("Browser").font(.title3.bold()).padding(.top, 24) + Button { + if let url = URL(string: "https://www.google.com") { + UIApplication.shared.open(url) + } + } label: { + Label("Open Website", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("open_browser") + + Text("Clipboard").font(.title3.bold()).padding(.top, 24) + TextField("Text to copy", text: $copyText) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("copy_text_field") + + Button { + pastedText = UIPasteboard.general.string ?? "Nothing in clipboard" + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("paste_button") + + Text(pastedText) + .font(.system(size: 14)) + .accessibilityIdentifier("pasted_text") + } + .padding(16) + } + .navigationTitle("Device") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/native/ios-swift/Sources/Screens/DynamicPage.swift b/native/ios-swift/Sources/Screens/DynamicPage.swift new file mode 100644 index 0000000..926b723 --- /dev/null +++ b/native/ios-swift/Sources/Screens/DynamicPage.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// Mirrors lib/features/dynamic/presentation/pages/dynamic_page.dart. +struct DynamicPage: View { + @State private var showAbBanner = Bool.random() + @State private var countdown = 10 + @State private var actionCount = 0 + @State private var fadeOpacity: Double = 0 + @State private var showErrorDialog = false + @State private var timer: Timer? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if showAbBanner { + Text("Special Offer!") + .font(.system(size: 18, weight: .bold)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(16) + .background(Color.orange.opacity(0.3)) + .cornerRadius(8) + .accessibilityIdentifier("ab_banner") + } + + Text("Countdown").font(.system(size: 18, weight: .bold)) + Text("\(countdown)") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(countdown <= 3 ? .red : .primary) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("countdown") + + Text("Fade Animation").font(.system(size: 18, weight: .bold)) + Text("I faded in!") + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 80) + .background(Color.teal) + .cornerRadius(8) + .opacity(fadeOpacity) + .accessibilityIdentifier("fade_widget") + + Text("Error Handling").font(.system(size: 18, weight: .bold)) + Button("Trigger Error") { + showErrorDialog = true + } + .buttonStyle(.borderedProminent) + .tint(.red) + .accessibilityIdentifier("trigger_error") + + Text("Repeatable Action").font(.system(size: 18, weight: .bold)) + Button("Tap Me") { + actionCount += 1 + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("repeat_action") + Text("Tapped: \(actionCount)") + .accessibilityIdentifier("action_count") + } + .padding(16) + } + .navigationTitle("Dynamic") + .navigationBarTitleDisplayMode(.inline) + .alert("Error", isPresented: $showErrorDialog) { + Button("OK", role: .cancel) {} + } message: { + Text("Something went wrong!") + } + .onAppear { + withAnimation(.easeIn(duration: 2)) { + fadeOpacity = 1 + } + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { t in + DispatchQueue.main.async { + if countdown > 0 { + countdown -= 1 + } else { + t.invalidate() + } + } + } + } + .onDisappear { + timer?.invalidate() + timer = nil + } + } +} diff --git a/native/ios-swift/Sources/Screens/GesturesPage.swift b/native/ios-swift/Sources/Screens/GesturesPage.swift new file mode 100644 index 0000000..4dc276f --- /dev/null +++ b/native/ios-swift/Sources/Screens/GesturesPage.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Mirrors lib/features/gestures/presentation/pages/gestures_page.dart. +struct GesturesPage: View { + @State private var gestureCount = 0 + @State private var doubleTapCount = 0 + @State private var dragAccepted = false + @State private var swipeCardVisible = true + @State private var showContextMenu = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Gesture Count: \(gestureCount)") + .font(.headline) + .accessibilityIdentifier("gesture_count") + .padding(.bottom, 16) + + Text("Drag & Drop").bold() + HStack { + Spacer() + Text("Drag") + .frame(width: 80, height: 80) + .background(Color.blue) + .foregroundColor(.white) + .accessibilityIdentifier("drag_source") + .draggable("blue_box") { + Text("Drag") + .frame(width: 80, height: 80) + .background(Color.blue.opacity(0.7)) + .foregroundColor(.white) + } + Spacer() + Text(dragAccepted ? "Done!" : "Drop") + .frame(width: 80, height: 80) + .background(dragAccepted ? Color.green.opacity(0.6) : Color.green) + .foregroundColor(.white) + .accessibilityIdentifier("drag_target") + .dropDestination(for: String.self) { _, _ in + dragAccepted = true + gestureCount += 1 + return true + } + Spacer() + } + .padding(.vertical, 8) + + Text("Double Tap").bold().padding(.top, 24) + Text("Double taps: \(doubleTapCount)") + .frame(maxWidth: .infinity) + .frame(height: 80) + .background(Color.orange.opacity(0.2)) + .accessibilityIdentifier("double_tap_area") + .onTapGesture(count: 2) { + doubleTapCount += 1 + gestureCount += 1 + } + + Text("Long Press").bold().padding(.top, 24) + ZStack(alignment: .topLeading) { + Text("Long press me") + .frame(maxWidth: .infinity) + .frame(height: 80) + .background(Color.purple.opacity(0.2)) + .accessibilityIdentifier("long_press_area") + .onLongPressGesture { + gestureCount += 1 + showContextMenu = true + } + if showContextMenu { + VStack(alignment: .leading, spacing: 0) { + Button("Copy") { showContextMenu = false } + .padding(10) + Divider() + Button("Delete") { showContextMenu = false } + .padding(10) + } + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(radius: 6) + .offset(x: 24, y: 24) + .zIndex(1) + } + } + + Text("Swipe Card").bold().padding(.top, 24) + if swipeCardVisible { + HStack { + Image(systemName: "arrow.left.and.right") + Text("Swipe me to dismiss") + Spacer() + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .accessibilityIdentifier("swipe_card") + .swipeToDismiss(direction: .horizontal) { + swipeCardVisible = false + gestureCount += 1 + } + } else { + Text("Card dismissed!") + } + } + .padding(16) + } + .navigationTitle("Gestures") + .navigationBarTitleDisplayMode(.inline) + .onTapGesture { + showContextMenu = false + } + } +} diff --git a/native/ios-swift/Sources/Screens/HomePage.swift b/native/ios-swift/Sources/Screens/HomePage.swift new file mode 100644 index 0000000..5785bce --- /dev/null +++ b/native/ios-swift/Sources/Screens/HomePage.swift @@ -0,0 +1,150 @@ +import SwiftUI + +/// Mirrors lib/navigation/home_page.dart — a 3-tab shell (Home/Tests/About) +/// with a AppBar title that persists across all three tabs. +struct HomePage: View { + @EnvironmentObject var router: AppRouter + @State private var selectedTab: Int = 0 + + var body: some View { + VStack(spacing: 0) { + Group { + switch selectedTab { + case 0: homeTab + case 1: testsTab + default: aboutTab + } + } + .frame(maxHeight: .infinity) + Divider() + tabBar + } + .navigationTitle("FlutterProbe Test App") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + } + + // MARK: Home tab + + private var homeTab: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + Text("Welcome to the FlutterProbe Test App") + .font(.system(size: 20, weight: .bold)) + .multilineTextAlignment(.center) + .padding(.top, 24) + .padding(.bottom, 16) + .padding(.horizontal, 24) + .accessibilityIdentifier("welcome_text") + + navRow(key: "nav_login", icon: "arrow.right.to.line.circle", label: "Login") { + router.push(.login) + } + navRow(key: "nav_biometric", icon: "faceid", label: "Biometric Login") { + router.push(.biometric) + } + navRow(key: "nav_signal", icon: "bell.badge", label: "Signal Demo") { + router.push(.signal) + } + navRow(key: "nav_dashboard", icon: "gauge", label: "Dashboard") { + router.push(.dashboard) + } + navRow(key: "nav_settings", icon: "gearshape", label: "Settings") { + router.push(.settings) + } + navRow(key: "nav_items", icon: "list.bullet", label: "Items") { + router.push(.items) + } + navRow(key: "nav_gestures", icon: "hand.tap", label: "Gestures") { + router.push(.gestures) + } + navRow(key: "nav_api", icon: "network", label: "API Tests") { + router.push(.api) + } + navRow(key: "nav_device", icon: "iphone", label: "Device") { + router.push(.device) + } + navRow(key: "nav_visual", icon: "eye", label: "Visual") { + router.push(.visual) + } + navRow(key: "nav_dynamic", icon: "waveform.path", label: "Dynamic") { + router.push(.dynamicScreen) + } + } + } + Text("Version 1.0") + .font(.system(size: 12)) + .foregroundColor(.gray) + .padding(.vertical, 8) + .accessibilityIdentifier("version_text") + } + } + + private func navRow(key: String, icon: String, label: String, action: @escaping () -> Void) -> some View { + VStack(spacing: 0) { + Button(action: action) { + HStack { + Image(systemName: icon) + .frame(width: 28) + Text(label) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(key) + Divider().padding(.leading, 16) + } + } + + // MARK: Tests / About tabs + + private var testsTab: some View { + Text("Test Suites") + .font(.system(size: 18)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("tests_tab_content") + } + + private var aboutTab: some View { + Text("About FlutterProbe Test App") + .font(.system(size: 18)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("about_tab_content") + } + + // MARK: Bottom tab bar + + private var tabBar: some View { + HStack(spacing: 0) { + tabButton(index: 0, key: "tab_home", iconKey: "tab_home_icon", icon: "house.fill", label: "Home") + tabButton(index: 1, key: "tab_tests", iconKey: "tab_tests_icon", icon: "testtube.2", label: "Tests") + tabButton(index: 2, key: "tab_about", iconKey: "tab_about_icon", icon: "info.circle.fill", label: "About") + } + .padding(.vertical, 8) + .background(.bar) + } + + private func tabButton(index: Int, key: String, iconKey: String, icon: String, label: String) -> some View { + Button { + selectedTab = index + } label: { + VStack(spacing: 4) { + Image(systemName: icon) + .accessibilityIdentifier(iconKey) + Text(label) + .font(.caption2) + } + .foregroundColor(selectedTab == index ? .accentColor : .gray) + .frame(maxWidth: .infinity) + } + .accessibilityIdentifier(key) + } +} diff --git a/native/ios-swift/Sources/Screens/ItemsPage.swift b/native/ios-swift/Sources/Screens/ItemsPage.swift new file mode 100644 index 0000000..e6b1294 --- /dev/null +++ b/native/ios-swift/Sources/Screens/ItemsPage.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Mirrors lib/features/items/presentation/pages/item_list_page.dart. +struct ItemsPage: View { + @EnvironmentObject var items: ItemsStore + @EnvironmentObject var snackbar: SnackbarCenter + @State private var searchText: String = "" + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + TextField("Search items...", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding() + .onChange(of: searchText) { _, newValue in + items.search(newValue) + } + .accessibilityIdentifier("search_field") + + if items.filteredItems.isEmpty { + Text("No items found") + .font(.title3) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("empty_state") + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(items.filteredItems.enumerated()), id: \.element.id) { index, item in + row(index: index, item: item) + Divider().padding(.leading, 16) + } + } + } + .accessibilityIdentifier("scrollable_list") + } + } + + Button { + snackbar.show("Add item tapped") + } label: { + Image(systemName: "plus") + .font(.title2) + .foregroundColor(.white) + .frame(width: 56, height: 56) + .background(Circle().fill(Color.accentColor)) + .shadow(radius: 4) + } + .padding(20) + .accessibilityIdentifier("fab_add") + } + .navigationTitle("Items") + .navigationBarTitleDisplayMode(.inline) + } + + private func row(index: Int, item: AppItem) -> some View { + HStack { + Text("\(item.id)") + .frame(width: 32, height: 32) + .background(Circle().fill(Color.accentColor.opacity(0.2))) + VStack(alignment: .leading) { + Text(item.title) + Text(item.description) + .font(.caption) + .foregroundColor(.gray) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .accessibilityIdentifier("list_item_\(index)") + .background(Color(.systemBackground)) + .swipeToDismiss(direction: .leftOnly) { + items.deleteItem(id: item.id) + } + .accessibilityIdentifier("dismissible_\(item.id)") + } +} diff --git a/native/ios-swift/Sources/Screens/LoginPage.swift b/native/ios-swift/Sources/Screens/LoginPage.swift new file mode 100644 index 0000000..35e7da1 --- /dev/null +++ b/native/ios-swift/Sources/Screens/LoginPage.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// Mirrors lib/features/auth/presentation/pages/login_page.dart. +struct LoginPage: View { + @EnvironmentObject var auth: AuthStore + @EnvironmentObject var router: AppRouter + @EnvironmentObject var snackbar: SnackbarCenter + + @State private var email: String = "" + @State private var password: String = "" + + var body: some View { + ScrollView { + VStack(spacing: 16) { + Spacer().frame(height: 48) + + Image(systemName: "lock") + .font(.system(size: 80)) + .foregroundColor(.accentColor) + + TextField("Enter your email", text: $email) + .textFieldStyle(.roundedBorder) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(.horizontal, 24) + .accessibilityIdentifier("email_field") + + SecureField("Enter your password", text: $password) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 24) + .accessibilityIdentifier("password_field") + + if let error = auth.error { + Text(error) + .foregroundColor(.red) + .font(.system(size: 14)) + .accessibilityIdentifier("error_message") + } + + if auth.isLoading { + ProgressView() + .accessibilityIdentifier("loading_indicator") + .padding(.top, 8) + } else { + Button(action: handleSignIn) { + Text("Sign In") + .font(.system(size: 16)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 24) + .accessibilityIdentifier("sign_in_button") + } + + Button("Forgot Password?") { + snackbar.show("Password reset not implemented") + } + .accessibilityIdentifier("forgot_password") + + Spacer() + } + } + .navigationTitle("Login") + .navigationBarTitleDisplayMode(.inline) + } + + private func handleSignIn() { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + let success = await auth.login(email: trimmedEmail, password: password) + if success { + router.replaceStackWithDashboard() + } + } + } +} diff --git a/native/ios-swift/Sources/Screens/SettingsPage.swift b/native/ios-swift/Sources/Screens/SettingsPage.swift new file mode 100644 index 0000000..5674d20 --- /dev/null +++ b/native/ios-swift/Sources/Screens/SettingsPage.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Mirrors lib/features/settings/presentation/pages/settings_page.dart. +struct SettingsPage: View { + @EnvironmentObject var settings: SettingsStore + @EnvironmentObject var snackbar: SnackbarCenter + + var body: some View { + List { + Toggle("Dark Mode", isOn: $settings.darkMode) + .accessibilityIdentifier("dark_mode_toggle") + + Toggle("Notifications", isOn: $settings.notifications) + .accessibilityIdentifier("notifications_toggle") + + Toggle("Agree to Terms", isOn: $settings.termsAccepted) + .accessibilityIdentifier("terms_checkbox") + + Picker("Language", selection: $settings.language) { + ForEach(SettingsStore.languages, id: \.self) { language in + Text(language).tag(language) + } + } + .accessibilityIdentifier("language_dropdown") + + Button { + settings.savePreferences() + snackbar.show("Settings saved") + } label: { + Text("Save") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("save_button") + .listRowInsets(EdgeInsets()) + .padding() + .listRowSeparator(.hidden) + + Button { + // Permanently disabled, matching the Flutter twin (onPressed: null). + } label: { + Text("Delete Account") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.gray) + .disabled(true) + .accessibilityIdentifier("delete_button") + .listRowInsets(EdgeInsets()) + .padding() + .listRowSeparator(.hidden) + + Text("Build: 1.0.42+dev") + .font(.caption) + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .accessibilityIdentifier("build_info") + .listRowInsets(EdgeInsets()) + .padding(.top, 8) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/native/ios-swift/Sources/Screens/SignalDemoPage.swift b/native/ios-swift/Sources/Screens/SignalDemoPage.swift new file mode 100644 index 0000000..b889f80 --- /dev/null +++ b/native/ios-swift/Sources/Screens/SignalDemoPage.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// Mirrors lib/features/signal/presentation/pages/signal_demo_page.dart. +/// See Stores.swift SignalStore doc comment re: why this always takes the +/// Flutter twin's non-agent fallback branch. +struct SignalDemoPage: View { + @EnvironmentObject var signal: SignalStore + + var body: some View { + ScrollView { + VStack(spacing: 12) { + Spacer().frame(height: 24) + + Button("Request Push Permission") { + Task { await signal.requestPushPermission() } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(signal.busy) + .accessibilityIdentifier("request_push_permission") + + Button("Start Payment") { + Task { await signal.startPayment() } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(signal.busy) + .accessibilityIdentifier("start_payment") + + Button("Open Deep Link") { + Task { await signal.openDeepLink() } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(signal.busy) + .accessibilityIdentifier("open_deep_link") + + if signal.busy { + ProgressView() + .padding(.top, 24) + .accessibilityIdentifier("signal_progress") + } + + if !signal.status.isEmpty { + Text(signal.status) + .font(.title3) + .multilineTextAlignment(.center) + .padding(.top, 24) + .accessibilityIdentifier("signal_status") + } + } + .padding(24) + } + .navigationTitle("Signal Demo") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/native/ios-swift/Sources/Screens/VisualPage.swift b/native/ios-swift/Sources/Screens/VisualPage.swift new file mode 100644 index 0000000..9c4ba46 --- /dev/null +++ b/native/ios-swift/Sources/Screens/VisualPage.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Mirrors lib/features/visual/presentation/pages/visual_page.dart. Deliberately +/// deterministic (no animation, no async work) for pixel/visual-regression tests. +struct VisualPage: View { + @State private var counter = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Visual Test") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(24) + .background(Color(red: 0.4, green: 0.2, blue: 0.6)) + .cornerRadius(12) + .accessibilityIdentifier("visual_header") + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + colorCell("Red", .red, textColor: .white) + colorCell("Green", .green, textColor: .white) + colorCell("Blue", .blue, textColor: .white) + colorCell("Yellow", .yellow, textColor: .black) + } + .accessibilityIdentifier("color_grid") + + VStack(spacing: 12) { + Text("Count: \(counter)") + .font(.system(size: 32, weight: .bold)) + .accessibilityIdentifier("visual_counter") + + Button("Increment") { + counter += 1 + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("increment_button") + } + .frame(maxWidth: .infinity) + + VStack(spacing: 8) { + Image(systemName: "photo") + .font(.system(size: 48)) + .foregroundColor(.gray) + Text("Image Placeholder") + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity) + .frame(height: 150) + .background(Color(.systemGray5)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray)) + .cornerRadius(8) + .accessibilityIdentifier("static_image") + + VStack(alignment: .leading, spacing: 8) { + Text("Heading 1").font(.system(size: 32, weight: .bold)) + Text("Heading 2").font(.system(size: 24, weight: .semibold)) + Text("Body text at 16px").font(.system(size: 16)) + Text("Caption text at 12px").font(.system(size: 12)) + Text("Small text at 10px").font(.system(size: 10)) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(.systemGray4))) + .accessibilityIdentifier("typography_sample") + } + .padding(16) + } + .navigationTitle("Visual") + .navigationBarTitleDisplayMode(.inline) + } + + private func colorCell(_ label: String, _ color: Color, textColor: Color) -> some View { + Text(label) + .font(.system(size: 18)) + .foregroundColor(textColor) + .frame(maxWidth: .infinity) + .frame(height: 80) + .background(color) + .cornerRadius(8) + } +} diff --git a/native/ios-swift/Sources/Support/SwipeToDismiss.swift b/native/ios-swift/Sources/Support/SwipeToDismiss.swift new file mode 100644 index 0000000..930537c --- /dev/null +++ b/native/ios-swift/Sources/Support/SwipeToDismiss.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// A lightweight stand-in for Flutter's `Dismissible` widget: a single continuous +/// drag past a threshold fully dismisses the row, rather than SwiftUI's native +/// two-step swipeActions (reveal, then tap a button). This matches the gesture +/// shape ProbeScript's `swipe left on #key` / `swipe right` verbs expect. +struct SwipeToDismissModifier: ViewModifier { + enum Direction { + case leftOnly + case horizontal + } + + let direction: Direction + let onDismiss: () -> Void + + @State private var offsetX: CGFloat = 0 + @State private var isGone = false + + private let threshold: CGFloat = 90 + + func body(content: Content) -> some View { + ZStack { + if offsetX != 0 { + HStack { + if offsetX < 0 { Spacer() } + Image(systemName: "trash") + .foregroundColor(.white) + .padding(.horizontal, 16) + if offsetX > 0 { Spacer() } + } + .frame(maxWidth: .infinity) + .background(Color.red) + } + content + .offset(x: offsetX) + .gesture( + DragGesture() + .onChanged { value in + let dx = value.translation.width + switch direction { + case .leftOnly: + offsetX = min(0, dx) + case .horizontal: + offsetX = dx + } + } + .onEnded { value in + let dx = value.translation.width + let dismissedLeft = direction == .leftOnly && dx < -threshold + let dismissedEither = direction == .horizontal && abs(dx) > threshold + if dismissedLeft || dismissedEither { + withAnimation(.easeOut) { + offsetX = dx < 0 ? -600 : 600 + isGone = true + } + onDismiss() + } else { + withAnimation(.spring()) { offsetX = 0 } + } + } + ) + } + .opacity(isGone ? 0 : 1) + .clipped() + } +} + +extension View { + func swipeToDismiss(direction: SwipeToDismissModifier.Direction, onDismiss: @escaping () -> Void) -> some View { + modifier(SwipeToDismissModifier(direction: direction, onDismiss: onDismiss)) + } +} diff --git a/native/ios-swift/UITests/ProbeNativeTwinUITests.swift b/native/ios-swift/UITests/ProbeNativeTwinUITests.swift new file mode 100644 index 0000000..85681d3 --- /dev/null +++ b/native/ios-swift/UITests/ProbeNativeTwinUITests.swift @@ -0,0 +1,48 @@ +import XCTest + +/// Build-verification smoke test — confirms a handful of accessibilityIdentifiers +/// used by the ValueKey-parity table actually resolve at runtime. This is a plain +/// XCTest target (run via `xcodebuild test`), independent of flutter-probe's N-2 +/// XCTest/WebDriverAgent bridge, which is what would let flutter-probe's own +/// ProbeScript runner drive this app (see probe-tests/native_suite.probe). +final class ProbeNativeTwinUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testHomeScreenIdentifiersResolve() throws { + let app = XCUIApplication() + app.launch() + + XCTAssertTrue(app.staticTexts["welcome_text"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["version_text"].exists) + XCTAssertTrue(app.buttons["nav_login"].exists) + XCTAssertTrue(app.buttons["nav_dashboard"].exists) + XCTAssertTrue(app.buttons["tab_home"].exists) + XCTAssertTrue(app.buttons["tab_tests"].exists) + XCTAssertTrue(app.buttons["tab_about"].exists) + } + + func testLoginScreenIdentifiersResolve() throws { + let app = XCUIApplication() + app.launch() + + app.buttons["nav_login"].tap() + + XCTAssertTrue(app.textFields["email_field"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.secureTextFields["password_field"].exists) + XCTAssertTrue(app.buttons["sign_in_button"].exists) + XCTAssertTrue(app.buttons["forgot_password"].exists) + } + + func testDashboardReachableWithoutAuth() throws { + let app = XCUIApplication() + app.launch() + + app.buttons["nav_dashboard"].tap() + + XCTAssertTrue(app.staticTexts["welcome_banner"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["stat_card_1"].exists) + XCTAssertTrue(app.buttons["logout_button"].exists) + } +} diff --git a/native/ios-swift/probe-tests/native_suite.probe b/native/ios-swift/probe-tests/native_suite.probe new file mode 100644 index 0000000..863b816 --- /dev/null +++ b/native/ios-swift/probe-tests/native_suite.probe @@ -0,0 +1,208 @@ +# ============================================================================ +# PENDING N-2 iOS NATIVE UI BRIDGE — NOT YET RUNNABLE +# +# flutter-probe's `tap`/`type`/`see`/`wait until` verbs currently drive apps +# through the embedded flutter_probe_agent Dart package, which only exists +# inside Flutter apps. This app (native/ios-swift) is plain SwiftUI with no +# Flutter runtime, so none of those verbs can reach it today. +# +# iOS support for driving arbitrary native UI (via WebDriverAgent/XCTest) is +# tracked as proposal N-2 in flutter-probe: +# docs/proposals/n2-ios-native-ui-bridging.md +# As of this writing it is a scoped, written proposal — NOT implemented. +# See flutter-probe/website/src/content/docs/platform/ios.md, "Native UI +# Automation" section, for current status. +# +# This file records the intended test scenarios for the native twin so it is +# ready to run the moment N-2 lands. Every selector below (#key) maps 1:1 to +# an .accessibilityIdentifier already present in the SwiftUI source, verified +# to resolve via a standalone XCUITest smoke suite +# (native/ios-swift/UITests/ProbeNativeTwinUITests.swift) — see the PR +# description for confirmation this build/install/identifier-resolution step +# was actually run, since ProbeScript itself cannot yet exercise this app. +# +# Biometric match/no-match ARE independently testable today (flutter-probe +# drives Face ID/Touch ID at the Simulator level via `xcrun simctl ... +# notifyutil`, not through the app), but the surrounding `tap`/`wait until` +# steps around them still require N-2. The `deliver signal` verb has no +# native-app equivalent at all yet — this app has no probe-agent socket +# bridge, so its Signal Demo screen always takes the same fallback path the +# Flutter twin uses when PROBE_AGENT is unset (see SignalStore in +# Sources/App/Stores.swift). Treat those two tests as an additional, +# separate gap beyond N-2. +# ============================================================================ + +test "home screen shows all navigation tiles" + open the app + wait until "Welcome to the FlutterProbe Test App" appears + see "FlutterProbe Test App" + see "Welcome to the FlutterProbe Test App" + see "Version 1.0" + see "Login" + see "Biometric Login" + see "Signal Demo" + see "Dashboard" + see "Settings" + see "Items" + see "Gestures" + see "API Tests" + see "Device" + see "Visual" + see "Dynamic" + +test "bottom tab bar switches content" + open the app + tap #tab_tests + see "Test Suites" + tap #tab_about + see "About FlutterProbe Test App" + tap #tab_home + see "Welcome to the FlutterProbe Test App" + +test "login with valid credentials reaches dashboard" + open the app + tap "Login" + wait until "Sign In" appears + type "test@test.com" into #email_field + type "password" into #password_field + tap #sign_in_button + wait until "Dashboard" appears + see "Welcome, Test User" + +test "login with invalid credentials shows error" + open the app + tap "Login" + wait until "Sign In" appears + type "wrong@test.com" into #email_field + type "badpass" into #password_field + tap #sign_in_button + wait until "Invalid email or password" appears + see "Invalid email or password" + +test "biometric match unlocks the app" + before all tests: enroll biometric + open the app + tap "Biometric Login" + wait until "Sign in with Face ID" appears + tap "Sign in with Face ID" + biometric match + wait until "Dashboard" appears + see "Welcome, Biometric User" + +test "biometric no-match shows error banner" + before all tests: enroll biometric + open the app + tap "Biometric Login" + wait until "Sign in with Face ID" appears + tap "Sign in with Face ID" + biometric no match + wait until "Authentication failed" appears + see "Sign in with Face ID" + +test "dashboard reachable without auth shows Guest" + open the app + tap "Dashboard" + see "Welcome, Guest" + see "Tests Run" + see "42" + see "Passed" + see "38" + tap #item_0 + see "Tapped item 1" + tap #refresh_button + see "Refreshed" + tap #logout_button + see "FlutterProbe Test App" + +test "settings toggles and dropdown" + open the app + tap "Settings" + see "Dark Mode" + tap #dark_mode_toggle + tap #notifications_toggle + tap #terms_checkbox + see "Build: 1.0.42+dev" + tap #save_button + see "Settings saved" + +test "items search and delete" + open the app + tap "Items" + see "Item 0" + type "Item 5" into #search_field + wait 1 seconds + clear #search_field + swipe left on #dismissible_0 + tap #fab_add + see "Add item tapped" + +test "gestures double tap and drag" + open the app + tap "Gestures" + see "Double taps: 0" + double tap #double_tap_area + see "Double taps: 1" + long press #long_press_area + see "Copy" + see "Delete" + see "Drop" + drag #drag_source to #drag_target + see "Done!" + swipe right + see "Card dismissed!" + +test "api demo fetch and post" + open the app + tap "API Tests" + tap #fetch_users_button + wait until "Leanne Graham" appears + tap #create_post_button + wait until "Post created with id: 101" appears + +test "device permissions and clipboard" + open the app + scroll down + tap "Device" + see "Permissions" + tap #request_camera + see "Camera: Granted" + tap #request_location + see "Location: 37.7749, -122.4194" + copy "Hello World" to clipboard + tap #paste_button + see "Hello World" + +test "visual regression baseline" + open the app + scroll down + tap "Visual" + see "Visual Test" + see "Red" + see "Green" + see "Blue" + see "Yellow" + see "Count: 0" + tap #increment_button + see "Count: 1" + take screenshot "native_visual_baseline" + +test "dynamic countdown and repeat action" + open the app + scroll down + tap "Dynamic" + see "Tapped: 0" + repeat 3 times + tap #repeat_action + see "Tapped: 3" + tap #trigger_error + see "Something went wrong!" + tap "OK" + +# --- Not portable even after N-2: no signal-delivery bridge exists yet --- +# test "signal demo push permission" +# open the app +# tap #nav_signal +# tap "Request Push Permission" +# see "Waiting for permission…" +# deliver signal "push_permission" +# see "Notifications enabled" diff --git a/native/ios-swift/project.yml b/native/ios-swift/project.yml new file mode 100644 index 0000000..bd98dd7 --- /dev/null +++ b/native/ios-swift/project.yml @@ -0,0 +1,57 @@ +name: ProbeNativeTwin +options: + bundleIdPrefix: com.alphawavesystems + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +targets: + ProbeNativeTwin: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - Sources + info: + path: Sources/Info.plist + properties: + CFBundleDisplayName: Probe Native Twin + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" + UILaunchScreen: {} + NSFaceIDUsageDescription: "Biometric login test screen uses Face ID." + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.alphawavesystems.probeTestApp.native + PRODUCT_NAME: ProbeNativeTwin + TARGETED_DEVICE_FAMILY: "1" + SWIFT_VERSION: "5.9" + CODE_SIGN_STYLE: Automatic + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + ProbeNativeTwinUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "17.0" + sources: + - UITests + dependencies: + - target: ProbeNativeTwin + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.alphawavesystems.probeTestApp.native.uitests + SWIFT_VERSION: "5.9" + CODE_SIGN_STYLE: Automatic + TEST_TARGET_NAME: ProbeNativeTwin +schemes: + ProbeNativeTwin: + build: + targets: + ProbeNativeTwin: all + ProbeNativeTwinUITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - ProbeNativeTwinUITests