Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions native/ios-swift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build/
*.xcodeproj/
Sources/Info.plist
40 changes: 40 additions & 0 deletions native/ios-swift/Sources/App/AppRoute.swift
Original file line number Diff line number Diff line change
@@ -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 = []
}
}
10 changes: 10 additions & 0 deletions native/ios-swift/Sources/App/ProbeNativeTwinApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

@main
struct ProbeNativeTwinApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
49 changes: 49 additions & 0 deletions native/ios-swift/Sources/App/RootView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
208 changes: 208 additions & 0 deletions native/ios-swift/Sources/App/Stores.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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
}
}
Loading
Loading