
Oma Mobile
- 125 installs
- 41 repo stars
- Updated August 4, 2026
- gracefullight/stock-checker
Implement and refine OMA mobile screens, navigation, and stock-check UX for the stock-checker app on iOS and Android targets.
About
Provides OMA mobile development guidance for the stock-checker project, covering screen structure, stock lookup interactions, and client patterns that connect cleanly to orchestrated backend and agent workflows.
- OMA mobile patterns
- Stock list UX
- Device navigation
- Form and scan flows
- Client state handling
Oma Mobile by the numbers
- 125 all-time installs (skills.sh)
- Ranked #542 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gracefullight/stock-checker --skill oma-mobileAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 125 |
|---|---|
| repo stars | ★ 41 |
| Last updated | August 4, 2026 |
| Repository | gracefullight/stock-checker ↗ |
What it does
Implement and refine OMA mobile screens, navigation, and stock-check UX for the stock-checker app on iOS and Android targets.
Files
Mobile Agent - Cross-Platform Mobile Specialist
Scheduling
Goal
Build, modify, and verify cross-platform mobile application features with clean architecture, platform-appropriate UI, state management, performance, and E2E coverage.
Intent signature
- User asks for mobile app, Flutter, Dart, React Native, iOS, Android, Riverpod, widgets, camera, GPS, push notifications, or offline-first work.
- User needs native or cross-platform mobile behavior rather than web frontend work.
When to use
- Building native mobile applications (iOS + Android)
- Mobile-specific UI patterns
- Platform features (camera, GPS, push notifications)
- Offline-first architecture
When NOT to use
- Web frontend -> use Frontend Agent
- Backend APIs -> use Backend Agent
Expected inputs
- Target screen, widget, feature, platform capability, or mobile flow
- Existing app architecture, state management pattern, API contract, and platform constraints
- Test expectations for unit, widget, integration, or Maestro E2E coverage
Expected outputs
- Mobile code changes in domain, data, presentation, platform, or test files
- UI aligned with Material Design 3 and iOS HIG as applicable
- Verification results from mobile checks and critical-flow tests
Dependencies
- Flutter/Dart or React Native toolchain as detected from the project
- Riverpod/Bloc, Dio, platform SDKs, and Maestro where applicable
resources/execution-protocol.md, examples, snippets, checklist, and screen template
Control-flow features
- Branches by platform, state management pattern, offline requirement, native permission, and test level
- Reads and writes mobile codebase files
- May call build, test, simulator, emulator, or E2E commands
Structural Flow
Entry
1. Identify target platform(s), screen/feature, architecture layer, and state boundary. 2. Inspect existing mobile patterns and dependencies. 3. Determine test level and verification environment.
Scenes
1. PREPARE: Load app architecture, platform constraints, and acceptance criteria. 2. ACQUIRE: Read existing widgets/screens, providers/blocs, API clients, and tests. 3. ACT: Implement mobile UI, state, platform integration, offline handling, and tests. 4. VERIFY: Run relevant unit/widget/integration/E2E checks. 5. FINALIZE: Report behavior, platforms covered, and verification results.
Transitions
- If business logic is complex, keep it in domain/data layers before presentation.
- If network calls are needed, use Dio with interceptors and offline handling.
- If a critical user flow changes, add or update Maestro E2E coverage.
- If backend contracts are missing, coordinate with backend/API work.
Failure and recovery
- If platform SDK or emulator is unavailable, report verification limits.
- If a permission or native capability is missing, add explicit platform configuration or document blocker.
- If tests fail, fix before handoff or report the failing check.
Exit
- Success: mobile feature works for target platforms and passes relevant checks.
- Partial success: platform, simulator, dependency, or verification gaps are explicit.
Logical Operations
Actions
| Action | SSL primitive | Evidence |
|---|---|---|
| Inspect mobile architecture | READ | Domain/data/presentation files |
| Select state and platform strategy | SELECT | Riverpod/Bloc and platform constraints |
| Implement mobile code | WRITE | Widgets, screens, providers, clients |
| Validate lifecycle and permissions | VALIDATE | Dispose, permissions, offline behavior |
| Call verification tools | CALL_TOOL | Tests, builds, Maestro |
| Report result | NOTIFY | Final summary |
Tools and instruments
- Flutter/Dart or React Native stack
- Riverpod/Bloc, Dio, platform SDKs, Maestro
- Unit, widget, integration, and E2E test commands
Canonical workflow path
rg --files
rg "Riverpod|Bloc|Dio|Widget|Maestro|dispose\\(|permission" .Then run the project's mobile verification commands, typically unit/widget tests and Maestro E2E for critical flows.
Resource scope
| Scope | Resource target |
|---|---|
CODEBASE | Mobile source, tests, platform config |
LOCAL_FS | Templates, snippets, resources |
PROCESS | Build, test, emulator, simulator, E2E commands |
NETWORK | Backend APIs when the feature integrates remotely |
Preconditions
- Target mobile feature and platform scope are identifiable.
- Required SDKs, permissions, and API contracts are available or assumptions are stated.
Effects and side effects
- Mutates mobile source, tests, and platform configuration.
- May affect permissions, app lifecycle, offline data, or performance.
Guardrails
1. Clean Architecture: domain -> data -> presentation 2. Riverpod/Bloc for state management (no raw setState for complex logic) 3. Material Design 3 (Android) + iOS HIG (iOS) 4. All controllers disposed in dispose() method 5. Dio with interceptors for API calls; handle offline gracefully 6. 60fps target; test on both platforms 7. Use Maestro for E2E testing of critical user flows 8. Swift native: SwiftUI + @Observable (Observation framework, iOS 17+) for state management 9. Swift native: use the generated Client from swift-openapi-generator — never hand-roll URLRequest/JSONDecoder for API calls 10. Swift native: follow App/Core/Features/Shared project layout 11. Swift native: iOS Human Interface Guidelines for all UI decisions 12. Swift native: XCTest/XCUITest for critical flows; cancel Task in deinit to prevent leaks
References
Follow resources/execution-protocol.md step by step. See resources/examples.md for input/output examples. Before submitting, run resources/checklist.md. Vendor-specific execution protocols are injected automatically by oma agent:spawn. Source files live under ../_shared/runtime/execution-protocols/{vendor}.md.
- Execution steps:
resources/execution-protocol.md - Code examples:
resources/examples.md - Code snippets (Flutter/RN):
resources/snippets.md - Code snippets (Swift):
variants/swift-ios/snippets.md - Checklist:
resources/checklist.md - Error recovery:
resources/error-playbook.md - Tech stack (Flutter/RN):
resources/tech-stack.md - Tech stack (Swift):
variants/swift-ios/tech-stack.md - Screen template (Flutter):
resources/screen-template.dart - Screen template (Swift):
resources/screen-template.swift - API service template (Swift):
variants/swift-ios/api-template.swift - Variant registry:
variants/README.md - Context loading:
../_shared/core/context-loading.md - Reasoning templates:
../_shared/core/reasoning-templates.md - Clarification:
../_shared/core/clarification-protocol.md - Context budget:
../_shared/core/context-budget.md - Lessons learned:
../_shared/core/lessons-learned.md - Observability handoff:
../oma-observability/SKILL.md§Integrations — offline queuing, crash analytics, battery-aware sampling
Mobile Agent - Self-Verification Checklist
Run through every item before submitting your work.
Architecture
- [ ] Clean Architecture layers: domain -> data -> presentation
- [ ] Entities are pure Dart (no framework dependencies)
- [ ] Repository pattern with interface + implementation
- [ ] Riverpod/Bloc for state management (no setState in complex widgets)
Platform
- [ ] Material Design 3 for Android
- [ ] iOS Human Interface Guidelines followed
- [ ] Platform-specific code guarded with
Platform.isIOS/Platform.isAndroid - [ ] Tested on both iOS and Android (emulator or device)
- [ ] Dark mode supported
Performance
- [ ] 60fps scrolling (no jank)
- [ ] Controllers disposed in
dispose()method - [ ] No memory leaks (listeners, subscriptions cleaned up)
- [ ] Images cached and sized appropriately
- [ ] Cold start < 2s
API Integration
- [ ] Dio with interceptors (auth, error handling)
- [ ] Loading states shown during API calls
- [ ] Error states with retry action
- [ ] Offline handling (graceful degradation or offline-first)
Testing
- [ ] Unit tests for domain logic and providers
- [ ] Widget tests for key screens
- [ ] E2E tests with Maestro for critical user flows
- [ ] Edge cases: empty lists, error states, offline mode
- [ ] Tests pass on both platforms
Swift Native (iOS)
Applies when the project is Swift native (Package.swift/.xcodeprojpresent). Skip for Flutter/RN.
- [ ]
App/Core/Features/Sharedlayout respected (App = entry/DI, Core = networking/generated client, Features = view+@ObservableVM slices, Shared = reusable UI/util) - [ ] API access goes through the generated
Clientfromswift-openapi-generator— no hand-rolledURLRequest/JSONDecoderfor spec-covered endpoints - [ ] OpenAPI document present at
Core/Networking/openapi.yamland synced from the backend before build - [ ] SwiftUI state via
@Observable(Observation framework);Tasks cancelled indeinitto avoid leaks - [ ] Loading / error (with retry) / empty / data states handled in views
- [ ] iOS Human Interface Guidelines followed
- [ ]
swift buildsucceeds (runs the generator plugin) andswift testpasses - [ ] XCTest/XCUITest coverage for critical flows
Mobile Agent - Error Recovery Playbook
When you encounter a failure, find the matching scenario and follow the recovery steps. Do NOT stop or ask for help until you have exhausted the playbook.
---
Dart Analysis Error
Symptoms: flutter analyze errors, type mismatch, null safety issues
1. Read the error: file, line, expected type vs actual 2. Null safety issue: add ? for nullable, ! only if you're certain it's non-null 3. Type mismatch: check the model class to see if it matches the API response 4. Missing override: add @override annotation 5. NEVER do this: // ignore: comments to suppress analysis warnings
---
Build Failure
Symptoms: flutter build fails, Gradle/Xcode errors
1. Gradle (Android):
Could not resolve: dependency version conflict → checkpubspec.yamlminSdkVersion: updateandroid/app/build.gradleminimum SDK
2. Xcode (iOS):
Pod installfailure: note in result; may needpod repo update- Minimum deployment target: check
ios/Podfile
3. Clean and retry: flutter clean && flutter pub get 4. If persists: note in result with full error; may be an environment issue
---
Test Failure
Symptoms: flutter test FAILED, widget test assertion errors
1. Read the error: which test, which widget, expected vs actual 2. Widget test: check if pumpAndSettle() is needed (async operations) 3. Provider not found: wrap test widget with ProviderScope (Riverpod) 4. Mock missing: ensure all dependencies are mocked 5. Re-run specific test: flutter test test/path/to_test.dart 6. After 3 failures: Try a different approach
---
State Management Issue
Symptoms: UI not updating, stale state, provider errors
1. Riverpod: Check provider type (StateNotifierProvider vs FutureProvider vs AsyncNotifierProvider) 2. Is the widget watching correctly? (ref.watch not ref.read for UI) 3. Is the state being mutated instead of replaced? (create new state object) 4. Add debug print in provider to trace state changes 5. Check: is dispose being called prematurely?
---
Platform-Specific Crash
Symptoms: Works on one platform, crashes on another
1. Check for Platform.isIOS / Platform.isAndroid guards 2. Check permissions: camera, location, storage (different per platform) 3. Check native plugin compatibility; some plugins don't support both platforms 4. If plugin issue: note in result with platform and version info 5. Test on emulator for the failing platform
---
Memory Leak
Symptoms: App slows down over time, flutter run shows increasing memory
1. Check: are all controllers disposed? (TextEditingController, AnimationController) 2. Check: are streams closed? (StreamSubscription.cancel()) 3. Check: are listeners removed? (removeListener in dispose) 4. Check: are Timer / Timer.periodic cancelled? 5. Use DevTools memory tab to identify leak source
---
API Integration Error
Symptoms: Dio errors, DioException, wrong response parsing
1. Connection refused: backend running? correct URL/port? 2. 401: auth interceptor sending token? token expired? 3. Parse error: response.data shape doesn't match model → log raw response 4. Timeout: increase Dio timeout or check network conditions 5. If backend issue: document expected contract in result
---
Rate Limit / Quota / Memory Fallback
Same as the backend playbook: see ../../oma-backend/resources/error-playbook.md §"Rate Limit / Quota Error (Gemini API)" and §"Serena Memory Unavailable".
---
Swift: swift build Fails — Missing or Invalid OpenAPI Document
Symptoms: Build error from the swift-openapi-generator plugin such as error: openapi.yaml not found or YAML parse error; the build fails before any Swift file is compiled.
1. Confirm the spec exists at Core/Networking/openapi.yaml — this is where the build plugin looks. 2. If the file is absent: re-sync it from the backend (curl -o Sources/Core/Networking/openapi.yaml https://<backend>/api-docs/openapi.yaml or copy from the CI artifact). The iOS project is a consumer of the spec; never edit openapi.yaml directly. 3. If the file is present but the error is a parse error: validate the YAML (python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" Sources/Core/Networking/openapi.yaml); fix the upstream spec and re-sync. 4. After syncing, swift build will regenerate Client, Operations, and Components automatically — no manual code-gen step needed. 5. NEVER: commit a placeholder or empty openapi.yaml just to silence the build; it will produce an incomplete Client and cause compile errors downstream.
---
Swift: Generated Client/Operations Symbols Not Found
Symptoms: Compile errors such as cannot find type 'Client' in scope or use of unresolved identifier 'Operations'; the generator plugin appears not to have run.
1. Confirm the build plugin is attached in Package.swift:
- The target must list
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")under itsplugins:key. - The target's dependencies must include
OpenAPIRuntimeandOpenAPIURLSession.
2. Confirm openapi-generator-config.yaml exists in the same directory as openapi.yaml (Core/Networking/). Without this file the plugin silently skips generation. 3. Check that both config files are inside the target's declared source directory (the directory that contains the .target in Package.swift). Files outside the source root are invisible to the build plugin. 4. Run swift package clean && swift build to force a full regeneration. 5. If Xcode is used: Product → Clean Build Folder, then build again. Derived data caching sometimes hides generation failures.
---
Swift: Compile Error for Undocumented Response Case
Symptoms: Compile error such as expression pattern of type 'Operations.CreateTodo.Output.conflict' cannot match values of type '...'; a response case (e.g., .conflict, .created, .unprocessableContent) referenced in the Swift code does not exist on the generated Output enum.
1. The root cause is that the OpenAPI spec does not declare that HTTP status code for the operation. The generator only emits cases for status codes listed in the spec. 2. Do NOT add the case manually to the generated file — it will be overwritten on the next swift build. 3. Ask the backend team to add the missing status code to the spec (e.g., add 409: description: Conflict under the operation's responses:). 4. Re-sync Core/Networking/openapi.yaml once the spec is updated. 5. Run swift build to regenerate; the new response case will appear and the compile error will resolve. 6. Update the Swift switch in the service layer to handle the new case explicitly.
---
Swift: @Observable State Not Updating the SwiftUI View
Symptoms: Mutating a property on the view model has no visual effect; the view does not re-render even though the value changed.
1. Confirm the view model class is annotated with @Observable (Observation framework, not ObservableObject). Without the macro the view has no tracking infrastructure. 2. Check the deployment target: @Observable requires iOS 17.0+. If the target is set to iOS 16 or earlier the macro compiles but observation tracking is absent — raise the minimum deployment target in Package.swift (.iOS(.v17)) and the Xcode project settings. 3. Verify the view holds the VM with @State (not a plain let or var): @State private var viewModel: MyViewModel. A non-@State reference is not tracked by SwiftUI. 4. If the VM is passed into a child view that needs to mutate it, use @Bindable in the child: @Bindable var viewModel: MyViewModel. Passing it as a plain argument prevents change propagation. 5. Confirm properties are mutated on the main actor. Mutations from a background Task without await MainActor.run { … } (or @MainActor on the method) can update state off-main and miss the SwiftUI render pass. 6. Avoid wrapping the @Observable class in another @Published property or ObservableObject — mixing the two observation systems breaks change tracking.
---
General Principles
- After 3 failures: If same approach fails 3 times, must try a different method
- Blocked: If no progress after 5 turns, save current state,
Status: blocked - Out of scope: Backend/frontend issues; only record in result
Mobile Agent - Examples
Example 1: List Screen with CRUD
Input: "Create a todo list screen with add, toggle, and swipe-to-delete"
Output:
## Task: Todo List Screen
### Implementation
- Screens: TodoListScreen, AddTodoSheet (bottom sheet)
- State: Riverpod with AsyncNotifier for todo list
- API: Dio with auth interceptor -> /api/todos
### Platform Support
- [x] iOS (14.0+) - Cupertino swipe actions
- [x] Android (API 24+) - Material Dismissible
- [x] Dark mode
### Files Created
- lib/features/todo/domain/entities/todo.dart
- lib/features/todo/domain/repositories/todo_repository.dart
- lib/features/todo/data/models/todo_model.dart
- lib/features/todo/data/repositories/todo_repository_impl.dart
- lib/features/todo/presentation/providers/todo_provider.dart
- lib/features/todo/presentation/screens/todo_list_screen.dart
- lib/features/todo/presentation/widgets/todo_item.dart
- test/features/todo/todo_provider_test.dart
- test/features/todo/todo_list_screen_test.dartExample 2: Authentication Flow
Input: "Create login and register screens with JWT auth"
Output:
## Task: Authentication Screens
### Implementation
- Screens: LoginScreen, RegisterScreen
- State: Riverpod AuthNotifier (token storage via flutter_secure_storage)
- Navigation: GoRouter redirect guard for unauthenticated users
- Validation: Form validation for email + password
### Files Created
- lib/core/auth/auth_provider.dart
- lib/core/auth/auth_interceptor.dart (Dio interceptor)
- lib/features/auth/presentation/screens/login_screen.dart
- lib/features/auth/presentation/screens/register_screen.dart
- lib/features/auth/presentation/widgets/auth_form.dart
- test/core/auth/auth_provider_test.dartExample 3: Offline-First Feature
Input: "Make the notes feature work offline with sync"
Output:
## Task: Offline Notes with Sync
### Implementation
- Local DB: Drift (SQLite) for offline storage
- Sync: Queue-based sync on connectivity change
- UI: Sync status indicator (synced/pending/error)
### Architecture
- NoteLocalDataSource (Drift) + NoteRemoteDataSource (Dio)
- NoteRepository merges local + remote with conflict resolution
- ConnectivityProvider triggers sync on reconnect
### Files Created
- lib/features/notes/data/local/note_dao.dart
- lib/features/notes/data/local/note_database.dart
- lib/features/notes/data/repositories/note_sync_repository.dart
- lib/core/sync/sync_queue.dart---
Example 4: Swift Native iOS — Todo List Screen
Platform: Swift iOS Native (SwiftUI + @Observable + swift-openapi-generator)
Input: "Create a native iOS todo list screen that fetches todos from the API and supports pull-to-refresh"
Output:
## Task: Swift Native Todo List Screen (iOS 17+)
### Implementation
- State: @Observable TodosViewModel with TodosViewState enum (idle/loading/loaded/empty/error)
- Networking: TodoService wrapping the generated Client from swift-openapi-generator
- View: SwiftUI TodosView switching over TodosViewState; pull-to-refresh via .refreshable
- Tests: XCTest unit tests for the view model using a protocol-based mock service
### Platform Support
- [x] iOS 17.0+ (Observation framework requires iOS 17)
- [x] Dark mode (SwiftUI adaptive colors)
- [x] Dynamic Type (List / Label respect system font scaling)
### Project Layout (App/Core/Features/Shared)
Features/
Todos/
TodosView.swift # SwiftUI view — owns the @Observable VM via @State
TodosViewModel.swift # @Observable; drives loading/error/empty/data states
Core/
Networking/
openapi.yaml # Vendored OpenAPI spec; source of truth for generator
openapi-generator-config.yaml
APIClient.swift # Wraps generated Client; URLSession transport + auth
TodoService.swift # Typed wrapper around generated client.listTodos()
App/
MyApp.swift # @main; instantiates AppDependencies
AppDependencies.swift # Composition root; injects TodoService into TodosView
Tests/
TodosViewModelTests.swift # XCTest; MockTodoService via subclass/protocol override
### Files Created
- Sources/Features/Todos/TodosViewModel.swift
- Sources/Features/Todos/TodosView.swift
- Sources/Core/Networking/TodoService.swift
- Sources/Core/Networking/APIClient.swift
- Sources/App/AppDependencies.swift
- Tests/TodosViewModelTests.swift
### Key Patterns
- @Observable replaces ObservableObject/@Published — no Combine dependency
- View holds VM with @State (not @StateObject); init via State(wrappedValue:)
- .task { viewModel.load() } cancels automatically when view disappears
- TodoService calls the generated client.listTodos() — never hand-rolled URLRequest
- deinit { loadTask?.cancel() } prevents Task leaks when VM is deallocatedMobile Agent - Execution Protocol
Step 0: Prepare
1. Assess difficulty: see ../../_shared/core/difficulty-guide.md
- Simple: Skip to Step 3 | Medium: All 4 steps | Complex: All steps + checkpoints
2. Check lessons: read your domain section in ../../_shared/core/lessons-learned.md 3. Clarify requirements: follow ../../_shared/core/clarification-protocol.md
- Check Uncertainty Triggers: business logic, security/auth, existing code conflicts?
- Determine level: LOW → proceed | MEDIUM → present options | HIGH → ask immediately
4. Budget context: follow ../../_shared/core/context-budget.md (read symbols, not whole files)
Intelligent Escalation: When uncertain, escalate early. Don't blindly proceed.
Follow these steps in order (adjust depth by difficulty).
Step 1: Analyze
- Read the task requirements carefully
- Identify target platform: check for
Package.swift(Swift iOS),pubspec.yaml(Flutter), orpackage.json+react-nativedep (React Native) - If Swift (Package.swift detected): identify which
Features/modules are affected; check forCore/Networking/openapi.yaml - If Flutter: identify screens, widgets, and Riverpod/Bloc providers
- Check existing code with Serena:
get_symbols_overview("Sources/Features")(Swift) orget_symbols_overview("lib/features")(Flutter) - Determine platform-specific requirements (iOS HIG vs Material Design 3)
- List assumptions; ask if unclear
Step 2: Plan
- Swift: plan using
App/Core/Features/Sharedlayers; define the@Observableview model state enum; identify whichOperations+Componentstypes the feature needs from the generatedClient - Flutter: decide on feature structure using Clean Architecture; define entities (domain) and repository interfaces; plan state management (Riverpod providers); identify navigation routes (GoRouter)
- Plan offline-first strategy if required
- Note platform differences (iOS HIG vs Material Design 3)
Step 3: Implement
- Create/modify files in this order:
1. Domain: entities and repository interfaces 2. Data: models, API clients (Dio), repository implementations 3. Presentation: providers (Riverpod), screens, widgets 4. Navigation: GoRouter routes 5. Tests: unit + widget tests
- Use
resources/screen-template.dartas reference - Follow Clean Architecture layers strictly
Step 4: Verify
- Run
resources/checklist.mditems - Run
../../_shared/core/common-checklist.mditems - Test on both iOS and Android (or emulators)
- Verify 60fps performance (no jank)
- Check dark mode support
On Error
See resources/error-playbook.md for recovery steps.
/**
* Screen Template for Mobile Agent (Flutter)
*
* This template demonstrates best practices for Flutter screens.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Provider definition (in separate file: providers/example_providers.dart)
/*
final exampleProvider = FutureProvider<List<ExampleModel>>((ref) async {
final repository = ref.watch(exampleRepositoryProvider);
return repository.fetchData();
});
*/
/// Example screen demonstrating common patterns
class ExampleScreen extends ConsumerStatefulWidget {
/// Route name for navigation
static const routeName = '/example';
/// Constructor
const ExampleScreen({super.key});
@override
ConsumerState<ExampleScreen> createState() => _ExampleScreenState();
}
class _ExampleScreenState extends ConsumerState<ExampleScreen> {
// Local state (if needed)
final _scrollController = ScrollController();
bool _showScrollToTop = false;
@override
void initState() {
super.initState();
_setupScrollListener();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _setupScrollListener() {
_scrollController.addListener(() {
final shouldShow = _scrollController.offset > 200;
if (shouldShow != _showScrollToTop) {
setState(() => _showScrollToTop = shouldShow);
}
});
}
@override
Widget build(BuildContext context) {
// Watch providers
final dataAsync = ref.watch(exampleProvider);
return Scaffold(
// App bar
appBar: AppBar(
title: const Text('Example Screen'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(exampleProvider),
tooltip: 'Refresh',
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: _navigateToSettings,
tooltip: 'Settings',
),
],
),
// Body with async handling
body: dataAsync.when(
// Success state
data: (items) => _buildContent(items),
// Loading state
loading: () => const Center(
child: CircularProgressIndicator(),
),
// Error state
error: (error, stackTrace) => _buildErrorState(error),
),
// Floating action button
floatingActionButton: _showScrollToTop
? FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
/// Builds main content
Widget _buildContent(List<dynamic> items) {
if (items.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: _handleRefresh,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${items.length} Items',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
// List
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[index];
return _buildListItem(item, index);
},
childCount: items.length,
),
),
],
),
);
}
/// Builds individual list item
Widget _buildListItem(dynamic item, int index) {
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text(item.title ?? 'Untitled'),
subtitle: Text(item.description ?? ''),
trailing: IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () => _navigateToDetail(item),
),
onTap: () => _navigateToDetail(item),
),
);
}
/// Builds empty state
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 16),
Text(
'No items yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Add your first item to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddDialog,
icon: const Icon(Icons.add),
label: const Text('Add Item'),
),
],
),
);
}
/// Builds error state
Widget _buildErrorState(Object error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Oops! Something went wrong',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => ref.invalidate(exampleProvider),
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
),
),
);
}
// Event handlers
Future<void> _handleRefresh() async {
await ref.refresh(exampleProvider.future);
}
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
}
void _navigateToDetail(dynamic item) {
Navigator.of(context).pushNamed(
'/detail',
arguments: item,
);
}
void _navigateToSettings() {
Navigator.of(context).pushNamed('/settings');
}
void _showAddDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Item'),
content: TextField(
decoration: const InputDecoration(
labelText: 'Title',
hintText: 'Enter title',
),
onSubmitted: (value) {
// Handle submission
Navigator.of(context).pop();
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
// Handle add
Navigator.of(context).pop();
},
child: const Text('Add'),
),
],
),
);
}
}
// Placeholder provider (move to separate file)
final exampleProvider = FutureProvider<List<dynamic>>((ref) async {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
return [];
});
/**
* Screen Template for Mobile Agent (Swift iOS Native)
*
* Demonstrates best practices for SwiftUI screens:
* - @Observable view model with explicit loading/error/empty/data states
* - .task modifier for async data loading (auto-cancelled on disappear)
* - Retry action wired to the view model
* - NavigationStack integration
* - iOS HIG-aligned layout
*/
import SwiftUI
import Observation
// MARK: - View Model
/// All possible display states for the example screen.
enum ExampleViewState {
case idle
case loading
case loaded([ExampleItem])
case empty
case error(String)
}
/// A plain value type representing one row in the list.
struct ExampleItem: Identifiable {
let id: String
let title: String
let subtitle: String
}
/// Protocol-backed so the view model is testable with a mock service.
protocol ExampleServiceProtocol {
func fetchItems() async throws -> [ExampleItem]
}
@Observable
final class ExampleViewModel {
// MARK: - State observed by the View
/// Current display state. The View switches on this value.
var viewState: ExampleViewState = .idle
// MARK: - Private
private let service: ExampleServiceProtocol
/// Retained so it can be cancelled before re-triggering a load.
private var loadTask: Task<Void, Never>?
init(service: ExampleServiceProtocol) {
self.service = service
}
// MARK: - Intents (called by the View)
/// Starts (or restarts) data loading. Safe to call multiple times.
func load() {
// Cancel any in-flight request before starting a fresh one.
loadTask?.cancel()
viewState = .loading
loadTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await service.fetchItems()
guard !Task.isCancelled else { return }
viewState = items.isEmpty ? .empty : .loaded(items)
} catch is CancellationError {
// Ignore — another load is replacing this one.
} catch {
viewState = .error(error.localizedDescription)
}
}
}
/// Convenience retry; identical to load() but named for the error-state button.
func retry() { load() }
deinit {
// Ensure in-flight work is cleaned up when the VM is deallocated.
loadTask?.cancel()
}
}
// MARK: - View
struct ExampleScreen: View {
// The View owns the view model via @State so it is scoped to this screen.
@State private var viewModel: ExampleViewModel
init(service: ExampleServiceProtocol) {
// Wrap in State so @Observable tracking works correctly in SwiftUI.
_viewModel = State(wrappedValue: ExampleViewModel(service: service))
}
var body: some View {
NavigationStack {
content
.navigationTitle("Example")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.load()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
// Disable the button while a load is in progress.
.disabled({
if case .loading = viewModel.viewState { return true }
return false
}())
}
}
}
// .task is preferred over .onAppear for async work:
// it creates a structured Task that is cancelled when the View disappears.
.task { viewModel.load() }
}
// MARK: - Content switch
/// Switches over the view model's state and renders the appropriate sub-view.
@ViewBuilder
private var content: some View {
switch viewModel.viewState {
case .idle, .loading:
loadingView
case .loaded(let items):
listView(items)
case .empty:
emptyView
case .error(let message):
errorView(message: message)
}
}
// MARK: - State sub-views
/// Shown while the first load is in progress.
private var loadingView: some View {
ProgressView("Loading…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Shown when data is available.
private func listView(_ items: [ExampleItem]) -> some View {
List(items) { item in
NavigationLink {
// Replace with a real detail screen.
Text(item.title)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
// Pull-to-refresh triggers a fresh load.
.refreshable { viewModel.load() }
}
/// Shown when the API returns an empty collection.
private var emptyView: some View {
ContentUnavailableView(
"Nothing Here",
systemImage: "tray",
description: Text("There are no items yet. Create one to get started.")
)
}
/// Shown when the load fails. Includes a labelled retry button.
private func errorView(message: String) -> some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
Text("Something went wrong")
.font(.title2)
.bold()
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
viewModel.retry()
} label: {
Label("Try Again", systemImage: "arrow.clockwise")
.padding(.horizontal, 8)
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
// MARK: - Preview
#Preview {
// Provide a lightweight stub for canvas previews.
struct PreviewService: ExampleServiceProtocol {
func fetchItems() async throws -> [ExampleItem] {
try? await Task.sleep(nanoseconds: 500_000_000)
return [
ExampleItem(id: "1", title: "First Item", subtitle: "Detail about the first item"),
ExampleItem(id: "2", title: "Second Item", subtitle: "Detail about the second item"),
]
}
}
return ExampleScreen(service: PreviewService())
}
Mobile Agent - Code Snippets
Copy-paste ready patterns. Use these as starting points, adapt to the specific task.
---
Riverpod AsyncNotifier
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'todo_provider.g.dart';
@riverpod
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
return repository.fetchAll();
}
Future<void> add(String title) async {
final repository = ref.read(todoRepositoryProvider);
await repository.create(title);
ref.invalidateSelf();
}
Future<void> toggle(String id) async {
final repository = ref.read(todoRepositoryProvider);
await repository.toggle(id);
ref.invalidateSelf();
}
Future<void> delete(String id) async {
final repository = ref.read(todoRepositoryProvider);
await repository.delete(id);
ref.invalidateSelf();
}
}---
Screen with AsyncValue
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TodoListScreen extends ConsumerWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListProvider);
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: todosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $error'),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(todoListProvider),
child: const Text('Retry'),
),
],
),
),
data: (todos) => todos.isEmpty
? const Center(child: Text('No todos yet'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => TodoItem(todo: todos[index]),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
);
}
}---
Repository Pattern
abstract class TodoRepository {
Future<List<Todo>> fetchAll();
Future<Todo> create(String title);
Future<void> toggle(String id);
Future<void> delete(String id);
}
class TodoRepositoryImpl implements TodoRepository {
final Dio _dio;
TodoRepositoryImpl(this._dio);
@override
Future<List<Todo>> fetchAll() async {
final response = await _dio.get('/api/todos');
return (response.data as List).map((e) => Todo.fromJson(e)).toList();
}
@override
Future<Todo> create(String title) async {
final response = await _dio.post('/api/todos', data: {'title': title});
return Todo.fromJson(response.data);
}
@override
Future<void> toggle(String id) async {
await _dio.patch('/api/todos/$id/toggle');
}
@override
Future<void> delete(String id) async {
await _dio.delete('/api/todos/$id');
}
}---
Dio with Auth Interceptor
class AuthInterceptor extends Interceptor {
final Ref _ref;
AuthInterceptor(this._ref);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _ref.read(authProvider).accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
_ref.read(authProvider.notifier).logout();
}
handler.next(err);
}
}---
GoRouter Config
final routerProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authProvider);
return GoRouter(
redirect: (context, state) {
final isLoggedIn = auth.isAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
if (isLoggedIn && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/todos', builder: (_, __) => const TodoListScreen()),
],
);
});---
Entity (freezed)
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo.freezed.dart';
part 'todo.g.dart';
@freezed
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
@Default(false) bool completed,
required DateTime createdAt,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}---
Widget Test
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('TodoListScreen shows loading then data', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
todoListProvider.overrideWith(() => MockTodoList()),
],
child: const MaterialApp(home: TodoListScreen()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Test Todo'), findsOneWidget);
});
}Mobile Agent - Tech Stack Reference
Flutter (Recommended)
- Framework: Flutter 3.19+
- Language: Dart 3.3+
- State: Riverpod 2.4+, Bloc, Provider
- Navigation: GoRouter 13+
- API Client: Dio
- Local Storage: Drift, Hive
- Testing: flutter_test, mockito
- E2E Testing: Maestro
React Native (Alternative)
- Framework: React Native 0.73+
- Language: TypeScript
- State: Redux Toolkit, Zustand
- Navigation: React Navigation 6+
- Testing: Jest, React Native Testing Library
- E2E Testing: Maestro
Project Structure (Flutter)
lib/
main.dart
core/ # Theme, router, utils
features/
[feature]/
data/ # Models, repositories
domain/ # Entities, use cases
presentation/ # Screens, widgets, providers
shared/ # Shared widgetsArchitecture Pattern
Clean Architecture with Riverpod: 1. Entity (Domain) - Pure business objects 2. Repository Interface (Domain) - Abstract data access 3. Repository Implementation (Data) - Dio, database 4. Providers (Presentation) - State management 5. Screens/Widgets (Presentation) - UI
Platform Guidelines
- Material Design 3 for Android
- iOS Human Interface Guidelines for iOS
- Use
Platform.isIOSfor platform-specific code
Swift (iOS Native)
- Language: Swift 5.9+ (Swift 6 compatible)
- UI Framework: SwiftUI
- State Management: Observation framework (
@Observable, iOS 17+) - API Client:
swift-openapi-generator(SwiftPM build plugin) +swift-openapi-runtime+swift-openapi-urlsession - Concurrency: async/await, structured concurrency
- Local Storage: SwiftData, UserDefaults, Keychain
- Testing: XCTest, XCUITest
Full reference: ../variants/swift-ios/tech-stack.md
Project Layout (App / Core / Features / Shared)
Sources/
App/ # @main entry, composition root, DI wiring
Core/
Networking/ # openapi.yaml, generated Client, transport, auth middleware
Services/ # AuthService, TokenStore, etc.
Features/ # Vertical slices — one folder per feature (View + @Observable ViewModel)
Shared/ # Reusable UI components, extensions, utilities
Tests/Architecture Pattern
View (SwiftUI) -> @Observable ViewModel -> Core Service -> Generated Client -> Backendvariants/ — Mobile Platform Variants
The variants/ directory is the intended home for all mobile platform stacks, mirroring the layout used by oma-backend/variants/. The swift-ios variant lands here first. Flutter and React Native assets currently live in resources/ as a transitional measure — they will migrate to variants/flutter/ and variants/react-native/ respectively in a follow-up pass once the variant schema and verification pipeline are fully validated. This asymmetry is intentional and time-boxed: the end-state is every supported mobile platform represented under variants/{platform}/ with a stack.yaml, tech-stack.md, snippets.md, and an API or screen template file, making the resources/ directory strictly for protocol and meta documents shared across all platforms.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Mobile Stack Manifest",
"description": "Per-variant stack declaration and verify contract consumed by `oma verify mobile` and agent structured lookup.",
"type": "object",
"required": ["language"],
"properties": {
"$schema": { "type": "string" },
"language": {
"type": "string",
"enum": ["swift", "flutter", "react-native"],
"description": "Primary implementation language of the variant."
},
"framework": {
"type": "string",
"description": "Primary UI framework (e.g., swiftui, uikit)."
},
"ui": {
"type": "string",
"description": "UI toolkit or paradigm (e.g., swiftui, uikit)."
},
"api_generator": {
"type": "string",
"description": "API client generator used (e.g., swift-openapi-generator)."
},
"api_spec": {
"type": "string",
"description": "Relative path to the OpenAPI document consumed by the generator (e.g., Core/Networking/openapi.yaml)."
},
"structure": {
"type": "string",
"description": "Top-level module layout (e.g., App/Core/Features/Shared)."
},
"source": {
"type": "string",
"enum": ["variant", "migrated", "user", "detected"],
"description": "Origin marker. `variant` = shipped with repo, `migrated` = produced by legacy migration, `user` = hand-customized, `detected` = generated by the stack-set workflow from codebase detection."
},
"verify": {
"$ref": "../../_shared/core/stack-verify.schema.json#/definitions/verify"
}
},
"additionalProperties": true
}
/**
* API Service Template for Mobile Agent (Swift iOS Native)
*
* This file wraps the generated `Client` produced by `swift-openapi-generator`
* from `Core/Networking/openapi.yaml`. It is the **only** place that calls the
* backend API — never construct `URLRequest` or decode `Data` manually for
* endpoints that exist in the OpenAPI spec.
*
* Dependencies (all auto-generated at `swift build` time):
* - `Client` — generated root type; one instance per app
* - `Operations` — one namespace per operation (e.g., `Operations.listTodos`)
* - `Components` — shared schema types (e.g., `Components.Schemas.Todo`)
*
* File layout (split into real files in production):
* Core/Networking/
* openapi.yaml <- vendored OpenAPI spec (source of truth)
* openapi-generator-config.yaml <- generator config (types + client, public)
* APIClient.swift <- URLSession transport + auth middleware wiring
* TodoService.swift <- this file
*/
// ---------------------------------------------------------------------------
// Core/Networking/TodoService.swift
// ---------------------------------------------------------------------------
import Foundation
import OpenAPIRuntime
/// Typed errors surfaced by `TodoService`.
public enum TodoServiceError: Error, LocalizedError {
case notFound
case conflict
case undocumented(statusCode: Int)
public var errorDescription: String? {
switch self {
case .notFound: return "The requested todo was not found."
case .conflict: return "A todo with that title already exists."
case .undocumented(let code): return "Unexpected server response: HTTP \(code)."
}
}
}
/// CRUD service for the `/todos` resource.
///
/// Depends on `Client` (generated from `Core/Networking/openapi.yaml`).
/// Inject via `AppDependencies` at app startup; never instantiate directly in views.
public final class TodoService {
private let client: Client
public init(client: Client) {
self.client = client
}
// MARK: - List
/// Returns all todos for the authenticated user.
public func listTodos() async throws -> [Components.Schemas.Todo] {
let response = try await client.listTodos(.init())
switch response {
case .ok(let ok):
return try ok.body.json
case .undocumented(let statusCode, _):
throw TodoServiceError.undocumented(statusCode: statusCode)
}
}
// MARK: - Create
/// Creates a new todo with the given title.
public func createTodo(title: String) async throws -> Components.Schemas.Todo {
let body = Components.Schemas.CreateTodoRequest(title: title)
let response = try await client.createTodo(.init(body: .json(body)))
switch response {
case .created(let created):
return try created.body.json
case .conflict:
throw TodoServiceError.conflict
case .undocumented(let statusCode, _):
throw TodoServiceError.undocumented(statusCode: statusCode)
}
}
// MARK: - Toggle
/// Toggles the `completed` flag on the todo with the given ID.
public func toggleTodo(id: String) async throws -> Components.Schemas.Todo {
let response = try await client.toggleTodo(.init(path: .init(id: id)))
switch response {
case .ok(let ok):
return try ok.body.json
case .notFound:
throw TodoServiceError.notFound
case .undocumented(let statusCode, _):
throw TodoServiceError.undocumented(statusCode: statusCode)
}
}
// MARK: - Delete
/// Permanently deletes the todo with the given ID.
public func deleteTodo(id: String) async throws {
let response = try await client.deleteTodo(.init(path: .init(id: id)))
switch response {
case .noContent:
return
case .notFound:
throw TodoServiceError.notFound
case .undocumented(let statusCode, _):
throw TodoServiceError.undocumented(statusCode: statusCode)
}
}
}
Mobile Agent - Code Snippets (Swift iOS Native)
Copy-paste ready patterns. Use these as starting points; adapt to the specific task. Always use the generated Client — never hand-roll URLRequest/JSONDecoder for API calls.
---
1. Package.swift with OpenAPI Build Plugin
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v17)],
dependencies: [
// Code-generation build plugin (dev / build-time only)
.package(
url: "https://github.com/apple/swift-openapi-generator",
from: "1.3.0"
),
// Runtime types used by the generated Client
.package(
url: "https://github.com/apple/swift-openapi-runtime",
from: "1.5.0"
),
// URLSession transport
.package(
url: "https://github.com/apple/swift-openapi-urlsession",
from: "1.0.2"
),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
],
// The generator discovers openapi.yaml + openapi-generator-config.yaml
// inside this target's source directory and runs at every `swift build`.
plugins: [
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
]
),
.testTarget(
name: "MyAppTests",
dependencies: ["MyApp"]
),
]
)---
2. openapi-generator-config.yaml
# Core/Networking/openapi-generator-config.yaml
# Placed alongside openapi.yaml inside the target source directory.
generate:
- types
- client
accessModifier: public---
3. @Observable View Model
// Features/Todos/TodosViewModel.swift
import Foundation
import Observation
/// Possible states for the Todos screen.
enum TodosViewState {
case idle
case loading
case loaded([Components.Schemas.Todo])
case empty
case error(String)
}
@Observable
final class TodosViewModel {
// MARK: - Published state (observed by the View automatically)
var viewState: TodosViewState = .idle
// MARK: - Private
private let service: TodoService
private var loadTask: Task<Void, Never>?
init(service: TodoService) {
self.service = service
}
// MARK: - Intent
func load() {
// Cancel any in-flight task before starting a new one.
loadTask?.cancel()
viewState = .loading
loadTask = Task { [weak self] in
guard let self else { return }
do {
let todos = try await self.service.listTodos()
guard !Task.isCancelled else { return }
self.viewState = todos.isEmpty ? .empty : .loaded(todos)
} catch is CancellationError {
// Silently ignore — another load will follow.
} catch {
self.viewState = .error(error.localizedDescription)
}
}
}
func retry() { load() }
// Cancel the in-flight task when the view model is deallocated.
deinit { loadTask?.cancel() }
}---
4. SwiftUI Feature View
// Features/Todos/TodosView.swift
import SwiftUI
struct TodosView: View {
// @State owns the view model; the View is the allocation site.
@State private var viewModel: TodosViewModel
init(service: TodoService) {
_viewModel = State(wrappedValue: TodosViewModel(service: service))
}
var body: some View {
NavigationStack {
content
.navigationTitle("Todos")
.task { viewModel.load() } // runs on appear, cancelled on disappear
}
}
// MARK: - Content switch
@ViewBuilder
private var content: some View {
switch viewModel.viewState {
case .idle, .loading:
loadingView
case .loaded(let todos):
todoList(todos)
case .empty:
emptyView
case .error(let message):
errorView(message)
}
}
// MARK: - State views
private var loadingView: some View {
ProgressView("Loading…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func todoList(_ todos: [Components.Schemas.Todo]) -> some View {
List(todos, id: \.id) { todo in
Label(todo.title, systemImage: todo.completed ? "checkmark.circle.fill" : "circle")
}
.refreshable { viewModel.load() }
}
private var emptyView: some View {
ContentUnavailableView(
"No Todos",
systemImage: "tray",
description: Text("Add your first todo to get started.")
)
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { viewModel.retry() }
.buttonStyle(.borderedProminent)
}
.padding()
}
}---
5. Core/Networking API Service (wrapping the generated Client)
// Core/Networking/APIClient.swift
import Foundation
import OpenAPIRuntime
import OpenAPIURLSession
/// Configures the generated Client with the server URL and auth middleware.
/// Inject this as a singleton from App/AppDependencies.swift.
public final class APIClient {
public let client: Client
public init(serverURL: URL, tokenProvider: @escaping () -> String?) {
let transport = URLSessionTransport()
let authMiddleware = BearerAuthMiddleware(tokenProvider: tokenProvider)
self.client = try! Client(
serverURL: serverURL,
transport: transport,
middlewares: [authMiddleware]
)
}
}
// ---------------------------------------------------------------------------
// Core/Networking/BearerAuthMiddleware.swift
// ---------------------------------------------------------------------------
import OpenAPIRuntime
import HTTPTypes
/// Injects a bearer token into every outgoing request.
public struct BearerAuthMiddleware: ClientMiddleware {
private let tokenProvider: () -> String?
public init(tokenProvider: @escaping () -> String?) {
self.tokenProvider = tokenProvider
}
public func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String,
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
var request = request
if let token = tokenProvider() {
request.headerFields[.authorization] = "Bearer \(token)"
}
return try await next(request, body, baseURL)
}
}---
6. Generated-Client Call Pattern
// Core/Networking/TodoService.swift (excerpt showing call + response handling)
import OpenAPIRuntime
public final class TodoService {
private let client: Client
public init(client: Client) {
self.client = client
}
/// List all todos for the authenticated user.
public func listTodos() async throws -> [Components.Schemas.Todo] {
// Use the generated operation initialiser — never construct URLRequest by hand.
let response = try await client.listTodos(.init())
switch response {
case .ok(let ok):
// Decode the typed body; the generator guarantees the shape.
return try ok.body.json
case .undocumented(let statusCode, _):
throw APIError.undocumented(statusCode: statusCode)
}
}
public enum APIError: Error {
case undocumented(statusCode: Int)
case notFound
}
}---
7. App Entry Point and Dependency Injection
// App/MyApp.swift
import SwiftUI
@main
struct MyApp: App {
// Composition root: build the dependency graph once at launch.
private let dependencies = AppDependencies()
var body: some Scene {
WindowGroup {
// Pass the concrete service down; Views never import Core directly.
TodosView(service: dependencies.todoService)
}
}
}
// ---------------------------------------------------------------------------
// App/AppDependencies.swift
// ---------------------------------------------------------------------------
import Foundation
/// Builds and owns shared singletons. Constructed once in @main.
final class AppDependencies {
let todoService: TodoService
init() {
let serverURL = URL(string: ProcessInfo.processInfo.environment["API_BASE_URL"]
?? "https://api.example.com")!
let apiClient = APIClient(serverURL: serverURL, tokenProvider: {
// TODO: replace with real keychain / token store lookup
UserDefaults.standard.string(forKey: "accessToken")
})
self.todoService = TodoService(client: apiClient.client)
}
}---
8. XCTest Unit Test for the View Model
// Tests/TodosViewModelTests.swift
import XCTest
@testable import MyApp
// MARK: - Mock
final class MockTodoService: TodoService {
var stubbedTodos: [Components.Schemas.Todo] = []
var shouldThrow: Error?
override func listTodos() async throws -> [Components.Schemas.Todo] {
if let error = shouldThrow { throw error }
return stubbedTodos
}
}
// MARK: - Tests
final class TodosViewModelTests: XCTestCase {
// Test that a successful response transitions to .loaded.
func testLoad_success_transitionsToLoaded() async {
let mock = MockTodoService(client: .mock)
mock.stubbedTodos = [
.init(id: "1", title: "Buy milk", completed: false),
]
let sut = TodosViewModel(service: mock)
sut.load()
// Give the Task a tick to complete.
try? await Task.sleep(nanoseconds: 50_000_000)
guard case .loaded(let todos) = sut.viewState else {
return XCTFail("Expected .loaded, got \(sut.viewState)")
}
XCTAssertEqual(todos.count, 1)
XCTAssertEqual(todos[0].title, "Buy milk")
}
// Test that an empty response transitions to .empty.
func testLoad_emptyResponse_transitionsToEmpty() async {
let mock = MockTodoService(client: .mock)
mock.stubbedTodos = []
let sut = TodosViewModel(service: mock)
sut.load()
try? await Task.sleep(nanoseconds: 50_000_000)
guard case .empty = sut.viewState else {
return XCTFail("Expected .empty, got \(sut.viewState)")
}
}
// Test that a thrown error transitions to .error.
func testLoad_networkError_transitionsToError() async {
let mock = MockTodoService(client: .mock)
mock.shouldThrow = URLError(.notConnectedToInternet)
let sut = TodosViewModel(service: mock)
sut.load()
try? await Task.sleep(nanoseconds: 50_000_000)
guard case .error = sut.viewState else {
return XCTFail("Expected .error, got \(sut.viewState)")
}
}
}# yaml-language-server: $schema=../stack.schema.json
language: swift
framework: swiftui
ui: swiftui
api_generator: swift-openapi-generator
api_spec: Core/Networking/openapi.yaml
structure: App/Core/Features/Shared
source: variant
verify:
detect: Package.swift
syntax:
cmd: "swift build"
skip_if_missing: swift
tests:
cmd: "swift test"
pass_signal: "Test Suite 'All tests' passed"
skip_if_missing: swift
Mobile Agent - Tech Stack Reference (Swift iOS Native)
Framework: SwiftUI + Observation
- Language: Swift 5.9+ (Swift 6 compatible)
- UI Framework: SwiftUI
- State Management: Observation framework (
@Observable, iOS 17+) - Concurrency: Swift async/await + structured concurrency (
Task,TaskGroup,AsyncStream) - Minimum Deployment: iOS 17.0
- Tooling: Xcode 15+, Swift Package Manager (SwiftPM)
@Observable replaces ObservableObject/@Published for SwiftUI view models. The macro synthesizes observation tracking at compile time with zero boilerplate and no Combine dependency.
API Client: swift-openapi-generator
| Component | Package |
|---|---|
| Build plugin (code gen) | apple/swift-openapi-generator |
| Runtime types | apple/swift-openapi-runtime |
| URLSession transport | apple/swift-openapi-urlsession |
The generator is a SwiftPM build plugin — it runs automatically during swift build and produces Swift source from Core/Networking/openapi.yaml and Core/Networking/openapi-generator-config.yaml. No manual code generation step is needed. The generated Client is the only way to call the backend API; never hand-roll URLRequest/JSONDecoder for endpoints covered by the spec.
Where the API contract comes from
The OpenAPI document is vendored at Core/Networking/openapi.yaml. Its source of truth is the backend service (typically emitted by the server-side OpenAPI generator or a hand-maintained spec). The sync workflow is:
1. Backend team publishes or exports openapi.yaml (e.g., from NestJS Swagger or a CI artifact). 2. iOS team copies or downloads the new spec into Core/Networking/openapi.yaml before starting feature work that touches the API surface. 3. Running swift build automatically regenerates the Client, Operations, and Components Swift types from the updated spec. 4. Any breaking schema changes surface as Swift compile errors at that point, not at runtime.
The iOS project is purely a consumer of the spec. It never modifies openapi.yaml directly. If the spec is missing, swift build fails with a generator error — ensure the sync step is complete before building.
Local Storage
- SwiftData (iOS 17+) — Swift-native ORM built on Core Data; preferred for structured persistence.
- UserDefaults / `@AppStorage` — lightweight key-value preferences.
- Keychain (
Securityframework) — tokens and credentials.
Testing
| Layer | Framework |
|---|---|
| Unit (domain + services) | XCTest or Swift Testing (@Test) |
| UI / snapshot | XCUITest |
| Mocking | Protocol-based; no third-party mock lib required |
Run tests with swift test (SwiftPM projects) or via Xcode's test runner. Target pass signal for CI: Test Suite 'All tests' passed.
Project Layout: App / Core / Features / Shared
MyApp/
Package.swift # SwiftPM manifest; registers the OpenAPI build plugin
Sources/
App/
MyApp.swift # @main entry point
AppDependencies.swift # Composition root — wires Core services into Feature VMs
Core/
Networking/
openapi.yaml # Vendored OpenAPI spec (source of truth for the generator)
openapi-generator-config.yaml
APIClient.swift # Wraps the generated Client; adds URLSession transport + auth
BearerAuthMiddleware.swift # ClientMiddleware for bearer token injection
Services/
AuthService.swift
TokenStore.swift
Features/
Todos/
TodosView.swift # SwiftUI View
TodosViewModel.swift # @Observable view model
TodoDetailView.swift
TodoDetailViewModel.swift
Auth/
LoginView.swift
LoginViewModel.swift
Shared/
Components/
LoadingView.swift
ErrorView.swift
EmptyStateView.swift
Extensions/
View+ErrorAlert.swift
Utilities/
Logger.swift
Tests/
TodosViewModelTests.swift
APIClientTests.swiftArchitecture Pattern
View (SwiftUI)
| observes
v
@Observable ViewModel (Features/<Feature>/FeatureViewModel.swift)
| calls
v
Core Service (Core/Networking/APIClient.swift or Core/Services/…)
| calls
v
Generated Client (auto-generated from Core/Networking/openapi.yaml)
| HTTP via URLSession transport
v
Backend REST APIEach Features/<Name>/ folder is a vertical slice: it owns its own View + ViewModel and depends only on Core services injected at app startup. Shared/ contains stateless, reusable UI components and Swift extensions with no feature knowledge.