diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b19d5c2..0b90772 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -135,3 +135,51 @@ Providers expose `DfuService`, `FirmwareManager`, and `FilesystemUploadService` - **Protocol reference**: `gadgetbridge_api.txt` — complete JSON message format for phone↔watch communication - **Firmware counterpart**: `ZSWatch-Firmware/app/src/ble/gadgetbridge/ble_gadgetbridge.c` — firmware-side protocol implementation - **Known issues**: `Issues:.md` — current bugs and TODOs at root level + +## AI Testbench (`ai_testbench/`) + +Standalone Flutter desktop app for benchmarking and evaluating on-device LLM models before shipping them in the companion app. It tests the same prompts and schemas used in production (`chrono_ai_flow` package) against local GGUF models via `fllama`. + +### What It Tests + +| Benchmark | Service | Purpose | +|-----------|---------|---------| +| **Structured extraction** | `model_benchmark_service.dart` | Validates LLM outputs valid JSON matching `chrono_ai_flow` schema (intent, title, datetime fields) | +| **Time extraction** | `time_extraction_benchmark_service.dart` | End-to-end: transcript → LLM extraction → `chrono_dart` parsing → resolved `DateTime` | +| **Correction** | `correction_benchmark_service.dart` | Verifies LLM can fix common STT errors (homophones, filler, punctuation) | + +### Running Benchmarks + +```bash +cd ai_testbench +flutter pub get + +# Interactive GUI +flutter run -d linux + +# Headless (compiled for consistent timing) +flutter build linux --release +./build/linux/x64/release/bundle/ai_testbench --headless --output results.json +./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +./build/linux/x64/release/bundle/ai_testbench --headless-correction --model-dir models/ +``` + +### Key Files + +- `lib/main.dart` — Entry point with headless mode dispatch (`--headless`, `--headless-time`, `--headless-correction`) +- `lib/services/llm_service.dart` — `fllama` wrapper (`LlmService`) for inference with configurable parameters +- `lib/prompts/` — Prompt templates (delegates to `chrono_ai_flow` for production prompts) +- `benchmark_results/` — Saved benchmark summaries (Markdown) + +### Dependencies + +- **fllama** — Flutter llama.cpp bindings for on-device inference +- **chrono_ai_flow** — Shared prompt templates and JSON schema (local package at `../packages/chrono_ai_flow`) +- **chrono_dart** — Natural language time expression parsing + +### Notes + +- GGUF model files go in `models/` (gitignored — download separately from HuggingFace) +- Native `.so` libraries go in `native_libs/` (gitignored — build from llama.cpp if using the CLI `bin/` runner) +- Test cases are defined inline in the service files — add new cases there +- The testbench shares the same `chrono_ai_flow` package as the main app, so prompt changes are tested against both diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4026948..2c97439 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,7 +18,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.10.1" + flutter-version: "3.38.3" cache: true - name: Cache pub dependencies @@ -55,7 +55,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.10.1" + flutter-version: "3.38.3" cache: true - name: Cache pub dependencies @@ -88,7 +88,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.10.1" + flutter-version: "3.38.3" cache: true - name: Cache pub dependencies @@ -107,6 +107,9 @@ jobs: key: gradle-${{ runner.os }}-${{ hashFiles('zswatch_app/android/**/*.gradle*', 'zswatch_app/android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- + - name: Accept Android SDK licenses + run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true + - name: Get dependencies working-directory: zswatch_app run: flutter pub get @@ -136,7 +139,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.10.1" + flutter-version: "3.38.3" cache: true - name: Cache pub dependencies diff --git a/.gitignore b/.gitignore index 950bfbf..cf16e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ gadgetbridge_api.txt # Development notes Issues:.md +# Android signing keys (use GitHub Secrets in CI instead) +zswatch_app/android/key.properties +zswatch_app/android/*.keystore +zswatch_app/android/*.jks + # IDE and editor directories .idea/ .vscode/ diff --git a/.gitmodules b/.gitmodules index c1b1963..771bf62 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "Flutter-nRF-Connect-Device-Manager"] path = Flutter-nRF-Connect-Device-Manager url = https://github.com/ZSWatch/Flutter-nRF-Connect-Device-Manager.git +[submodule "third_party/fllama"] + path = third_party/fllama + url = https://github.com/ZSWatch/fllama.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9f965f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## [1.1.0] - 2026-02-17 + +### What's New +- The firmware update screen now shows only the firmware compatible with your watch, making updates simpler and safer. A new option for watches with a rotated display is also available. + +### Fixes & Improvements +- Fixed an issue on iOS where Bluetooth permission was not requested at the right time, which could prevent the app from connecting to your watch. +- Improved reliability when loading firmware update files from local storage. +- Fixed app icon, splash screen, and launcher icon display on Android and iOS. + +## [1.0.0] - 2026-02-12 + +Initial release. diff --git a/Flutter-nRF-Connect-Device-Manager b/Flutter-nRF-Connect-Device-Manager index 4efb1c2..2aabd59 160000 --- a/Flutter-nRF-Connect-Device-Manager +++ b/Flutter-nRF-Connect-Device-Manager @@ -1 +1 @@ -Subproject commit 4efb1c2e942091ef70a5aa7ed22a9825defb46c3 +Subproject commit 2aabd598990fb9fb3cba918d15f98084e9ee687c diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..9c57a84 --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,380 @@ +# ZSWatchApp Refactoring Plan + +**Created:** 2026-03-16 +**Status:** Not started +**Scope:** `zswatch_app/` only. `ai_testbench/` must keep working but is not refactored. `packages/chrono_ai_flow` is clean — leave it alone. Do not touch the `mcumgr_flutter` submodule. +**Backwards compatibility:** NOT required. Wipe database, remove all migrations, start at schema v1. No migration code needed. + +--- + +## Progress Overview + +| ID | Title | Status | +|----|-------|--------| +| C1 | Add `freezed`, migrate all domain models | ✅ Done | +| C2 | Wipe database, clean schema | ✅ Done | +| C3 | Fix silent error swallowing | ✅ Done | +| C4 | Split `watch_service.dart` + connection state machine | ✅ Done | +| H1 | Repository base class | ✅ Done | +| H2 | Provider consolidation + base notifier | ✅ Done | +| H3 | Demo mode abstraction | ⏭️ Skipped | +| H4 | Break down oversized screens | ✅ Done | +| H5 | Standardize error display | ✅ Done | +| M1 | Typed navigation | ✅ Done | +| M2 | Stream subscription audit | ✅ Done | +| M3 | Extract shared widgets | ✅ Done | +| L1 | Remove `equatable` dependency | ✅ Done | +| L2 | Replace magic numbers with named constants | ✅ Done | +| L3 | Clean up `time_resolver_debug.dart` | ✅ Done | + +**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done · ⏭️ Skipped + +--- + +## Suggested Execution Order + +1. C1 — freezed migration (unblocks everything, models used everywhere) +2. C2 — database wipe (clean break, do early) +3. C3 — error handling (mechanical, safe alongside C1/C2) +4. C4 — service split + connection state machine (biggest structural change) +5. H1 — repository base class (after models are frozen) +6. H2 — provider consolidation (after services are clean) +7. H3 — demo mode abstraction +8. H4 — screen extraction +9. H5, M1, M2, M3 — in any order +10. L1–L3 — last + +--- + +## Testing Gates + +**After every change (no device needed):** +```bash +cd zswatch_app +flutter pub get +flutter analyze # Must produce 0 errors +flutter test # Must pass +dart run build_runner build --delete-conflicting-outputs # After freezed/drift changes +``` + +**Device verification (required after C4, recommended after C1/C2):** +```bash +flutter run --debug 2>&1 | tee /tmp/zswatch_app.log +``` + +Healthy connection sequence to look for in logs: +1. `[BleConnectionManager]` scanning started +2. Device found, connecting → bonding +3. `Negotiated MTU: ` +4. `[WatchService]` services discovered +5. `[WatchService]` Re-discovery complete +6. Firmware log entries flowing (`` / `` prefixed lines) +7. No `` entries, no `Connection error:`, no `Reconnect attempt ... failed` + +| Phase | Gate | +|-------|------| +| C1 | `flutter analyze` clean + `flutter test` passes + `build_runner` no conflicts | +| C2 | App cold-starts without crash, `flutter analyze` clean | +| C3 | `flutter analyze` clean — `cancel_subscriptions` + `unawaited_futures` rules catch regressions | +| C4 | `flutter analyze` + **device test**: full connection sequence in logs, no reconnect loops | +| H1–H2 | `flutter analyze` + `flutter test` | +| H3 | `flutter analyze` + verify no `demoModeProvider` checks remain in screen build methods | +| H4 | `flutter analyze` — no unused imports, no missing widget references | +| H5, M, L | `flutter analyze` | + +--- + +## Detailed Task Specs + +--- + +### 🔴 C1 — Add `freezed`, migrate all domain models + +**Goal:** Replace all manual `copyWith`, `==`, `hashCode`, `toString` with `freezed` code generation. Remove `equatable`. + +**Add to `zswatch_app/pubspec.yaml`:** +```yaml +dependencies: + freezed_annotation: ^2.4.4 + +dev_dependencies: + freezed: ^2.5.7 + # build_runner already present + # json_annotation / json_serializable only if JSON serialization is needed +``` + +**Files to convert** (all in `lib/data/models/`): +- `watch.dart` +- `health_sample.dart` +- `voice_memo.dart` +- `extracted_action.dart` +- `connection_event.dart` +- `notification.dart` +- `http_request.dart` +- `sensor_reading.dart` +- `sensor_fusion_data.dart` +- `firmware_image.dart` +- `filesystem_image.dart` +- `comm_log_entry.dart` +- `log_entry.dart` +- `log_filter.dart` +- `connection_state.dart` +- `dfu_state.dart` + +Also apply to Riverpod state classes in `lib/providers/` that have manual `copyWith`. + +**Steps:** +1. Add `freezed_annotation` + `freezed` to `pubspec.yaml`, run `flutter pub get` +2. For each model: add `part 'filename.freezed.dart';`, annotate with `@freezed`, convert to freezed factory constructors +3. Run `dart run build_runner build --delete-conflicting-outputs` +4. Delete all hand-written `copyWith`, `==`, `hashCode`, `toString`, `Equatable` extends from converted files +5. Remove `equatable` from `pubspec.yaml` once all files converted (that's task L1, but can be done here) + +**Do NOT convert** classes in `packages/chrono_ai_flow` — those are clean and minimal. + +--- + +### 🔴 C2 — Wipe database, clean schema + +**Goal:** Drop all migration code. Clean schema. Schema version = 1. + +**Files:** +- `lib/data/database/app_database.dart` — main database class +- `lib/data/database/tables/` — all 7 table definitions +- `lib/data/database/app_database.g.dart` — regenerated, do not edit + +**Steps:** +1. Set `@DriftDatabase(schemaVersion: 1, ...)` +2. Replace `MigrationStrategy` body with only `onCreate: (m) => m.createAll()` +3. Delete all `onUpgrade` migration lambdas +4. Schema cleanups while rewriting: + - `voice_memos` table: convert `processing_status` (text) and `action_review_state` (text) to proper Drift-typed enums + - Verify whether `connection_events` table is used outside analytics — if only for analytics, consider dropping it + - Document or remove `extracted_actions.platform_target_id` (currently nullable, purpose unclear) +5. Run `dart run build_runner build --delete-conflicting-outputs` to regenerate `app_database.g.dart` +6. Cold-start the app on device — database file will be recreated fresh + +--- + +### 🔴 C3 — Fix silent error swallowing + +**Goal:** No `catch` block silently discards errors. + +**Search patterns to find all instances:** +```bash +grep -rn "catch (_)" lib/ +grep -rn "catch (e)" lib/ | grep -v rethrow | grep -v AsyncValue +``` + +**Primary files:** `firmware_manager.dart`, `watch_service.dart`, `health_sync_service.dart`, `gps_service.dart` + +**Rule for every catch block — must do one of:** +1. Surface via `AsyncValue.error(e, st)` to UI +2. Re-throw (`rethrow`) +3. Log with `debugPrint` AND have an explicit comment explaining why swallowing is intentional + +**Also fix:** `debugPrint`-only catches that don't surface the error to any state — these are silent from the user's perspective even if they appear in logs. + +--- + +### 🔴 C4 — Split `watch_service.dart` + connection state machine + +**Goal:** Replace 1,504-line god object + 8 boolean flags with focused services and a proper state machine. + +**New connection state machine** (use `freezed` sealed class): +```dart +// lib/data/models/connection_phase.dart +@freezed +sealed class ConnectionPhase with _$ConnectionPhase { + const factory ConnectionPhase.disconnected() = Disconnected; + const factory ConnectionPhase.scanning() = Scanning; + const factory ConnectionPhase.connecting() = Connecting; + const factory ConnectionPhase.settingUp() = SettingUp; + const factory ConnectionPhase.connected() = Connected; + const factory ConnectionPhase.reconnecting({required int attempt}) = Reconnecting; + const factory ConnectionPhase.error({required ConnectionErrorType type}) = PhaseError; +} +``` + +**New service files:** + +| New file | Responsibility | +|----------|----------------| +| `lib/services/ble/ble_connection_service.dart` | Connection lifecycle, reconnect loop. Exposes `Stream` as single source of truth. | +| `lib/services/protocol/protocol_service.dart` | Absorb all Gadgetbridge protocol logic (partially exists already). | +| `lib/services/watch/battery_monitoring_service.dart` | Periodic battery polling and storage. | +| `lib/services/watch/device_info_service.dart` | Firmware version, hardware version retrieval. | + +**Remove boolean flags** from `watch_service.dart`: +- `_isSettingUp`, `_isCancelled`, `_isReconnecting`, `_isWaitingForAutoConnect`, `_isInitialConnection`, `_isScanning`, `_autoReconnect`, `_isSettingUpCompleted` — all replaced by `ConnectionPhase` state. + +**Consolidate connection state:** Delete duplicate state variables in `BleConnectionManager` and `BleNotifier`. All providers (`bleConnectionProvider`, `watchConnectionProvider`, `isConnectedProvider`, `currentConnectionProvider`) must derive from `ble_connection_service.dart`'s single stream. + +**Device test gate:** After this change, run on device and verify the full connection sequence appears in logs with no reconnect loops. + +--- + +### 🟠 H1 — Repository base class + +**Goal:** Remove duplicated query boilerplate across 8 repositories. + +**Create:** `lib/data/repositories/base_repository.dart` +```dart +abstract class BaseRepository { + Model fromEntity(Entity entity); +} +``` + +**Apply to:** +- `watch_repository.dart` +- `health_repository.dart` +- `battery_repository.dart` +- `voice_memo_repository.dart` +- `extracted_action_repository.dart` +- `connection_analytics_repository.dart` +- `comm_log_repository.dart` +- `settings_repository.dart` + +Remove duplicated `_entityToModel()` / `_modelToCompanion()` pattern from each. Consolidate shared query helpers (`getById`, `getAll`, `watch`) into base where possible. + +--- + +### 🟠 H2 — Provider consolidation + base notifier + +**Goal:** Reduce 42 StateNotifier classes with near-identical boilerplate. Consolidate 19 provider files. + +**Create:** `lib/providers/base_async_notifier.dart` with shared `init`, `handleError`, `reset` methods. Apply to all StateNotifier subclasses. + +**Consolidate provider files:** + +| Old files | New file | +|-----------|----------| +| `ble_providers.dart` + `watch_service_provider.dart` + `auto_reconnect_provider.dart` + `watch_state_provider.dart` | `connection_providers.dart` | +| `foreground_service_providers.dart` + `permission_providers.dart` + `gps_providers.dart` | `platform_providers.dart` | +| `developer_providers.dart` + `analytics_providers.dart` | `developer_providers.dart` | +| `ai_providers.dart` + `voice_memo_providers.dart` | keep separate if either exceeds 200 lines, otherwise merge | + +--- + +### 🟠 H3 — Demo mode abstraction + +**Goal:** Screens never check `demoModeProvider` directly in their `build` methods. + +**Steps:** +1. Define `WatchServiceInterface` abstract class covering all public methods/streams of `WatchService` +2. `WatchService` implements `WatchServiceInterface` +3. Create `DemoWatchService` implementing `WatchServiceInterface` with static/fake data +4. Provider selects implementation: `demoModeProvider` check lives only in `watch_service_provider.dart` +5. Remove all inline `ref.watch(demoModeProvider)` checks from screen files + +--- + +### 🟠 H4 — Break down oversized screens + +**Goal:** No screen file exceeds ~400 lines. Extract named widget classes to `lib/ui/widgets//`. + +**Files to extract:** + +`voice_memos_screen.dart` (2,935 lines) → extract: +- `lib/ui/widgets/voice_memos/memo_list_item.dart` +- `lib/ui/widgets/voice_memos/search_bar.dart` +- `lib/ui/widgets/voice_memos/sync_progress_bar.dart` +- `lib/ui/widgets/voice_memos/transcription_card.dart` +- `lib/ui/widgets/voice_memos/ai_result_card.dart` + +`ai_models_settings_screen.dart` (2,403 lines) → extract each settings section as a widget + +`firmware_update_screen.dart` (1,883 lines) → extract: +- `lib/ui/widgets/firmware/dfu_progress_card.dart` +- `lib/ui/widgets/firmware/firmware_image_card.dart` +- `lib/ui/widgets/firmware/filesystem_upload_section.dart` + +--- + +### 🟠 H5 — Standardize error display + +**Goal:** One error display contract across the entire app. + +**Contract:** +- Transient errors (network, BLE timeout) → `ScaffoldMessenger` snackbar +- Blocking/fatal errors → `AppErrorWidget` (already in `app.dart` — use it consistently) +- Action-required errors → dialog + +**Steps:** +1. Audit all screens for ad-hoc error display +2. Replace with the above contract +3. Ensure `AppErrorWidget` is exported and usable from all screen files + +--- + +### 🟡 M1 — Typed navigation + +**Goal:** No raw route strings in `context.go()` / `context.push()` calls outside `AppRoutes`. + +**Steps:** +1. Add static typed methods to `AppRoutes` for every route +2. Replace all `context.go('/settings')` etc. with `AppRoutes.settings(context)` (or similar pattern) +3. Remove or document `_PlaceholderScreen` routes — either implement or add a `// TODO` with reason + +--- + +### 🟡 M2 — Stream subscription audit + +**Goal:** Every `StreamSubscription` field is cancelled in `dispose()`. No fire-and-forget subscriptions. + +**Steps:** +1. Search: `grep -rn "StreamSubscription" lib/` +2. For each: verify `cancel()` is called in `dispose()` or `ref.onDispose()` +3. Prefer `StreamProvider` / `ref.watch()` over manual subscription management where possible + +--- + +### 🟡 M3 — Extract shared widgets + +**Goal:** Common UI patterns live in `lib/ui/widgets/common/` and are reused. + +**Create:** +- `lib/ui/widgets/common/battery_indicator.dart` — reused on dashboard, firmware screen, settings +- `lib/ui/widgets/common/connection_status_card.dart` — reused on multiple screens +- `lib/ui/widgets/common/async_value_widget.dart` — standard loading/error/data wrapper + +--- + +### 🟢 L1 — Remove `equatable` dependency + +**When:** After C1 is fully complete. + +Remove `equatable: ^2.0.7` from `zswatch_app/pubspec.yaml`. Run `flutter pub get`. Fix any remaining compilation errors (there should be none after C1). + +--- + +### 🟢 L2 — Replace magic numbers with named constants + +**Create:** `lib/core/constants/app_constants.dart` + +Known magic numbers to name: +- `2000` — notification deduplication window (ms) in `notification_providers.dart:82` +- `3` — quick reconnect attempts in `watch_service.dart:54` +- `5000` — comm log entry rotation limit in comm log service +- `1000` — log viewer in-memory buffer size +- `185` — minimum acceptable MTU (iOS) +- `60` — analytics retention days + +--- + +### 🟢 L3 — Clean up `time_resolver_debug.dart` + +**File:** `packages/chrono_ai_flow/test/time_resolver_debug.dart` + +This is a manual debug script, not a formal test. Either: +- Rename to `time_resolver_debug_manual.dart` and move outside `test/` into a `scripts/` directory, or +- Delete it if it's no longer needed + +--- + +## Notes + +- `packages/chrono_ai_flow` — clean, well-structured, do not refactor +- `ai_testbench/` — separate desktop Flutter app, must keep working, not in scope for refactoring +- `mcumgr_flutter` — git submodule, do not touch unless a change there would dramatically simplify code in `zswatch_app/services/dfu/` +- `flutter analyze` has 2 existing warnings before refactoring starts (unused import + unused `_deleteWatch` in `start_page_screen.dart`) — fix these as part of C3 diff --git a/ai_testbench/.gitignore b/ai_testbench/.gitignore new file mode 100644 index 0000000..29235f2 --- /dev/null +++ b/ai_testbench/.gitignore @@ -0,0 +1,58 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Large model files (download separately) +models/ +*.gguf + +# Native shared libraries (build separately) +native_libs/ + +# Agent tools +.agent_tools/ + +# MLCEngine virtualenv +.mlc_venv/ diff --git a/ai_testbench/.metadata b/ai_testbench/.metadata new file mode 100644 index 0000000..792284a --- /dev/null +++ b/ai_testbench/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: android + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ai_testbench/README.md b/ai_testbench/README.md new file mode 100644 index 0000000..c52c234 --- /dev/null +++ b/ai_testbench/README.md @@ -0,0 +1,114 @@ +# AI Testbench + +Standalone Flutter app for benchmarking and evaluating on-device LLM models used by the ZSWatch companion app. It tests structured JSON extraction (intent classification, time extraction, correction) against local GGUF models via [fllama](https://pub.dev/packages/fllama), producing pass/fail results and tokens-per-second metrics. + +## Purpose + +The ZSWatch companion app uses on-device LLMs to process voice memos — classifying intents (reminder, event, note), extracting time expressions, and correcting transcription errors. This testbench: + +- **Evaluates candidate GGUF models** for accuracy and speed before shipping them in the app. +- **Benchmarks structured extraction** — verifies the model outputs valid JSON matching the `chrono_ai_flow` schema. +- **Tests time expression resolution** — end-to-end from transcript → LLM extraction → `chrono_dart` parsing → resolved `DateTime`. +- **Tests transcript correction** — verifies the model can fix common STT errors (homophones, filler words, punctuation). + +## Directory Structure + +``` +ai_testbench/ +├── lib/ +│ ├── main.dart # Entry point (GUI + headless modes) +│ ├── benchmark_main.dart # Model benchmark runner (structured extraction) +│ ├── correction_main.dart # Correction benchmark runner +│ ├── time_extraction_main.dart # Time extraction benchmark runner +│ ├── prompts/ # Prompt templates (shared via chrono_ai_flow) +│ ├── screens/ # Flutter UI screens for interactive testing +│ └── services/ +│ ├── llm_service.dart # fllama wrapper for inference +│ ├── model_benchmark_service.dart # Structured extraction benchmark logic +│ ├── correction_benchmark_service.dart # Correction benchmark logic +│ ├── time_extraction_benchmark_service.dart # Time extraction benchmark logic +│ └── time_expression_resolver.dart # chrono_dart time resolution +├── bin/ +│ └── test_time_extraction.dart # CLI test runner (uses llama_cpp_dart directly) +├── models/ # GGUF model files (gitignored, download separately) +├── native_libs/ # Native shared libraries (gitignored, build separately) +├── benchmark_results/ # Saved benchmark summaries +└── test/ +``` + +## Prerequisites + +- Flutter SDK (channel stable) +- GGUF model files placed in `models/` (not committed — download from HuggingFace or equivalent) +- Linux desktop support enabled (`flutter config --enable-linux-desktop`) + +## Setup + +```bash +cd ai_testbench +flutter pub get +``` + +Place one or more `.gguf` model files in the `models/` directory. Recommended starting model: `Qwen3.5-2B-Q4_K_M.gguf`. + +## Usage + +### Interactive GUI + +```bash +flutter run -d linux +``` + +Opens a desktop window with screens for running benchmarks interactively. + +### Headless Benchmarks + +Run from a compiled release build for consistent timing: + +```bash +flutter build linux --release +``` + +**Structured extraction benchmark** (all models in `models/`): +```bash +./build/linux/x64/release/bundle/ai_testbench --headless --output results.json +``` + +**Time extraction benchmark** (single model): +```bash +./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +``` + +**Correction benchmark**: +```bash +./build/linux/x64/release/bundle/ai_testbench --headless-correction --model-dir models/ --output correction.json +``` + +### CLI Options + +| Flag | Description | +|------|-------------| +| `--headless` | Run structured extraction benchmark (all models) | +| `--headless-time` | Run time extraction benchmark | +| `--headless-correction` | Run correction benchmark | +| `--model ` | Filter to a specific model filename | +| `--model-dir ` | Path to directory containing `.gguf` files (default: `models/`) | +| `--output ` | Write JSON results to file | +| `--language-hint` | Include language hint in time extraction prompts | +| `--retry-invalid` | Retry on invalid JSON output | +| `--prompt-variant ` | Select prompt template variant | + +## Key Dependencies + +- **[fllama](https://pub.dev/packages/fllama)** — Flutter bindings for llama.cpp (model inference) +- **chrono_ai_flow** — Shared prompt templates and JSON schema for voice memo classification (local package in `../packages/chrono_ai_flow`) +- **chrono_dart** — Natural language time expression parsing + +## Adding New Test Cases + +Benchmark cases are defined directly in the service files: +- `lib/services/model_benchmark_service.dart` — structured extraction cases +- `lib/services/correction_benchmark_service.dart` — correction cases +- `lib/services/time_extraction_benchmark_service.dart` — time extraction cases + +Each case specifies input transcript, expected intent, expected outputs, and validation criteria. diff --git a/ai_testbench/analysis_options.yaml b/ai_testbench/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/ai_testbench/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/ai_testbench/android/.gitignore b/ai_testbench/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/ai_testbench/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/ai_testbench/android/app/build.gradle.kts b/ai_testbench/android/app/build.gradle.kts new file mode 100644 index 0000000..d367f34 --- /dev/null +++ b/ai_testbench/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.ai_testbench" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.ai_testbench" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 24 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/ai_testbench/android/app/src/debug/AndroidManifest.xml b/ai_testbench/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/ai_testbench/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/ai_testbench/android/app/src/main/AndroidManifest.xml b/ai_testbench/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..90d976b --- /dev/null +++ b/ai_testbench/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt b/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt new file mode 100644 index 0000000..27f8187 --- /dev/null +++ b/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.ai_testbench + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml b/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/drawable/launch_background.xml b/ai_testbench/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/ai_testbench/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ai_testbench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/ai_testbench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ai_testbench/android/app/src/main/res/values-night/styles.xml b/ai_testbench/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/ai_testbench/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/values/styles.xml b/ai_testbench/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/ai_testbench/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/ai_testbench/android/app/src/profile/AndroidManifest.xml b/ai_testbench/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/ai_testbench/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/ai_testbench/android/build.gradle.kts b/ai_testbench/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/ai_testbench/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/ai_testbench/android/gradle.properties b/ai_testbench/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/ai_testbench/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties b/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/ai_testbench/android/settings.gradle.kts b/ai_testbench/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/ai_testbench/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md b/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md new file mode 100644 index 0000000..6f88d51 --- /dev/null +++ b/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md @@ -0,0 +1,52 @@ +# Headless benchmark summary — 2026-03-08 + +Benchmark scope: +- 9 GGUF models +- 6 strict structured-extraction cases +- Ranking by strict passes first, then average tokens/second + +## Ranking + +| Rank | Model | Strict passes | Avg tok/s | Total time | +| --- | --- | ---: | ---: | ---: | +| 1 | Qwen3.5-2B-Q4_K_M.gguf | 5/6 | 19.3 | 61.3s | +| 2 | SmolLM3-Q4_K_M.gguf | 4/6 | 16.2 | 51.5s | +| 3 | qwen2.5-1.5b-instruct-q5_k_m.gguf | 2/6 | 26.6 | 39.8s | +| 4 | qwen2.5-1.5b-instruct-q8_0.gguf | 2/6 | 23.6 | 44.2s | +| 5 | Qwen2.5-3B-Instruct-Q4_K_M.gguf | 1/6 | 17.0 | 50.8s | +| 6 | Llama-3.2-3B-Instruct-Q4_K_M.gguf | 1/6 | 16.3 | 42.9s | +| 7 | Qwen2.5-0.5B-Instruct-Q4_K_M.gguf | 0/6 | 71.0 | 13.3s | +| 8 | Qwen3-1.7B-Q4_K_M.gguf | 0/6 | 32.8 | 70.5s | +| 9 | Qwen2.5-1.5B-Instruct-Q4_K_M.gguf | 0/6 | 32.1 | 25.6s | + +## Top-model notes + +### Qwen3.5-2B-Q4_K_M.gguf +- Passed 5 of 6 cases. +- Passed all calendar/reminder cases except `task_de_deadline`. +- The only miss was action count on the German task case. +- Best overall model in this run. + +### SmolLM3-Q4_K_M.gguf +- Passed 4 of 6 cases. +- Strong fallback model. +- Missed `calendar_en_precise` and `calendar_en_with_reminder`. + +### Qwen3-1.7B-Q4_K_M.gguf +- Fast, but failed 6 of 6 cases. +- Main failure mode was invalid JSON across the board. +- Not recommended for structured extraction in the app. + +## Recommendation + +1. Primary recommendation: **Qwen3.5-2B-Q4_K_M.gguf** + - Best strict accuracy by a clear margin. + - Speed is acceptable. + - Use this first if the target runtime remains stable. + +2. Safe backup: **SmolLM3-Q4_K_M.gguf** + - Second-best accuracy. + - Good fallback if Qwen3.5 shows runtime-specific issues. + +3. Do not promote **Qwen3-1.7B-Q4_K_M.gguf** + - Good speed, but unusable here for strict JSON extraction. diff --git a/ai_testbench/benchmark_results/results.json b/ai_testbench/benchmark_results/results.json new file mode 100644 index 0000000..07ef33b --- /dev/null +++ b/ai_testbench/benchmark_results/results.json @@ -0,0 +1,1158 @@ +{ + "startedAt": "2026-03-13T21:31:24.883597Z", + "finishedAt": "2026-03-13T21:38:04.722009Z", + "modelCount": 1, + "caseCount": 51, + "results": [ + { + "modelPath": "/Users/jakkra/Documents/ZSWatch-App/ai_testbench/models/Qwen3.5-2B-Q4_K_M.gguf", + "modelName": "Qwen3.5-2B-Q4_K_M.gguf", + "passedCases": 46, + "totalCases": 51, + "avgTokensPerSecond": 6.700685793811264, + "totalElapsedMs": 399367, + "cases": [ + { + "caseName": "en_event_precise_time", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-14 15:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "design review", + "datetimeOriginal": "March 14 at 3:30 PM", + "datetimeEnglish": "March 14th at 3:30 pm", + "elapsedMs": 7653, + "tokensPerSecond": 6.010714752384686, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"design review\",\"datetime_expression_original\":\"March 14 at 3:30 PM\",\"datetime_expression_english\":\"March 14th at 3:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_tomorrow", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 07:15:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "take the prototype battery off the charger", + "datetimeOriginal": "tomorrow at 7:15 AM", + "datetimeEnglish": "tomorrow at 7:15 am", + "elapsedMs": 7261, + "tokensPerSecond": 6.335215535050269, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"take the prototype battery off the charger\",\"datetime_expression_original\":\"tomorrow at 7:15 AM\",\"datetime_expression_english\":\"tomorrow at 7:15 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_next_tuesday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-17 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "meeting with John", + "datetimeOriginal": "next Tuesday at 2 pm", + "datetimeEnglish": "next Tuesday at 2 pm", + "elapsedMs": 6822, + "tokensPerSecond": 5.277044854881266, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"meeting with John\",\"datetime_expression_original\":\"next Tuesday at 2 pm\",\"datetime_expression_english\":\"next Tuesday at 2 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_next_friday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-20 17:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "finish PCB layout review", + "datetimeOriginal": "by next Friday at 5 PM", + "datetimeEnglish": "by next Friday at 5 pm", + "elapsedMs": 6968, + "tokensPerSecond": 5.597014925373134, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"finish PCB layout review\",\"datetime_expression_original\":\"by next Friday at 5 PM\",\"datetime_expression_english\":\"by next Friday at 5 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_dentist", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-04-22 10:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "dentist appointment", + "datetimeOriginal": "April 22nd at 10:30 AM", + "datetimeEnglish": "April 22nd at 10:30 AM", + "elapsedMs": 7445, + "tokensPerSecond": 6.715916722632639, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"dentist appointment\",\"datetime_expression_original\":\"April 22nd at 10:30 AM\",\"datetime_expression_english\":\"April 22nd at 10:30 AM\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_note_no_time", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "pressure sensor for altitude detection", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6432, + "tokensPerSecond": 4.197761194029851, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"pressure sensor for altitude detection\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_this_afternoon", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-11 15:00:00.000 OK (via regex); ", + "intent": "reminder", + "title": "call the plumber", + "datetimeOriginal": "this afternoon at 3", + "datetimeEnglish": "this afternoon at 3 pm", + "elapsedMs": 6776, + "tokensPerSecond": 5.1652892561983474, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"call the plumber\",\"datetime_expression_original\":\"this afternoon at 3\",\"datetime_expression_english\":\"this afternoon at 3 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_tomorrow", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found ringa, tandläkare in \"ringa tandläkaren\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "ringa tandläkaren", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 7067, + "tokensPerSecond": 5.8016131314560635, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"ringa tandläkaren\",\"datetime_expression_original\":\"imorgon klockan 8\",\"datetime_expression_english\":\"tomorrow at 8 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_meeting", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found möte, projektgrupp in \"möte med projektgruppen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "möte med projektgruppen", + "datetimeOriginal": "på torsdag klockan 14", + "datetimeEnglish": "on Thursday at 2 pm", + "elapsedMs": 8184, + "tokensPerSecond": 7.697947214076247, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"möte med projektgruppen\",\n \"datetime_expression_original\": \"på torsdag klockan 14\",\n \"datetime_expression_english\": \"on Thursday at 2 pm\"\n }\n]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_note_no_time", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found köp, mjölk in \"köp mjölk\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "köp mjölk", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 7556, + "tokensPerSecond": 6.749602964531498, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"köp mjölk\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"köp bröd\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 1, + "countMatch": false, + "itemFailures": [ + "item[1] unexpected extra extraction" + ] + }, + { + "caseName": "sv_note_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found stegräknare, klocka in \"stegräknare i klockan\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "stegräknare i klockan", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6588, + "tokensPerSecond": 4.705525197328476, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"stegräknare i klockan\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_specific_date", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare in \"tandläkare\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-15 09:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "tandläkare", + "datetimeOriginal": "den 15 mars klockan halv 10", + "datetimeEnglish": "March 15th at 9:30 am", + "elapsedMs": 7507, + "tokensPerSecond": 6.6604502464366595, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"tandläkare\",\"datetime_expression_original\":\"den 15 mars klockan halv 10\",\"datetime_expression_english\":\"March 15th at 9:30 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "de_event_appointment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found arzt, termin in \"Arzttermin\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Arzttermin", + "datetimeOriginal": "am Donnerstag um 9 Uhr", + "datetimeEnglish": "Thursday at 9 am", + "elapsedMs": 6770, + "tokensPerSecond": 5.1698670605613, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Arzttermin\",\"datetime_expression_original\":\"am Donnerstag um 9 Uhr\",\"datetime_expression_english\":\"Thursday at 9 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "de_reminder_deadline", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bericht, chef, schicken in \"Bericht an Chef schicken\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 17:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "Bericht an Chef schicken", + "datetimeOriginal": "bis Freitag um 17 Uhr", + "datetimeEnglish": "on Friday at 5 pm", + "elapsedMs": 7025, + "tokensPerSecond": 5.551601423487544, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Bericht an Chef schicken\",\"datetime_expression_original\":\"bis Freitag um 17 Uhr\",\"datetime_expression_english\":\"on Friday at 5 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_specific_date", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found expense, report in \"expense report\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-20 09:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "expense report", + "datetimeOriginal": "March 20th at 9 AM", + "datetimeEnglish": "March 20th at 9 AM", + "elapsedMs": 7073, + "tokensPerSecond": 5.7966916442810685, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"expense report\",\"datetime_expression_original\":\"March 20th at 9 AM\",\"datetime_expression_english\":\"March 20th at 9 AM\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_birthday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found birthday in \"mom's birthday party\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-04-05 18:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "mom's birthday party", + "datetimeOriginal": "mom's birthday party on April 5th at 6 PM", + "datetimeEnglish": "mom's birthday party on April 5th at 6 pm", + "elapsedMs": 7549, + "tokensPerSecond": 6.755861703536892, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"mom's birthday party\",\"datetime_expression_original\":\"mom's birthday party on April 5th at 6 PM\",\"datetime_expression_english\":\"mom's birthday party on April 5th at 6 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_note_idea_short", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found rust, sensor in \"Try using Rust for the sensor driver\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "Try using Rust for the sensor driver", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6492, + "tokensPerSecond": 4.467036352433765, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"Try using Rust for the sensor driver\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_fika", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found fika, lisa in \"fika med Lisa\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 15:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "fika med Lisa", + "datetimeOriginal": "på fredag klockan 15", + "datetimeEnglish": "on Friday at 3 pm", + "elapsedMs": 7016, + "tokensPerSecond": 5.701254275940707, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"fika med Lisa\",\"datetime_expression_original\":\"på fredag klockan 15\",\"datetime_expression_english\":\"on Friday at 3 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_pickup_kids", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found hämta, barn in \"hämta barnen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 16:30:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "hämta barnen", + "datetimeOriginal": "imorgon klockan halv 5", + "datetimeEnglish": "tomorrow at 4:30 pm", + "elapsedMs": 7219, + "tokensPerSecond": 6.09502701205153, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"hämta barnen\",\"datetime_expression_original\":\"imorgon klockan halv 5\",\"datetime_expression_english\":\"tomorrow at 4:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_doctor", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found läkar in \"Läkartid\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-18 10:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Läkartid", + "datetimeOriginal": "den 18 mars klockan 10", + "datetimeEnglish": "March 18th at 10 am", + "elapsedMs": 7314, + "tokensPerSecond": 6.289308176100629, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Läkartid\",\"datetime_expression_original\":\"den 18 mars klockan 10\",\"datetime_expression_english\":\"March 18th at 10 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_medicine", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found medicin in \"Ta medicinen varje dag\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "Ta medicinen varje dag", + "datetimeOriginal": "klockan 8 på morgonen", + "datetimeEnglish": "at 8 am every day", + "elapsedMs": 7030, + "tokensPerSecond": 5.689900426742532, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Ta medicinen varje dag\",\"datetime_expression_original\":\"klockan 8 på morgonen\",\"datetime_expression_english\":\"at 8 am every day\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_dinner", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found middag, mamma in \"middag hos mamma och pappa\"; ", + "timeResolutionCorrect": false, + "timeResolutionDetail": "item[0]: got 2026-03-11 18:00:00.000, expected 2026-03-14 18:00:00.000 (diff 4320min, tolerance 5min); ", + "intent": "event", + "title": "middag hos mamma och pappa", + "datetimeOriginal": "middag klockan 18", + "datetimeEnglish": "lunch at 6 pm", + "elapsedMs": 7095, + "tokensPerSecond": 5.778717406624383, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"middag hos mamma och pappa\",\"datetime_expression_original\":\"middag klockan 18\",\"datetime_expression_english\":\"lunch at 6 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true, + "itemFailures": [ + "item[0] time: got 2026-03-11 18:00:00.000, expected 2026-03-14 18:00:00.000 (diff 4320min, tolerance 5min)" + ] + }, + { + "caseName": "sv_event_car_service", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bilservice in \"bilservice\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-17 08:00:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "bilservice", + "datetimeOriginal": "på tisdag klockan 8", + "datetimeEnglish": "on Tuesday at 8 am", + "elapsedMs": 6960, + "tokensPerSecond": 5.459770114942529, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"bilservice\",\"datetime_expression_original\":\"på tisdag klockan 8\",\"datetime_expression_english\":\"on Tuesday at 8 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_note_grocery", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found handla, potatis in \"handla potatis\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "handla potatis", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 8541, + "tokensPerSecond": 8.312843929282286, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"handla potatis\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"lök\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"grädde\",\"datetime_expression_original\":null,\"datetime_ex...", + "error": null, + "extractedCount": 3, + "expectedCount": 1, + "countMatch": false, + "itemFailures": [ + "item[1] unexpected extra extraction", + "item[2] unexpected extra extraction" + ] + }, + { + "caseName": "sv_event_parents_meeting", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found föräldramöte in \"föräldramöte\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-18 18:30:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "föräldramöte", + "datetimeOriginal": "onsdag klockan 18:30", + "datetimeEnglish": "on Wednesday at 6:30 pm", + "elapsedMs": 7450, + "tokensPerSecond": 6.308724832214765, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"föräldramöte\",\"datetime_expression_original\":\"onsdag klockan 18:30\",\"datetime_expression_english\":\"on Wednesday at 6:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_deadline", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found rapport, skicka in \"Skicka in rapporten\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 12:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "Skicka in rapporten", + "datetimeOriginal": "senast fredag klockan 12", + "datetimeEnglish": "on Friday at 12 pm", + "elapsedMs": 7158, + "tokensPerSecond": 6.007264599050013, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Skicka in rapporten\",\"datetime_expression_original\":\"senast fredag klockan 12\",\"datetime_expression_english\":\"on Friday at 12 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found compass in \"add compass widget\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "add compass widget", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6295, + "tokensPerSecond": 3.971405877680699, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"add compass widget\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_sv_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found e-paper, display in \"testa att använda e-paper display till nästa version\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "testa att använda e-paper display till nästa version", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6682, + "tokensPerSecond": 4.938641125411553, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"testa att använda e-paper display till nästa version\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_quick_reminder", + "passed": false, + "validJson": true, + "intentMatch": false, + "timePresenceMatch": false, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found water, plant in \"water the plants\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "water the plants", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6302, + "tokensPerSecond": 3.9669946048873377, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"water the plants\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true, + "itemFailures": [ + "item[0] intent: got \"note\", expected \"reminder\"", + "item[0] time presence: got false, expected true" + ] + }, + { + "caseName": "voice_short_sv_quick_reminder", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found ring, försäkring in \"ring försäkringsbolaget\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "ring försäkringsbolaget", + "datetimeOriginal": "imorgon", + "datetimeEnglish": "tomorrow", + "elapsedMs": 6685, + "tokensPerSecond": 4.93642483171279, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"ring försäkringsbolaget\",\"datetime_expression_original\":\"imorgon\",\"datetime_expression_english\":\"tomorrow\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_fragment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found batter in \"buy batteries\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "buy batteries", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6305, + "tokensPerSecond": 3.8065027755749408, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"buy batteries\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_sv_fragment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found boka, frisör in \"boka tid hos frisören\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "boka tid hos frisören", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6440, + "tokensPerSecond": 4.3478260869565215, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"boka tid hos frisören\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_en_rambling_reminder", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found dry clean in \"pick up the dry cleaning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "pick up the dry cleaning", + "datetimeOriginal": "tomorrow before noon", + "datetimeEnglish": "tomorrow before noon", + "elapsedMs": 6796, + "tokensPerSecond": 5.002942907592701, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"pick up the dry cleaning\",\"datetime_expression_original\":\"tomorrow before noon\",\"datetime_expression_english\":\"tomorrow before noon\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_sv_rambling_event", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found budget in \"budget meeting\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 10:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "budget meeting", + "datetimeOriginal": "på torsdag klockan 10", + "datetimeEnglish": "Thursday at 10 am", + "elapsedMs": 6991, + "tokensPerSecond": 5.435560005721642, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"budget meeting\",\"datetime_expression_original\":\"på torsdag klockan 10\",\"datetime_expression_english\":\"Thursday at 10 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_en_idea_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found light, sensor in \"integrate ambient light sensor\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "integrate ambient light sensor", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6545, + "tokensPerSecond": 4.125286478227655, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"integrate ambient light sensor\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_sv_idea_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sömnspårning in \"lägg till sömnspårning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "lägg till sömnspårning", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6691, + "tokensPerSecond": 4.334180242116275, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"lägg till sömnspårning\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_multi_fika_and_errand", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found fika, anna in \"fika med Anna\"; item[1]: found paket in \"lämna in paketet\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 10:00:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "fika med Anna", + "datetimeOriginal": "imorgon klockan 10", + "datetimeEnglish": "tomorrow at 10 am", + "elapsedMs": 8277, + "tokensPerSecond": 7.973903588256616, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"fika med Anna\",\"datetime_expression_original\":\"imorgon klockan 10\",\"datetime_expression_english\":\"tomorrow at 10 am\"},{\"intent\":\"note\",\"title\":\"lämna in paketet\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_three_items", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare, ring in \"ring tandläkaren\"; item[1]: found present in \"köp presenter\"; item[2]: found möte, chef in \"möte med chefen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); item[1]: no time check; item[2]: 2026-03-13 14:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "ring tandläkaren", + "datetimeOriginal": "imorgon klockan 9", + "datetimeEnglish": "tomorrow at 9 am", + "elapsedMs": 13035, + "tokensPerSecond": 12.58151131568853, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"ring tandläkaren\",\n \"datetime_expression_original\": \"imorgon klockan 9\",\n \"datetime_expression_english\": \"tomorrow at 9 am\"\n },\n {\n \"intent\": \"note\",\n \"title\": \"köp presenter\",\n \"datetime_expression_original\": null,\n \"datetime_express...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true + }, + { + "caseName": "en_multi_event_and_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found standup in \"team standup\"; item[1]: found notification, prototype in \"prototype notification system\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:15:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "team standup", + "datetimeOriginal": "tomorrow at 9:15", + "datetimeEnglish": "tomorrow at 9:15 am", + "elapsedMs": 10205, + "tokensPerSecond": 10.289073983341499, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"team standup\",\n \"datetime_expression_original\": \"tomorrow at 9:15\",\n \"datetime_expression_english\": \"tomorrow at 9:15 am\"\n },\n {\n \"intent\": \"note\",\n \"title\": \"prototype notification system\",\n \"datetime_expression_original\": null,\n \"datet...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "voice_long_multi_sv", + "passed": false, + "validJson": true, + "intentMatch": false, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found gym in \"gå till gymmet\"; item[1]: found projekt in \"prata om projektet\"; item[2]: found kattsand in \"köpa kattsand\"; ", + "timeResolutionCorrect": false, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); item[1]: got 2026-03-11 15:00:00.000, expected 2026-03-12 15:00:00.000 (diff 1440min, tolerance 5min); item[2]: no time check; ", + "intent": "reminder", + "title": "gå till gymmet", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 13049, + "tokensPerSecond": 12.568012874549774, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"gå till gymmet\",\n \"datetime_expression_original\": \"imorgon klockan 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"prata om projektet\",\n \"datetime_expression_original\": \"på eftermiddagen typ k...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true, + "itemFailures": [ + "item[1] time: got 2026-03-11 15:00:00.000, expected 2026-03-12 15:00:00.000 (diff 1440min, tolerance 5min)", + "item[2] intent: got \"reminder\", expected \"note\"" + ] + }, + { + "caseName": "sv_multi_dev_tasks_swenglish", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found clean, kod in \"cleana upp klockans kod\"; item[1]: found testa in \"testa att avbryta en kalender tilläggning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "note", + "title": "cleana upp klockans kod", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 10023, + "tokensPerSecond": 10.07682330639529, + "outputPreview": "[\n {\n \"intent\": \"note\",\n \"title\": \"cleana upp klockans kod\",\n \"datetime_expression_original\": null,\n \"datetime_expression_english\": null\n },\n {\n \"intent\": \"note\",\n \"title\": \"testa att avbryta en kalender tilläggning\",\n \"datetime_expression_original\": null,\n \"datetime_expre...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_casual_planning", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bugg, bluetooth in \"fixa buggen med Bluetooth-anslutningen\"; item[1]: found refaktor, sensor in \"refaktorera sensor-koden\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "note", + "title": "fixa buggen med Bluetooth-anslutningen", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 7934, + "tokensPerSecond": 7.436349886564154, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"fixa buggen med Bluetooth-anslutningen\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"refaktorera sensor-koden\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_note_swenglish_long", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sync, notification, feature in \"add feature to sync notifications via BLE\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "add feature to sync notifications via BLE", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6552, + "tokensPerSecond": 4.426129426129426, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"add feature to sync notifications via BLE\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_multi_two_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found dog, pick in \"pick up the dog\"; item[1]: found light in \"turn off all lights\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "reminder", + "title": "pick up the dog", + "datetimeOriginal": "tomorrow at 5 pm", + "datetimeEnglish": "tomorrow at 5 pm", + "elapsedMs": 8378, + "tokensPerSecond": 8.11649558367152, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"pick up the dog\",\"datetime_expression_original\":\"tomorrow at 5 pm\",\"datetime_expression_english\":\"tomorrow at 5 pm\"},{\"intent\":\"reminder\",\"title\":\"turn off all lights\",\"datetime_expression_original\":\"at 9\",\"datetime_expression_english\":\"tomorrow at 9 pm\"}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_reminder_and_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found plumber in \"call the plumber\"; item[1]: found light, bulb in \"buy new light bulbs\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "reminder", + "title": "call the plumber", + "datetimeOriginal": "call the plumber at 3 pm tomorrow", + "datetimeEnglish": "call the plumber at 3 pm tomorrow", + "elapsedMs": 8096, + "tokensPerSecond": 7.781620553359684, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"call the plumber\",\"datetime_expression_original\":\"call the plumber at 3 pm tomorrow\",\"datetime_expression_english\":\"call the plumber at 3 pm tomorrow\"},{\"intent\":\"note\",\"title\":\"buy new light bulbs\",\"datetime_expression_original\":null,\"datetime_expression_english\":null...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_two_events", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sarah in \"Meeting with Sarah\"; item[1]: found lunch in \"lunch with the team\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-16 10:00:00.000 OK (via chrono+adjusted); item[1]: 2026-03-18 12:00:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "Meeting with Sarah", + "datetimeOriginal": "on Monday at 10 am", + "datetimeEnglish": "on Monday at 10 am", + "elapsedMs": 8572, + "tokensPerSecond": 8.399440037330846, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Meeting with Sarah\",\"datetime_expression_original\":\"on Monday at 10 am\",\"datetime_expression_english\":\"on Monday at 10 am\"},{\"intent\":\"event\",\"title\":\"lunch with the team\",\"datetime_expression_original\":\"on Wednesday at noon\",\"datetime_expression_english\":\"on Wednesday at...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_three_mixed", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; item[1]: found lunch in \"have lunch with Mike\"; item[2]: found grocer in \"buy groceries\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; item[2]: no time check; ", + "intent": "reminder", + "title": "go for a run", + "datetimeOriginal": "tomorrow at 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 12174, + "tokensPerSecond": 12.074913750616068, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"go for a run\",\n \"datetime_expression_original\": \"tomorrow at 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"have lunch with Mike\",\n \"datetime_expression_original\": \"at noon\",\n \"datetime_ex...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true + }, + { + "caseName": "sv_multi_event_and_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare in \"tandläkare\"; item[1]: found handla in \"handla mat\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-15 09:30:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "tandläkare", + "datetimeOriginal": "den 15 mars klockan halv 10", + "datetimeEnglish": "March 15th at 9:30 am", + "elapsedMs": 8567, + "tokensPerSecond": 8.40434224349247, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"tandläkare\",\"datetime_expression_original\":\"den 15 mars klockan halv 10\",\"datetime_expression_english\":\"March 15th at 9:30 am\"},{\"intent\":\"note\",\"title\":\"handla mat\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "de_multi_two_events", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found arzt in \"Arzttermin\"; item[1]: found zahnarzt in \"Zahnarzt\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); item[1]: 2026-03-13 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Arzttermin", + "datetimeOriginal": "am Donnerstag um 9 Uhr", + "datetimeEnglish": "Thursday at 9 am", + "elapsedMs": 10491, + "tokensPerSecond": 10.675817367267182, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"Arzttermin\",\n \"datetime_expression_original\": \"am Donnerstag um 9 Uhr\",\n \"datetime_expression_english\": \"Thursday at 9 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"Zahnarzt\",\n \"datetime_expression_original\": \"am Freitag um 14 Uhr\",\n \"dateti...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_same_day_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found electrician in \"call the electrician\"; item[1]: found kids in \"pick up the kids from school\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-11 15:00:00.000 OK (via chrono); item[1]: 2026-03-11 18:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "call the electrician", + "datetimeOriginal": "Today at 3 pm", + "datetimeEnglish": "Today at 3 pm", + "elapsedMs": 10440, + "tokensPerSecond": 10.632183908045977, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"call the electrician\",\n \"datetime_expression_original\": \"Today at 3 pm\",\n \"datetime_expression_english\": \"Today at 3 pm\"\n },\n {\n \"intent\": \"reminder\",\n \"title\": \"pick up the kids from school\",\n \"datetime_expression_original\": \"at 6 pm\",\n...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_two_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found gym in \"gå till gymmet\"; item[1]: found paket in \"hämta paketet på posten\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); item[1]: 2026-03-12 15:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "gå till gymmet", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 10867, + "tokensPerSecond": 11.134627772154227, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"gå till gymmet\",\n \"datetime_expression_original\": \"imorgon klockan 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"reminder\",\n \"title\": \"hämta paketet på posten\",\n \"datetime_expression_original\": \"klockan 15\",\n ...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + } + ] + } + ] +} \ No newline at end of file diff --git a/ai_testbench/bin/test_time_extraction.dart b/ai_testbench/bin/test_time_extraction.dart new file mode 100644 index 0000000..b2a08f2 --- /dev/null +++ b/ai_testbench/bin/test_time_extraction.dart @@ -0,0 +1,487 @@ +/// CLI testbench for the voice memo → time extraction pipeline. +/// +/// Run with: +/// cd ai_testbench +/// LD_LIBRARY_PATH=native_libs dart run bin/test_time_extraction.dart +/// +/// Options: +/// --model Model file in models/ (default: Qwen3.5-2B-Q4_K_M.gguf) +/// --verbose Print full raw LLM output +/// +/// Requires native_libs/libllama.so built first. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:chrono_dart/chrono_dart.dart'; +import 'package:llama_cpp_dart/llama_cpp_dart.dart'; + +import '../lib/prompts/time_extraction_prompts.dart'; +import '../lib/services/time_expression_resolver.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class TestCase { + final String name; + final String transcript; + final String expectedIntent; // 'reminder', 'event', 'note' + final String? expectedTimeEnglish; // null = no time expected + final DateTime? expectedDateTime; // null = no time expected + final int toleranceMinutes; // for relative times like "in 30 minutes" + + const TestCase({ + required this.name, + required this.transcript, + required this.expectedIntent, + this.expectedTimeEnglish, + this.expectedDateTime, + this.toleranceMinutes = 2, + }); +} + +// ── LLM response structure ────────────────────────────────────────────── + +class LlmExtractionResult { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String rawOutput; + + const LlmExtractionResult({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + required this.rawOutput, + }); +} + +// ── Test result ───────────────────────────────────────────────────────── + +enum TestStatus { pass, fail, partial } + +class TestResult { + final TestCase testCase; + final LlmExtractionResult? llmResult; + final ResolvedTime? resolvedTime; + final Duration llmDuration; + final int tokenCount; + final TestStatus status; + final List failures; + + const TestResult({ + required this.testCase, + this.llmResult, + this.resolvedTime, + required this.llmDuration, + required this.tokenCount, + required this.status, + this.failures = const [], + }); +} + +// ── Main ──────────────────────────────────────────────────────────────── + +void main(List args) async { + // Parse CLI args + var modelFile = 'Qwen3.5-2B-Q4_K_M.gguf'; + var verbose = false; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--model' && i + 1 < args.length) { + modelFile = args[++i]; + } else if (args[i] == '--verbose') { + verbose = true; + } + } + + print('╔══════════════════════════════════════════════════════════╗'); + print('║ ZSWatch Time Extraction Testbench — CLI ║'); + print('╚══════════════════════════════════════════════════════════╝'); + print(''); + + // ── 1. Resolve paths ───────────────────────────────────────────────── + final scriptDir = File(Platform.script.toFilePath()).parent.parent.path; + final nativeLib = '$scriptDir/native_libs/libllama.so'; + final modelPath = '$scriptDir/models/$modelFile'; + + if (!File(nativeLib).existsSync()) { + stderr.writeln('ERROR: Native library not found at $nativeLib'); + stderr.writeln('Build it first — see README.'); + exit(1); + } + if (!File(modelPath).existsSync()) { + stderr.writeln('ERROR: Model not found at $modelPath'); + stderr.writeln('Available models:'); + Directory('$scriptDir/models') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.gguf')) + .forEach((f) => stderr.writeln(' ${f.uri.pathSegments.last}')); + exit(1); + } + + // ── 2. Set native lib path ──────────────────────────────────────────── + print('[1/4] Setting native library path'); + Llama.libraryPath = nativeLib; + + // ── 3. Load model ───────────────────────────────────────────────────── + print('[2/4] Loading model: ${modelFile}'); + final sw = Stopwatch()..start(); + + late Llama llama; + try { + llama = Llama( + modelPath, + modelParams: ModelParams() + ..nGpuLayers = 0 + ..mainGpu = -1, + contextParams: ContextParams() + ..nCtx = 2048 + ..nBatch = 512 + ..nThreads = 4 + ..nThreadsBatch = 4 + ..nPredict = 300, + samplerParams: SamplerParams() + ..temp = 0.1 + ..greedy = false + ..topK = 40 + ..topP = 0.9 + ..penaltyRepeat = 1.1, + verbose: false, + ); + } catch (e) { + stderr.writeln('FAILED to load model: $e'); + exit(1); + } + sw.stop(); + print(' Model loaded in ${sw.elapsed.inMilliseconds}ms ✓'); + print(''); + + // ── 4. Define reference time ────────────────────────────────────────── + // Use a fixed reference time so tests are deterministic + final referenceTime = DateTime(2026, 3, 9, 10, 15); // Monday March 9, 10:15 + print('[3/4] Reference time: $referenceTime (Monday)'); + print(''); + + // ── 5. Define test cases ────────────────────────────────────────────── + final testCases = [ + TestCase( + name: 'EN: Simple reminder with time', + transcript: 'Remind me tomorrow at 10 am to buy milk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10 am', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'SV: Reminder with time', + transcript: 'påminn mig imorgon klockan 10 att köpa mjölk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'DE: Reminder with time', + transcript: 'erinnere mich morgen um 10 milch zu kaufen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'EN: Meeting next Tuesday', + transcript: 'meeting with John next Tuesday at 2 pm', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), // next Tue from Mon Mar 9 + ), + TestCase( + name: 'EN: No time mentioned', + transcript: 'remember to buy milk', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TestCase( + name: 'SV: Relative minutes', + transcript: 'ring tandläkaren om 30 minuter', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 30 minutes', + expectedDateTime: referenceTime.add(const Duration(minutes: 30)), + toleranceMinutes: 5, + ), + TestCase( + name: 'FR: Friday at 3pm', + transcript: "rappelle-moi vendredi à 15h d'appeler le médecin", + expectedIntent: 'reminder', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), // next Friday + ), + TestCase( + name: 'EN: Specific date', + transcript: 'dentist appointment on March 15th at 9:30', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TestCase( + name: 'SV: No time, just task', + transcript: 'köp bröd på vägen hem', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TestCase( + name: 'EN: This afternoon', + transcript: 'call the plumber this afternoon at 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + ]; + + // ── 6. Run tests ───────────────────────────────────────────────────── + print('[4/4] Running ${testCases.length} test cases...'); + print(''); + + final chatml = ChatMLFormat(); + final resolver = TimeExpressionResolver(); + final results = []; + + for (var i = 0; i < testCases.length; i++) { + final tc = testCases[i]; + print('─── Test ${i + 1}/${testCases.length}: ${tc.name} ───────────────────────'); + print(' Input: "${tc.transcript}"'); + + // Build prompt + final formatted = chatml.formatMessages([ + {'role': 'system', 'content': TimeExtractionPrompts.systemPrompt}, + { + 'role': 'user', + 'content': TimeExtractionPrompts.userMessage( + transcript: tc.transcript, + now: referenceTime, + timezone: 'Europe/Stockholm', + ), + }, + ]); + + // Run inference + llama.clear(); + llama.setPrompt(formatted); + + final genSw = Stopwatch()..start(); + final buffer = StringBuffer(); + int tokenCount = 0; + + try { + await for (final chunk in llama.generateText()) { + buffer.write(chunk); + tokenCount++; + if (tokenCount >= 300) break; + } + } catch (e) { + stderr.writeln(' ERROR during generation: $e'); + results.add(TestResult( + testCase: tc, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: TestStatus.fail, + failures: ['LLM generation error: $e'], + )); + print(''); + continue; + } + + genSw.stop(); + + String raw = buffer.toString().trim(); + // Strip end-of-turn tokens + raw = raw.replaceAll('<|im_end|>', '').trim(); + // Strip thinking blocks (Qwen3 models may use these) + raw = raw.replaceAll(RegExp(r'.*?', dotAll: true), '').trim(); + + final secs = genSw.elapsed.inMilliseconds / 1000; + print(' LLM time: ${secs.toStringAsFixed(2)}s (~${(tokenCount / secs).toStringAsFixed(1)} tok/s)'); + + if (verbose) { + print(' Raw output:'); + print(' $raw'); + } + + // Parse JSON from LLM output + LlmExtractionResult? llmResult; + try { + final jsonStart = raw.indexOf('{'); + final jsonEnd = raw.lastIndexOf('}'); + if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) { + throw FormatException('No JSON object found in output'); + } + final jsonStr = raw.substring(jsonStart, jsonEnd + 1); + final parsed = jsonDecode(jsonStr) as Map; + + llmResult = LlmExtractionResult( + intent: parsed['intent'] as String?, + title: parsed['title'] as String?, + datetimeExpressionOriginal: + parsed['datetime_expression_original'] as String?, + datetimeExpressionEnglish: + parsed['datetime_expression_english'] as String?, + rawOutput: raw, + ); + + print(' LLM result:'); + print(' intent: ${llmResult.intent}'); + print(' title: ${llmResult.title}'); + print(' time (orig): ${llmResult.datetimeExpressionOriginal}'); + print(' time (EN): ${llmResult.datetimeExpressionEnglish}'); + } catch (e) { + print(' ❌ JSON parse failed: $e'); + if (!verbose) { + print(' Raw output: $raw'); + } + results.add(TestResult( + testCase: tc, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: TestStatus.fail, + failures: ['JSON parse failed: $e'], + )); + print(''); + continue; + } + + // Resolve time expression with chrono + ResolvedTime? resolvedTime; + // Try English translation first, fall back to original expression + final timeExpr = llmResult.datetimeExpressionEnglish ?? + llmResult.datetimeExpressionOriginal; + if (timeExpr != null) { + resolvedTime = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + if (resolvedTime != null) { + print(' Chrono parse: ${resolvedTime.dateTime} (via ${resolvedTime.method})'); + } else { + print(' Chrono parse: FAILED — could not resolve "$timeExpr"'); + } + } else { + print(' Chrono parse: N/A (no time expression)'); + } + + // Evaluate results + final failures = []; + + // Check 1: Intent + final intentMatch = _intentMatches(llmResult.intent, tc.expectedIntent); + if (!intentMatch) { + failures.add( + 'Intent mismatch: got "${llmResult.intent}", expected "${tc.expectedIntent}"'); + } + + // Check 2: Time expression present/absent + if (tc.expectedTimeEnglish != null && + llmResult.datetimeExpressionEnglish == null) { + failures.add('Expected time expression but got null'); + } + if (tc.expectedTimeEnglish == null && + llmResult.datetimeExpressionEnglish != null) { + failures.add( + 'Expected no time expression but got "${llmResult.datetimeExpressionEnglish}"'); + } + + // Check 3: Chrono parse succeeded when expected + if (tc.expectedDateTime != null && resolvedTime == null) { + failures.add('Chrono failed to parse time expression'); + } + if (tc.expectedDateTime == null && resolvedTime != null) { + failures.add( + 'Expected no resolved time but got ${resolvedTime.dateTime}'); + } + + // Check 4: DateTime accuracy + if (tc.expectedDateTime != null && resolvedTime != null) { + final diff = + resolvedTime.dateTime.difference(tc.expectedDateTime!).inMinutes.abs(); + if (diff > tc.toleranceMinutes) { + failures.add( + 'DateTime mismatch: got ${resolvedTime.dateTime}, expected ${tc.expectedDateTime} (diff: ${diff}min, tolerance: ${tc.toleranceMinutes}min)'); + } + } + + final status = failures.isEmpty + ? TestStatus.pass + : (failures.length == 1 && !failures.first.contains('Intent')) + ? TestStatus.partial + : TestStatus.fail; + + if (failures.isEmpty) { + print(' ✅ PASS'); + } else { + for (final f in failures) { + print(' ❌ $f'); + } + } + + if (tc.expectedDateTime != null) { + print(' Expected: ${tc.expectedDateTime}'); + } + + results.add(TestResult( + testCase: tc, + llmResult: llmResult, + resolvedTime: resolvedTime, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: status, + failures: failures, + )); + + print(''); + } + + // ── 7. Summary ──────────────────────────────────────────────────────── + llama.dispose(); + + final passed = results.where((r) => r.status == TestStatus.pass).length; + final partial = results.where((r) => r.status == TestStatus.partial).length; + final failed = results.where((r) => r.status == TestStatus.fail).length; + final totalLlmTime = results.fold( + Duration.zero, (sum, r) => sum + r.llmDuration); + + print('╔══════════════════════════════════════════════════════════╗'); + print('║ Results: $passed passed, $partial partial, $failed failed ' + 'out of ${testCases.length} tests'); + print('║ Total LLM time: ${(totalLlmTime.inMilliseconds / 1000).toStringAsFixed(1)}s'); + print('║ Model: $modelFile'); + print('╚══════════════════════════════════════════════════════════╝'); + + // Print detailed failure summary + if (failed + partial > 0) { + print(''); + print('Failed/partial tests:'); + for (final r in results.where( + (r) => r.status == TestStatus.fail || r.status == TestStatus.partial)) { + print(' ${r.testCase.name}:'); + for (final f in r.failures) { + print(' - $f'); + } + } + } + + exit(failed > 0 ? 1 : 0); +} + +/// Compare intents loosely — 'event' matches 'event', 'reminder' matches +/// 'reminder'. For 'note', also accept 'task' since the boundary is fuzzy. +bool _intentMatches(String? got, String expected) { + if (got == null) return false; + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // Allow note ↔ task since models often confuse these for simple items + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; +} diff --git a/ai_testbench/lib/benchmark_main.dart b/ai_testbench/lib/benchmark_main.dart new file mode 100644 index 0000000..ba49133 --- /dev/null +++ b/ai_testbench/lib/benchmark_main.dart @@ -0,0 +1,315 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'screens/testbench_screen.dart'; +import 'services/model_benchmark_service.dart'; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + final config = _parseConfig(args); + final modelDir = config.modelDir; + final hasModels = Directory(modelDir).existsSync(); + + if (!hasModels) { + stdout.writeln('[BenchmarkRunner] No models directory found at $modelDir'); + exitCode = 1; + return; + } else { + final modelPaths = _discoverModelPaths(modelDir); + final modelNames = modelPaths + .map((path) => path.split(Platform.pathSeparator).last) + .toList(); + + if (config.headless) { + if (modelPaths.isEmpty) { + stdout.writeln('[BenchmarkRunner] No .gguf files found yet'); + exitCode = 1; + return; + } + + final filteredModelPaths = _filterModelPaths( + modelPaths, + config.modelFilter, + ); + if (filteredModelPaths.isEmpty) { + stdout.writeln('[BenchmarkRunner] No matching .gguf files found'); + if (config.modelFilter != null) { + stdout.writeln('[BenchmarkRunner] Model filter: ${config.modelFilter}'); + } + exitCode = 1; + return; + } + + final selectedCases = _selectBenchmarkCases( + caseFilter: config.caseFilter, + caseLimit: config.caseLimit, + ); + if (selectedCases.isEmpty) { + stdout.writeln('[BenchmarkRunner] No benchmark cases matched the request'); + if (config.caseFilter != null) { + stdout.writeln('[BenchmarkRunner] Case filter: ${config.caseFilter}'); + } + exitCode = 1; + return; + } + + await _runHeadlessBenchmark( + modelPaths: filteredModelPaths, + outputPath: config.outputPath, + selectedCases: selectedCases, + modelFilter: config.modelFilter, + caseFilter: config.caseFilter, + ); + return; + } + + stdout.writeln('[BenchmarkRunner] Launching benchmark UI'); + stdout.writeln('[BenchmarkRunner] Models directory: $modelDir'); + if (modelNames.isEmpty) { + stdout.writeln('[BenchmarkRunner] No .gguf files found yet'); + } else { + for (final modelName in modelNames) { + stdout.writeln(' - $modelName'); + } + } + } + + runApp( + BenchmarkApp( + modelDirectory: modelDir, + ), + ); +} + +class _RunnerConfig { + const _RunnerConfig({ + required this.headless, + required this.modelDir, + required this.outputPath, + required this.modelFilter, + required this.caseFilter, + required this.caseLimit, + }); + + final bool headless; + final String modelDir; + final String? outputPath; + final String? modelFilter; + final String? caseFilter; + final int? caseLimit; +} + +_RunnerConfig _parseConfig(List args) { + bool hasFlag(String flag) => args.contains(flag); + String? readValue(String name) { + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == name) { + return args[i + 1]; + } + } + return null; + } + + final modelDir = readValue('--model-dir') ?? Directory('models').absolute.path; + final outputPath = readValue('--output'); + final modelFilter = readValue('--model'); + final caseFilter = readValue('--case'); + final caseLimitValue = readValue('--case-limit'); + final caseLimit = caseLimitValue == null ? null : int.tryParse(caseLimitValue); + final headless = hasFlag('--headless') || Platform.environment['AI_BENCH_HEADLESS'] == '1'; + + return _RunnerConfig( + headless: headless, + modelDir: modelDir, + outputPath: outputPath, + modelFilter: modelFilter, + caseFilter: caseFilter, + caseLimit: caseLimit, + ); +} + +List _discoverModelPaths(String modelDir) { + return Directory(modelDir) + .listSync() + .whereType() + .map((file) => file.path) + .where((path) => path.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); +} + +List _filterModelPaths(List modelPaths, String? modelFilter) { + if (modelFilter == null || modelFilter.isEmpty) { + return modelPaths; + } + + final filter = modelFilter.toLowerCase(); + return modelPaths + .where((path) => path.toLowerCase().contains(filter)) + .toList(growable: false); +} + +List _selectBenchmarkCases({ + String? caseFilter, + int? caseLimit, +}) { + Iterable selected = ModelBenchmarkService.benchmarkCases; + + if (caseFilter != null && caseFilter.isNotEmpty) { + final filter = caseFilter.toLowerCase(); + selected = selected.where((benchmarkCase) { + return benchmarkCase.name.toLowerCase().contains(filter) || + benchmarkCase.transcript.toLowerCase().contains(filter); + }); + } + + if (caseLimit != null && caseLimit > 0) { + selected = selected.take(caseLimit); + } + + return selected.toList(growable: false); +} + +Future _runHeadlessBenchmark({ + required List modelPaths, + required String? outputPath, + required List selectedCases, + required String? modelFilter, + required String? caseFilter, +}) async { + final service = ModelBenchmarkService(); + + stdout.writeln('[BenchmarkRunner] Running headless benchmark'); + stdout.writeln('[BenchmarkRunner] Model count: ${modelPaths.length}'); + for (final modelPath in modelPaths) { + stdout.writeln(' - ${modelPath.split(Platform.pathSeparator).last}'); + } + if (modelFilter != null && modelFilter.isNotEmpty) { + stdout.writeln('[BenchmarkRunner] Model filter: $modelFilter'); + } + stdout.writeln('[BenchmarkRunner] Case count: ${selectedCases.length}'); + if (caseFilter != null && caseFilter.isNotEmpty) { + stdout.writeln('[BenchmarkRunner] Case filter: $caseFilter'); + } + for (final benchmarkCase in selectedCases) { + stdout.writeln(' * ${benchmarkCase.name}'); + } + + final startedAt = DateTime.now().toUtc(); + final results = await service.runForModels( + modelPaths, + selectedCases: selectedCases, + onProgress: (progress) { + final completed = progress.completedRuns; + final total = progress.totalRuns; + stdout.writeln( + '[BenchmarkRunner] Progress $completed/$total ' + 'model=${progress.currentModelName} ' + 'case=${progress.currentCaseName}', + ); + }, + ); + final finishedAt = DateTime.now().toUtc(); + + final report = { + 'startedAt': startedAt.toIso8601String(), + 'finishedAt': finishedAt.toIso8601String(), + 'modelCount': results.length, + 'caseCount': selectedCases.length, + if (modelFilter != null && modelFilter.isNotEmpty) 'modelFilter': modelFilter, + if (caseFilter != null && caseFilter.isNotEmpty) 'caseFilter': caseFilter, + 'results': results.map(_serializeModelResult).toList(growable: false), + }; + + final resolvedOutputPath = outputPath ?? + '${Directory.current.path}${Platform.pathSeparator}benchmark_results_${DateTime.now().millisecondsSinceEpoch}.json'; + final outputFile = File(resolvedOutputPath); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(report)); + + stdout.writeln('[BenchmarkRunner] Headless benchmark complete'); + stdout.writeln('[BenchmarkRunner] Results written to ${outputFile.path}'); + + final ranked = [...results] + ..sort((a, b) { + final passCompare = b.passedCases.compareTo(a.passedCases); + if (passCompare != 0) return passCompare; + return b.avgTokensPerSecond.compareTo(a.avgTokensPerSecond); + }); + + for (final result in ranked) { + stdout.writeln( + '[BenchmarkRunner] Summary ${result.modelName}: ' + '${result.passedCases}/${result.cases.length} strict passes, ' + '${result.avgTokensPerSecond.toStringAsFixed(1)} tok/s avg, ' + '${result.totalElapsed.inSeconds}s total', + ); + } +} + +Map _serializeModelResult(BenchmarkModelResult result) { + return { + 'modelPath': result.modelPath, + 'modelName': result.modelName, + 'passedCases': result.passedCases, + 'totalCases': result.cases.length, + 'avgTokensPerSecond': result.avgTokensPerSecond, + 'totalElapsedMs': result.totalElapsed.inMilliseconds, + 'cases': result.cases.map((caseResult) { + return { + 'caseName': caseResult.caseName, + 'passed': caseResult.passed, + 'validJson': caseResult.validJson, + 'intentMatch': caseResult.intentMatch, + 'timePresenceMatch': caseResult.timePresenceMatch, + 'titleLanguageMatch': caseResult.titleLanguageMatch, + 'titleLanguageDetail': caseResult.titleLanguageDetail, + 'timeResolutionCorrect': caseResult.timeResolutionCorrect, + 'timeResolutionDetail': caseResult.timeResolutionDetail, + 'intent': caseResult.intent, + 'title': caseResult.title, + 'datetimeOriginal': caseResult.datetimeOriginal, + 'datetimeEnglish': caseResult.datetimeEnglish, + 'elapsedMs': caseResult.elapsed.inMilliseconds, + 'tokensPerSecond': caseResult.tokensPerSecond, + 'outputPreview': caseResult.outputPreview, + 'error': caseResult.error, + 'extractedCount': caseResult.extractedCount, + 'expectedCount': caseResult.expectedCount, + 'countMatch': caseResult.countMatch, + if (caseResult.itemFailures.isNotEmpty) + 'itemFailures': caseResult.itemFailures, + }; + }).toList(growable: false), + }; +} + +class BenchmarkApp extends StatelessWidget { + const BenchmarkApp({ + super.key, + required this.modelDirectory, + }); + + final String modelDirectory; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ZSWatch AI Benchmark', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6750A4), + brightness: Brightness.dark, + ), + ), + home: TestbenchScreen( + autoStartBenchmark: true, + searchDirectories: [modelDirectory], + ), + ); + } +} diff --git a/ai_testbench/lib/correction_main.dart b/ai_testbench/lib/correction_main.dart new file mode 100644 index 0000000..81f9ac4 --- /dev/null +++ b/ai_testbench/lib/correction_main.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'services/correction_benchmark_service.dart'; + +/// Headless entry point for correction benchmark. +/// +/// Usage: +/// AI_BENCH_HEADLESS=1 ./build/linux/x64/release/bundle/ai_testbench \ +/// --headless-correction --model-dir /tmp/bench_single_model \ +/// --output /tmp/correction_bench.json +/// +/// Or interactively: +/// ./build/linux/x64/release/bundle/ai_testbench \ +/// --headless-correction --model Qwen3.5-2B-Q4_K_M.gguf +Future runHeadlessCorrectionBenchmark(List args) async { + String? readValue(String name) { + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == name) return args[i + 1]; + } + return null; + } + + final modelDir = readValue('--model-dir') ?? Directory('models').absolute.path; + final outputPath = readValue('--output'); + + final modelPaths = Directory(modelDir) + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + if (modelPaths.isEmpty) { + stdout.writeln('[CorrectionBench] No .gguf files found in $modelDir'); + exitCode = 1; + return; + } + + stdout.writeln('╔═══════════════════════════════════════════════════════╗'); + stdout.writeln('║ Correction Benchmark — Headless ║'); + stdout.writeln('╚═══════════════════════════════════════════════════════╝'); + stdout.writeln('[CorrectionBench] Models: ${modelPaths.length}'); + for (final p in modelPaths) { + stdout.writeln(' - ${p.split(Platform.pathSeparator).last}'); + } + stdout.writeln('[CorrectionBench] Cases: ${CorrectionBenchmarkService.benchmarkCases.length}'); + + final service = CorrectionBenchmarkService(); + final startedAt = DateTime.now().toUtc(); + + final results = await service.runForModels( + modelPaths, + onProgress: (p) { + stdout.writeln( + '[CorrectionBench] ${p.completedRuns}/${p.totalRuns} ' + 'model=${p.currentModelName} case=${p.currentCaseName}', + ); + }, + ); + + final finishedAt = DateTime.now().toUtc(); + + // ── Print summary ─────────────────────────────────────────────────── + + stdout.writeln(''); + stdout.writeln('${'═' * 70}'); + stdout.writeln(' CORRECTION BENCHMARK RESULTS'); + stdout.writeln('${'═' * 70}'); + + for (final model in results) { + stdout.writeln(''); + stdout.writeln('┌── ${model.modelName} ── ' + '${model.passedCases}/${model.cases.length} passed ──┐'); + + for (final c in model.cases) { + final tag = c.passed ? 'PASS' : 'FAIL'; + final reasons = []; + + if (!c.modificationMatch) { + reasons.add(c.modificationExpected + ? 'NOT_MODIFIED' + : 'UNEXPECTED_MODIFICATION'); + } + if (!c.allMustContainFound) { + reasons.add('MISSING[${c.missingKeywords.join(",")}]'); + } + if (!c.allMustNotContainAbsent) { + reasons.add('UNWANTED[${c.unwantedKeywordsFound.join(",")}]'); + } + if (!c.cleanOutput) { + reasons.add('DIRTY(${c.cleanOutputDetail})'); + } + if (c.error != null) { + reasons.add('ERROR'); + } + + final reasonStr = reasons.isEmpty ? '' : ' [${reasons.join(", ")}]'; + stdout.writeln('│ $tag ${c.caseName}$reasonStr'); + + // Always print input vs output for failed cases + if (!c.passed) { + stdout.writeln('│ input: "${c.input}"'); + stdout.writeln('│ expected: "${c.expectedOutput}"'); + stdout.writeln('│ got: "${c.actualOutput}"'); + } + } + stdout.writeln('└${'─' * 68}┘'); + } + + // ── JSON output ───────────────────────────────────────────────────── + + if (outputPath != null) { + final report = { + 'startedAt': startedAt.toIso8601String(), + 'finishedAt': finishedAt.toIso8601String(), + 'modelCount': results.length, + 'caseCount': CorrectionBenchmarkService.benchmarkCases.length, + 'results': results.map((r) => { + 'modelPath': r.modelPath, + 'modelName': r.modelName, + 'passedCases': r.passedCases, + 'totalCases': r.cases.length, + 'avgTokensPerSecond': r.avgTokensPerSecond, + 'totalElapsedMs': r.totalElapsed.inMilliseconds, + 'cases': r.cases.map((c) => { + 'caseName': c.caseName, + 'passed': c.passed, + 'wasModified': c.wasModified, + 'modificationExpected': c.modificationExpected, + 'modificationMatch': c.modificationMatch, + 'allMustContainFound': c.allMustContainFound, + 'missingKeywords': c.missingKeywords, + 'allMustNotContainAbsent': c.allMustNotContainAbsent, + 'unwantedKeywordsFound': c.unwantedKeywordsFound, + 'cleanOutput': c.cleanOutput, + 'cleanOutputDetail': c.cleanOutputDetail, + 'input': c.input, + 'expectedOutput': c.expectedOutput, + 'actualOutput': c.actualOutput, + 'elapsedMs': c.elapsed.inMilliseconds, + 'tokensPerSecond': c.tokensPerSecond, + 'error': c.error, + }).toList(growable: false), + }).toList(growable: false), + }; + + final file = File(outputPath); + file.parent.createSync(recursive: true); + file.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(report)); + stdout.writeln('\n[CorrectionBench] JSON results: ${file.path}'); + } + + // ── Ranked summary ────────────────────────────────────────────────── + + final ranked = [...results] + ..sort((a, b) => b.passedCases.compareTo(a.passedCases)); + stdout.writeln(''); + for (final r in ranked) { + stdout.writeln( + '[CorrectionBench] ${r.modelName}: ' + '${r.passedCases}/${r.cases.length} passed, ' + '${r.avgTokensPerSecond.toStringAsFixed(1)} tok/s, ' + '${r.totalElapsed.inSeconds}s total', + ); + } +} diff --git a/ai_testbench/lib/main.dart b/ai_testbench/lib/main.dart new file mode 100644 index 0000000..4845f98 --- /dev/null +++ b/ai_testbench/lib/main.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'benchmark_main.dart' as model_bench; +import 'correction_main.dart'; +import 'screens/testbench_screen.dart'; +import 'screens/time_extraction_screen.dart'; +import 'time_extraction_main.dart'; + +void main(List args) async { + // Headless mode: run time extraction tests from CLI + if (args.contains('--headless-time')) { + await runHeadlessTimeExtraction(args); + exit(exitCode); + } + + // Headless mode: run correction benchmark from CLI + if (args.contains('--headless-correction')) { + WidgetsFlutterBinding.ensureInitialized(); + await runHeadlessCorrectionBenchmark(args); + exit(exitCode); + } + + // Headless mode: run model benchmark from CLI + if (args.contains('--headless') || + Platform.environment['AI_BENCH_HEADLESS'] == '1') { + await model_bench.main(args); + exit(exitCode); + } + + WidgetsFlutterBinding.ensureInitialized(); + runApp(const AiTestbenchApp()); +} + +class AiTestbenchApp extends StatelessWidget { + const AiTestbenchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ZSWatch AI Testbench', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6750A4), + brightness: Brightness.dark, + ), + ), + home: const _HomeShell(), + ); + } +} + +class _HomeShell extends StatefulWidget { + const _HomeShell(); + + @override + State<_HomeShell> createState() => _HomeShellState(); +} + +class _HomeShellState extends State<_HomeShell> { + int _index = 0; + + static const _screens = [ + TestbenchScreen(autoStartBenchmark: false), + TimeExtractionScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _index, children: _screens), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.science), + label: 'Classify', + ), + NavigationDestination( + icon: Icon(Icons.access_time), + label: 'Time Extract', + ), + ], + ), + ); + } +} + diff --git a/ai_testbench/lib/prompts/prompt_templates.dart b/ai_testbench/lib/prompts/prompt_templates.dart new file mode 100644 index 0000000..30fbe17 --- /dev/null +++ b/ai_testbench/lib/prompts/prompt_templates.dart @@ -0,0 +1,50 @@ +/// Prompt templates for voice memo AI processing. +/// +/// Classification/extraction prompts are now shared via the chrono_ai_flow +/// package (ChronoPromptTemplate). This file retains only the summarise +/// prompt and the supported-language list used by the testbench UI. +class PromptTemplates { + PromptTemplates._(); + + // --------------------------------------------------------------------------- + // Summarisation prompt + // --------------------------------------------------------------------------- + + /// Build a summarisation prompt for showing a short preview in the UI. + /// + /// [language] – expected language of the transcript. + /// [transcript] – the raw STT output. + /// [maxWords] – target summary length. + static String summarize({ + required String language, + required String transcript, + int maxWords = 20, + }) { + return ''' +You are a concise summarisation assistant. +The following transcript is in $language. +Summarise the transcript in at most $maxWords words, in $language. +Output ONLY the summary text, no extra formatting. + +Transcript: "$transcript" +Summary: '''; + } + + /// All supported languages (displayed in the UI dropdown). + static const List supportedLanguages = [ + 'English', + 'Swedish', + 'German', + 'French', + 'Spanish', + 'Norwegian', + 'Danish', + 'Finnish', + 'Dutch', + 'Italian', + 'Portuguese', + 'Japanese', + 'Chinese', + 'Korean', + ]; +} diff --git a/ai_testbench/lib/prompts/time_extraction_prompts.dart b/ai_testbench/lib/prompts/time_extraction_prompts.dart new file mode 100644 index 0000000..dc0048c --- /dev/null +++ b/ai_testbench/lib/prompts/time_extraction_prompts.dart @@ -0,0 +1,58 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; + +enum TimeExtractionPromptVariant { full, medium, short } + +class TimeExtractionPrompts { + TimeExtractionPrompts._(); + + static String get fullSystemPrompt => ChronoPromptTemplate.defaultTemplate; + static String get mediumSystemPrompt => ChronoPromptTemplate.defaultTemplate; + static String get shortSystemPrompt => ChronoPromptTemplate.defaultTemplate; + + static String systemPromptForVariant(TimeExtractionPromptVariant variant) { + switch (variant) { + case TimeExtractionPromptVariant.full: + return fullSystemPrompt; + case TimeExtractionPromptVariant.medium: + return mediumSystemPrompt; + case TimeExtractionPromptVariant.short: + return shortSystemPrompt; + } + } + + static String get systemPrompt => fullSystemPrompt; + + /// Build the user message with context and transcript. + /// + /// [transcript] — the voice memo text. + /// [now] — current datetime for context (not for LLM to compute with, + /// but to help it understand what "today" means if needed). + /// [timezone] — timezone name (e.g. "Europe/Stockholm"). + static String userMessage({ + required String transcript, + DateTime? now, + String? timezone, + String? transcriptLanguage, + }) { + return ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: transcript, + now: now, + ); + } + + /// Combine system + user message into a single prompt string + /// (for models that don't support separate system/user roles). + static String singlePrompt({ + required String transcript, + DateTime? now, + String? timezone, + String? transcriptLanguage, + }) { + return ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: transcript, + now: now, + ); + } +} diff --git a/ai_testbench/lib/screens/testbench_screen.dart b/ai_testbench/lib/screens/testbench_screen.dart new file mode 100644 index 0000000..d1bbc0a --- /dev/null +++ b/ai_testbench/lib/screens/testbench_screen.dart @@ -0,0 +1,958 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; + +import '../prompts/prompt_templates.dart'; +import '../services/llm_service.dart'; +import '../services/model_benchmark_service.dart'; +import '../widgets/memo_card.dart'; + +/// Two-pane "Prompt Engineering Lab" for ZSWatch voice memo AI. +class TestbenchScreen extends StatefulWidget { + const TestbenchScreen({ + super.key, + this.autoStartBenchmark = true, + this.searchDirectories, + }); + + final bool autoStartBenchmark; + final List? searchDirectories; + + @override + State createState() => _TestbenchScreenState(); +} + +class _TestbenchScreenState extends State { + // ── Services ────────────────────────────────────────────────────────────── + final LlmService _llm = LlmService(); + final ModelBenchmarkService _benchmarkService = ModelBenchmarkService(); + + // ── Input state ─────────────────────────────────────────────────────────── + String _selectedLanguage = 'English'; + String? _modelPath; + List _availableModelPaths = const []; + final _transcriptController = TextEditingController( + text: + 'Remind me to call the mechanic tomorrow at 3 PM about the brakes and also pick up milk on the way home.', + ); + + // ── Config ──────────────────────────────────────────────────────────────── + int _nCtx = 2048; + int _nThreads = 4; + int _maxTokens = 512; + + // ── Mode ────────────────────────────────────────────────────────────────── + _RunMode _mode = _RunMode.classify; + + // ── Output state ────────────────────────────────────────────────────────── + String _rawOutput = ''; + String _formattedPrompt = ''; + Map? _parsedJson; + Duration _elapsed = Duration.zero; + bool _isRunning = false; + String? _error; + + // ── Streaming output ────────────────────────────────────────────────────── + String _streamBuffer = ''; + bool _isBenchmarking = false; + List _benchmarkResults = const []; + BenchmarkProgress? _benchmarkProgress; + DateTime? _benchmarkStartedAt; + + @override + void initState() { + super.initState(); + unawaited(_discoverModelsAndMaybeBenchmark()); + } + + @override + void dispose() { + _transcriptController.dispose(); + _llm.dispose(); + super.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Actions + // ───────────────────────────────────────────────────────────────────────── + + Future _pickModel() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a .gguf model file', + ); + if (result != null && result.files.single.path != null) { + final pickedPath = result.files.single.path!; + setState(() { + _modelPath = pickedPath; + _availableModelPaths = { + ..._availableModelPaths, + pickedPath, + }.toList() + ..sort(); + }); + } + } + + Future _discoverModelsAndMaybeBenchmark() async { + final discovered = {}; + + final candidateDirs = { + ...?widget.searchDirectories, + if (Platform.isAndroid) ...{ + '/data/user/0/com.example.ai_testbench/cache/file_picker', + '/sdcard/Download', + '/storage/emulated/0/Download', + }, + Directory('models').absolute.path, + }; + + for (final dirPath in candidateDirs) { + try { + final dir = Directory(dirPath); + if (!dir.existsSync()) continue; + for (final entity in dir.listSync(recursive: true)) { + if (entity is File && entity.path.toLowerCase().endsWith('.gguf')) { + discovered.add(entity.path); + } + } + } catch (_) { + // Ignore unreadable paths on Android scoped storage. + } + } + + final sorted = discovered.toList()..sort(); + if (!mounted) return; + + setState(() { + _availableModelPaths = sorted; + _modelPath ??= sorted.isNotEmpty ? sorted.first : null; + }); + + if (sorted.isNotEmpty && widget.autoStartBenchmark) { + await _runBenchmarks(sorted); + } + } + + void _loadModel() { + if (_modelPath == null) return; + setState(() { + _error = null; + }); + try { + _llm.setModel(_modelPath!); + _llm.nCtx = _nCtx; + _llm.nThreads = _nThreads; + _llm.maxTokens = _maxTokens; + setState(() { + _rawOutput = 'Model set ✓ (loads on first inference)'; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _rawOutput = ''; + }); + } + } + + Future _runBenchmarks([List? modelPaths]) async { + final models = modelPaths ?? _availableModelPaths; + if (models.isEmpty) return; + + setState(() { + _isBenchmarking = true; + _benchmarkResults = const []; + _benchmarkProgress = null; + _benchmarkStartedAt = DateTime.now(); + }); + + try { + final results = await _benchmarkService.runForModels( + models, + onProgress: (progress) { + if (!mounted) return; + setState(() { + _benchmarkProgress = progress; + }); + }, + ); + if (!mounted) return; + setState(() { + _benchmarkResults = results; + _benchmarkProgress = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Benchmark failed: $e'); + } finally { + if (mounted) { + setState(() { + _isBenchmarking = false; + _benchmarkStartedAt = null; + }); + } + } + } + + Future _runInference() async { + if (!_llm.isModelLoaded) { + setState(() => _error = 'Load a model first.'); + return; + } + + final transcript = _transcriptController.text.trim(); + if (transcript.isEmpty) { + setState(() => _error = 'Enter a transcript.'); + return; + } + + final prompt = _mode == _RunMode.classify + ? ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, transcript: transcript) + : PromptTemplates.summarize( + language: _selectedLanguage, transcript: transcript); + + setState(() { + _formattedPrompt = prompt; + _rawOutput = ''; + _streamBuffer = ''; + _parsedJson = null; + _error = null; + _isRunning = true; + _elapsed = Duration.zero; + }); + + try { + _llm.nCtx = _nCtx; + _llm.nThreads = _nThreads; + _llm.maxTokens = _maxTokens; + + final sw = Stopwatch()..start(); + + // Stream tokens for live preview (fllama yields cumulative responses) + int streamEvents = 0; + await for (final cumulative in _llm.generateStream(prompt)) { + streamEvents++; + _streamBuffer = cumulative; + setState(() => _rawOutput = _streamBuffer); + } + + sw.stop(); + debugPrint('[Testbench] Stream done: $streamEvents events, ' + '${_streamBuffer.length} chars, ${sw.elapsedMilliseconds}ms'); + if (_streamBuffer.isNotEmpty) { + debugPrint('[Testbench] Output preview: ${_streamBuffer.substring(0, _streamBuffer.length.clamp(0, 300))}'); + } else { + debugPrint('[Testbench] WARNING: output is empty!'); + } + + setState(() { + _elapsed = sw.elapsed; + _rawOutput = _streamBuffer.trim(); + _isRunning = false; + }); + + _tryParseJson(); + } catch (e) { + setState(() { + _error = e.toString(); + _isRunning = false; + }); + } + } + + void _tryParseJson() { + try { + final raw = _rawOutput; + final jsonStr = _extractFirstJsonObject(raw); + if (jsonStr != null) { + final parsed = jsonDecode(jsonStr) as Map; + setState(() => _parsedJson = parsed); + } + } catch (_) { + // Not valid JSON – that's fine, we still show raw output + } + } + + String? _extractFirstJsonObject(String raw) { + final start = raw.indexOf('{'); + if (start == -1) return null; + + var depth = 0; + var inString = false; + var escaping = false; + + for (var i = start; i < raw.length; i++) { + final char = raw[i]; + if (escaping) { + escaping = false; + continue; + } + if (char == '\\' && inString) { + escaping = true; + continue; + } + if (char == '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (char == '{') depth++; + if (char == '}') { + depth--; + if (depth == 0) { + return raw.substring(start, i + 1); + } + } + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('ZSWatch AI Testbench'), + actions: [ + if (_llm.isModelLoaded) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Chip( + avatar: const Icon(Icons.check_circle, size: 18, color: Colors.green), + label: Text( + _modelPath?.split(Platform.pathSeparator).last ?? '', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + body: Row( + children: [ + // ── LEFT PANE: Inputs ────────────────────────────────────────── + Expanded(flex: 2, child: _buildInputPane()), + const VerticalDivider(width: 1), + // ── RIGHT PANE: Outputs ──────────────────────────────────────── + Expanded(flex: 3, child: _buildOutputPane()), + ], + ), + ); + } + + Widget _buildInputPane() { + return Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + // ── Model selection ──────────────────────────────────────────── + Text('Model', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _modelPath, + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + hint: const Text('No model selected'), + items: _availableModelPaths + .map( + (path) => DropdownMenuItem( + value: path, + child: Text( + path.split(Platform.pathSeparator).last, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (value) { + setState(() => _modelPath = value); + }, + ), + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: _pickModel, + icon: const Icon(Icons.folder_open, size: 18), + label: const Text('Browse'), + ), + ], + ), + if (_modelPath != null) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _isRunning ? null : _loadModel, + icon: const Icon(Icons.memory, size: 18), + label: Text(_llm.isModelLoaded ? 'Reload Model' : 'Set Model'), + ), + OutlinedButton.icon( + onPressed: _isRunning || _isBenchmarking || _availableModelPaths.isEmpty + ? null + : () => _runBenchmarks(), + icon: _isBenchmarking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined, size: 18), + label: Text( + _isBenchmarking && _benchmarkProgress != null + ? 'Benchmarking ${_benchmarkProgress!.completedRuns}/${_benchmarkProgress!.totalRuns}' + : _isBenchmarking + ? 'Benchmarking…' + : 'Benchmark All', + ), + ), + ], + ), + ], + + const SizedBox(height: 24), + + // ── Config ──────────────────────────────────────────────────── + Text('Configuration', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberField( + label: 'Context (nCtx)', + value: _nCtx, + onChanged: (v) => setState(() => _nCtx = v), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _NumberField( + label: 'Threads', + value: _nThreads, + onChanged: (v) => setState(() => _nThreads = v), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _NumberField( + label: 'Max tokens', + value: _maxTokens, + onChanged: (v) => setState(() => _maxTokens = v), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // ── Language selector ────────────────────────────────────────── + Text('Language', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedLanguage, + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + items: PromptTemplates.supportedLanguages + .map((l) => DropdownMenuItem(value: l, child: Text(l))) + .toList(), + onChanged: (v) => setState(() => _selectedLanguage = v!), + ), + + const SizedBox(height: 24), + + // ── Mode selector ───────────────────────────────────────────── + Text('Mode', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SegmentedButton<_RunMode>( + segments: const [ + ButtonSegment( + value: _RunMode.classify, + label: Text('Classify'), + icon: Icon(Icons.category), + ), + ButtonSegment( + value: _RunMode.summarize, + label: Text('Summarize'), + icon: Icon(Icons.summarize), + ), + ], + selected: {_mode}, + onSelectionChanged: (s) => setState(() => _mode = s.first), + ), + + const SizedBox(height: 24), + + // ── Transcript input ────────────────────────────────────────── + Text('Transcript', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _transcriptController, + maxLines: 8, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Paste a fake Whisper transcript here…', + ), + ), + + const SizedBox(height: 16), + + // ── Run button ──────────────────────────────────────────────── + SizedBox( + height: 48, + child: FilledButton.icon( + onPressed: _isRunning ? null : _runInference, + icon: _isRunning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow), + label: Text(_isRunning ? 'Running…' : 'Run Inference'), + ), + ), + + const SizedBox(height: 24), + + // ── Sample transcripts ───────────────────────────────────────── + Text('Sample Transcripts', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._sampleTranscripts.map( + (sample) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ActionChip( + label: Text( + sample.label, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + _transcriptController.text = sample.text; + setState(() => _selectedLanguage = sample.language); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildOutputPane() { + return Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + // ── Error banner ────────────────────────────────────────────── + if (_error != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.4)), + ), + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + + // ── Stats bar ───────────────────────────────────────────────── + if (_isBenchmarking) ...[ + _buildBenchmarkProgressCard(), + const SizedBox(height: 16), + ], + + if (_elapsed.inMilliseconds > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Wrap( + spacing: 16, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_outlined, size: 16), + const SizedBox(width: 6), + Text( + '${(_elapsed.inMilliseconds / 1000).toStringAsFixed(2)}s', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, size: 16), + const SizedBox(width: 6), + Text( + _rawOutput.isNotEmpty && _elapsed.inMilliseconds > 0 + ? '~${(_rawOutput.split(RegExp(r'\s+')).length / (_elapsed.inMilliseconds / 1000)).toStringAsFixed(1)} tok/s' + : '–', + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.text_fields, size: 16), + const SizedBox(width: 6), + Text('${_rawOutput.length} chars'), + ], + ), + ], + ), + ), + + if (_benchmarkResults.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('Benchmark Results', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._benchmarkResults.map( + (result) => Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.modelName, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 6), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + Text('Pass: ${result.passedCases}/${result.cases.length}'), + Text( + 'Avg tok/s: ${result.avgTokensPerSecond.toStringAsFixed(1)}', + ), + Text( + 'Total: ${(result.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s', + ), + ], + ), + const SizedBox(height: 8), + ...result.cases.map( + (caseResult) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${caseResult.passed ? '✅' : '❌'} ${caseResult.caseName}: ' + 'json=${caseResult.validJson ? '✓' : '✗'} · ' + 'intent=${caseResult.intent}${caseResult.intentMatch ? '✓' : '✗'} · ' + 'time=${caseResult.timePresenceMatch ? '✓' : '✗'} · ' + 'lang=${caseResult.titleLanguageMatch ? '✓' : '✗'} · ' + 'resolve=${caseResult.timeResolutionCorrect ? '✓' : '✗'} · ' + 'count=${caseResult.extractedCount}/${caseResult.expectedCount}${caseResult.countMatch ? '✓' : '✗'} · ' + '${(caseResult.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s · ' + '${caseResult.tokensPerSecond.toStringAsFixed(1)} tok/s', + ), + if (caseResult.title != null) + Text( + 'title: ${caseResult.title}', + style: const TextStyle(fontSize: 11), + ), + if (caseResult.timeResolutionDetail != null) + Text( + 'time: ${caseResult.timeResolutionDetail}', + style: TextStyle( + color: caseResult.timeResolutionCorrect ? Colors.green : Colors.orangeAccent, + fontSize: 11, + ), + ), + if (caseResult.titleLanguageDetail != null && + !caseResult.titleLanguageMatch) + Text( + 'lang: ${caseResult.titleLanguageDetail}', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), + if (caseResult.error != null) + Text( + 'error: ${caseResult.error}', + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12, + ), + ), + if (caseResult.itemFailures.isNotEmpty) + ...caseResult.itemFailures.map( + (f) => Text( + ' ⚠ $f', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + + // ── Parsed card preview ─────────────────────────────────────── + if (_parsedJson != null) ...[ + Text('Card Preview', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + MemoCard(data: _parsedJson!), + const SizedBox(height: 24), + ], + + // ── Raw JSON output ─────────────────────────────────────────── + Text('Raw Model Output', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white12), + ), + child: SelectableText( + _rawOutput.isEmpty ? '(output will appear here)' : _rawOutput, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + height: 1.5, + ), + ), + ), + + const SizedBox(height: 24), + + // ── Formatted prompt (expandable) ───────────────────────────── + if (_formattedPrompt.isNotEmpty) ...[ + ExpansionTile( + title: const Text('Full Prompt Sent to Model'), + initiallyExpanded: false, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black38, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + _formattedPrompt, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + height: 1.4, + color: Colors.amber, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildBenchmarkProgressCard() { + final progress = _benchmarkProgress; + final fraction = progress?.fractionComplete ?? 0; + final completed = progress?.completedRuns ?? 0; + final total = progress?.totalRuns ?? 0; + final startedAt = _benchmarkStartedAt; + + Duration? eta; + if (startedAt != null && completed > 0 && total > completed) { + final elapsed = DateTime.now().difference(startedAt); + final avgPerRunMs = elapsed.inMilliseconds / completed; + eta = Duration(milliseconds: (avgPerRunMs * (total - completed)).round()); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + progress == null + ? 'Preparing benchmark…' + : 'Running ${progress.currentModelName} · case ${progress.currentCaseIndex + 1}/${progress.totalCasesPerModel}', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator(value: total == 0 ? null : fraction.clamp(0, 1)), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + Text('Completed: $completed/$total'), + if (progress != null) + Text('Model: ${progress.currentModelIndex + 1}/${progress.totalModels}'), + if (progress != null && progress.currentCaseName.isNotEmpty) + Text('Case: ${progress.currentCaseName}'), + if (eta != null) Text('ETA: ${_formatDuration(eta)}'), + ], + ), + ], + ), + ); + } + + String _formatDuration(Duration duration) { + final totalSeconds = duration.inSeconds; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } + return '${seconds}s'; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Run mode +// ═════════════════════════════════════════════════════════════════════════════ + +enum _RunMode { classify, summarize } + +// ═════════════════════════════════════════════════════════════════════════════ +// Number field helper +// ═════════════════════════════════════════════════════════════════════════════ + +class _NumberField extends StatelessWidget { + const _NumberField({ + required this.label, + required this.value, + required this.onChanged, + }); + + final String label; + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return TextField( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + controller: TextEditingController(text: value.toString()) + ..selection = TextSelection.collapsed(offset: value.toString().length), + onSubmitted: (v) { + final n = int.tryParse(v); + if (n != null && n > 0) onChanged(n); + }, + ); + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Sample transcripts for quick testing +// ═════════════════════════════════════════════════════════════════════════════ + +class _SampleTranscript { + final String label; + final String text; + final String language; + const _SampleTranscript(this.label, this.text, this.language); +} + +const _sampleTranscripts = [ + _SampleTranscript( + '🇬🇧 TODO: Errands', + 'I need to pick up the dry cleaning, buy groceries, and call the dentist to reschedule my appointment.', + 'English', + ), + _SampleTranscript( + '🇬🇧 EVENT: Meeting', + 'Remind me about the team standup tomorrow at 9 AM in the main conference room.', + 'English', + ), + _SampleTranscript( + '🇬🇧 NOTE: Idea', + 'Had an interesting idea about using sensor fusion for step detection. Should look into the BMI270 FIFO watermark interrupt as a trigger.', + 'English', + ), + _SampleTranscript( + '🇸🇪 TODO: Handla', + 'Påminn mig om att köpa mjölk och fixa dörren i helgen.', + 'Swedish', + ), + _SampleTranscript( + '🇸🇪 EVENT: Möte', + 'Jag har ett möte med tandläkaren på fredag klockan 14.', + 'Swedish', + ), + _SampleTranscript( + '🇸🇪 NOTE: Anteckning', + 'Bra presentation idag om maskininlärning och edge computing. Kolla upp TensorFlow Lite för mikrokontrollers.', + 'Swedish', + ), +]; diff --git a/ai_testbench/lib/screens/time_extraction_screen.dart b/ai_testbench/lib/screens/time_extraction_screen.dart new file mode 100644 index 0000000..3294393 --- /dev/null +++ b/ai_testbench/lib/screens/time_extraction_screen.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../services/time_extraction_benchmark_service.dart'; + +/// Screen for running the voice memo → time extraction pipeline testbench. +/// +/// Auto-discovers models, auto-runs tests, and displays results in a +/// scrollable log view. +class TimeExtractionScreen extends StatefulWidget { + const TimeExtractionScreen({super.key}); + + @override + State createState() => _TimeExtractionScreenState(); +} + +class _TimeExtractionScreenState extends State { + final TimeExtractionBenchmarkService _service = + TimeExtractionBenchmarkService(); + final ScrollController _scrollController = ScrollController(); + + List _availableModels = const []; + String? _selectedModel; + bool _isRunning = false; + TimeExtractionProgress? _progress; + List _results = const []; + final List _logLines = []; + + @override + void initState() { + super.initState(); + _discoverModels(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _discoverModels() { + final modelDir = Directory('models'); + if (!modelDir.existsSync()) { + _log('No models/ directory found'); + return; + } + final models = modelDir + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + setState(() { + _availableModels = models; + if (models.isNotEmpty) _selectedModel = models.first; + }); + _log('Found ${models.length} model(s)'); + for (final m in models) { + _log(' - ${m.split(Platform.pathSeparator).last}'); + } + } + + void _log(String line) { + setState(() => _logLines.add(line)); + // Auto-scroll after next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + }); + } + + Future _runTests() async { + if (_selectedModel == null) return; + + setState(() { + _isRunning = true; + _results = []; + _logLines.clear(); + }); + + _log('══════════════════════════════════════════════════'); + _log(' Time Extraction Testbench'); + _log(' Model: ${_selectedModel!.split(Platform.pathSeparator).last}'); + _log(' Reference: ${TimeExtractionBenchmarkService.referenceTime}'); + _log(' Cases: ${TimeExtractionBenchmarkService.testCases.length}'); + _log('══════════════════════════════════════════════════'); + _log(''); + + final results = await _service.runForModels( + [_selectedModel!], + onProgress: (p) { + setState(() => _progress = p); + _log('[${p.completedCases}/${p.totalCases}] ${p.currentCaseName}'); + }, + ); + + setState(() { + _results = results; + _isRunning = false; + _progress = null; + }); + + // Print formatted results to log + final formatted = TimeExtractionBenchmarkService.formatResults(results); + for (final line in formatted.split('\n')) { + _log(line); + } + } + + Future _runAllModels() async { + if (_availableModels.isEmpty) return; + + setState(() { + _isRunning = true; + _results = []; + _logLines.clear(); + }); + + _log('══════════════════════════════════════════════════'); + _log(' Time Extraction Testbench — ALL MODELS'); + _log(' Models: ${_availableModels.length}'); + _log('══════════════════════════════════════════════════'); + _log(''); + + final results = await _service.runForModels( + _availableModels, + onProgress: (p) { + setState(() => _progress = p); + _log('[${p.modelName}] [${p.completedCases}/${p.totalCases}] ' + '${p.currentCaseName}'); + }, + ); + + setState(() { + _results = results; + _isRunning = false; + _progress = null; + }); + + final formatted = TimeExtractionBenchmarkService.formatResults(results); + for (final line in formatted.split('\n')) { + _log(line); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Time Extraction Testbench'), + ), + body: Column( + children: [ + // ── Controls bar ── + _buildControlsBar(), + // ── Progress indicator ── + if (_isRunning && _progress != null) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: _progress!.fraction, + ), + const SizedBox(height: 4), + Text( + '${_progress!.modelName} — ' + '${_progress!.completedCases}/${_progress!.totalCases}: ' + '${_progress!.currentCaseName}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const Divider(height: 1), + // ── Results table ── + if (_results.isNotEmpty) ...[ + _buildResultsSummary(), + const Divider(height: 1), + ], + // ── Log output ── + Expanded(child: _buildLog()), + ], + ), + ); + } + + Widget _buildControlsBar() { + return Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Model dropdown + Expanded( + child: DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Model', + border: OutlineInputBorder(), + isDense: true, + ), + initialValue: _selectedModel, + items: _availableModels.map((path) { + final name = path.split(Platform.pathSeparator).last; + return DropdownMenuItem(value: path, child: Text(name)); + }).toList(), + onChanged: _isRunning + ? null + : (v) => setState(() => _selectedModel = v), + ), + ), + const SizedBox(width: 12), + // Run selected + FilledButton.icon( + onPressed: _isRunning ? null : _runTests, + icon: const Icon(Icons.play_arrow), + label: const Text('Run'), + ), + const SizedBox(width: 8), + // Run all + OutlinedButton.icon( + onPressed: _isRunning ? null : _runAllModels, + icon: const Icon(Icons.all_inclusive), + label: const Text('All Models'), + ), + ], + ), + ); + } + + Widget _buildResultsSummary() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: DataTable( + columnSpacing: 20, + headingRowHeight: 36, + dataRowMinHeight: 28, + dataRowMaxHeight: 28, + columns: const [ + DataColumn(label: Text('Model')), + DataColumn(label: Text('Pass'), numeric: true), + DataColumn(label: Text('Partial'), numeric: true), + DataColumn(label: Text('Fail'), numeric: true), + DataColumn(label: Text('Time'), numeric: true), + DataColumn(label: Text('tok/s'), numeric: true), + ], + rows: _results.map((model) { + return DataRow(cells: [ + DataCell(Text(model.modelName, + style: const TextStyle(fontWeight: FontWeight.bold))), + DataCell(Text('${model.passedCount}', + style: const TextStyle(color: Colors.green))), + DataCell(Text('${model.partialCount}', + style: const TextStyle(color: Colors.orange))), + DataCell(Text('${model.failedCount}', + style: const TextStyle(color: Colors.red))), + DataCell(Text( + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s')), + DataCell( + Text(model.avgTokensPerSecond.toStringAsFixed(1))), + ]); + }).toList(), + ), + ); + } + + Widget _buildLog() { + return Container( + color: Colors.black, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(12), + itemCount: _logLines.length, + itemBuilder: (context, index) { + final line = _logLines[index]; + // Color-code based on content + Color color = Colors.grey.shade300; + if (line.contains('✅')) { + color = Colors.green; + } else if (line.contains('❌')) { + color = Colors.red; + } else if (line.contains('⚠️')) { + color = Colors.orange; + } else if (line.contains('═') || line.contains('║')) { + color = Colors.cyan; + } + + return Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: color, + height: 1.4, + ), + ); + }, + ), + ); + } +} diff --git a/ai_testbench/lib/services/correction_benchmark_service.dart b/ai_testbench/lib/services/correction_benchmark_service.dart new file mode 100644 index 0000000..480aa6f --- /dev/null +++ b/ai_testbench/lib/services/correction_benchmark_service.dart @@ -0,0 +1,675 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class CorrectionBenchmarkCase { + /// Unique name for the test case. + final String name; + + /// The "bad" transcript as Whisper might produce it. + final String input; + + /// The ideal corrected output (used for display / human review). + final String expectedOutput; + + /// Words/phrases that MUST appear in the corrected output (case-insensitive). + /// Used to verify the model actually performed a specific fix. + final List mustContain; + + /// Words/phrases that must NOT appear in the corrected output (case-insensitive). + /// Used to verify the model removed errors / filler. + final List mustNotContain; + + /// If true, the model MUST change the text (detect and fix an error). + /// If false (clean input), the model should return it mostly unchanged. + final bool expectModification; + + /// Language of the input. + final String language; + + const CorrectionBenchmarkCase({ + required this.name, + required this.input, + required this.expectedOutput, + this.mustContain = const [], + this.mustNotContain = const [], + this.expectModification = true, + this.language = 'en', + }); +} + +// ── Test result ────────────────────────────────────────────────────────── + +class CorrectionCaseResult { + final String caseName; + final String input; + final String expectedOutput; + final String actualOutput; + final bool wasModified; + final bool modificationExpected; + final bool modificationMatch; + final bool allMustContainFound; + final List missingKeywords; + final bool allMustNotContainAbsent; + final List unwantedKeywordsFound; + final bool cleanOutput; + final String? cleanOutputDetail; + final Duration elapsed; + final double tokensPerSecond; + final String? error; + + const CorrectionCaseResult({ + required this.caseName, + required this.input, + required this.expectedOutput, + required this.actualOutput, + required this.wasModified, + required this.modificationExpected, + required this.modificationMatch, + required this.allMustContainFound, + required this.missingKeywords, + required this.allMustNotContainAbsent, + required this.unwantedKeywordsFound, + required this.cleanOutput, + this.cleanOutputDetail, + required this.elapsed, + required this.tokensPerSecond, + this.error, + }); + + bool get passed => + error == null && + modificationMatch && + allMustContainFound && + allMustNotContainAbsent && + cleanOutput; +} + +// ── Progress ───────────────────────────────────────────────────────────── + +class CorrectionBenchmarkProgress { + final int totalModels; + final int totalCasesPerModel; + final int totalRuns; + final int completedRuns; + final int currentModelIndex; + final int currentCaseIndex; + final String currentModelPath; + final String currentCaseName; + + const CorrectionBenchmarkProgress({ + required this.totalModels, + required this.totalCasesPerModel, + required this.totalRuns, + required this.completedRuns, + required this.currentModelIndex, + required this.currentCaseIndex, + required this.currentModelPath, + required this.currentCaseName, + }); + + double get fractionComplete => totalRuns == 0 ? 0 : completedRuns / totalRuns; + String get currentModelName => + currentModelPath.split(Platform.pathSeparator).last; +} + +// ── Aggregate result for a model ───────────────────────────────────────── + +class CorrectionModelResult { + final String modelPath; + final List cases; + + const CorrectionModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCases => cases.where((c) => c.passed).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.elapsed); +} + +// ── Service ────────────────────────────────────────────────────────────── + +class CorrectionBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 60); + + // ── The correction prompt — uses the shared package ─────────────────── + + static String buildCorrectionPrompt(String transcript) { + return CorrectionPromptTemplate.render( + CorrectionPromptTemplate.defaultTemplate, + transcript: transcript, + ); + } + + // ── Benchmark cases ─────────────────────────────────────────────────── + + static final benchmarkCases = [ + // ── English: Whisper homophone / wrong-word errors ────────────────── + + const CorrectionBenchmarkCase( + name: 'en_homophone_weak_week', + input: 'I need to finish the report by next weak', + expectedOutput: 'I need to finish the report by next week.', + mustContain: ['week'], + mustNotContain: ['weak'], + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_by_buy', + input: 'remind me to by milk on the way home', + expectedOutput: 'Remind me to buy milk on the way home.', + mustContain: ['buy'], + mustNotContain: ['by milk'], // "by" alone might appear in "nearby" etc + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_meat_meet', + input: "let's meat at the café at half passed three", + expectedOutput: "Let's meet at the café at half past three.", + mustContain: ['meet', 'past'], + mustNotContain: ['meat', 'passed'], + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_wood_would', + input: 'she said she wood come at for a clock', + expectedOutput: "She said she would come at four o'clock.", + mustContain: ['would'], + mustNotContain: ['wood'], + ), + const CorrectionBenchmarkCase( + name: 'en_wrong_word_nonsense', + input: 'I have a dentist appointment and I need to cancel it because I have a cold and a terrible headache', + expectedOutput: 'I have a dentist appointment and I need to cancel it because I have a cold and a terrible headache.', + mustContain: ['dentist', 'cancel', 'headache'], + expectModification: false, // Clean input — should pass through + ), + + // ── English: filler words + stuttering ────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_filler_um_stutter', + input: 'I I need to um finish the report and and send it to the the boss', + expectedOutput: 'I need to finish the report and send it to the boss.', + mustContain: ['finish the report', 'send it to the boss'], + mustNotContain: ['I I', 'and and', 'the the', ' um '], + ), + const CorrectionBenchmarkCase( + name: 'en_filler_heavy', + input: 'so uh you know I was like thinking we should you know maybe like schedule a meeting', + expectedOutput: 'I was thinking we should maybe schedule a meeting.', + mustContain: ['schedule', 'meeting'], + mustNotContain: [' uh '], + ), + + // ── English: missing/wrong punctuation ────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_missing_punctuation', + input: 'call the plumber tomorrow at 3 then pick up the kids at 5 and dont forget to buy groceries', + expectedOutput: "Call the plumber tomorrow at 3, then pick up the kids at 5, and don't forget to buy groceries.", + mustContain: ['plumber', 'kids', 'groceries'], + mustNotContain: [], + expectModification: false, // punctuation-only change is acceptable either way + ), + + // ── English: Whisper word-boundary / context errors ───────────────── + + const CorrectionBenchmarkCase( + name: 'en_word_boundary', + input: 'I can not believe they moved the meeting to an other day with out telling us', + expectedOutput: 'I cannot believe they moved the meeting to another day without telling us.', + mustContain: ['another', 'without'], + mustNotContain: ['an other', 'with out'], + ), + + // ── Swedish: Whisper errors ───────────────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'sv_filler_stutter', + input: 'jag ska eh köpa köpa mjölk och bröd på på hemvägen', + expectedOutput: 'Jag ska köpa mjölk och bröd på hemvägen.', + mustContain: ['köpa mjölk'], + mustNotContain: ['köpa köpa', 'på på', ' eh '], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_split_word_imorgon', + input: 'påminn mig att skicka rapporten till chefen i morgan', + expectedOutput: 'Påminn mig att skicka rapporten till chefen imorgon.', + mustContain: ['imorgon'], + mustNotContain: ['i morgan'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_missing_umlaut', + input: 'vi har mote med kunden pa torsdag klockan tva', + expectedOutput: 'Vi har möte med kunden på torsdag klockan två.', + mustContain: ['möte', 'på', 'två'], + mustNotContain: ['mote', ' pa ', 'tva'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_clean_passthrough', + input: 'Boka tandläkare på fredag klockan 10.', + expectedOutput: 'Boka tandläkare på fredag klockan 10.', + mustContain: ['tandläkare', 'fredag'], + expectModification: false, // Clean input + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_wrong_word_whisper', + input: 'ring veterinären och boka en tid för katten som har ont i ögat', + expectedOutput: 'Ring veterinären och boka en tid för katten som har ont i ögat.', + mustContain: ['veterinären', 'katten'], + expectModification: false, // Clean-ish input + language: 'sv', + ), + + // ── German: Whisper errors ────────────────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'de_missing_umlaut', + input: 'ich muss den Arzt anrufen und einen Termin fur nachste Woche machen', + expectedOutput: 'Ich muss den Arzt anrufen und einen Termin für nächste Woche machen.', + mustContain: ['für', 'nächste'], + mustNotContain: [' fur ', 'nachste'], + language: 'de', + ), + const CorrectionBenchmarkCase( + name: 'de_filler_stutter', + input: 'also ich äh muss muss noch die die Präsentation fertig machen', + expectedOutput: 'Ich muss noch die Präsentation fertig machen.', + mustContain: ['Präsentation', 'fertig'], + mustNotContain: ['muss muss', 'die die', ' äh ', 'also ich'], + language: 'de', + ), + + // ── English: Whisper misheard context-dependent words ────────────── + + const CorrectionBenchmarkCase( + name: 'en_misheard_their_there', + input: 'I left my keys over their on the table', + expectedOutput: 'I left my keys over there on the table.', + mustContain: ['there'], + mustNotContain: ['their'], + ), + const CorrectionBenchmarkCase( + name: 'en_misheard_your_youre', + input: 'your going to be late if you dont leave now', + expectedOutput: "You're going to be late if you don't leave now.", + mustContain: ['late', 'leave'], + mustNotContain: [], + ), + const CorrectionBenchmarkCase( + name: 'en_clean_long_sentence', + input: 'I had a great meeting with the design team today and we agreed on the new color scheme for the watch face', + expectedOutput: 'I had a great meeting with the design team today and we agreed on the new color scheme for the watch face.', + mustContain: ['design team', 'color scheme', 'watch face'], + expectModification: false, + ), + + // ── English: Whisper garbled multi-word ───────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_garbled_sentence', + input: 'the whether is really nice today so lets go for a walk in the park', + expectedOutput: "The weather is really nice today so let's go for a walk in the park.", + mustContain: ['weather'], + mustNotContain: ['whether'], + ), + const CorrectionBenchmarkCase( + name: 'en_multiple_errors_combined', + input: 'I need to by some flower for the party its on wendsday at there house', + expectedOutput: "I need to buy some flowers for the party, it's on Wednesday at their house.", + mustContain: ['buy', 'Wednesday'], + mustNotContain: ['by some', 'wendsday'], + ), + + // ── Swedish: more realistic Whisper errors ─────────────────── + + const CorrectionBenchmarkCase( + name: 'sv_filler_liksom', + input: 'jag tankte liksom att vi kanske liksom borde traffas imorgon', + expectedOutput: 'Jag tänkte att vi kanske borde träffas imorgon.', + mustContain: ['tänkte', 'träffas'], + mustNotContain: ['tankte', 'traffas'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_whisper_number', + input: 'klockan ar halv atta och jag maste ga nu', + expectedOutput: 'Klockan är halv åtta och jag måste gå nu.', + mustContain: ['är', 'åtta', 'måste', 'gå'], + mustNotContain: [' ar ', ' atta', 'maste', ' ga '], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_clean_longer', + input: 'Vi ses på kontoret imorgon bitti för att gå igenom rapporten.', + expectedOutput: 'Vi ses på kontoret imorgon bitti för att gå igenom rapporten.', + mustContain: ['kontoret', 'rapporten'], + expectModification: false, + language: 'sv', + ), + + // ── German: more realistic Whisper errors ─────────────────── + + const CorrectionBenchmarkCase( + name: 'de_eszett_and_umlaut', + input: 'ich weiss nicht ob er die strasse finden konnte', + expectedOutput: 'Ich weiß nicht, ob er die Straße finden konnte.', + mustContain: ['weiß', 'Straße'], + mustNotContain: ['weiss', 'strasse'], + language: 'de', + ), + const CorrectionBenchmarkCase( + name: 'de_clean_passthrough', + input: 'Bitte ruf mich morgen früh an.', + expectedOutput: 'Bitte ruf mich morgen früh an.', + mustContain: ['morgen', 'früh'], + expectModification: false, + language: 'de', + ), + ]; + + // ── Run benchmark ───────────────────────────────────────────────────── + + Future> runForModels( + List modelPaths, { + void Function(CorrectionBenchmarkProgress progress)? onProgress, + }) async { + final results = []; + final totalCases = benchmarkCases.length; + final totalRuns = modelPaths.length * totalCases; + var completedRuns = 0; + + for (var modelIndex = 0; modelIndex < modelPaths.length; modelIndex++) { + final modelPath = modelPaths[modelIndex]; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 2048 + ..nThreads = Platform.numberOfProcessors + ..temperature = 0.0 + ..enableThinking = false; + + final caseResults = []; + try { + for (var caseIndex = 0; caseIndex < benchmarkCases.length; caseIndex++) { + final testCase = benchmarkCases[caseIndex]; + onProgress?.call( + CorrectionBenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + + final prompt = buildCorrectionPrompt(testCase.input); + final maxTok = + CorrectionPromptTemplate.estimateMaxTokens(testCase.input); + + try { + final result = await llm + .generate(prompt, overrideMaxTokens: maxTok) + .timeout(perCaseTimeout); + + final output = _cleanOutput(result.output); + final check = _evaluate(testCase, output); + + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: output, + wasModified: check.wasModified, + modificationExpected: testCase.expectModification, + modificationMatch: check.modificationMatch, + allMustContainFound: check.allMustContainFound, + missingKeywords: check.missingKeywords, + allMustNotContainAbsent: check.allMustNotContainAbsent, + unwantedKeywordsFound: check.unwantedKeywordsFound, + cleanOutput: check.cleanOutput, + cleanOutputDetail: check.cleanOutputDetail, + elapsed: result.elapsed, + tokensPerSecond: result.tokensPerSecond, + )); + } on TimeoutException { + llm.cancelInference(); + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: '', + wasModified: false, + modificationExpected: testCase.expectModification, + modificationMatch: false, + allMustContainFound: false, + missingKeywords: testCase.mustContain, + allMustNotContainAbsent: true, + unwantedKeywordsFound: const [], + cleanOutput: false, + cleanOutputDetail: 'Timed out', + elapsed: perCaseTimeout, + tokensPerSecond: 0, + error: 'Timed out after ${perCaseTimeout.inSeconds}s', + )); + } catch (e) { + llm.cancelInference(); + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: '', + wasModified: false, + modificationExpected: testCase.expectModification, + modificationMatch: false, + allMustContainFound: false, + missingKeywords: testCase.mustContain, + allMustNotContainAbsent: true, + unwantedKeywordsFound: const [], + cleanOutput: false, + cleanOutputDetail: 'Error: $e', + elapsed: Duration.zero, + tokensPerSecond: 0, + error: e.toString(), + )); + } + + completedRuns++; + onProgress?.call( + CorrectionBenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + } + } finally { + llm.dispose(); + } + + results.add(CorrectionModelResult(modelPath: modelPath, cases: caseResults)); + } + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /// Strip common LLM wrapper cruft from the output. + String _cleanOutput(String raw) { + var output = raw.trim(); + + // Strip markdown code fences (```text ... ``` or ```\n ... ```) + final codeFenceRe = RegExp(r'^```[a-zA-Z]*\n?(.*?)\n?```$', dotAll: true); + final codeFenceMatch = codeFenceRe.firstMatch(output); + if (codeFenceMatch != null) { + output = codeFenceMatch.group(1)!.trim(); + } + + // Remove surrounding quotes + if ((output.startsWith('"') && output.endsWith('"')) || + (output.startsWith("'") && output.endsWith("'"))) { + output = output.substring(1, output.length - 1).trim(); + } + + // If model prefixed with "Output:" or "Corrected:" etc., take what's after + final prefixes = [ + 'output:', + 'corrected:', + 'corrected transcription:', + 'corrected text:', + ]; + final lower = output.toLowerCase(); + for (final prefix in prefixes) { + if (lower.startsWith(prefix)) { + output = output.substring(prefix.length).trim(); + // Remove surrounding quotes again after prefix removal + if ((output.startsWith('"') && output.endsWith('"')) || + (output.startsWith("'") && output.endsWith("'"))) { + output = output.substring(1, output.length - 1).trim(); + } + break; + } + } + + return output; + } + + _EvalResult _evaluate(CorrectionBenchmarkCase testCase, String output) { + final outputLower = output.toLowerCase(); + final inputLower = testCase.input.toLowerCase(); + + // Check if modified (normalize whitespace + case for comparison) + final normalizedInput = inputLower.replaceAll(RegExp(r'\s+'), ' ').trim(); + final normalizedOutput = outputLower.replaceAll(RegExp(r'\s+'), ' ').trim(); + // Strip trailing punctuation for comparison + final normalizedInputNoPunct = + normalizedInput.replaceAll(RegExp(r'[.!?,;:]+$'), '').trim(); + final normalizedOutputNoPunct = + normalizedOutput.replaceAll(RegExp(r'[.!?,;:]+$'), '').trim(); + final wasModified = normalizedInputNoPunct != normalizedOutputNoPunct; + + // Modification expectation check + // If modification is expected, it must have changed + // If no modification expected, echoing it back is fine (but changing is also OK) + final modificationMatch = + testCase.expectModification ? wasModified : true; + + // Must-contain check + final missingKeywords = []; + for (final keyword in testCase.mustContain) { + if (!outputLower.contains(keyword.toLowerCase())) { + missingKeywords.add(keyword); + } + } + + // Must-not-contain check + final unwantedFound = []; + for (final keyword in testCase.mustNotContain) { + if (outputLower.contains(keyword.toLowerCase())) { + unwantedFound.add(keyword); + } + } + + // Clean output check — no markdown, no explanations + final isClean = _checkClean(output); + + return _EvalResult( + wasModified: wasModified, + modificationMatch: modificationMatch, + allMustContainFound: missingKeywords.isEmpty, + missingKeywords: missingKeywords, + allMustNotContainAbsent: unwantedFound.isEmpty, + unwantedKeywordsFound: unwantedFound, + cleanOutput: isClean.passed, + cleanOutputDetail: isClean.detail, + ); + } + + _CleanCheck _checkClean(String output) { + if (output.isEmpty) { + return const _CleanCheck(passed: false, detail: 'empty output'); + } + + // Check for markdown + if (output.contains('```') || output.contains('**') || output.contains('##')) { + return const _CleanCheck(passed: false, detail: 'contains markdown'); + } + + // Check for explanatory preamble + final lower = output.toLowerCase(); + final preambles = [ + 'here is', + 'the corrected', + 'i have fixed', + 'i corrected', + 'below is', + 'note:', + 'explanation:', + 'changes made:', + ]; + for (final p in preambles) { + if (lower.startsWith(p)) { + return _CleanCheck(passed: false, detail: 'starts with preamble: "$p"'); + } + } + + // Check for excessive length (more than 3x input length likely means explanations) + // Relaxed — just flag it + if (output.length > 500) { + return const _CleanCheck(passed: false, detail: 'output suspiciously long (>500 chars)'); + } + + return const _CleanCheck(passed: true, detail: null); + } +} + +class _EvalResult { + final bool wasModified; + final bool modificationMatch; + final bool allMustContainFound; + final List missingKeywords; + final bool allMustNotContainAbsent; + final List unwantedKeywordsFound; + final bool cleanOutput; + final String? cleanOutputDetail; + + const _EvalResult({ + required this.wasModified, + required this.modificationMatch, + required this.allMustContainFound, + required this.missingKeywords, + required this.allMustNotContainAbsent, + required this.unwantedKeywordsFound, + required this.cleanOutput, + this.cleanOutputDetail, + }); +} + +class _CleanCheck { + final bool passed; + final String? detail; + const _CleanCheck({required this.passed, this.detail}); +} diff --git a/ai_testbench/lib/services/llm_service.dart b/ai_testbench/lib/services/llm_service.dart new file mode 100644 index 0000000..2ba39c8 --- /dev/null +++ b/ai_testbench/lib/services/llm_service.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fllama/fllama.dart'; +import 'package:flutter/foundation.dart'; + +/// Result of a single inference run. +class InferenceResult { + final String output; + final Duration elapsed; + final int promptTokens; + final int outputTokens; + + const InferenceResult({ + required this.output, + required this.elapsed, + this.promptTokens = 0, + this.outputTokens = 0, + }); + + double get tokensPerSecond { + if (elapsed.inMilliseconds == 0) return 0; + if (outputTokens > 0) { + return outputTokens / (elapsed.inMilliseconds / 1000); + } + // Rough estimate: count whitespace-separated words ≈ tokens + final estimatedTokens = output.split(RegExp(r'\s+')).length; + return estimatedTokens / (elapsed.inMilliseconds / 1000); + } +} + +/// Wraps fllama for LLM inference. +/// +/// Usage: +/// ```dart +/// final svc = LlmService(); +/// svc.setModel('/path/to/model.gguf'); +/// final result = await svc.generate(prompt); +/// svc.dispose(); +/// ``` +class LlmService { + String? _modelPath; + int _runningRequestId = -1; + bool _requestInFlight = false; + + // Configuration + int nCtx = 4096; + int nThreads = 2; + int maxTokens = 512; + double temperature = 0.3; + double topP = 1.0; + double presencePenalty = 2.0; + int numGpuLayers = 0; + /// When false, disables thinking/reasoning for models like Qwen3/3.5. + bool enableThinking = true; + + static void _logFilter(String log) { + if (log.contains('loaded') || log.contains('error') || log.contains('Error') || + log.contains('token') || log.contains('speed') || log.contains('FAILED') || + log.contains('Model loaded') || log.contains('Initialized') || + log.contains('Backend initialized') || log.contains('Available backends')) { + debugPrint('[llama.cpp] $log'); + } + } + + bool get isModelLoaded => _modelPath != null; + String? get loadedModelPath => _modelPath; + + /// Set the model path. fllama loads on first inference and caches it. + void setModel(String path) { + if (!File(path).existsSync()) { + throw ArgumentError('Model file not found: $path'); + } + _modelPath = path; + } + + /// Run inference with the given [prompt] and return the complete result. + /// + /// Uses fllamaChat with the OpenAI-compatible API. + /// The prompt is sent as a user message; for system prompts, provide + /// [systemPrompt]. + Future generate( + String prompt, { + String? systemPrompt, + int? overrideMaxTokens, + }) async { + if (_modelPath == null) { + throw StateError('No model set – call setModel() first.'); + } + + final sw = Stopwatch()..start(); + final completer = Completer(); + + final messages = [ + if (systemPrompt != null) Message(Role.system, systemPrompt), + Message(Role.user, prompt), + ]; + + final request = OpenAiRequest( + messages: messages, + modelPath: _modelPath!, + maxTokens: overrideMaxTokens ?? maxTokens, + numGpuLayers: numGpuLayers, + numThreads: nThreads, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: nCtx, + logger: _logFilter, + ); + + _requestInFlight = true; + _runningRequestId = await fllamaChat( + request, + (String response, String responseJson, bool done) { + if (done && !completer.isCompleted) { + _requestInFlight = false; + _runningRequestId = -1; + completer.complete(response); + } + }, + ); + + final output = await completer.future; + sw.stop(); + _requestInFlight = false; + _runningRequestId = -1; + + // Count output tokens + int outputTokenCount = 0; + try { + outputTokenCount = await fllamaTokenize( + FllamaTokenizeRequest(input: output, modelPath: _modelPath!), + ); + } catch (_) { + // Fallback estimate + outputTokenCount = output.split(RegExp(r'\s+')).length; + } + + return InferenceResult( + output: _stripThinkingTags(output.trim()), + elapsed: sw.elapsed, + outputTokens: outputTokenCount, + ); + } + + /// Strip Qwen3-style reasoning blocks from output. + static String _stripThinkingTags(String text) { + // Remove complete ... blocks + var cleaned = text.replaceAll(RegExp(r'.*?', dotAll: true), ''); + // Remove unclosed (thinking consumed entire budget) + cleaned = cleaned.replaceAll(RegExp(r'.*', dotAll: true), ''); + return cleaned.trim(); + } + + /// Stream inference tokens as they are generated (for live preview). + /// + /// Yields cumulative responses (each yield is the full response so far). + Stream generateStream( + String prompt, { + String? systemPrompt, + int? overrideMaxTokens, + }) { + if (_modelPath == null) { + throw StateError('No model set – call setModel() first.'); + } + + final controller = StreamController(); + + final messages = [ + if (systemPrompt != null) Message(Role.system, systemPrompt), + Message(Role.user, prompt), + ]; + + final request = OpenAiRequest( + messages: messages, + modelPath: _modelPath!, + maxTokens: overrideMaxTokens ?? maxTokens, + numGpuLayers: numGpuLayers, + numThreads: nThreads, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: nCtx, + logger: _logFilter, + ); + + _requestInFlight = true; + fllamaChat( + request, + (String response, String responseJson, bool done) { + debugPrint('[LlmService] stream cb: done=$done, len=${response.length}'); + if (!controller.isClosed) { + controller.add(response); + if (done) { + _requestInFlight = false; + _runningRequestId = -1; + controller.close(); + } + } + }, + ).then((id) { + _runningRequestId = id; + }); + + return controller.stream; + } + + /// Cancel any running inference. + void cancelInference() { + if (_requestInFlight && _runningRequestId >= 0) { + fllamaCancelInference(_runningRequestId); + _runningRequestId = -1; + _requestInFlight = false; + } + } + + void dispose() { + cancelInference(); + _modelPath = null; + } +} diff --git a/ai_testbench/lib/services/model_benchmark_service.dart b/ai_testbench/lib/services/model_benchmark_service.dart new file mode 100644 index 0000000..0848620 --- /dev/null +++ b/ai_testbench/lib/services/model_benchmark_service.dart @@ -0,0 +1,1146 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +/// Expected extraction for one item in a multi-item benchmark case. +class ExpectedItem { + final String expectedIntent; + final bool expectTime; + final List titleLanguageKeywords; + final DateTime? expectedDateTime; + final int toleranceMinutes; + + const ExpectedItem({ + required this.expectedIntent, + required this.expectTime, + this.titleLanguageKeywords = const [], + this.expectedDateTime, + this.toleranceMinutes = 5, + }); +} + +class BenchmarkCase { + final String name; + final String transcript; + + /// Expected items. For single-extraction cases, this has one element. + final List expectedItems; + + BenchmarkCase({ + required this.name, + required this.transcript, + required this.expectedItems, + }); + + /// Convenience constructor for single-extraction cases (backward compat). + BenchmarkCase.single({ + required this.name, + required this.transcript, + required String expectedIntent, + required bool expectTime, + List titleLanguageKeywords = const [], + DateTime? expectedDateTime, + int toleranceMinutes = 5, + }) : expectedItems = [ + ExpectedItem( + expectedIntent: expectedIntent, + expectTime: expectTime, + titleLanguageKeywords: titleLanguageKeywords, + expectedDateTime: expectedDateTime, + toleranceMinutes: toleranceMinutes, + ), + ]; + + /// Shorthand accessors for single-item cases (used by existing code). + String get expectedIntent => expectedItems.first.expectedIntent; + bool get expectTime => expectedItems.first.expectTime; + List get titleLanguageKeywords => + expectedItems.first.titleLanguageKeywords; + DateTime? get expectedDateTime => expectedItems.first.expectedDateTime; + int get toleranceMinutes => expectedItems.first.toleranceMinutes; + + int get expectedCount => expectedItems.length; + bool get isMultiItem => expectedItems.length > 1; +} + +// ── Test result ────────────────────────────────────────────────────────── + +class BenchmarkCaseResult { + final String caseName; + final bool validJson; + final bool intentMatch; + final bool timePresenceMatch; + final bool titleLanguageMatch; + final String? titleLanguageDetail; + final bool timeResolutionCorrect; + final String? timeResolutionDetail; + final String intent; + final String? title; + final String? datetimeOriginal; + final String? datetimeEnglish; + final Duration elapsed; + final double tokensPerSecond; + final String outputPreview; + final String? error; + + /// Number of items extracted from the output. + final int extractedCount; + + /// Number of items expected. + final int expectedCount; + + /// Whether the count of extracted items matches expected. + final bool countMatch; + + /// Per-item validation details for multi-item cases. + final List itemFailures; + + const BenchmarkCaseResult({ + required this.caseName, + required this.validJson, + required this.intentMatch, + required this.timePresenceMatch, + this.titleLanguageMatch = true, + this.titleLanguageDetail, + this.timeResolutionCorrect = true, + this.timeResolutionDetail, + required this.intent, + this.title, + this.datetimeOriginal, + this.datetimeEnglish, + required this.elapsed, + required this.tokensPerSecond, + required this.outputPreview, + this.error, + this.extractedCount = 1, + this.expectedCount = 1, + this.countMatch = true, + this.itemFailures = const [], + }); + + bool get passed => + validJson && + intentMatch && + timePresenceMatch && + titleLanguageMatch && + timeResolutionCorrect && + countMatch && + itemFailures.isEmpty; +} + +// ── Progress ───────────────────────────────────────────────────────────── + +class BenchmarkProgress { + final int totalModels; + final int totalCasesPerModel; + final int totalRuns; + final int completedRuns; + final int currentModelIndex; + final int currentCaseIndex; + final String currentModelPath; + final String currentCaseName; + + const BenchmarkProgress({ + required this.totalModels, + required this.totalCasesPerModel, + required this.totalRuns, + required this.completedRuns, + required this.currentModelIndex, + required this.currentCaseIndex, + required this.currentModelPath, + required this.currentCaseName, + }); + + double get fractionComplete => totalRuns == 0 ? 0 : completedRuns / totalRuns; + int get remainingRuns => totalRuns - completedRuns; + String get currentModelName => + currentModelPath.split(Platform.pathSeparator).last; +} + +// ── Aggregate result for a model ───────────────────────────────────────── + +class BenchmarkModelResult { + final String modelPath; + final List cases; + + const BenchmarkModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCases => cases.where((c) => c.passed).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.elapsed); +} + +// ── Service ────────────────────────────────────────────────────────────── + +class ModelBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 90); + static const ChronoLlmParser _parser = ChronoLlmParser(); + + /// Fixed reference time for deterministic tests. + /// Wednesday March 11, 2026, 10:15 AM. + static final DateTime referenceTime = DateTime(2026, 3, 11, 10, 15); + + static final benchmarkCases = [ + // ── English single-item cases ────────────────────────────────────── + + BenchmarkCase.single( + name: 'en_event_precise_time', + transcript: + 'Schedule a design review with Erik and Sara on March 14 at 3:30 PM in Lab 3.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 14, 15, 30), + ), + BenchmarkCase.single( + name: 'en_reminder_tomorrow', + transcript: + 'Remind me tomorrow at 7:15 AM to take the prototype battery off the charger.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 15), + ), + BenchmarkCase.single( + name: 'en_event_next_tuesday', + transcript: 'Meeting with John next Tuesday at 2 pm.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 17, 14, 0), + ), + BenchmarkCase.single( + name: 'en_reminder_next_friday', + transcript: + 'I need to finish the PCB layout review and send it to the manufacturer by next Friday at 5 PM.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 20, 17, 0), + ), + BenchmarkCase.single( + name: 'en_event_dentist', + transcript: + 'Dentist appointment on April 22nd at 10:30 AM at the clinic downtown.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 4, 22, 10, 30), + ), + BenchmarkCase.single( + name: 'en_note_no_time', + transcript: + 'Had an interesting idea about using a pressure sensor to detect altitude changes for the hiking app.', + expectedIntent: 'note', + expectTime: false, + ), + BenchmarkCase.single( + name: 'en_reminder_this_afternoon', + transcript: 'Call the plumber this afternoon at 3.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + + // ── Swedish single-item cases (native language title validation) ── + + BenchmarkCase.single( + name: 'sv_reminder_tomorrow', + transcript: 'Påminn mig imorgon klockan 8 att ringa tandläkaren.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ringa', 'tandläkare'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + BenchmarkCase.single( + name: 'sv_event_meeting', + transcript: + 'Möte med projektgruppen på torsdag klockan 14 i stora konferensrummet.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'projektgrupp'], + expectedDateTime: DateTime(2026, 3, 12, 14, 0), + ), + BenchmarkCase.single( + name: 'sv_note_no_time', + transcript: 'Köp mjölk och bröd på vägen hem.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['köp', 'mjölk', 'bröd'], + ), + BenchmarkCase.single( + name: 'sv_note_idea', + transcript: + 'Bra idé om att lägga till stegräknare i klockan, kanske använda BMI270 sensorn.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['stegräknare', 'klocka', 'idé', 'sensor'], + ), + BenchmarkCase.single( + name: 'sv_event_specific_date', + transcript: 'Tandläkare den 15 mars klockan halv 10.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['tandläkare'], + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + + // ── German single-item cases (native language title validation) ─── + + BenchmarkCase.single( + name: 'de_event_appointment', + transcript: + 'Arzttermin am Donnerstag um 9 Uhr in der Praxis am Marktplatz.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['arzt', 'termin', 'praxis'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + BenchmarkCase.single( + name: 'de_reminder_deadline', + transcript: + 'Ich muss den Bericht bis Freitag um 17 Uhr fertig haben und an den Chef schicken.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['bericht', 'chef', 'schicken'], + expectedDateTime: DateTime(2026, 3, 13, 17, 0), + ), + + // ── Additional English single-item cases ───────────────────────── + + BenchmarkCase.single( + name: 'en_reminder_specific_date', + transcript: 'Submit the expense report by March 20th at 9 AM.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['expense', 'report'], + expectedDateTime: DateTime(2026, 3, 20, 9, 0), + ), + BenchmarkCase.single( + name: 'en_event_birthday', + transcript: 'Mom\'s birthday party on April 5th at 6 PM.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['birthday'], + expectedDateTime: DateTime(2026, 4, 5, 18, 0), + ), + BenchmarkCase.single( + name: 'en_note_idea_short', + transcript: 'Try using Rust for the sensor driver.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['rust', 'sensor'], + ), + + // ── Additional Swedish single-item cases ───────────────────────── + + BenchmarkCase.single( + name: 'sv_event_fika', + transcript: 'Fika med Lisa på fredag klockan 15.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['fika', 'lisa'], + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + BenchmarkCase.single( + name: 'sv_reminder_pickup_kids', + transcript: 'Hämta barnen på förskolan imorgon klockan halv 5.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['hämta', 'barn'], + expectedDateTime: DateTime(2026, 3, 12, 16, 30), + ), + BenchmarkCase.single( + name: 'sv_event_doctor', + transcript: 'Läkartid på vårdcentralen den 18 mars klockan 10.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['läkar'], + expectedDateTime: DateTime(2026, 3, 18, 10, 0), + ), + BenchmarkCase.single( + name: 'sv_reminder_medicine', + transcript: 'Ta medicinen varje dag klockan 8 på morgonen.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['medicin'], + ), + BenchmarkCase.single( + name: 'sv_event_dinner', + transcript: 'Middag hos mamma och pappa på lördag klockan 18.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['middag', 'mamma'], + expectedDateTime: DateTime(2026, 3, 14, 18, 0), + ), + BenchmarkCase.single( + name: 'sv_event_car_service', + transcript: 'Bilservice på tisdag klockan 8 hos Mekonomen.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['bilservice'], + expectedDateTime: DateTime(2026, 3, 17, 8, 0), + ), + BenchmarkCase.single( + name: 'sv_note_grocery', + transcript: 'Handla potatis, lök och grädde till middagen.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['handla', 'potatis'], + ), + BenchmarkCase.single( + name: 'sv_event_parents_meeting', + transcript: 'Föräldramöte på skolan onsdag klockan 18:30.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['föräldramöte'], + expectedDateTime: DateTime(2026, 3, 18, 18, 30), + ), + BenchmarkCase.single( + name: 'sv_reminder_deadline', + transcript: 'Skicka in rapporten senast fredag klockan 12.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['rapport', 'skicka'], + expectedDateTime: DateTime(2026, 3, 13, 12, 0), + ), + + // ── Voice note tests – short (1-2 sentences, casual/fragmented) ── + + BenchmarkCase.single( + name: 'voice_short_en_idea', + transcript: 'Maybe add a compass widget to the watch face.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['compass'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_idea', + transcript: 'Testa att använda e-paper display till nästa version.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['e-paper', 'display'], + ), + BenchmarkCase.single( + name: 'voice_short_en_quick_reminder', + transcript: 'Oh yeah, water the plants at 5.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['water', 'plant'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_quick_reminder', + transcript: 'Ah just det, ring försäkringsbolaget imorgon.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ring', 'försäkring'], + ), + BenchmarkCase.single( + name: 'voice_short_en_fragment', + transcript: 'Buy batteries.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['batter'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_fragment', + transcript: 'Boka tid hos frisören.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['boka', 'frisör'], + ), + + // ── Voice note tests – long (rambling, filler words, multi-sentence) ─ + + BenchmarkCase.single( + name: 'voice_long_en_rambling_reminder', + transcript: + 'So I was thinking, um, I really need to remember to pick up the dry cleaning, ' + 'I think the ticket is in my jacket, anyway I should do it tomorrow before noon ' + 'because they close early on Thursdays.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['dry clean'], + ), + BenchmarkCase.single( + name: 'voice_long_sv_rambling_event', + transcript: + 'Öh jag pratade med Johan igår och vi bestämde att vi ska ha ett möte, ' + 'tror det var på torsdag klockan 10 eller nåt, i alla fall ska vi gå igenom ' + 'hela budgeten för nästa kvartal.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'johan', 'budget'], + expectedDateTime: DateTime(2026, 3, 12, 10, 0), + ), + BenchmarkCase.single( + name: 'voice_long_en_idea_note', + transcript: + 'Had a really interesting conversation with the hardware team today about, ' + 'you know, potentially integrating an ambient light sensor so the display ' + 'brightness adjusts automatically. Could save a lot of battery and the user ' + 'experience would be much smoother. Should look into the VEML7700 or maybe ' + 'the BH1750 sensor, both are I2C and pretty cheap.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['light', 'sensor', 'display'], + ), + BenchmarkCase.single( + name: 'voice_long_sv_idea_note', + transcript: + 'Jag tänkte på en grej, vi borde kanske lägga till sömnspårning i appen, ' + 'alltså använda accelerometern för att mäta rörelser under natten och sen ' + 'visa statistik på morgonen. Det finns en bra algoritm i den där forskningsartikeln ' + 'jag läste förra veckan, kolla upp det.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['sömnspårning', 'accelerometer', 'app'], + ), + + // ── Additional multi-item cases ────────────────────────────────── + + BenchmarkCase( + name: 'sv_multi_fika_and_errand', + transcript: + 'Fika med Anna imorgon klockan 10 och sen lämna in paketet på posten.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['fika', 'anna'], + expectedDateTime: DateTime(2026, 3, 12, 10, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['paket'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_three_items', + transcript: + 'Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och ' + 'möte med chefen på fredag klockan 14.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['tandläkare', 'ring'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['present', 'kalas'], + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'chef'], + expectedDateTime: DateTime(2026, 3, 13, 14, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_event_and_idea', + transcript: + 'Team standup tomorrow at 9:15 and I should really prototype ' + 'the new notification system with stacked cards.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['standup'], + expectedDateTime: DateTime(2026, 3, 12, 9, 15), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['notification', 'prototype'], + ), + ], + ), + BenchmarkCase( + name: 'voice_long_multi_sv', + transcript: + 'Okej så imorgon klockan 8 måste jag gå till gymmet och sen på eftermiddagen ' + 'typ klockan 3 ska jag träffa Erik för att prata om projektet och sen behöver ' + 'jag också komma ihåg att köpa kattsand.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['gym'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['erik', 'projekt'], + expectedDateTime: DateTime(2026, 3, 12, 15, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['kattsand'], + ), + ], + ), + + // ── Conversational Swedish / Swenglish voice notes ─────────────── + + BenchmarkCase( + name: 'sv_multi_dev_tasks_swenglish', + transcript: + 'Vi ska cleana upp klockans kod för voice memons sen ska vi testa att ' + 'det går avbryta en calender tilläggning genom att klicka cancel på popup på klockan', + expectedItems: [ + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['clean', 'kod', 'voice'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['testa', 'calender', 'cancel'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_casual_planning', + transcript: + 'Vi behöver fixa buggen med Bluetooth-anslutningen och sen ska vi ' + 'refaktorera sensor-koden lite grann.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['bugg', 'bluetooth'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['refaktor', 'sensor'], + ), + ], + ), + BenchmarkCase.single( + name: 'sv_note_swenglish_long', + transcript: + 'Jag tänkte att vi kanske borde adda en feature för att synca ' + 'notifications mellan telefonen och klockan typ via BLE, ' + 'kolla om det finns nåt library för det.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['sync', 'notification', 'feature'], + ), + + // ── Multi-item cases ───────────────────────────────────────────── + + BenchmarkCase( + name: 'en_multi_two_reminders', + transcript: + 'Tomorrow at 5 pm we have to pick up the dog and then at 9 turn off all lights.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['dog', 'pick'], + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['light'], + ), + ], + ), + BenchmarkCase( + name: 'en_multi_reminder_and_note', + transcript: + 'Call the plumber at 3 pm tomorrow and also buy new light bulbs.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['plumber'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['light', 'bulb'], + ), + ], + ), + BenchmarkCase( + name: 'en_multi_two_events', + transcript: + 'Meeting with Sarah on Monday at 10 am and lunch with the team on Wednesday at noon.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['sarah'], + expectedDateTime: DateTime(2026, 3, 16, 10, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['lunch'], + expectedDateTime: DateTime(2026, 3, 18, 12, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_three_mixed', + transcript: + 'Tomorrow at 8 go for a run then have lunch with Mike at noon and buy groceries.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['lunch'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['grocer'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_event_and_note', + transcript: + 'Tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['tandläkare'], + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['handla'], + ), + ], + ), + BenchmarkCase( + name: 'de_multi_two_events', + transcript: + 'Arzttermin am Donnerstag um 9 Uhr und Zahnarzt am Freitag um 14 Uhr.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['arzt'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['zahnarzt'], + expectedDateTime: DateTime(2026, 3, 13, 14, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_same_day_reminders', + transcript: + 'Today at 3 pm call the electrician and at 6 pm pick up the kids from school.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['electrician'], + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['kids'], + expectedDateTime: DateTime(2026, 3, 11, 18, 0), + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_two_reminders', + transcript: + 'Imorgon klockan 8 gå till gymmet och klockan 15 hämta paketet på posten.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['gym'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['paket'], + expectedDateTime: DateTime(2026, 3, 12, 15, 0), + ), + ], + ), + ]; + + Future> runForModels( + List modelPaths, { + void Function(BenchmarkProgress progress)? onProgress, + List? selectedCases, + }) async { + final results = []; + final casesToRun = selectedCases ?? benchmarkCases; + final totalCases = casesToRun.length; + final totalRuns = modelPaths.length * totalCases; + var completedRuns = 0; + final resolver = TimeExpressionResolver(); + + for (var modelIndex = 0; modelIndex < modelPaths.length; modelIndex++) { + final modelPath = modelPaths[modelIndex]; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 4096 + ..nThreads = Platform.numberOfProcessors + ..maxTokens = 384 + ..temperature = 0.3 + ..topP = 1.0 + ..presencePenalty = 2.0 + ..enableThinking = false; + + final caseResults = []; + try { + for (var caseIndex = 0; + caseIndex < casesToRun.length; + caseIndex++) { + final testCase = casesToRun[caseIndex]; + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + + // Use the shared ChronoPromptTemplate from chrono_ai_flow + final prompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: testCase.transcript, + now: referenceTime, + ); + + try { + final result = await llm + .generate(prompt) + .timeout(perCaseTimeout); + + // Parse using the shared ChronoLlmParser + final parseResult = _parser.parse(result.output); + final extractions = parseResult.extractions; + final validJson = extractions.isNotEmpty; + + final extractedCount = extractions.length; + final expectedCount = testCase.expectedCount; + final countMatch = extractedCount == expectedCount; + + // Validate each expected item against extracted items. + // Use positional matching: expected[i] ↔ extracted[i]. + var allIntentMatch = true; + var allTimePresenceMatch = true; + var allTitleLangMatch = true; + var allTimeResMatch = true; + String? allTitleLangDetail; + String? allTimeResDetail; + final itemFailures = []; + + final checkCount = + extractedCount < expectedCount ? extractedCount : expectedCount; + for (var i = 0; i < checkCount; i++) { + final ext = extractions[i]; + final exp = testCase.expectedItems[i]; + + final intentOk = _intentMatches(ext.intent, exp.expectedIntent); + if (!intentOk) { + allIntentMatch = false; + itemFailures.add( + 'item[$i] intent: got "${ext.intent}", expected "${exp.expectedIntent}"'); + } + + final hasTime = ext.datetimeExpressionOriginal != null || + ext.datetimeExpressionEnglish != null; + if (hasTime != exp.expectTime) { + allTimePresenceMatch = false; + itemFailures.add( + 'item[$i] time presence: got $hasTime, expected ${exp.expectTime}'); + } + + final titleLang = + _checkTitleLanguageForItem(ext.title, exp); + if (!titleLang.passed) { + allTitleLangMatch = false; + itemFailures.add( + 'item[$i] title lang: ${titleLang.detail}'); + } + allTitleLangDetail = (allTitleLangDetail ?? '') + + 'item[$i]: ${titleLang.detail}; '; + + final timeRes = _checkTimeResolutionForItem( + ext.datetimeExpressionEnglish ?? + ext.datetimeExpressionOriginal, + exp, + resolver, + ); + if (!timeRes.passed) { + allTimeResMatch = false; + itemFailures.add( + 'item[$i] time: ${timeRes.detail}'); + } + allTimeResDetail = (allTimeResDetail ?? '') + + 'item[$i]: ${timeRes.detail}; '; + } + + // If count mismatch, mark missing items as failures + for (var i = checkCount; i < expectedCount; i++) { + itemFailures.add('item[$i] missing from output'); + allIntentMatch = false; + allTimePresenceMatch = false; + } + for (var i = checkCount; i < extractedCount; i++) { + itemFailures + .add('item[$i] unexpected extra extraction'); + } + + // Use first extraction for summary fields (backward compat) + final first = extractions.isNotEmpty ? extractions.first : null; + + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: validJson, + intentMatch: allIntentMatch, + timePresenceMatch: allTimePresenceMatch, + titleLanguageMatch: allTitleLangMatch, + titleLanguageDetail: allTitleLangDetail, + timeResolutionCorrect: allTimeResMatch, + timeResolutionDetail: allTimeResDetail, + intent: first?.intent ?? '', + title: first?.title, + datetimeOriginal: first?.datetimeExpressionOriginal, + datetimeEnglish: first?.datetimeExpressionEnglish, + elapsed: result.elapsed, + tokensPerSecond: result.tokensPerSecond, + outputPreview: result.output.length > 300 + ? '${result.output.substring(0, 300)}...' + : result.output, + extractedCount: extractedCount, + expectedCount: expectedCount, + countMatch: countMatch, + itemFailures: itemFailures, + ), + ); + } on TimeoutException { + llm.cancelInference(); + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: false, + intentMatch: false, + timePresenceMatch: false, + titleLanguageMatch: false, + timeResolutionCorrect: false, + intent: 'timeout', + elapsed: perCaseTimeout, + tokensPerSecond: 0, + outputPreview: + 'Timed out after ${perCaseTimeout.inSeconds}s', + error: 'Timed out after ${perCaseTimeout.inSeconds}s', + extractedCount: 0, + expectedCount: testCase.expectedCount, + countMatch: false, + ), + ); + } catch (e) { + llm.cancelInference(); + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: false, + intentMatch: false, + timePresenceMatch: false, + titleLanguageMatch: false, + timeResolutionCorrect: false, + intent: 'error', + elapsed: Duration.zero, + tokensPerSecond: 0, + outputPreview: 'Error: $e', + error: e.toString(), + extractedCount: 0, + expectedCount: testCase.expectedCount, + countMatch: false, + ), + ); + } + + completedRuns++; + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + } + } finally { + llm.dispose(); + } + + results.add( + BenchmarkModelResult(modelPath: modelPath, cases: caseResults)); + } + + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: + modelPaths.isEmpty ? 0 : modelPaths.length - 1, + currentCaseIndex: totalCases == 0 ? 0 : totalCases - 1, + currentModelPath: modelPaths.isEmpty ? '' : modelPaths.last, + currentCaseName: + benchmarkCases.isEmpty ? '' : benchmarkCases.last.name, + ), + ); + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + bool _intentMatches(String got, String expected) { + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // Allow note <-> task fuzzy match (no time = note) + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; + } + + _CheckResult _checkTitleLanguage(String? title, BenchmarkCase testCase) { + return _checkTitleLanguageForItem(title, testCase.expectedItems.first); + } + + _CheckResult _checkTitleLanguageForItem(String? title, ExpectedItem item) { + if (item.titleLanguageKeywords.isEmpty) { + return const _CheckResult(passed: true, detail: 'no keyword check'); + } + if (title == null || title.isEmpty) { + return const _CheckResult( + passed: false, + detail: 'no title in output', + ); + } + + final lower = title.toLowerCase(); + final matched = []; + for (final keyword in item.titleLanguageKeywords) { + if (lower.contains(keyword.toLowerCase())) { + matched.add(keyword); + } + } + + final passed = matched.isNotEmpty; + final detail = passed + ? 'found ${matched.join(", ")} in "$title"' + : 'none of [${item.titleLanguageKeywords.join(", ")}] found in "$title"'; + + return _CheckResult(passed: passed, detail: detail); + } + + _CheckResult _checkTimeResolution( + String? timeExpr, + BenchmarkCase testCase, + TimeExpressionResolver resolver, + ) { + return _checkTimeResolutionForItem( + timeExpr, testCase.expectedItems.first, resolver); + } + + _CheckResult _checkTimeResolutionForItem( + String? timeExpr, + ExpectedItem item, + TimeExpressionResolver resolver, + ) { + if (item.expectedDateTime == null) { + return const _CheckResult(passed: true, detail: 'no time check'); + } + if (timeExpr == null || timeExpr.isEmpty) { + return const _CheckResult( + passed: false, + detail: 'no time expression to resolve', + ); + } + + final resolved = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + + if (resolved == null) { + return _CheckResult( + passed: false, + detail: 'chrono failed to parse "$timeExpr"', + ); + } + + final diff = resolved.dateTime + .difference(item.expectedDateTime!) + .inMinutes + .abs(); + if (diff > item.toleranceMinutes) { + return _CheckResult( + passed: false, + detail: + 'got ${resolved.dateTime}, expected ${item.expectedDateTime} ' + '(diff ${diff}min, tolerance ${item.toleranceMinutes}min)', + ); + } + + return _CheckResult( + passed: true, + detail: '${resolved.dateTime} OK (via ${resolved.method})', + ); + } +} + +class _CheckResult { + final bool passed; + final String? detail; + const _CheckResult({required this.passed, this.detail}); +} diff --git a/ai_testbench/lib/services/time_expression_resolver.dart b/ai_testbench/lib/services/time_expression_resolver.dart new file mode 100644 index 0000000..99afb42 --- /dev/null +++ b/ai_testbench/lib/services/time_expression_resolver.dart @@ -0,0 +1,2 @@ +export 'package:chrono_ai_flow/chrono_ai_flow.dart' + show ResolvedTime, TimeExpressionResolver; diff --git a/ai_testbench/lib/services/time_extraction_benchmark_service.dart b/ai_testbench/lib/services/time_extraction_benchmark_service.dart new file mode 100644 index 0000000..608a448 --- /dev/null +++ b/ai_testbench/lib/services/time_extraction_benchmark_service.dart @@ -0,0 +1,565 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; +import '../prompts/time_extraction_prompts.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class TimeExtractionTestCase { + final String name; + final String transcript; + final String expectedIntent; // 'reminder', 'event', 'note' + final String? expectedTimeEnglish; // null = no time expected + final DateTime? expectedDateTime; // null = no time expected + final int toleranceMinutes; // for relative times like "in 30 minutes" + + const TimeExtractionTestCase({ + required this.name, + required this.transcript, + required this.expectedIntent, + this.expectedTimeEnglish, + this.expectedDateTime, + this.toleranceMinutes = 2, + }); +} + +// ── LLM response structure ────────────────────────────────────────────── + +class LlmExtractionResult { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String rawOutput; + + const LlmExtractionResult({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + required this.rawOutput, + }); +} + +// ── Test result ───────────────────────────────────────────────────────── + +enum TestStatus { pass, fail, partial } + +class TimeExtractionTestResult { + final TimeExtractionTestCase testCase; + final LlmExtractionResult? llmResult; + final ResolvedTime? resolvedTime; + final Duration llmDuration; + final double tokensPerSecond; + final TestStatus status; + final List failures; + + const TimeExtractionTestResult({ + required this.testCase, + this.llmResult, + this.resolvedTime, + required this.llmDuration, + required this.tokensPerSecond, + required this.status, + this.failures = const [], + }); +} + +// ── Progress ──────────────────────────────────────────────────────────── + +class TimeExtractionProgress { + final int totalCases; + final int completedCases; + final String currentCaseName; + final String modelName; + + const TimeExtractionProgress({ + required this.totalCases, + required this.completedCases, + required this.currentCaseName, + required this.modelName, + }); + + double get fraction => + totalCases == 0 ? 0 : completedCases / totalCases; +} + +// ── Aggregate result for a model ──────────────────────────────────────── + +class TimeExtractionModelResult { + final String modelPath; + final List cases; + + const TimeExtractionModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCount => + cases.where((c) => c.status == TestStatus.pass).length; + int get partialCount => + cases.where((c) => c.status == TestStatus.partial).length; + int get failedCount => + cases.where((c) => c.status == TestStatus.fail).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.llmDuration); +} + +// ── Service ───────────────────────────────────────────────────────────── + +class TimeExtractionBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 90); + static const ChronoLlmParser _parser = ChronoLlmParser(); + + /// Fixed reference time for deterministic tests. + /// Monday March 9, 2026, 10:15 AM. + static final DateTime referenceTime = DateTime(2026, 3, 9, 10, 15); + + static final testCases = [ + TimeExtractionTestCase( + name: 'EN: Simple reminder with time', + transcript: 'Remind me tomorrow at 10 am to buy milk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10 am', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'SV: Reminder with time', + transcript: 'påminn mig imorgon klockan 10 att köpa mjölk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'DE: Reminder with time', + transcript: 'erinnere mich morgen um 10 milch zu kaufen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'EN: Meeting next Tuesday', + transcript: 'meeting with John next Tuesday at 2 pm', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), + ), + TimeExtractionTestCase( + name: 'EN: No time mentioned', + transcript: 'remember to buy milk', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TimeExtractionTestCase( + name: 'SV: Relative minutes', + transcript: 'ring tandläkaren om 30 minuter', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 30 minutes', + expectedDateTime: DateTime(2026, 3, 9, 10, 45), + toleranceMinutes: 5, + ), + TimeExtractionTestCase( + name: 'FR: Friday at 3pm', + transcript: "rappelle-moi vendredi à 15h d'appeler le médecin", + expectedIntent: 'reminder', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + TimeExtractionTestCase( + name: 'EN: Specific date', + transcript: 'dentist appointment on March 15th at 9:30', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TimeExtractionTestCase( + name: 'SV: No time, just task', + transcript: 'köp bröd på vägen hem', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TimeExtractionTestCase( + name: 'EN: This afternoon', + transcript: 'call the plumber this afternoon at 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + // ── Additional Swedish cases (translation stress tests) ────────── + TimeExtractionTestCase( + name: 'SV: Meeting next Tuesday', + transcript: 'möte med Erik nästa tisdag klockan 14', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), + ), + TimeExtractionTestCase( + name: 'SV: This afternoon', + transcript: 'ring rörmokaren i eftermiddag klockan 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + TimeExtractionTestCase( + name: 'SV: Specific date and time', + transcript: 'tandläkare den 15 mars klockan halv 10', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TimeExtractionTestCase( + name: 'SV: Friday with 24h time', + transcript: 'boka konferensrum på fredag klockan 15', + expectedIntent: 'event', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + TimeExtractionTestCase( + name: 'SV: In two hours', + transcript: 'påminn mig om två timmar att ta medicinen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 2 hours', + expectedDateTime: DateTime(2026, 3, 9, 12, 15), + toleranceMinutes: 5, + ), + TimeExtractionTestCase( + name: 'SV: Day after tomorrow morning', + transcript: 'skicka rapporten i övermorgon på morgonen klockan 8', + expectedIntent: 'reminder', + expectedTimeEnglish: 'day after tomorrow at 8 am', + expectedDateTime: DateTime(2026, 3, 11, 8, 0), + ), + TimeExtractionTestCase( + name: 'SV: Tonight', + transcript: 'handla mat ikväll klockan 6', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tonight at 6', + expectedDateTime: DateTime(2026, 3, 9, 18, 0), + ), + ]; + + Future> runForModels( + List modelPaths, { + void Function(TimeExtractionProgress progress)? onProgress, + bool includeLanguageHint = false, + bool retryInvalidOutput = false, + TimeExtractionPromptVariant promptVariant = + TimeExtractionPromptVariant.full, + }) async { + final results = []; + final resolver = TimeExpressionResolver(); + + for (final modelPath in modelPaths) { + final modelName = modelPath.split(Platform.pathSeparator).last; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 2048 + ..nThreads = 4 + ..maxTokens = 384 + ..temperature = 0.1; + + final caseResults = []; + + try { + for (var i = 0; i < testCases.length; i++) { + final tc = testCases[i]; + + onProgress?.call(TimeExtractionProgress( + totalCases: testCases.length, + completedCases: i, + currentCaseName: tc.name, + modelName: modelName, + )); + + debugPrint( + '\n─── Test ${i + 1}/${testCases.length}: ${tc.name} ───', + ); + debugPrint(' Input: "${tc.transcript}"'); + + try { + final prompt = TimeExtractionPrompts.singlePrompt( + transcript: tc.transcript, + now: referenceTime, + ); + + Duration totalElapsed = Duration.zero; + double lastTokensPerSecond = 0; + var attempts = 0; + InferenceResult? result; + LlmExtractionResult? llmResult; + + while (true) { + attempts++; + result = await llm + .generate(prompt) + .timeout(perCaseTimeout); + totalElapsed += result.elapsed; + lastTokensPerSecond = result.tokensPerSecond; + llmResult = _parseLlmOutput(result.output); + + if (!retryInvalidOutput || + attempts >= 2 || + !_shouldRetryInvalidOutput(tc, llmResult)) { + break; + } + + debugPrint(' ↻ Retrying invalid output (attempt ${attempts + 1}/2)'); + } + + debugPrint(' LLM time: ${totalElapsed.inMilliseconds}ms ' + '(${lastTokensPerSecond.toStringAsFixed(1)} tok/s)'); + debugPrint(' attempts: $attempts'); + debugPrint(' intent: ${llmResult.intent}'); + debugPrint(' title: ${llmResult.title}'); + debugPrint(' time (orig): ${llmResult.datetimeExpressionOriginal}'); + debugPrint(' time (EN): ${llmResult.datetimeExpressionEnglish}'); + + // Resolve time expression + ResolvedTime? resolvedTime; + final timeExpr = llmResult.datetimeExpressionEnglish ?? + llmResult.datetimeExpressionOriginal; + if (timeExpr != null) { + resolvedTime = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + debugPrint(resolvedTime != null + ? ' Chrono: ${resolvedTime.dateTime} (via ${resolvedTime.method})' + : ' Chrono: FAILED for "$timeExpr"'); + } + + // Evaluate + final failures = _evaluate(tc, llmResult, resolvedTime); + final status = failures.isEmpty + ? TestStatus.pass + : (failures.length == 1 && + !failures.first.contains('Intent')) + ? TestStatus.partial + : TestStatus.fail; + + for (final f in failures) { + debugPrint(' ❌ $f'); + } + if (failures.isEmpty) { + debugPrint(' ✅ PASS'); + } + + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmResult: llmResult, + resolvedTime: resolvedTime, + llmDuration: totalElapsed, + tokensPerSecond: lastTokensPerSecond, + status: status, + failures: failures, + )); + } on TimeoutException { + llm.cancelInference(); + debugPrint(' ⏱ TIMEOUT'); + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmDuration: perCaseTimeout, + tokensPerSecond: 0, + status: TestStatus.fail, + failures: ['Timed out after ${perCaseTimeout.inSeconds}s'], + )); + } catch (e) { + llm.cancelInference(); + debugPrint(' ❌ ERROR: $e'); + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmDuration: Duration.zero, + tokensPerSecond: 0, + status: TestStatus.fail, + failures: ['Error: $e'], + )); + } + + onProgress?.call(TimeExtractionProgress( + totalCases: testCases.length, + completedCases: i + 1, + currentCaseName: tc.name, + modelName: modelName, + )); + } + } finally { + llm.dispose(); + } + + results.add(TimeExtractionModelResult( + modelPath: modelPath, + cases: caseResults, + )); + } + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + LlmExtractionResult _parseLlmOutput(String raw) { + final parsed = _parser.parse(raw); + return LlmExtractionResult( + intent: parsed.extraction?.intent, + title: parsed.extraction?.title, + datetimeExpressionOriginal: parsed.extraction?.datetimeExpressionOriginal, + datetimeExpressionEnglish: parsed.extraction?.datetimeExpressionEnglish, + rawOutput: parsed.rawOutput, + ); + } + + bool _shouldRetryInvalidOutput( + TimeExtractionTestCase testCase, + LlmExtractionResult llmResult, + ) { + final hasIntent = llmResult.intent != null && llmResult.intent!.isNotEmpty; + final hasAnyTime = (llmResult.datetimeExpressionEnglish != null && + llmResult.datetimeExpressionEnglish!.isNotEmpty) || + (llmResult.datetimeExpressionOriginal != null && + llmResult.datetimeExpressionOriginal!.isNotEmpty); + final hasJsonFields = hasIntent || + llmResult.title != null || + llmResult.datetimeExpressionEnglish != null || + llmResult.datetimeExpressionOriginal != null; + + if (!hasJsonFields) { + return true; + } + + if (!hasIntent) { + return true; + } + + if (testCase.expectedTimeEnglish != null && !hasAnyTime) { + return true; + } + + return false; + } + + List _evaluate( + TimeExtractionTestCase tc, + LlmExtractionResult llm, + ResolvedTime? resolved, + ) { + final failures = []; + + // Intent match + if (!_intentMatches(llm.intent, tc.expectedIntent)) { + failures.add( + 'Intent: got "${llm.intent}", expected "${tc.expectedIntent}"', + ); + } + + // Time expression present/absent + if (tc.expectedTimeEnglish != null && + llm.datetimeExpressionEnglish == null && + llm.datetimeExpressionOriginal == null) { + failures.add('Expected time expression but got null'); + } + if (tc.expectedTimeEnglish == null && + llm.datetimeExpressionEnglish != null) { + failures.add( + 'Expected no time but got "${llm.datetimeExpressionEnglish}"', + ); + } + + // Chrono parse success + if (tc.expectedDateTime != null && resolved == null) { + failures.add('Chrono failed to parse time expression'); + } + if (tc.expectedDateTime == null && resolved != null) { + failures.add('Expected no resolved time but got ${resolved.dateTime}'); + } + + // DateTime accuracy + if (tc.expectedDateTime != null && resolved != null) { + final diff = resolved.dateTime + .difference(tc.expectedDateTime!) + .inMinutes + .abs(); + if (diff > tc.toleranceMinutes) { + failures.add( + 'DateTime: got ${resolved.dateTime}, expected ${tc.expectedDateTime} ' + '(diff ${diff}min, tolerance ${tc.toleranceMinutes}min)', + ); + } + } + + return failures; + } + + bool _intentMatches(String? got, String expected) { + if (got == null) return false; + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // note ↔ task are fuzzy + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; + } + + /// Format results as a readable string for display. + static String formatResults(List results) { + final buf = StringBuffer(); + + for (final model in results) { + buf.writeln('╔══════════════════════════════════════════════════════╗'); + buf.writeln('║ Model: ${model.modelName}'); + buf.writeln('║ Results: ${model.passedCount} passed, ' + '${model.partialCount} partial, ${model.failedCount} failed ' + 'of ${model.cases.length}'); + buf.writeln('║ Total time: ' + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s'); + buf.writeln('║ Avg: ' + '${model.avgTokensPerSecond.toStringAsFixed(1)} tok/s'); + buf.writeln('╚══════════════════════════════════════════════════════╝'); + buf.writeln(); + + for (final r in model.cases) { + final icon = switch (r.status) { + TestStatus.pass => '✅', + TestStatus.partial => '⚠️', + TestStatus.fail => '❌', + }; + buf.writeln('$icon ${r.testCase.name}'); + buf.writeln(' Input: "${r.testCase.transcript}"'); + if (r.llmResult != null) { + buf.writeln(' Intent: ${r.llmResult!.intent}'); + buf.writeln(' Title: ${r.llmResult!.title}'); + buf.writeln( + ' Time (orig): ${r.llmResult!.datetimeExpressionOriginal}'); + buf.writeln( + ' Time (EN): ${r.llmResult!.datetimeExpressionEnglish}'); + } + if (r.resolvedTime != null) { + buf.writeln( + ' Resolved: ${r.resolvedTime!.dateTime} (${r.resolvedTime!.method})'); + } + if (r.testCase.expectedDateTime != null) { + buf.writeln(' Expected: ${r.testCase.expectedDateTime}'); + } + buf.writeln( + ' LLM: ${r.llmDuration.inMilliseconds}ms, ' + '${r.tokensPerSecond.toStringAsFixed(1)} tok/s'); + for (final f in r.failures) { + buf.writeln(' ↳ $f'); + } + buf.writeln(); + } + } + + return buf.toString(); + } +} diff --git a/ai_testbench/lib/time_extraction_main.dart b/ai_testbench/lib/time_extraction_main.dart new file mode 100644 index 0000000..86b1244 --- /dev/null +++ b/ai_testbench/lib/time_extraction_main.dart @@ -0,0 +1,136 @@ +/// Headless entry point for time extraction benchmark. +/// +/// Runs the time extraction test suite and prints results to stdout. +/// Requires Flutter (for fllama), so it must be compiled as a Flutter app. +/// +/// Usage: +/// flutter run -d linux --dart-entrypoint-args '--model Qwen3.5-2B-Q4_K_M.gguf' +/// Or compiled: +/// ./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'prompts/time_extraction_prompts.dart'; +import 'services/time_extraction_benchmark_service.dart'; + +/// Run the time extraction benchmark headlessly. +/// +/// Call from main() when --headless-time flag is detected. +Future runHeadlessTimeExtraction(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + final modelDir = Directory('models').absolute.path; + String? modelFilter; + var includeLanguageHint = false; + var retryInvalidOutput = false; + var promptVariant = TimeExtractionPromptVariant.full; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--model' && i + 1 < args.length) { + modelFilter = args[++i]; + } else if (args[i] == '--language-hint') { + includeLanguageHint = true; + } else if (args[i] == '--retry-invalid') { + retryInvalidOutput = true; + } else if (args[i] == '--prompt-variant' && i + 1 < args.length) { + final value = args[++i].toLowerCase(); + switch (value) { + case 'full': + promptVariant = TimeExtractionPromptVariant.full; + break; + case 'medium': + promptVariant = TimeExtractionPromptVariant.medium; + break; + case 'short': + promptVariant = TimeExtractionPromptVariant.short; + break; + default: + stdout.writeln('ERROR: Unknown prompt variant "$value"'); + exitCode = 1; + return; + } + } + } + + // Discover models + final modelsDirectory = Directory(modelDir); + if (!modelsDirectory.existsSync()) { + stdout.writeln('ERROR: models/ directory not found at $modelDir'); + exitCode = 1; + return; + } + + var modelPaths = modelsDirectory + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + if (modelFilter != null) { + modelPaths = modelPaths + .where((p) => + p.toLowerCase().contains(modelFilter!.toLowerCase())) + .toList(); + } + + if (modelPaths.isEmpty) { + stdout.writeln('ERROR: No matching .gguf models found'); + if (modelFilter != null) { + stdout.writeln(' Filter: $modelFilter'); + } + exitCode = 1; + return; + } + + stdout.writeln(''); + stdout.writeln('╔═══════════════════════════════════════════════════════╗'); + stdout.writeln('║ Time Extraction Benchmark — Headless ║'); + stdout.writeln('╚═══════════════════════════════════════════════════════╝'); + stdout.writeln(''); + stdout.writeln('Models: ${modelPaths.length}'); + for (final p in modelPaths) { + stdout.writeln(' - ${p.split(Platform.pathSeparator).last}'); + } + stdout.writeln('Test cases: ${TimeExtractionBenchmarkService.testCases.length}'); + stdout.writeln('Reference time: ${TimeExtractionBenchmarkService.referenceTime}'); + stdout.writeln('Prompt variant: ${promptVariant.name}'); + stdout.writeln('Language hint: ${includeLanguageHint ? 'enabled' : 'disabled'}'); + stdout.writeln('Retry invalid output: ${retryInvalidOutput ? 'enabled' : 'disabled'}'); + stdout.writeln(''); + + final service = TimeExtractionBenchmarkService(); + final results = await service.runForModels( + modelPaths, + includeLanguageHint: includeLanguageHint, + retryInvalidOutput: retryInvalidOutput, + promptVariant: promptVariant, + onProgress: (p) { + stdout.writeln( + '[${p.modelName}] ${p.completedCases}/${p.totalCases} ' + '${p.currentCaseName}', + ); + }, + ); + + stdout.writeln(''); + stdout.write(TimeExtractionBenchmarkService.formatResults(results)); + + // Exit summary + for (final model in results) { + stdout.writeln( + 'SUMMARY ${model.modelName}: ' + '${model.passedCount}/${model.cases.length} pass, ' + '${model.partialCount} partial, ' + '${model.failedCount} fail, ' + '${model.avgTokensPerSecond.toStringAsFixed(1)} tok/s, ' + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s total', + ); + } + + exitCode = results.any((m) => m.failedCount > 0) ? 1 : 0; +} diff --git a/ai_testbench/lib/widgets/memo_card.dart b/ai_testbench/lib/widgets/memo_card.dart new file mode 100644 index 0000000..f71452b --- /dev/null +++ b/ai_testbench/lib/widgets/memo_card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +/// Visual preview card for a chrono-extracted memo. +/// +/// Expects the JSON map to follow the chrono_ai_flow schema: +/// intent, title, datetime_expression_original, datetime_expression_english +class MemoCard extends StatelessWidget { + const MemoCard({super.key, required this.data}); + + final Map data; + + @override + Widget build(BuildContext context) { + final intent = (data['intent'] as String?) ?? 'note'; + final title = (data['title'] as String?) ?? '—'; + final dtOriginal = data['datetime_expression_original'] as String?; + final dtEnglish = data['datetime_expression_english'] as String?; + + final (icon, color) = _intentStyle(intent); + + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────── + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + intent.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 1, + ), + ), + ), + const SizedBox(height: 4), + Text( + title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ), + + // ── Datetime expressions ──────────────────────────────────── + if (dtOriginal != null || dtEnglish != null) ...[ + const Divider(height: 24), + if (dtOriginal != null) + _dateTimeRow( + context, + label: 'Original', + value: dtOriginal, + color: color, + ), + if (dtEnglish != null && dtEnglish != dtOriginal) + _dateTimeRow( + context, + label: 'English', + value: dtEnglish, + color: color, + ), + ], + + if (dtOriginal == null && intent != 'note') ...[ + const Divider(height: 24), + Text( + 'No time expression extracted.', + style: TextStyle( + color: Colors.grey[500], + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ); + } + + static Widget _dateTimeRow( + BuildContext context, { + required String label, + required String value, + required Color color, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.schedule, size: 18, color: color.withValues(alpha: 0.6)), + const SizedBox(width: 8), + Text( + '$label: ', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + + static (IconData, Color) _intentStyle(String intent) { + return switch (intent.toLowerCase()) { + 'reminder' || 'task' || 'todo' => (Icons.checklist, Colors.amber), + 'event' || 'meeting' => (Icons.event, Colors.lightBlue), + 'note' || 'idea' => (Icons.sticky_note_2, Colors.green), + _ => (Icons.notes, Colors.grey), + }; + } +} diff --git a/ai_testbench/linux/.gitignore b/ai_testbench/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/ai_testbench/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/ai_testbench/linux/CMakeLists.txt b/ai_testbench/linux/CMakeLists.txt new file mode 100644 index 0000000..bc8aa44 --- /dev/null +++ b/ai_testbench/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ai_testbench") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.zswatch.ai_testbench") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/ai_testbench/linux/flutter/CMakeLists.txt b/ai_testbench/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/ai_testbench/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/ai_testbench/linux/flutter/generated_plugin_registrant.cc b/ai_testbench/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/ai_testbench/linux/flutter/generated_plugin_registrant.h b/ai_testbench/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/ai_testbench/linux/flutter/generated_plugins.cmake b/ai_testbench/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f2b498c --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/ai_testbench/linux/runner/CMakeLists.txt b/ai_testbench/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/ai_testbench/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/ai_testbench/linux/runner/main.cc b/ai_testbench/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/ai_testbench/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/ai_testbench/linux/runner/my_application.cc b/ai_testbench/linux/runner/my_application.cc new file mode 100644 index 0000000..baa4ba3 --- /dev/null +++ b/ai_testbench/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "ai_testbench"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "ai_testbench"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/ai_testbench/linux/runner/my_application.h b/ai_testbench/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/ai_testbench/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/ai_testbench/macos/.gitignore b/ai_testbench/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/ai_testbench/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Flutter/Flutter-Release.xcconfig b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift b/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..d484543 --- /dev/null +++ b/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/ai_testbench/macos/Podfile b/ai_testbench/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/ai_testbench/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/ai_testbench/macos/Podfile.lock b/ai_testbench/macos/Podfile.lock new file mode 100644 index 0000000..5c7e944 --- /dev/null +++ b/ai_testbench/macos/Podfile.lock @@ -0,0 +1,35 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - fllama (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - fllama (from `Flutter/ephemeral/.symlinks/plugins/fllama/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + fllama: + :path: Flutter/ephemeral/.symlinks/plugins/fllama/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + fllama: 54acd3605cfd830c0cffea6b297eef964de58be1 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/ai_testbench/macos/Runner.xcodeproj/project.pbxproj b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b6a9431 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 1F7F7EC55BCE2F1F8553C53F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5BACA1072283716D69E57AB4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* ai_testbench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ai_testbench.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 6367D1B332C4EC45B4AD77AE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8164D56A44957E05F530EF8B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 8E913363B4CD69F5CA30DF09 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5BACA1072283716D69E57AB4 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1F7F7EC55BCE2F1F8553C53F /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 5C2190EC137385104FC591FA /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* ai_testbench.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 5C2190EC137385104FC591FA /* Pods */ = { + isa = PBXGroup; + children = ( + 8164D56A44957E05F530EF8B /* Pods-Runner.debug.xcconfig */, + 6367D1B332C4EC45B4AD77AE /* Pods-Runner.release.xcconfig */, + 8E913363B4CD69F5CA30DF09 /* Pods-Runner.profile.xcconfig */, + 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */, + 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */, + E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */, + 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 405C8E04DCD64439122D9136 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + DB8E0CF7AF0D76C764C51B99 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 0F0D8415CC687AEA57058C2C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* ai_testbench.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0F0D8415CC687AEA57058C2C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 405C8E04DCD64439122D9136 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DB8E0CF7AF0D76C764C51B99 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..30c0437 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ai_testbench/macos/Runner/AppDelegate.swift b/ai_testbench/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/ai_testbench/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/ai_testbench/macos/Runner/Base.lproj/MainMenu.xib b/ai_testbench/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/ai_testbench/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig b/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..10e5449 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = ai_testbench + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 dev.zswatch. All rights reserved. diff --git a/ai_testbench/macos/Runner/Configs/Debug.xcconfig b/ai_testbench/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/ai_testbench/macos/Runner/Configs/Release.xcconfig b/ai_testbench/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/ai_testbench/macos/Runner/Configs/Warnings.xcconfig b/ai_testbench/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/ai_testbench/macos/Runner/DebugProfile.entitlements b/ai_testbench/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/ai_testbench/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/ai_testbench/macos/Runner/Info.plist b/ai_testbench/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/ai_testbench/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/ai_testbench/macos/Runner/MainFlutterWindow.swift b/ai_testbench/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/ai_testbench/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/ai_testbench/macos/Runner/Release.entitlements b/ai_testbench/macos/Runner/Release.entitlements new file mode 100644 index 0000000..e89b7f3 --- /dev/null +++ b/ai_testbench/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/ai_testbench/macos/RunnerTests/RunnerTests.swift b/ai_testbench/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/ai_testbench/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ai_testbench/parse_iter4.py b/ai_testbench/parse_iter4.py new file mode 100644 index 0000000..bfd2a3d --- /dev/null +++ b/ai_testbench/parse_iter4.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import json +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +results_path = os.path.join(script_dir, 'benchmark_results', 'results.json') + +with open(results_path) as f: + data = json.load(f) + +for model in data['results']: + print('Model: ' + model['modelName']) + print('Passed: ' + str(model['passedCases']) + '/' + str(model['totalCases'])) + print('') + for c in model['cases']: + s = 'PASS' if c['passed'] else 'FAIL' + checks = [] + if not c.get('validJson'): + checks.append('json') + if not c.get('intentMatch'): + checks.append('intent') + if not c.get('timePresenceMatch'): + checks.append('timePres') + if not c.get('titleLanguageMatch'): + checks.append('titleLang') + if not c.get('timeResolutionCorrect'): + checks.append('timeRes') + if not c.get('countMatch'): + checks.append('count') + fails = '' + if checks: + fails = ' FAILED:' + ','.join(checks) + cnt = str(c.get('extractedCount', 1)) + '/' + str(c.get('expectedCount', 1)) + print('[' + s + '] ' + c['caseName'] + ' cnt=' + cnt + fails) + for item_f in c.get('itemFailures', []): + print(' ! ' + item_f) + if not c['passed']: + preview = c.get('outputPreview', '')[:300] + print(' output: ' + preview) + print('') diff --git a/ai_testbench/parse_results.py b/ai_testbench/parse_results.py new file mode 100644 index 0000000..939e183 --- /dev/null +++ b/ai_testbench/parse_results.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +import json + +with open('/Users/jakkra/Documents/ZSWatch-App/ai_testbench/benchmark_results/results.json') as f: + data = json.load(f) + +for model in data['results']: + print(f"Model: {model['modelName']}") + print(f" Passed: {model['passedCases']}/{model['totalCases']}") + print(f" Avg tok/s: {model['avgTokensPerSecond']:.1f}") + print(f" Total time: {model['totalElapsedMs']/1000:.1f}s") + print() + for c in model['cases']: + status = 'PASS' if c['passed'] else 'FAIL' + extracted = c.get('extractedCount', 1) + expected = c.get('expectedCount', 1) + checks = [] + if not c.get('validJson'): checks.append('json') + if not c.get('intentMatch'): checks.append('intent') + if not c.get('timePresenceMatch'): checks.append('timePresence') + if not c.get('titleLanguageMatch'): checks.append('titleLang') + if not c.get('timeResolutionCorrect'): checks.append('timeResolve') + if not c.get('countMatch'): checks.append('count') + fail_str = ' FAILED:[' + ','.join(checks) + ']' if checks else '' + print(f' [{status}] {c["caseName"]}: intent={c.get("intent","?")} count={extracted}/{expected} ({c["elapsedMs"]/1000:.1f}s {c.get("tokensPerSecond",0):.1f}tok/s){fail_str}') + for f in c.get('itemFailures', []): + print(f' ! {f}') + if not c['passed']: + preview = c.get('outputPreview', '')[:250] + print(f' output: {preview}') diff --git a/ai_testbench/pubspec.lock b/ai_testbench/pubspec.lock new file mode 100644 index 0000000..db6bdb1 --- /dev/null +++ b/ai_testbench/pubspec.lock @@ -0,0 +1,544 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + chrono_ai_flow: + dependency: "direct main" + description: + path: "../packages/chrono_ai_flow" + relative: true + source: path + version: "0.1.0" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fllama: + dependency: "direct main" + description: + path: "../third_party/fllama" + relative: true + source: path + version: "0.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jinja: + dependency: transitive + description: + name: jinja + sha256: "67485c43c8551688669a81b4e01fe94f6126578ba8c194908d00f254f23f9b8b" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + textwrap: + dependency: transitive + description: + name: textwrap + sha256: "7e79503c220a9c772d370075e0d4117204546ed4c6479ab1c9ee4d4c27add606" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" + flutter: ">=3.35.0" diff --git a/ai_testbench/pubspec.yaml b/ai_testbench/pubspec.yaml new file mode 100644 index 0000000..b5ea58b --- /dev/null +++ b/ai_testbench/pubspec.yaml @@ -0,0 +1,97 @@ +name: ai_testbench +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + fllama: + path: ../third_party/fllama + file_picker: ^10.3.10 + path_provider: ^2.1.5 + http: ^1.2.2 + chrono_dart: ^2.0.2 + chrono_ai_flow: + path: ../packages/chrono_ai_flow + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/ai_testbench/run_benchmark.py b/ai_testbench/run_benchmark.py new file mode 100644 index 0000000..7434392 --- /dev/null +++ b/ai_testbench/run_benchmark.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Python wrapper to run ai_testbench headless benchmark and capture output.""" + +import subprocess +import sys +import json +import os +import argparse + +APP_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "build", "macos", "Build", "Products", "Release", + "ai_testbench.app", "Contents", "MacOS", "ai_testbench", +) + +MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") +OUTPUT_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "benchmark_results", "results.json") + + +def main(): + parser = argparse.ArgumentParser(description="Run ai_testbench headless benchmark and capture output.") + parser.add_argument("--model", help="Substring filter for model filename") + parser.add_argument("--case", dest="case_filter", help="Substring filter for benchmark case name or transcript") + parser.add_argument("--case-limit", type=int, help="Maximum number of benchmark cases to run after filtering") + parser.add_argument("--output", help="Override output JSON path") + args = parser.parse_args() + + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + output_file = args.output or OUTPUT_FILE + + cmd = [ + APP_PATH, + "--headless", + "--model-dir", MODEL_DIR, + "--output", output_file, + ] + + if args.model: + cmd.extend(["--model", args.model]) + if args.case_filter: + cmd.extend(["--case", args.case_filter]) + if args.case_limit is not None: + cmd.extend(["--case-limit", str(args.case_limit)]) + + print(f"Running: {' '.join(cmd)}") + print(f"Model dir: {MODEL_DIR}") + print(f"Output: {output_file}") + print("-" * 60) + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + full_output = [] + for line in proc.stdout: + line = line.rstrip("\n") + full_output.append(line) + print(line) + + proc.wait() + print("-" * 60) + print(f"Exit code: {proc.returncode}") + + # Try to load and pretty-print results + if os.path.exists(output_file): + with open(output_file) as f: + results = json.load(f) + + print("\n" + "=" * 60) + print("BENCHMARK RESULTS SUMMARY") + print("=" * 60) + + for model_result in results.get("results", []): + model_name = model_result.get("modelName", "unknown") + passed = model_result.get("passedCases", 0) + total = model_result.get("totalCases", 0) + avg_tok = model_result.get("avgTokensPerSecond", 0) + total_ms = model_result.get("totalElapsedMs", 0) + + print(f"\nModel: {model_name}") + print(f" Passed: {passed}/{total}") + print(f" Avg tok/s: {avg_tok:.1f}") + print(f" Total time: {total_ms / 1000:.1f}s") + print() + + for case in model_result.get("cases", []): + status = "PASS" if case.get("passed") else "FAIL" + name = case.get("caseName", "?") + intent = case.get("intent", "?") + extracted = case.get("extractedCount", 1) + expected = case.get("expectedCount", 1) + elapsed_s = case.get("elapsedMs", 0) / 1000 + + checks = [] + if not case.get("validJson"): checks.append("json") + if not case.get("intentMatch"): checks.append("intent") + if not case.get("timePresenceMatch"): checks.append("time") + if not case.get("titleLanguageMatch"): checks.append("lang") + if not case.get("timeResolutionCorrect"): checks.append("resolve") + if not case.get("countMatch"): checks.append("count") + + failures = case.get("itemFailures", []) + fail_str = f" [{', '.join(checks)}]" if checks else "" + + print(f" [{status}] {name}: intent={intent} " + f"count={extracted}/{expected} " + f"({elapsed_s:.1f}s){fail_str}") + + for f in failures: + print(f" ⚠ {f}") + + if case.get("error"): + print(f" ERROR: {case['error']}") + else: + print(f"No output file found at {output_file}") + + return proc.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ai_testbench/show_failures.py b/ai_testbench/show_failures.py new file mode 100644 index 0000000..9f4ec0c --- /dev/null +++ b/ai_testbench/show_failures.py @@ -0,0 +1,12 @@ +import json +with open('benchmark_results/results.json') as f: + data = json.load(f) +for c in data['results'][0]['cases']: + if not c['passed']: + fails = c.get('itemFailures', []) + out = c.get('outputPreview','')[:200] + print(f"FAIL: {c['caseName']}") + print(f" output: {out}") + for f2 in fails: + print(f" - {f2}") + print() diff --git a/ai_testbench/test/widget_test.dart b/ai_testbench/test/widget_test.dart new file mode 100644 index 0000000..4f8cd24 --- /dev/null +++ b/ai_testbench/test/widget_test.dart @@ -0,0 +1,17 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:ai_testbench/main.dart'; + +void main() { + testWidgets('App launches smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const AiTestbenchApp()); + expect(find.text('ZSWatch AI Testbench'), findsOneWidget); + }); +} diff --git a/ai_testbench/test_single_prompt.py b/ai_testbench/test_single_prompt.py new file mode 100644 index 0000000..8d947f7 --- /dev/null +++ b/ai_testbench/test_single_prompt.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Quick test: run a single transcript through the model and print the raw output.""" +import subprocess, os, sys, json + +APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "build/macos/Build/Products/Release/ai_testbench.app/Contents/MacOS/ai_testbench") + +# The transcript to test - pass as arg or use default +transcript = sys.argv[1] if len(sys.argv) > 1 else \ + "Vi ska cleana upp klockans kod för voice memons sen ska vi testa att det går avbryta en calender tilläggning genom att klicka cancel på popup på klockan" + +MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models/Qwen3.5-2B-Q4_K_M.gguf") +OUTPUT = "/tmp/single_test_result.json" + +cmd = [APP, "--headless", "--model-dir", os.path.dirname(MODEL), "--output", OUTPUT] +print(f"Transcript: {transcript}") +print(f"Running benchmark (all cases)...") +print("(We'll grep the output for our specific case)") +print("-" * 60) + +proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) + +# Read results +if os.path.exists(OUTPUT): + with open(OUTPUT) as f: + data = json.load(f) + # Print summary for all cases + for model in data.get('results', []): + print(f"Model: {model['modelName']} — {model['passedCases']}/{model['totalCases']} passed") + for c in model['cases']: + s = 'PASS' if c['passed'] else 'FAIL' + cnt = f"{c.get('extractedCount',1)}/{c.get('expectedCount',1)}" + fails = [] + for f_item in c.get('itemFailures', []): + fails.append(f_item) + fail_str = f" [{', '.join(fails)}]" if fails else "" + print(f" [{s}] {c['caseName']} cnt={cnt}{fail_str}") + if 'swenglish' in c['caseName'] or 'casual_planning' in c['caseName'] or 'swenglish_long' in c['caseName']: + print(f" OUTPUT: {c.get('outputPreview','')}") +else: + print("No output file found") + print("STDOUT:", proc.stdout[-2000:] if proc.stdout else "") + print("STDERR:", proc.stderr[-2000:] if proc.stderr else "") diff --git a/ai_testbench/windows/.gitignore b/ai_testbench/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/ai_testbench/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/ai_testbench/windows/CMakeLists.txt b/ai_testbench/windows/CMakeLists.txt new file mode 100644 index 0000000..32c449d --- /dev/null +++ b/ai_testbench/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(ai_testbench LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ai_testbench") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/ai_testbench/windows/flutter/CMakeLists.txt b/ai_testbench/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/ai_testbench/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/ai_testbench/windows/flutter/generated_plugin_registrant.cc b/ai_testbench/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/ai_testbench/windows/flutter/generated_plugin_registrant.h b/ai_testbench/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/ai_testbench/windows/flutter/generated_plugins.cmake b/ai_testbench/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..17bba99 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/ai_testbench/windows/runner/CMakeLists.txt b/ai_testbench/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/ai_testbench/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/ai_testbench/windows/runner/Runner.rc b/ai_testbench/windows/runner/Runner.rc new file mode 100644 index 0000000..a0fdcae --- /dev/null +++ b/ai_testbench/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.zswatch" "\0" + VALUE "FileDescription", "ai_testbench" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "ai_testbench" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.zswatch. All rights reserved." "\0" + VALUE "OriginalFilename", "ai_testbench.exe" "\0" + VALUE "ProductName", "ai_testbench" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/ai_testbench/windows/runner/flutter_window.cpp b/ai_testbench/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/ai_testbench/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/ai_testbench/windows/runner/flutter_window.h b/ai_testbench/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/ai_testbench/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/ai_testbench/windows/runner/main.cpp b/ai_testbench/windows/runner/main.cpp new file mode 100644 index 0000000..af3f03b --- /dev/null +++ b/ai_testbench/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"ai_testbench", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/ai_testbench/windows/runner/resource.h b/ai_testbench/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/ai_testbench/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/ai_testbench/windows/runner/resources/app_icon.ico b/ai_testbench/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/ai_testbench/windows/runner/resources/app_icon.ico differ diff --git a/ai_testbench/windows/runner/runner.exe.manifest b/ai_testbench/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/ai_testbench/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/ai_testbench/windows/runner/utils.cpp b/ai_testbench/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/ai_testbench/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/ai_testbench/windows/runner/utils.h b/ai_testbench/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/ai_testbench/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/ai_testbench/windows/runner/win32_window.cpp b/ai_testbench/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/ai_testbench/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/ai_testbench/windows/runner/win32_window.h b/ai_testbench/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/ai_testbench/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/chrono_ai_flow/lib/chrono_ai_flow.dart b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart new file mode 100644 index 0000000..f507411 --- /dev/null +++ b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart @@ -0,0 +1,5 @@ +export 'src/correction_prompt_template.dart'; +export 'src/models.dart'; +export 'src/prompt_template.dart'; +export 'src/parser.dart'; +export 'src/time_expression_resolver.dart'; diff --git a/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart new file mode 100644 index 0000000..ab6ea1f --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart @@ -0,0 +1,58 @@ +/// Shared prompt template for speech-to-text correction. +/// +/// Used by both the main app (`llm_service.dart`) and the AI testbench +/// (`correction_benchmark_service.dart`). Edit this file to tune the +/// correction prompt — changes apply everywhere automatically. +class CorrectionPromptTemplate { + CorrectionPromptTemplate._(); + + static const String promptPlaceholderTranscript = '{{transcript}}'; + + static const String defaultTemplate = ''' +Fix speech-to-text errors. Output ONLY the corrected text. + +IMPORTANT: NEVER translate. The output language MUST be the same as the input language. Swedish input → Swedish output. German input → German output. + +Fix these common STT errors: +- Homophones: "there"/"their", "weak"/"week", "by"/"buy" +- Stuttering/repeats: "I I need" → "I need", "och och" → "och" +- Filler words: remove um, uh, eh, like, you know, alltså, liksom, also +- Missing punctuation: add periods, commas, capitalize first word +- Missing diacritics: "mote" → "möte", "fur" → "für", "pa" → "på" +- Split/joined words: "i morgan" → "imorgon", "can not" → "cannot" + +If already correct, repeat the input verbatim. Do NOT add explanations. Output only the corrected text. + +Input: "remind me to uh call the the dentist and dont forget to by milk" +Output: "Remind me to call the dentist and don't forget to buy milk." + +Input: "vi har mote med kunden pa torsdag klockan tva" +Output: "Vi har möte med kunden på torsdag klockan två." + +Input: "also ich äh muss muss den Bericht fertig machen" +Output: "Ich muss den Bericht fertig machen." + +Input: "$promptPlaceholderTranscript" +/no_think +Output:'''; + + /// Render the correction prompt with the given [transcript]. + static String render( + String template, { + required String transcript, + }) { + return template.replaceAll(promptPlaceholderTranscript, transcript); + } + + /// Estimate a reasonable maxTokens for the correction output. + /// + /// The corrected text is always roughly the same length as (or shorter than) + /// the input transcript. We use ~3.5 chars/token (conservative for + /// mixed-language text with diacritics) and add a fixed margin for + /// punctuation/capitalization changes. + static int estimateMaxTokens(String transcript, {int margin = 32}) { + final estimated = (transcript.length / 3.5).ceil() + margin; + // Clamp to a sane range: at least 64, at most 512. + return estimated.clamp(64, 512); + } +} diff --git a/packages/chrono_ai_flow/lib/src/models.dart b/packages/chrono_ai_flow/lib/src/models.dart new file mode 100644 index 0000000..536db56 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/models.dart @@ -0,0 +1,37 @@ +class ChronoLlmExtraction { + final String intent; + final String title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + + const ChronoLlmExtraction({ + required this.intent, + required this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + }); +} + +class ChronoLlmParseResult { + final String rawOutput; + final String? parsedJson; + final ChronoLlmExtraction? extraction; + + /// All extractions when the model returns a JSON array. + /// For single-object output, this contains just the one [extraction]. + final List extractions; + + const ChronoLlmParseResult({ + required this.rawOutput, + this.parsedJson, + this.extraction, + this.extractions = const [], + }); +} + +class ResolvedTime { + final DateTime dateTime; + final String method; + + const ResolvedTime({required this.dateTime, required this.method}); +} diff --git a/packages/chrono_ai_flow/lib/src/parser.dart b/packages/chrono_ai_flow/lib/src/parser.dart new file mode 100644 index 0000000..b199a78 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/parser.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'models.dart'; + +class ChronoLlmParser { + const ChronoLlmParser(); + + ChronoLlmParseResult parse(String raw) { + final cleaned = sanitizeModelOutput(raw); + + // Try array first (new format), then fall back to single object + final arrayStr = extractFirstJsonArray(cleaned); + if (arrayStr != null) { + try { + final list = jsonDecode(arrayStr) as List; + final extractions = []; + for (final item in list.whereType>()) { + final extraction = _parseOneObject(item); + if (extraction != null) { + extractions.add(extraction); + } + } + if (extractions.isNotEmpty) { + return ChronoLlmParseResult( + rawOutput: cleaned, + parsedJson: arrayStr, + extraction: extractions.first, + extractions: extractions, + ); + } + } catch (_) { + // Fall through to single-object parsing + } + } + + final jsonStr = extractFirstJsonObject(cleaned); + if (jsonStr == null) { + return ChronoLlmParseResult(rawOutput: cleaned); + } + + try { + final parsed = jsonDecode(jsonStr) as Map; + final extraction = _parseOneObject(parsed); + if (extraction == null) { + return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); + } + + return ChronoLlmParseResult( + rawOutput: cleaned, + parsedJson: jsonStr, + extraction: extraction, + extractions: [extraction], + ); + } catch (_) { + return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); + } + } + + ChronoLlmExtraction? _parseOneObject(Map parsed) { + if (!parsed.containsKey('intent')) { + return null; + } + + final intent = _normalizeIntent(parsed['intent'] as String?); + final title = + ((parsed['title'] ?? parsed['summary']) as String?)?.trim() ?? ''; + final datetimeOriginal = + (parsed['datetime_expression_original'] as String?)?.trim(); + final datetimeEnglish = + (parsed['datetime_expression_english'] as String?)?.trim(); + + return ChronoLlmExtraction( + intent: intent, + title: title, + datetimeExpressionOriginal: + (datetimeOriginal?.isNotEmpty ?? false) ? datetimeOriginal : null, + datetimeExpressionEnglish: + (datetimeEnglish?.isNotEmpty ?? false) ? datetimeEnglish : null, + ); + } + + String sanitizeModelOutput(String raw) { + return raw + .replaceAll('<|im_end|>', '') + .replaceAll(RegExp(r'.*?', dotAll: true), '') + .replaceAll(RegExp(r'.*', dotAll: true), '') + .trim(); + } + + String? extractFirstJsonArray(String raw) { + final cleaned = sanitizeModelOutput(raw); + final start = cleaned.indexOf('['); + if (start == -1) { + return null; + } + return _extractBalanced(cleaned, start, '[', ']'); + } + + String? extractFirstJsonObject(String raw) { + final cleaned = sanitizeModelOutput(raw); + final start = cleaned.indexOf('{'); + if (start == -1) { + return null; + } + return _extractBalanced(cleaned, start, '{', '}'); + } + + String? _extractBalanced(String text, int start, String open, String close) { + var depth = 0; + var inString = false; + var escaping = false; + + for (var i = start; i < text.length; i++) { + final char = text[i]; + + if (escaping) { + escaping = false; + continue; + } + + if (char == '\\' && inString) { + escaping = true; + continue; + } + + if (char == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char == open) { + depth++; + } else if (char == close) { + depth--; + if (depth == 0) { + return text.substring(start, i + 1); + } + } + } + + return null; + } + + String normalizeIntent(String? rawIntent) => _normalizeIntent(rawIntent); + + bool shouldRetryInvalidChronoOutput(String raw) { + final parsed = parse(raw); + if (parsed.parsedJson == null) { + return true; + } + if (parsed.extractions.isEmpty) { + return true; + } + if (parsed.extractions.first.intent.trim().isEmpty) { + return true; + } + return false; + } + + String _normalizeIntent(String? rawIntent) { + switch ((rawIntent ?? '').trim().toLowerCase()) { + case 'event': + case 'meeting': + case 'calendar_event': + return 'event'; + case 'reminder': + case 'task': + case 'todo': + return 'reminder'; + default: + return 'note'; + } + } +} diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart new file mode 100644 index 0000000..cbdaa39 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -0,0 +1,263 @@ +class ChronoPromptTemplate { + ChronoPromptTemplate._(); + + static const String promptPlaceholderCurrentLocalDateTime = + '{{current_local_datetime}}'; + static const String promptPlaceholderCurrentLocalDateTimeCompact = + '{{current_local_datetime_compact}}'; + static const String promptPlaceholderWeekday = '{{weekday}}'; + static const String promptPlaceholderTimezoneOffset = '{{timezone_offset}}'; + static const String promptPlaceholderTranscript = '{{transcript}}'; + + static const String defaultTemplate = + ''' +You extract structured information from a voice memo. + +A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. + +The memo may be in ANY language. + +Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. + +Your tasks per item: +1. Detect intent: "reminder", "event", or "note". +2. Extract the time/date phrase exactly as it appears in the memo. +3. Translate that time/date phrase into natural English. If already English, copy it. +4. Extract a short title (the task or event, NOT the time part). + +IMPORTANT — item splitting: +- ALWAYS return a JSON array, even for a single item. +- NEVER drop items. Every distinct action in the memo MUST appear as a separate object. +- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses, sentence boundaries. +- A single idea with elaboration/details stays as ONE item. +- Comma-separated lists (shopping items, ingredients, supplies) are ONE item. + +IMPORTANT — intent classification: +- "event" = meetings, appointments, social plans, bookings, standups — things you ATTEND with others or at a place (dentist, fika with someone, team standup, conference, lunch with a person) +- "reminder" = personal tasks/actions WITH a specific time — things you DO alone (call someone at 3 pm, pick up package at 5, go to gym at 8, take medicine at 8) +- "note" = NO time/date mentioned — ideas, shopping lists, tasks without a deadline (buy bread, good idea about sensors, prototype a feature) +- A task with NO time appearing alongside timed tasks is still a "note" + +Rules: +- Multi-item date context: if a later item only mentions a time (e.g. "at 3 pm"), reuse the most recent date from a previous item. Example: "tomorrow at 8 am ... at 3 pm" → item 2 is "tomorrow at 3 pm". +- Copy title words directly from the memo. Never translate the title. Keep titles short (2-5 words). +- Title must NOT contain time or date words. +- Do not resolve relative dates to absolute dates or ISO timestamps. Keep expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Copy the original time phrase exactly. Fill "datetime_expression_english" whenever "datetime_expression_original" is not null. +- If the memo is in English, copy the same phrase to both datetime fields. +- If no time/date for an item, set both datetime fields to null. +- Translate time expressions to natural English. Convert 24-hour to 12-hour. Use PM for afternoon/evening context. +- Do NOT add "next" to weekday translations unless the original explicitly says "next" / "nästa" / "nächsten". +- Deadlines ARE time expressions: "by Friday", "senast fredag", "bis Freitag" → extract them. +- NOT time expressions: locations ("at the store"), vague conditions ("when I get home", "after lunch"), words that look like time but aren't ("boka tid" = book appointment) + +Output JSON schema (always an array): +[ + { + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null + } +] + +Examples: + +Memo: "Remind me tomorrow at 10 am to buy milk" +[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}] + +Memo: "Tomorrow at 5 pm pick up the dog and then at 9 turn off all lights" +[{"intent":"reminder","title":"pick up the dog","datetime_expression_original":"tomorrow at 5 pm","datetime_expression_english":"tomorrow at 5 pm"},{"intent":"reminder","title":"turn off all lights","datetime_expression_original":"at 9","datetime_expression_english":"tomorrow at 9 pm"}] + +Memo: "påminn mig imorgon klockan 10 att köpa mjölk" +[{"intent":"reminder","title":"köpa mjölk","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"}] + +Memo: "tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem" +[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "remember to buy milk" +[{"intent":"note","title":"buy milk","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "köp bröd på vägen hem" +[{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "call the plumber this afternoon at 3" +[{"intent":"reminder","title":"call the plumber","datetime_expression_original":"this afternoon at 3","datetime_expression_english":"this afternoon at 3 pm"}] + +Memo: "Meeting with Sarah on Monday at 10 am and lunch with the team on Wednesday at noon" +[{"intent":"event","title":"meeting with Sarah","datetime_expression_original":"on Monday at 10 am","datetime_expression_english":"on Monday at 10 am"},{"intent":"event","title":"lunch with the team","datetime_expression_original":"on Wednesday at noon","datetime_expression_english":"on Wednesday at 12 pm"}] + +Memo: "Tomorrow at 3 pm call the electrician and also buy new light bulbs" +[{"intent":"reminder","title":"call the electrician","datetime_expression_original":"tomorrow at 3 pm","datetime_expression_english":"tomorrow at 3 pm"},{"intent":"note","title":"buy new light bulbs","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Arzttermin am Donnerstag um 9 Uhr und Zahnarzt am Freitag um 14 Uhr" +[{"intent":"event","title":"Arzttermin","datetime_expression_original":"am Donnerstag um 9 Uhr","datetime_expression_english":"Thursday at 9 am"},{"intent":"event","title":"Zahnarzt","datetime_expression_original":"am Freitag um 14 Uhr","datetime_expression_english":"Friday at 2 pm"}] + +Memo: "Den Bericht bis Freitag um 17 Uhr an den Chef schicken" +[{"intent":"reminder","title":"Bericht an Chef schicken","datetime_expression_original":"bis Freitag um 17 Uhr","datetime_expression_english":"on Friday at 5 pm"}] + +Memo: "Boka tid hos frisören" +[{"intent":"note","title":"boka tid hos frisören","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Fika med Anna imorgon klockan 10 och sen lämna in paketet på posten" +[{"intent":"event","title":"fika med Anna","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"},{"intent":"note","title":"lämna in paketet","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Vi behöver fixa buggen och sen refaktorera koden" +[{"intent":"note","title":"fixa buggen","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"note","title":"refaktorera koden","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Bilservice på tisdag klockan 8" +[{"intent":"event","title":"bilservice","datetime_expression_original":"på tisdag klockan 8","datetime_expression_english":"on Tuesday at 8 am"}] + +Memo: "Hämta barnen imorgon klockan halv 5" +[{"intent":"reminder","title":"hämta barnen","datetime_expression_original":"imorgon klockan halv 5","datetime_expression_english":"tomorrow at 4:30 pm"}] + +Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möte med chefen på fredag klockan 14" +[{"intent":"reminder","title":"ring tandläkaren","datetime_expression_original":"imorgon klockan 9","datetime_expression_english":"tomorrow at 9 am"},{"intent":"note","title":"köp presenter","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"event","title":"möte med chefen","datetime_expression_original":"på fredag klockan 14","datetime_expression_english":"on Friday at 2 pm"}] + +Memo: "Team standup tomorrow at 9:15 and I should prototype the new notification system" +[{"intent":"event","title":"team standup","datetime_expression_original":"tomorrow at 9:15","datetime_expression_english":"tomorrow at 9:15 am"},{"intent":"note","title":"prototype notification system","datetime_expression_original":null,"datetime_expression_english":null}] + +WRONG — never translate the title: +Memo: "Bra idé om att lägga till stegräknare i klockan" +WRONG: [{"intent":"note","title":"add step counter to watch",...}] +RIGHT: [{"intent":"note","title":"stegräknare i klockan","datetime_expression_original":null,"datetime_expression_english":null}] + +Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact +Timezone: UTC$promptPlaceholderTimezoneOffset + +Voice memo: + +$promptPlaceholderTranscript + +/no_think +JSON:'''; + + /// Compact prompt with fewer examples for low-memory devices (nCtx < 4096). + /// Same rules, 5 examples instead of 18. + static const String compactTemplate = + ''' +You extract structured information from a voice memo. + +A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. + +The memo may be in ANY language. + +Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. + +Your tasks per item: +1. Detect intent: "reminder", "event", or "note". +2. Extract the time/date phrase exactly as it appears in the memo. +3. Translate that time/date phrase into natural English. If already English, copy it. +4. Extract a short title (the task or event, NOT the time part). + +IMPORTANT — item splitting: +- ALWAYS return a JSON array, even for a single item. +- NEVER drop items. Every distinct action in the memo MUST appear as a separate object. +- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses, sentence boundaries. +- A single idea with elaboration/details stays as ONE item. +- Comma-separated lists (shopping items, ingredients, supplies) are ONE item. + +IMPORTANT — intent classification: +- "event" = meetings, appointments, social plans, bookings, standups — things you ATTEND with others or at a place +- "reminder" = personal tasks/actions WITH a specific time — things you DO alone +- "note" = NO time/date mentioned — ideas, shopping lists, tasks without a deadline +- A task with NO time appearing alongside timed tasks is still a "note" + +Rules: +- Multi-item date context: if a later item only mentions a time (e.g. "at 3 pm"), reuse the most recent date from a previous item. +- Copy title words directly from the memo. Never translate the title. Keep titles short (2-5 words). +- Title must NOT contain time or date words. +- Keep expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Copy the original time phrase exactly. Fill "datetime_expression_english" whenever "datetime_expression_original" is not null. +- If the memo is in English, copy the same phrase to both datetime fields. +- If no time/date for an item, set both datetime fields to null. +- Translate time expressions to natural English. Convert 24-hour to 12-hour. Use PM for afternoon/evening context. +- Do NOT add "next" to weekday translations unless the original explicitly says "next" / "nästa" / "nächsten". +- Deadlines ARE time expressions: "by Friday", "senast fredag", "bis Freitag" → extract them. +- NOT time expressions: locations ("at the store"), vague conditions ("when I get home", "after lunch"), words that look like time but aren't ("boka tid" = book appointment) + +Output JSON schema (always an array): +[ + { + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null + } +] + +Examples: + +Memo: "Remind me tomorrow at 10 am to buy milk" +[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}] + +Memo: "tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem" +[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Tomorrow at 3 pm call the electrician and also buy new light bulbs" +[{"intent":"reminder","title":"call the electrician","datetime_expression_original":"tomorrow at 3 pm","datetime_expression_english":"tomorrow at 3 pm"},{"intent":"note","title":"buy new light bulbs","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möte med chefen på fredag klockan 14" +[{"intent":"reminder","title":"ring tandläkaren","datetime_expression_original":"imorgon klockan 9","datetime_expression_english":"tomorrow at 9 am"},{"intent":"note","title":"köp presenter","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"event","title":"möte med chefen","datetime_expression_original":"på fredag klockan 14","datetime_expression_english":"on Friday at 2 pm"}] + +Memo: "köp bröd på vägen hem" +[{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null}] + +Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact +Timezone: UTC$promptPlaceholderTimezoneOffset + +Voice memo: + +$promptPlaceholderTranscript + +/no_think +JSON:'''; + + /// Returns the appropriate template for the given context size. + static String templateForContextSize(int nCtx) { + // Always use compactTemplate (5 examples, ~1030 tokens) — the full + // template with 19 examples produces ~2150 tokens which dominates + // inference time due to O(n²) attention. The compact template is + // sufficient for structured extraction quality. + return compactTemplate; + } + + static String render( + String template, { + required String transcript, + DateTime? now, + }) { + final localNow = now ?? DateTime.now(); + final weekday = const [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ][localNow.weekday - 1]; + final iso = localNow.toIso8601String(); + final tzOffset = localNow.timeZoneOffset; + final tzSign = tzOffset.isNegative ? '-' : '+'; + final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); + final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft( + 2, + '0', + ); + final tz = '$tzSign$tzHours:$tzMinutes'; + final compactDateTime = + '${localNow.year}-${localNow.month.toString().padLeft(2, '0')}-${localNow.day.toString().padLeft(2, '0')} ' + '${localNow.hour.toString().padLeft(2, '0')}:${localNow.minute.toString().padLeft(2, '0')}'; + + return template + .replaceAll(promptPlaceholderCurrentLocalDateTime, iso) + .replaceAll( + promptPlaceholderCurrentLocalDateTimeCompact, + compactDateTime, + ) + .replaceAll(promptPlaceholderWeekday, weekday) + .replaceAll(promptPlaceholderTimezoneOffset, tz) + .replaceAll(promptPlaceholderTranscript, transcript); + } +} diff --git a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart new file mode 100644 index 0000000..af6c4c5 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart @@ -0,0 +1,219 @@ +import 'package:chrono_dart/chrono_dart.dart'; + +import 'models.dart'; + +class TimeExpressionResolver { + static final _weekdayNames = RegExp( + r'\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b', + ); + + static const _weekdayMap = { + 'monday': 1, + 'tuesday': 2, + 'wednesday': 3, + 'thursday': 4, + 'friday': 5, + 'saturday': 6, + 'sunday': 7, + }; + + ResolvedTime? resolve(String expression, {DateTime? referenceDate}) { + if (expression.trim().isEmpty) return null; + + final ref = referenceDate ?? DateTime.now(); + final normalized = _normalize(expression); + + final specific = _specificPatterns(normalized, ref); + if (specific != null) { + return ResolvedTime(dateTime: specific, method: 'regex'); + } + + try { + final results = Chrono.parse( + normalized, + ref: ref, + option: ParsingOption(forwardDate: true), + ); + if (results.isNotEmpty) { + var resolved = results.first.date(); + if (resolved.isUtc) resolved = resolved.toLocal(); + + final weekdayMatch = _weekdayNames.firstMatch(normalized); + if (weekdayMatch != null) { + final mentionedDay = _weekdayMap[weekdayMatch.group(1)!]!; + + if (resolved.weekday != mentionedDay) { + var daysUntil = mentionedDay - ref.weekday; + if (daysUntil <= 0) daysUntil += 7; + resolved = DateTime( + ref.year, + ref.month, + ref.day + daysUntil, + resolved.hour, + resolved.minute, + resolved.second, + ); + return ResolvedTime( + dateTime: resolved, + method: 'chrono+adjusted', + ); + } + + if (resolved.isBefore(ref)) { + resolved = resolved.add(const Duration(days: 7)); + return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); + } + + // "on Wednesday" spoken on a Wednesday → chrono gives today, + // but the user likely means next week's occurrence. + if (mentionedDay == ref.weekday && + resolved.year == ref.year && + resolved.month == ref.month && + resolved.day == ref.day && + !normalized.contains('today') && + !normalized.contains('this')) { + resolved = resolved.add(const Duration(days: 7)); + return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); + } + + if (normalized.contains('next')) { + final daysFromRef = resolved.difference(ref).inDays; + // "next " means the occurrence in the following + // week. Days that haven't happened yet this week are + // "ahead" — chrono may resolve them to this-week's date. + final dayIsAheadInWeek = mentionedDay > ref.weekday; + if (dayIsAheadInWeek && daysFromRef < 7) { + // Chrono gave this week's occurrence — push to next week. + resolved = resolved.add(const Duration(days: 7)); + return ResolvedTime( + dateTime: resolved, method: 'chrono+adjusted'); + } + if (daysFromRef > 13) { + // Chrono jumped too far — pull back one week. + resolved = resolved.subtract(const Duration(days: 7)); + return ResolvedTime( + dateTime: resolved, method: 'chrono+adjusted'); + } + } + } + + return ResolvedTime(dateTime: resolved, method: 'chrono'); + } + } catch (_) { + // Ignore and continue to regex fallbacks. + } + + final regexResult = _generalRegex(normalized, ref); + if (regexResult != null) { + return ResolvedTime(dateTime: regexResult, method: 'regex'); + } + + return null; + } + + String _normalize(String expression) { + var s = expression.trim().toLowerCase(); + + const numberWords = { + 'one': '1', + 'two': '2', + 'three': '3', + 'four': '4', + 'five': '5', + 'six': '6', + 'seven': '7', + 'eight': '8', + 'nine': '9', + 'ten': '10', + 'eleven': '11', + 'twelve': '12', + 'thirteen': '13', + 'fourteen': '14', + 'fifteen': '15', + 'twenty': '20', + 'thirty': '30', + 'forty': '40', + 'fifty': '50', + }; + + for (final entry in numberWords.entries) { + s = s.replaceAllMapped(RegExp('\\b${entry.key}\\b'), (_) => entry.value); + } + + return s; + } + + DateTime? _specificPatterns(String expr, DateTime ref) { + final dayAfterTomorrow = RegExp( + r'\bday\s+after\s+tomorrow\b' + r'(?:\s+(?:morning|afternoon|evening))?' + r'(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', + ).firstMatch(expr); + if (dayAfterTomorrow != null) { + var hour = int.tryParse(dayAfterTomorrow.group(1) ?? '') ?? 9; + final minute = int.tryParse(dayAfterTomorrow.group(2) ?? '') ?? 0; + final ampm = dayAfterTomorrow.group(3); + if (ampm == 'pm' && hour < 12) hour += 12; + if (ampm == 'am' && hour == 12) hour = 0; + if (expr.contains('morning') && dayAfterTomorrow.group(1) == null) { + hour = 8; + } + return DateTime(ref.year, ref.month, ref.day + 2, hour, minute); + } + + final tonight = RegExp( + r'\btonight\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', + ).firstMatch(expr); + if (tonight != null) { + var hour = int.tryParse(tonight.group(1) ?? '') ?? 20; + final minute = int.tryParse(tonight.group(2) ?? '') ?? 0; + final ampm = tonight.group(3); + if (ampm == 'pm' && hour < 12) hour += 12; + if (ampm == null && hour < 12) hour += 12; + return DateTime(ref.year, ref.month, ref.day, hour, minute); + } + + final thisAfternoon = RegExp( + r'\bthis\s+afternoon\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(pm)?)?', + ).firstMatch(expr); + if (thisAfternoon != null) { + var hour = int.tryParse(thisAfternoon.group(1) ?? '') ?? 14; + final minute = int.tryParse(thisAfternoon.group(2) ?? '') ?? 0; + if (hour < 12) hour += 12; + return DateTime(ref.year, ref.month, ref.day, hour, minute); + } + + return null; + } + + DateTime? _generalRegex(String expr, DateTime ref) { + final inMinutes = RegExp(r'\bin\s+(\d+)\s+minutes?\b').firstMatch(expr); + if (inMinutes != null) { + final minutes = int.tryParse(inMinutes.group(1)!); + if (minutes != null) return ref.add(Duration(minutes: minutes)); + } + + final inHours = RegExp(r'\bin\s+(\d+)\s+hours?\b').firstMatch(expr); + if (inHours != null) { + final hours = int.tryParse(inHours.group(1)!); + if (hours != null) return ref.add(Duration(hours: hours)); + } + + final inDays = RegExp(r'\bin\s+(\d+)\s+days?\b').firstMatch(expr); + if (inDays != null) { + final days = int.tryParse(inDays.group(1)!); + if (days != null) return ref.add(Duration(days: days)); + } + + if (RegExp(r'\bin\s+half\s+an?\s+hour\b').hasMatch(expr)) { + return ref.add(const Duration(minutes: 30)); + } + + if (RegExp(r'\btomorrow\b').hasMatch(expr) && + !RegExp(r'\bat\b|\d+\s*(am|pm|:\d)').hasMatch(expr)) { + return DateTime(ref.year, ref.month, ref.day + 1, 9, 0); + } + + return null; + } +} diff --git a/packages/chrono_ai_flow/pubspec.lock b/packages/chrono_ai_flow/pubspec.lock new file mode 100644 index 0000000..d715f04 --- /dev/null +++ b/packages/chrono_ai_flow/pubspec.lock @@ -0,0 +1,397 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" + url: "https://pub.dev" + source: hosted + version: "96.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + meta: + dependency: transitive + description: + name: meta + sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f" + url: "https://pub.dev" + source: hosted + version: "1.18.1" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" diff --git a/packages/chrono_ai_flow/pubspec.yaml b/packages/chrono_ai_flow/pubspec.yaml new file mode 100644 index 0000000..6c9886b --- /dev/null +++ b/packages/chrono_ai_flow/pubspec.yaml @@ -0,0 +1,13 @@ +name: chrono_ai_flow +description: Shared chrono-based voice memo extraction flow for the app and benchmark. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.10.1 + +dependencies: + chrono_dart: ^2.0.2 + +dev_dependencies: + test: ^1.24.0 diff --git a/packages/chrono_ai_flow/scripts/time_resolver_debug_manual.dart b/packages/chrono_ai_flow/scripts/time_resolver_debug_manual.dart new file mode 100644 index 0000000..8bdae16 --- /dev/null +++ b/packages/chrono_ai_flow/scripts/time_resolver_debug_manual.dart @@ -0,0 +1,32 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:chrono_dart/chrono_dart.dart'; + +void main() { + final r = TimeExpressionResolver(); + final ref = DateTime(2026, 3, 11, 10, 0); + + // Direct chrono test + final chronoResult = Chrono.parse( + 'next friday at 5 pm', + ref: ref, + option: ParsingOption(forwardDate: true), + ); + if (chronoResult.isNotEmpty) { + final d = chronoResult.first.date(); + print('chrono raw: $d weekday=${d.weekday} daysFromRef=${d.difference(ref).inDays}'); + } + + final cases = [ + 'next Friday at 5 PM', + 'next Tuesday at 2 pm', + 'next Monday at 10 am', + 'next Wednesday at 12 pm', + 'tomorrow at 3 pm', + 'klockan 15', + ]; + + for (final expr in cases) { + final res = r.resolve(expr, referenceDate: ref); + print('$expr -> ${res?.dateTime} (method: ${res?.method})'); + } +} diff --git a/packages/chrono_ai_flow/test/parser_test.dart b/packages/chrono_ai_flow/test/parser_test.dart new file mode 100644 index 0000000..b8ee467 --- /dev/null +++ b/packages/chrono_ai_flow/test/parser_test.dart @@ -0,0 +1,139 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:test/test.dart'; + +void main() { + const parser = ChronoLlmParser(); + + group('Single object (backward compat)', () { + test('parses single JSON object with intent', () { + const raw = + '{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}'; + final result = parser.parse(raw); + expect(result.extraction, isNotNull); + expect(result.extractions, hasLength(1)); + expect(result.extraction!.intent, 'reminder'); + expect(result.extraction!.title, 'buy milk'); + expect(result.extraction!.datetimeExpressionEnglish, 'tomorrow at 10 am'); + }); + + test('wraps single object in extractions list', () { + const raw = + '{"intent":"note","title":"buy bread","datetime_expression_original":null,"datetime_expression_english":null}'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.intent, 'note'); + expect(result.extractions.first.title, 'buy bread'); + }); + }); + + group('Array output (multi-event)', () { + test('parses JSON array with multiple items', () { + const raw = + '[{"intent":"reminder","title":"pick up dog","datetime_expression_original":"tomorrow at 5 pm","datetime_expression_english":"tomorrow at 5 pm"},' + '{"intent":"reminder","title":"turn off lights","datetime_expression_original":"at 9","datetime_expression_english":"tomorrow at 9 pm"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(2)); + expect(result.extraction!.title, 'pick up dog'); + expect(result.extractions[0].intent, 'reminder'); + expect(result.extractions[0].title, 'pick up dog'); + expect( + result.extractions[0].datetimeExpressionEnglish, 'tomorrow at 5 pm'); + expect(result.extractions[1].intent, 'reminder'); + expect(result.extractions[1].title, 'turn off lights'); + expect( + result.extractions[1].datetimeExpressionEnglish, 'tomorrow at 9 pm'); + }); + + test('parses array with mixed intents', () { + const raw = + '[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},' + '{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(2)); + expect(result.extractions[0].intent, 'event'); + expect(result.extractions[0].title, 'tandläkare'); + expect(result.extractions[1].intent, 'note'); + expect(result.extractions[1].title, 'handla mat'); + expect(result.extractions[1].datetimeExpressionOriginal, isNull); + }); + + test('parses single-item array', () { + const raw = + '[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow","datetime_expression_english":"tomorrow"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extraction!.intent, 'reminder'); + expect(result.extraction!.title, 'buy milk'); + }); + + test('handles array with model prefix text', () { + const raw = + 'Here is the JSON:\n[{"intent":"reminder","title":"call plumber","datetime_expression_original":"at 3 pm","datetime_expression_english":"at 3 pm"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.title, 'call plumber'); + }); + + test('handles array with trailing model tokens', () { + const raw = + '[{"intent":"event","title":"dentist","datetime_expression_original":"Thursday at 9 am","datetime_expression_english":"Thursday at 9 am"}]<|im_end|>'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.title, 'dentist'); + }); + }); + + group('extractFirstJsonArray', () { + test('extracts array from mixed text', () { + const raw = 'prefix [{"a":1},{"b":2}] suffix'; + final result = parser.extractFirstJsonArray(raw); + expect(result, '[{"a":1},{"b":2}]'); + }); + + test('returns null when no array', () { + const raw = '{"a":1}'; + final result = parser.extractFirstJsonArray(raw); + expect(result, isNull); + }); + + test('handles nested brackets in strings', () { + const raw = '[{"title":"array [test]"}]'; + final result = parser.extractFirstJsonArray(raw); + expect(result, '[{"title":"array [test]"}]'); + }); + }); + + group('shouldRetryInvalidChronoOutput', () { + test('retries on empty output', () { + expect(parser.shouldRetryInvalidChronoOutput(''), true); + }); + + test('does not retry on valid array', () { + const raw = + '[{"intent":"note","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + expect(parser.shouldRetryInvalidChronoOutput(raw), false); + }); + + test('does not retry on valid single object', () { + const raw = + '{"intent":"note","title":"test","datetime_expression_original":null,"datetime_expression_english":null}'; + expect(parser.shouldRetryInvalidChronoOutput(raw), false); + }); + }); + + group('intent normalization', () { + test('normalizes task to reminder', () { + const raw = + '[{"intent":"task","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions.first.intent, 'reminder'); + }); + + test('normalizes meeting to event', () { + const raw = + '[{"intent":"meeting","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions.first.intent, 'event'); + }); + }); +} diff --git a/specs/ai-tasks/ai-enchanced-voice-notes.md b/specs/ai-tasks/ai-enchanced-voice-notes.md new file mode 100644 index 0000000..ab3ca01 --- /dev/null +++ b/specs/ai-tasks/ai-enchanced-voice-notes.md @@ -0,0 +1,607 @@ +# AI-Enhanced Voice Notes Specification + +## Status + +Draft v1 + +## Summary + +Bring local on-device AI to the companion app for synced watch voice memos. + +The feature will: + +- transcribe voice memos +- summarize them +- categorize them +- extract possible actions +- let the user review and confirm before creating calendar or reminder/task items + +The selected local model for v1 is: + +- `Qwen2.5-1.5B-Instruct-Q4_K_M` + +This model was chosen as the best mobile tradeoff across: + +- English +- Swedish +- German +- structured JSON reliability +- action extraction quality +- Android/iOS feasibility + +## Product Goals + +1. Turn raw voice recordings into useful structured notes. +2. Keep the original transcript and audio always accessible. +3. Build trust by requiring explicit confirmation before any OS action is created. +4. Work safely when the app is backgrounded or the phone is locked. +5. Keep the architecture flexible for future automation once trust is established. + +## Non-Goals for v1 + +- no automatic creation of calendar events or tasks without review +- no Google Tasks integration yet +- no background dialog presentation while phone is locked +- no server-side AI +- no support for arbitrary model selection in v1 + +## User Experience Overview + +The feature evolves voice memos into a capture pipeline: + +```text +Record on watch +↓ +Sync to phone +↓ +Convert audio +↓ +Transcribe +↓ +LLM summarize +↓ +LLM categorize +↓ +LLM extract actions +↓ +Persist results +↓ +User reviews and confirms actions +↓ +Create OS calendar/reminder entry +``` + +## Core UX Rules + +1. The original transcript must always be visible. +2. The original audio must always remain playable. +3. AI output is assistive, not authoritative. +4. OS actions must require explicit user confirmation. +5. If the app is backgrounded or locked, AI may continue processing, but confirmation must be deferred until foreground. + +## Voice Note Object Model + +Each synced memo becomes a voice note with AI-derived fields. + +```text +VoiceNote + ├ audio + ├ transcript + ├ summary + ├ category + ├ processing_status + ├ extracted_actions + │ ├ tasks/reminders + │ └ calendar_events + ├ task_created + ├ calendar_event_created + └ ai_metadata +``` + +## Categories + +Primary categories for v1: + +- `idea` +- `task` +- `reminder` +- `meeting` +- `note` + +LLM internals may still use the simpler benchmark categories: + +- `TODO` +- `EVENT` +- `NOTE` + +UI can map them as: + +- `TODO` → task or reminder +- `EVENT` → meeting or calendar event +- `NOTE` → idea or note + +## Main Screen + +Route: + +```text +/voice-notes +``` + +The screen becomes a summary-first timeline rather than a transcript-first memo list. + +### Timeline layout + +```text +Today + [voice note card] + [voice note card] + +Yesterday + [voice note card] + +March 2 + [voice note card] +``` + +Sorting: + +- descending by timestamp + +### Voice note card + +Each card should show: + +- summary as primary text +- timestamp +- category icon/tag +- audio duration +- play button +- processing status if not ready + +Example: + +```text +🗓 Today · 14:30 + +Call Erik about PCB panel order + +Task detected + +00:14 ▶ +``` + +Optional secondary text: + +- `Original note available` + +### Category icons + +| Category | Icon | +|---|---| +| Idea | 💡 | +| Task | ✔ | +| Reminder | ⏰ | +| Meeting | 📅 | +| Note | 📝 | + +### Card status indicators + +- `⬇ Downloading` +- `🧠 Processing` +- `✓ Ready` +- `⚠ Failed` + +## Voice Note Detail Screen + +Tapping a card opens the full note. + +Section order: + +1. Summary +2. Category +3. Transcript +4. Audio playback +5. Detected actions +6. Action status + +Example: + +```text +Summary +Call Erik about PCB panels + +Category +Task + +Transcript +Call Erik tomorrow about the PCB panel order. + +Audio +▶ Play +00:14 + +Detected Actions +[ Create Task ] +[ Create Calendar Event ] +``` + +## Action Review UX + +### Important rule + +For v1, AI must never write directly to calendar/tasks/reminders without explicit confirmation. + +### Detected actions section + +Display extracted actions as editable suggestions. + +Example: + +```text +Detected actions + +✔ Call Erik about PCB panels +📅 Meeting with Erik about panels +``` + +Each detected action can show: + +- type +- title +- time/due date +- location +- current status + +Available actions: + +- `Create Task` +- `Create Calendar Event` +- `Dismiss` + +### Task creation flow + +When user taps `Create Task`: + +1. show preview/edit dialog or sheet +2. allow editing of title and due date +3. confirm with explicit `Create` + +Preview example: + +```text +Create Task + +Title +Call Erik about PCB panels + +Due date +Tomorrow + +Cancel / Create +``` + +### Calendar event flow + +When user taps `Create Calendar Event`: + +1. show preview/edit dialog or sheet +2. allow editing of title, time, duration, location +3. confirm with explicit `Create` + +Preview example: + +```text +Create Calendar Event + +Title +Meeting with Erik + +Time +Tomorrow + +Duration +30 min + +Cancel / Create +``` + +### Post-creation status + +After creation, show status on the note: + +- `✔ Task created` +- `📅 Event created` + +This is needed to avoid duplicate creation. + +## Settings UX + +Add a new section under Voice Memo settings for Local AI. + +### New settings + +- `Enable local AI for voice notes` +- `Selected AI model` +- `Download model` +- `Delete model` +- `Retry download` +- `Auto-process new voice notes` + +Optional future settings, not required for v1: + +- process only on Wi‑Fi +- process only while charging +- allow background processing toggle + +### Settings behavior + +When local AI is enabled: + +1. app checks whether the required model exists locally +2. if not, the user is prompted to download it +3. download progress is shown clearly +4. state survives navigation and restart + +### Required download UI states + +- not downloaded +- preparing +- downloading +- paused or interrupted +- failed +- ready +- deleting + +The UI must show: + +- progress bar +- percentage +- downloaded size / total size +- current state text + +## Background and Locked-Phone Behavior + +This is a key requirement. + +### v1 approach + +If a voice memo arrives while the app is backgrounded or the phone is locked: + +1. sync may continue if platform/background conditions allow it +2. transcription and AI processing may continue when feasible +3. extracted actions are persisted as pending review +4. no modal dialog is shown immediately +5. when the app returns to foreground, the app surfaces pending AI suggestions for review + +### Why + +- locked/background state is not safe for dialogs +- calendar/reminder creation requires user trust and context +- this preserves automation benefits while keeping user control + +### Foreground re-entry behavior + +When app becomes active and there are pending AI actions: + +- show a lightweight banner, sheet, or entry point +- allow user to open the review flow +- do not force immediate interruption if the user is doing something else + +## Platform Integration Strategy + +### iOS + +Planned integrations: + +- calendar events via EventKit event flow +- reminders/tasks via EventKit reminders flow + +### Android + +Planned integrations for v1: + +- calendar events via calendar provider / insert flow +- TODO/reminder items initially handled as calendar/reminder-style entries + +### Future Android support + +Design the internal action schema so Google Tasks or similar service integrations can be added later without changing the AI output contract. + +## AI Output Contract + +The app should define a universal internal action schema, independent of platform. + +Suggested fields: + +- `category` +- `title` +- `body` +- `startTime` +- `endTime` +- `dueDate` +- `location` +- `actionItems` +- `priority` +- `reminderMinutes` +- `status` + +This schema should be persisted and used by the review UI. + +## Data Model Changes + +The current voice memo persistence must be extended. + +### New voice note fields + +- `summary` +- `category` +- `processingStatus` +- `aiModel` +- `aiProcessedAt` +- `taskCreated` +- `calendarEventCreated` +- `actionReviewState` + +### Structured action storage + +Use a normalized approach for actions if practical. + +Preferred direction: + +- keep `voice_memos` / `voice_notes` as parent records +- store extracted actions in a related table + +Each action record should include: + +- memo id +- action type +- title +- notes/body +- start time +- end time +- due date +- location +- reminder offset +- created flag +- dismissed flag +- created platform target id if available + +## Processing Pipeline States + +The UI must update incrementally as processing progresses. + +Proposed states: + +- `onWatchOnly` +- `downloading` +- `downloaded` +- `converting` +- `transcribing` +- `summarizing` +- `categorizing` +- `extractingActions` +- `ready` +- `failed` + +## Search and Filtering + +Search should match: + +- summary +- transcript +- category + +Add quick filters above the timeline: + +- `All` +- `Tasks` +- `Ideas` +- `Meetings` +- `Notes` + +## Failure and Fallback Behavior + +### If AI is disabled + +- sync and transcription still work +- no summaries/categories/actions are generated + +### If model is missing + +- show status in settings +- notes remain accessible without AI enrichment + +### If AI processing fails + +- transcript/audio remain accessible +- note shows failed status +- allow retry later + +### If permissions are denied + +- extracted action remains visible +- user can retry action creation later +- no AI output is discarded + +## Trust-Building Policy for v1 + +To build trust gradually: + +1. AI suggestions are visible and editable. +2. All task/calendar creation requires confirmation. +3. The app shows what the AI understood before any OS action happens. +4. The user can dismiss AI suggestions. +5. Duplicate prevention is visible. + +Future versions may reduce friction once accuracy is trusted, but v1 must stay explicit and review-based. + +## Recommended Technical Phases + +### Phase 1: Data model and spec foundation + +- add DB fields/tables +- add domain models +- add processing states + +### Phase 2: Local AI settings and model download + +- settings toggle +- model download management +- persistent progress/state + +### Phase 3: AI processing pipeline + +- add local LLM service +- run summarize/categorize/extract actions after transcription +- persist results + +### Phase 4: Voice notes UI refresh + +- summary-first timeline +- detail screen with actions +- filters and search updates + +### Phase 5: Action review and OS integrations + +- review dialogs/sheets +- event/reminder/task creation +- created-status tracking + +### Phase 6: Background-safe pending review flow + +- persist pending actions +- foreground surfacing UX +- polish + +## Acceptance Criteria for v1 + +1. User can enable Local AI in Settings. +2. User can download the required LLM and see progress. +3. Voice memos continue to work normally if Local AI is disabled. +4. After transcription, each processed memo can show summary and category. +5. If the memo contains actionable content, extracted actions are shown in the detail screen. +6. Creating a task/event always requires explicit confirmation. +7. If a memo is processed while app is backgrounded/locked, actions are deferred for later review. +8. After an action is created, the UI shows created status and prevents duplicate creation. +9. Transcript and audio remain accessible even if AI output is wrong or missing. +10. The system works with the selected local model `Qwen2.5-1.5B-Instruct-Q4_K_M`. + +## Open Questions for Later + +- whether reminders and tasks should remain unified in UI or split clearly +- whether Android should later support Google Tasks directly +- whether local notifications should be added for pending AI reviews +- whether users should be able to re-run AI processing on old memos in bulk +- whether AI confidence should be surfaced in UI + +## Final v1 Decision Summary + +- use local on-device AI +- use `Qwen2.5-1.5B-Instruct-Q4_K_M` +- show summaries and categories in the voice notes UI +- extract calendar/task suggestions automatically +- require confirmation before any OS write +- process in background when feasible +- defer confirmations until foreground +- store structured results persistently diff --git a/specs/ai-tasks/voice-memo-time-extraction.md b/specs/ai-tasks/voice-memo-time-extraction.md new file mode 100644 index 0000000..081039a --- /dev/null +++ b/specs/ai-tasks/voice-memo-time-extraction.md @@ -0,0 +1,260 @@ +# Voice Memo → Reminder/Calendar Time Extraction Pipeline + +**Status**: Draft +**Date**: 2026-03-09 +**Depends on**: ai-enhanced-voice-notes.md (existing AI pipeline) + +--- + +## 1. Problem Statement + +The current AI pipeline (`LlmService._buildClassifyPrompt`) asks the LLM to **compute absolute ISO-8601 datetimes** from relative expressions like "tomorrow at 10" or "nästa tisdag kl 14". Small local models (Qwen 2B class) frequently get date math wrong — producing incorrect dates, wrong days-of-week, or hallucinated timestamps. + +Additionally, `VoiceNoteAiPipeline._tryParseDate()` simply calls `DateTime.parse()` and returns `null` for anything that isn't already ISO-8601. Natural language dates are silently lost. + +## 2. Solution: Split Responsibilities + +| Component | Responsibility | +|-----------|---------------| +| **LLM** | Intent detection, title extraction, time phrase extraction, translation to English | +| **chrono_dart** | Deterministic parsing of English natural-language time → `DateTime` | +| **Fallback regex** | Simple patterns (`in X minutes`, `tomorrow`) if chrono fails | + +**Key rule**: The LLM must **never** compute absolute datetimes. It only extracts and translates the natural-language time expression. All date math is performed deterministically by `chrono_dart`. + +## 3. Pipeline Overview + +``` +Transcript (any language) + ↓ +[Existing] Transcription correction (LLM pass 1, optional) + ↓ +[Existing] Classification + summarization (LLM pass 2 — MODIFIED prompt) + ↓ +New fields: datetime_expression_original, datetime_expression_english + ↓ +chrono_dart.parse(english_expression, referenceDate: DateTime.now()) + ↓ +Resolved DateTime (or null) + ↓ +Populate existing ExtractedAction fields (startTime, dueDate, etc.) +``` + +## 4. What Changes vs. Current Pipeline + +### 4.1 LLM Prompt Changes + +The existing `_buildClassifyPrompt()` tells the LLM to: +> "If a relative date/time is mentioned, resolve it to an absolute ISO-8601 datetime" + +**New approach**: Instead of asking the LLM to resolve dates, we ask it to: +1. **Extract** the time phrase exactly as spoken (any language) +2. **Translate** the time phrase to English +3. Output these as two new JSON fields + +The prompt still produces `summary`, `category`, and `actions` — the schema gains two fields per action: +- `datetime_expression_original` — the raw phrase from the transcript +- `datetime_expression_english` — English translation of that phrase + +The existing `due_date`, `start_time`, `end_time` fields become **null** in LLM output (chrono fills them). + +### 4.2 Post-LLM Processing + +After parsing the LLM JSON, a new `TimeExpressionResolver` class: +1. Takes the `datetime_expression_english` string +2. Passes it to `chrono_dart` with `DateTime.now()` as reference +3. If chrono succeeds → populates `startTime`/`dueDate` on the `ExtractedAction` +4. If chrono fails → tries simple regex fallbacks +5. If all fails → leaves time as `null` (user can set manually) + +### 4.3 No Schema Changes Needed + +The existing `ExtractedAction` model and database table already have: +- `startTime`, `endTime`, `dueDate` (nullable `DateTime`) +- `reminderMinutes` (nullable `int`) + +We add no new DB columns. The time expression strings are intermediate — only the resolved `DateTime` gets persisted. + +## 5. Detailed Design + +### 5.1 Modified LLM JSON Schema (per action) + +```json +{ + "type": "reminder", + "title": "köpa mjölk", + "notes": null, + "datetime_expression_original": "imorgon klockan 10", + "datetime_expression_english": "tomorrow at 10 am", + "location": null, + "priority": null, + "reminder_minutes": null +} +``` + +Removed from LLM output: `due_date`, `start_time`, `end_time` (chrono fills these). + +### 5.2 Modified LLM Prompt (extraction section) + +``` +For each action: +- Extract the time/date phrase EXACTLY as spoken in the original transcript. +- Translate that phrase to English, preserving relative meaning. +- Do NOT compute or resolve dates. Do NOT output ISO timestamps. +- If no time is mentioned, use null for both datetime fields. +``` + +### 5.3 TimeExpressionResolver + +```dart +class TimeExpressionResolver { + /// Parse an English time expression into a DateTime. + /// Returns null if unparseable. + DateTime? resolve(String englishExpression, {DateTime? referenceDate}); + + /// Regex fallback for common patterns chrono might miss. + DateTime? _regexFallback(String expression, DateTime reference); +} +``` + +Chrono usage: +```dart +final results = Chrono.parse(englishExpression, referenceDate ?? DateTime.now()); +if (results.isNotEmpty) { + return results.first.start.date(); +} +``` + +### 5.4 Text Normalization (Pre-LLM, Optional) + +Convert number words to digits in the transcript before sending to LLM. This helps both the LLM and chrono: +- "tomorrow at ten" → "tomorrow at 10" +- "in five minutes" → "in 5 minutes" + +Scope: English number words only (the LLM handles other languages via translation). + +### 5.5 Integration Point + +In `LlmService._parseTranscriptResult()`, after extracting actions from JSON: +1. Read `datetime_expression_english` from each action +2. Call `TimeExpressionResolver.resolve()` +3. Map result to the action's `startTime` or `dueDate` based on action type: + - `reminder` → `dueDate` + - `calendar_event` → `startTime` + - `task` → `dueDate` + +## 6. CLI Testbench Spec + +### Purpose +Iterate on LLM prompt + chrono parsing on desktop without needing the full Flutter app, BLE, or a phone. Run test cases, evaluate accuracy, tune prompts. + +### Location +`ai_testbench/bin/test_time_extraction.dart` — a new CLI script in the existing testbench project. + +### Architecture +Reuses: +- `ai_testbench/native_libs/libllama.so` — existing native library +- `ai_testbench/models/` — existing downloaded models (especially `Qwen3.5-2B-Q4_K_M.gguf`) +- `llama_cpp_dart` package — for direct CLI inference (no Flutter dependency) + +New: +- `ai_testbench/lib/services/time_extraction_service.dart` — `TimeExpressionResolver` implementation +- `ai_testbench/lib/prompts/time_extraction_prompts.dart` — the new LLM prompt +- `ai_testbench/bin/test_time_extraction.dart` — CLI test runner +- `chrono_dart` dependency in `ai_testbench/pubspec.yaml` + +### Test Cases + +| # | Input (transcript) | Language | Expected intent | Expected title | Expected time phrase (EN) | Expected resolved DateTime | +|---|-------------------|----------|----------------|---------------|--------------------------|---------------------------| +| 1 | "Remind me tomorrow at 10 am to buy milk" | EN | reminder | buy milk | tomorrow at 10 am | 2026-03-10T10:00 | +| 2 | "påminn mig imorgon klockan 10 att köpa mjölk" | SV | reminder | köpa mjölk | tomorrow at 10 | 2026-03-10T10:00 | +| 3 | "erinnere mich morgen um 10 milch zu kaufen" | DE | reminder | milch kaufen | tomorrow at 10 | 2026-03-10T10:00 | +| 4 | "meeting with John next Tuesday at 2 pm" | EN | event | meeting with John | next Tuesday at 2 pm | 2026-03-10T14:00 (or 2026-03-17) | +| 5 | "remember to buy milk" | EN | note | buy milk | null | null | +| 6 | "ring tandläkaren om 30 minuter" | SV | reminder | ring tandläkaren | in 30 minutes | ~now+30m | +| 7 | "rappelle-moi vendredi à 15h d'appeler le médecin" | FR | reminder | appeler le médecin | Friday at 3 pm | next Friday 15:00 | +| 8 | "dentist appointment on March 15th at 9:30" | EN | event | dentist appointment | March 15th at 9:30 | 2026-03-15T09:30 | +| 9 | "köp bröd på vägen hem" | SV | task | köp bröd | null | null | +| 10 | "team standup every weekday at 9 AM" | EN | event | team standup | every weekday at 9 AM | (recurring — chrono may only get next occurrence) | + +### CLI Output Format + +``` +╔══════════════════════════════════════════════════════════╗ +║ ZSWatch Time Extraction Testbench — CLI ║ +╚══════════════════════════════════════════════════════════╝ + +[1/3] Loading model: Qwen3.5-2B-Q4_K_M.gguf + Model loaded in 1234ms ✓ + +─── Test 1: English reminder ───────────────────────────── + Input: "Remind me tomorrow at 10 am to buy milk" + LLM time: 2.3s (45.2 tok/s) + + LLM output: + intent: reminder + title: buy milk + time (orig): tomorrow at 10 am + time (EN): tomorrow at 10 am + + Chrono parse: 2026-03-10T10:00:00 ✓ + Expected: 2026-03-10T10:00:00 + Status: ✅ PASS + +─── Test 2: Swedish reminder ───────────────────────────── + ... + +╔══════════════════════════════════════════════════════════╗ +║ Results: 8 passed, 2 failed out of 10 tests ║ +║ Total LLM time: 23.4s ║ +╚══════════════════════════════════════════════════════════╝ +``` + +### Evaluation Criteria per Test Case + +1. **Intent correct** — does `intent` match expected? +2. **Title reasonable** — does `title` capture the task? (fuzzy, logged but not auto-scored) +3. **Time phrase extracted** — is `datetime_expression_english` non-null when expected? +4. **Chrono parse succeeds** — does `chrono_dart` produce a valid DateTime? +5. **DateTime correct** — does the resolved DateTime match expected (within ±1 minute tolerance for relative times)? + +A test PASSES if criteria 1, 3, 4, and 5 all succeed (or 3-5 are all null when no time expected). + +## 7. Implementation Plan + +### Phase 1: CLI Testbench (this task) +1. Add `chrono_dart` to `ai_testbench/pubspec.yaml` +2. Create `TimeExpressionResolver` in `ai_testbench/lib/services/` +3. Create time extraction prompt in `ai_testbench/lib/prompts/` +4. Create CLI test runner in `ai_testbench/bin/test_time_extraction.dart` +5. Run and iterate until ≥80% pass rate on test cases + +### Phase 2: Integrate into companion app (future task) +1. Add `chrono_dart` to `zswatch_app/pubspec.yaml` +2. Copy finalized `TimeExpressionResolver` to `zswatch_app/lib/services/ai/` +3. Modify `LlmService._buildClassifyPrompt()` with new prompt +4. Modify `LlmService._parseTranscriptResult()` to use chrono resolution +5. Update `VoiceNoteAiPipeline` to handle new fields + +## 8. Model Selection + +The testbench will default to `Qwen3.5-2B-Q4_K_M.gguf` (already present in `ai_testbench/models/`). The prompt is designed to work with any model in the models directory — the CLI can accept a `--model` flag to test different models. + +## 9. Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| LLM fails to extract time phrase in non-English | Prompt includes explicit examples in multiple languages | +| LLM translates time phrase incorrectly | Test with multilingual corpus; fallback to original phrase | +| chrono_dart doesn't parse some English expressions | Regex fallback for common patterns; log failures for prompt tuning | +| Small model hallucinates time phrases not in transcript | Prompt instructs to copy exact phrase; validation step compares to input | +| chrono_dart doesn't support all relative expressions | Acceptable — leave as null, user can set manually | + +## 10. Out of Scope + +- Recurring events (chrono may parse "every Tuesday" as next Tuesday only — acceptable for v1) +- Duration extraction (e.g., "30 minute meeting") — future enhancement +- Timezone conversion — we use device local time throughout +- Calendar/reminder OS integration — already handled by existing `ExtractedAction` flow +- UI changes — the existing action review UI works with resolved DateTimes diff --git a/specs/ci-cd.md b/specs/ci-cd.md new file mode 100644 index 0000000..15dd82a --- /dev/null +++ b/specs/ci-cd.md @@ -0,0 +1,121 @@ +# CI/CD for ZSWatch Companion App + +## Overview + +Two GitHub Actions workflows covering PR validation and nightly release builds. + +--- + +## Phase 1: PR Workflow ✅ + +**File:** `.github/workflows/pr.yml` +**Trigger:** `pull_request` (opened, synchronize, reopened) + +### Jobs + +| Job | Runner | Depends on | Purpose | +|-----|--------|------------|---------| +| `analyze` | ubuntu-latest | — | Format check + `flutter analyze` | +| `test` | ubuntu-latest | analyze | `flutter test` | +| `build-android` | ubuntu-latest | analyze | Debug APK (no signing secrets needed) | +| `build-ios` | macos-latest | analyze | `flutter build ios --no-codesign` | + +### Notes +- SSH submodule (`fllama`) rewritten to HTTPS before checkout via `git config --global url."https://github.com/".insteadOf "git@github.com:"` +- Code generation (`dart run build_runner build --delete-conflicting-outputs`) runs in every job before build +- Android debug APK uploaded as workflow artifact for manual inspection +- `android/key.properties` is gitignored — CI uses debug signing fallback for PRs (see `build.gradle.kts`) + +--- + +## Phase 2: Nightly Release Builds + +**File:** `.github/workflows/nightly.yml` +**Triggers:** `schedule: cron('0 2 * * *')` (2 AM UTC) + `workflow_dispatch` + +### Android Release Signing + +**One-time local setup:** +```bash +base64 -w 0 /path/to/zswatch-release.keystore +``` + +**GitHub Secrets required** (Settings → Secrets and variables → Actions): + +| Secret | Value | +|--------|-------| +| `ANDROID_KEYSTORE_BASE64` | base64 of the `.keystore` file | +| `ANDROID_KEY_ALIAS` | `zswatch` | +| `ANDROID_KEY_PASSWORD` | keystore key password | +| `ANDROID_STORE_PASSWORD` | keystore store password | + +**Workflow step — decode at runtime:** +```yaml +- name: Decode Android keystore + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > zswatch_app/android/zswatch-release.keystore + cat > zswatch_app/android/key.properties << EOF + storePassword=${{ secrets.ANDROID_STORE_PASSWORD }} + keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }} + keyAlias=${{ secrets.ANDROID_KEY_ALIAS }} + storeFile=zswatch-release.keystore + EOF +``` + +Then `flutter build apk --release`. Publish to rolling GitHub Release tagged `nightly` via `softprops/action-gh-release@v2` with `prerelease: true`. + +--- + +### iOS Release Signing (Apple Developer account required) + +**One-time local setup:** +1. Xcode → Settings → Accounts → Manage Certificates → create **Apple Distribution** cert → Export as `.p12` with a password +2. [developer.apple.com](https://developer.apple.com) → Profiles → create **Ad Hoc** (or **App Store**) profile for `com.zswatch.app` → download `.mobileprovision` +3. Base64-encode both files: + ```bash + base64 -w 0 certificate.p12 + base64 -w 0 profile.mobileprovision + ``` + +**GitHub Secrets required:** + +| Secret | Value | +|--------|-------| +| `IOS_CERTIFICATE_BASE64` | base64 of `.p12` | +| `IOS_CERTIFICATE_PASSWORD` | password set when exporting `.p12` | +| `IOS_PROVISIONING_PROFILE_BASE64` | base64 of `.mobileprovision` | + +**Workflow steps (macos-latest):** +```yaml +- name: Install certificate and provisioning profile + run: | + security create-keychain -p "" build.keychain + security set-keychain-settings -lut 21600 build.keychain + security unlock-keychain -p "" build.keychain + echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 -d > cert.p12 + security import cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 -d > profile.mobileprovision + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ + +- name: Build iOS IPA + working-directory: zswatch_app + run: flutter build ipa --release +``` + +Attach the IPA to the same `nightly` GitHub Release. + +**For TestFlight upload**, additionally add App Store Connect API key secrets and an `xcrun altool` or `fastlane deliver` upload step. + +--- + +## Verification + +**Phase 1:** Open a test PR → confirm all 4 jobs pass. + +**Phase 2:** +- Trigger `nightly.yml` manually via `workflow_dispatch` +- Android: check GitHub Releases for tag `nightly` with APK attached +- Android signing: `apksigner verify --print-certs app-release.apk` +- iOS: confirm IPA installable on registered devices (Ad Hoc) or visible in TestFlight diff --git a/third_party/fllama b/third_party/fllama new file mode 160000 index 0000000..3589948 --- /dev/null +++ b/third_party/fllama @@ -0,0 +1 @@ +Subproject commit 3589948d992680937259210634efcf3d8a6eb7c3 diff --git a/zswatch_app/android/app/build.gradle.kts b/zswatch_app/android/app/build.gradle.kts index 316723a..3dcbcbb 100644 --- a/zswatch_app/android/app/build.gradle.kts +++ b/zswatch_app/android/app/build.gradle.kts @@ -54,7 +54,7 @@ val hasReleaseKeystore = releaseKeystoreConfig != null android { namespace = "dev.zswatch.app" compileSdk = 36 - ndkVersion = flutter.ndkVersion + ndkVersion = "29.0.13113456" // Required by whisper_ggml_plus (highest NDK among all plugins) compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/zswatch_app/android/app/src/main/AndroidManifest.xml b/zswatch_app/android/app/src/main/AndroidManifest.xml index 078044d..8441e88 100644 --- a/zswatch_app/android/app/src/main/AndroidManifest.xml +++ b/zswatch_app/android/app/src/main/AndroidManifest.xml @@ -15,11 +15,19 @@ + + + + + + + + @@ -74,6 +82,12 @@ android:name=".BleConnectionForegroundService" android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + NSBluetoothAlwaysUsageDescription ZSWatch needs Bluetooth to communicate with your smartwatch for syncing data, notifications, and firmware updates. @@ -43,6 +43,18 @@ ZSWatch needs access to select firmware files from your photo library for watch updates. NSPhotoLibraryAddUsageDescription ZSWatch needs access to save images to your photo library. + + NSMicrophoneUsageDescription + ZSWatch needs microphone access to record audio for testing the voice memo AI pipeline. + + NSCalendarsUsageDescription + ZSWatch needs calendar access to create events from AI-detected voice note actions. + NSCalendarsFullAccessUsageDescription + ZSWatch needs calendar access to create events from AI-detected voice note actions. + NSRemindersUsageDescription + ZSWatch needs reminders access to create reminder actions from voice notes. + NSRemindersFullAccessUsageDescription + ZSWatch needs reminders access to create reminder actions from voice notes. UIApplicationSupportsIndirectInputEvents diff --git a/zswatch_app/lib/app.dart b/zswatch_app/lib/app.dart index d3c8659..c603443 100644 --- a/zswatch_app/lib/app.dart +++ b/zswatch_app/lib/app.dart @@ -2,13 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/theme/app_theme.dart'; +import 'providers/analytics_providers.dart'; import 'providers/ble_providers.dart'; +import 'providers/coredump_providers.dart'; import 'providers/developer_providers.dart'; import 'providers/foreground_service_providers.dart'; import 'providers/gps_providers.dart'; import 'providers/http_providers.dart'; import 'providers/notification_providers.dart'; import 'providers/permission_providers.dart'; +import 'providers/voice_memo_providers.dart'; import 'providers/watch_service_provider.dart'; import 'ui/navigation/app_router.dart'; @@ -37,7 +40,7 @@ class _ZSWatchAppState extends ConsumerState { // Initialize permission notifier first to check/track permission state // This also sets up the lifecycle observer to re-check on app resume ref.read(permissionNotifierProvider); - + await ref.read(bleNotifierProvider.notifier).initialize(); // Initialize notification forwarding so it works even when the // notification settings screen hasn't been opened yet @@ -59,6 +62,14 @@ class _ZSWatchAppState extends ConsumerState { // Initialize watch info persistence to sync firmware version and lastConnectedAt to database // This listens to watch info and connection state changes and persists them ref.read(watchInfoPersistenceProvider); + // Initialize voice memo sync service to handle recording sync from watch + // This subscribes to watch messages for new recording notifications + ref.read(voiceMemoSyncServiceProvider); + // Initialize analytics services (connection tracking, battery storage) + // so events are recorded from app startup, not just when analytics screen is opened + ref.read(analyticsServicesInitializedProvider); + // Initialize crash report persistence to save crash summaries to DB + ref.read(crashReportPersistenceProvider); } catch (e) { debugPrint('BLE initialization error: $e'); } @@ -82,9 +93,9 @@ class _ZSWatchAppState extends ConsumerState { builder: (context, child) { return MediaQuery( // Prevent system text scaling from breaking layouts - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.noScaling, - ), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.noScaling), child: child ?? const SizedBox.shrink(), ); }, @@ -96,10 +107,7 @@ class _ZSWatchAppState extends ConsumerState { class AppErrorWidget extends StatelessWidget { final FlutterErrorDetails details; - const AppErrorWidget({ - super.key, - required this.details, - }); + const AppErrorWidget({super.key, required this.details}); @override Widget build(BuildContext context) { diff --git a/zswatch_app/lib/core/constants/app_constants.dart b/zswatch_app/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..297e898 --- /dev/null +++ b/zswatch_app/lib/core/constants/app_constants.dart @@ -0,0 +1,23 @@ +/// App-wide named constants replacing magic numbers. +abstract final class AppConstants { + /// Notification deduplication window in milliseconds. + /// Prevents duplicate notifications from appearing within this window. + static const int notificationDeduplicationWindowMs = 2000; + + /// Maximum quick reconnect attempts before switching to slower backoff. + static const int maxQuickReconnectAttempts = 3; + + /// Maximum number of comm log entries kept in the in-memory buffer. + static const int commLogMaxEntries = 5000; + + /// Maximum number of log entries kept in the log viewer in-memory buffer. + static const int logViewerMaxEntries = 1000; + + /// Minimum acceptable MTU negotiated with a BLE device. + /// iOS typically negotiates 185–512 bytes; values below this indicate a + /// degraded connection that may cause packet fragmentation. + static const int minimumAcceptableMtu = 185; + + /// Number of days of health / analytics data to retain before pruning. + static const int analyticsRetentionDays = 60; +} diff --git a/zswatch_app/lib/core/constants/ble_constants.dart b/zswatch_app/lib/core/constants/ble_constants.dart index 558e32f..98fb806 100644 --- a/zswatch_app/lib/core/constants/ble_constants.dart +++ b/zswatch_app/lib/core/constants/ble_constants.dart @@ -45,7 +45,8 @@ abstract final class HeartRateUuids { static const String measurement = '00002a37-0000-1000-8000-00805f9b34fb'; /// Body Sensor Location Characteristic (0x2A38) - static const String bodySensorLocation = '00002a38-0000-1000-8000-00805f9b34fb'; + static const String bodySensorLocation = + '00002a38-0000-1000-8000-00805f9b34fb'; } /// ZSWatch Sensor Service UUIDs (Adafruit Bluefruit format) @@ -55,50 +56,55 @@ abstract final class HeartRateUuids { /// Each sensor is a separate GATT service with one characteristic. abstract final class SensorServiceUuids { /// Temperature Service UUID (ADAFRUIT_SERVICE_TEMPERATURE 0xADAF0100) - static const String temperatureService = 'adaf0100-c332-42a8-93bd-25e905756cb8'; - + static const String temperatureService = + 'adaf0100-c332-42a8-93bd-25e905756cb8'; + /// Temperature Characteristic UUID (ADAFRUIT_CHAR_TEMPERATURE 0xADAF0101) static const String temperatureChar = 'adaf0101-c332-42a8-93bd-25e905756cb8'; /// Accelerometer Service UUID (ADAFRUIT_SERVICE_ACCEL 0xADAF0200) - static const String accelerometerService = 'adaf0200-c332-42a8-93bd-25e905756cb8'; - + static const String accelerometerService = + 'adaf0200-c332-42a8-93bd-25e905756cb8'; + /// Accelerometer Characteristic UUID (ADAFRUIT_CHAR_ACCEL 0xADAF0201) - static const String accelerometerChar = 'adaf0201-c332-42a8-93bd-25e905756cb8'; + static const String accelerometerChar = + 'adaf0201-c332-42a8-93bd-25e905756cb8'; /// Light Sensor Service UUID (ADAFRUIT_SERVICE_LIGHT 0xADAF0300) static const String lightService = 'adaf0300-c332-42a8-93bd-25e905756cb8'; - + /// Light Sensor Characteristic UUID (ADAFRUIT_CHAR_LIGHT 0xADAF0301) static const String lightChar = 'adaf0301-c332-42a8-93bd-25e905756cb8'; /// Gyroscope Service UUID (ADAFRUIT_SERVICE_GYRO 0xADAF0400) static const String gyroscopeService = 'adaf0400-c332-42a8-93bd-25e905756cb8'; - + /// Gyroscope Characteristic UUID (ADAFRUIT_CHAR_GYRO 0xADAF0401) static const String gyroscopeChar = 'adaf0401-c332-42a8-93bd-25e905756cb8'; /// Magnetometer Service UUID (ADAFRUIT_SERVICE_MAG 0xADAF0500) - static const String magnetometerService = 'adaf0500-c332-42a8-93bd-25e905756cb8'; - + static const String magnetometerService = + 'adaf0500-c332-42a8-93bd-25e905756cb8'; + /// Magnetometer Characteristic UUID (ADAFRUIT_CHAR_MAG 0xADAF0501) static const String magnetometerChar = 'adaf0501-c332-42a8-93bd-25e905756cb8'; /// Humidity Service UUID (ADAFRUIT_SERVICE_HUMIDITY 0xADAF0700) static const String humidityService = 'adaf0700-c332-42a8-93bd-25e905756cb8'; - + /// Humidity Characteristic UUID (ADAFRUIT_CHAR_HUMIDITY 0xADAF0701) static const String humidityChar = 'adaf0701-c332-42a8-93bd-25e905756cb8'; /// Pressure Service UUID (ADAFRUIT_SERVICE_PRESSURE 0xADAF0800) static const String pressureService = 'adaf0800-c332-42a8-93bd-25e905756cb8'; - + /// Pressure Characteristic UUID (ADAFRUIT_CHAR_PRESSURE 0xADAF0801) static const String pressureChar = 'adaf0801-c332-42a8-93bd-25e905756cb8'; /// 3D/Sensor Fusion Service UUID (ADAFRUIT_SERVICE_3D 0xADAF0D00) - static const String sensorFusionService = 'adaf0d00-c332-42a8-93bd-25e905756cb8'; - + static const String sensorFusionService = + 'adaf0d00-c332-42a8-93bd-25e905756cb8'; + /// 3D/Sensor Fusion Characteristic UUID (ADAFRUIT_CHAR_3D 0xADAF0D01) /// Data: quaternion [w, x, y, z] as 4x float32 little-endian (16 bytes) static const String sensorFusionChar = 'adaf0d01-c332-42a8-93bd-25e905756cb8'; @@ -174,4 +180,3 @@ abstract final class BleConfig { /// Alternative device name pattern static const String deviceNamePattern = 'ZSWatch'; } - diff --git a/zswatch_app/lib/core/constants/ble_uuids.dart b/zswatch_app/lib/core/constants/ble_uuids.dart index 9b387c7..c96ee38 100644 --- a/zswatch_app/lib/core/constants/ble_uuids.dart +++ b/zswatch_app/lib/core/constants/ble_uuids.dart @@ -12,117 +12,114 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; /// Nordic UART Service (NUS) UUIDs for Gadgetbridge protocol abstract final class NusUuids { /// Nordic UART Service UUID - static final Guid service = - Guid('6e400001-b5a3-f393-e0a9-e50e24dcca9e'); + static final Guid service = Guid('6e400001-b5a3-f393-e0a9-e50e24dcca9e'); /// TX Characteristic - Write (App to Watch) - static final Guid txCharacteristic = - Guid('6e400002-b5a3-f393-e0a9-e50e24dcca9e'); + static final Guid txCharacteristic = Guid( + '6e400002-b5a3-f393-e0a9-e50e24dcca9e', + ); /// RX Characteristic - Notify (Watch to App) - static final Guid rxCharacteristic = - Guid('6e400003-b5a3-f393-e0a9-e50e24dcca9e'); + static final Guid rxCharacteristic = Guid( + '6e400003-b5a3-f393-e0a9-e50e24dcca9e', + ); } /// ZSWatch Extended API Service UUIDs abstract final class ZswatchExtendedUuids { /// ZSWatch Extended Service UUID - static final Guid service = - Guid('12345678-1234-5678-1234-56789abcdef0'); + static final Guid service = Guid('12345678-1234-5678-1234-56789abcdef0'); /// TX Characteristic - Write (App to Watch) - static final Guid txCharacteristic = - Guid('12345678-1234-5678-1234-56789abcdef1'); + static final Guid txCharacteristic = Guid( + '12345678-1234-5678-1234-56789abcdef1', + ); /// RX Characteristic - Notify (Watch to App) - static final Guid rxCharacteristic = - Guid('12345678-1234-5678-1234-56789abcdef2'); + static final Guid rxCharacteristic = Guid( + '12345678-1234-5678-1234-56789abcdef2', + ); } /// Standard Heart Rate Service UUIDs (GATT 0x180D) abstract final class HeartRateUuids { /// Heart Rate Service UUID - static final Guid service = - Guid('0000180d-0000-1000-8000-00805f9b34fb'); + static final Guid service = Guid('0000180d-0000-1000-8000-00805f9b34fb'); /// Heart Rate Measurement Characteristic - static final Guid measurement = - Guid('00002a37-0000-1000-8000-00805f9b34fb'); + static final Guid measurement = Guid('00002a37-0000-1000-8000-00805f9b34fb'); /// Body Sensor Location Characteristic - static final Guid bodySensorLocation = - Guid('00002a38-0000-1000-8000-00805f9b34fb'); + static final Guid bodySensorLocation = Guid( + '00002a38-0000-1000-8000-00805f9b34fb', + ); } /// Standard Battery Service UUIDs (GATT 0x180F) abstract final class BatteryUuids { /// Battery Service UUID - static final Guid service = - Guid('0000180f-0000-1000-8000-00805f9b34fb'); + static final Guid service = Guid('0000180f-0000-1000-8000-00805f9b34fb'); /// Battery Level Characteristic - static final Guid level = - Guid('00002a19-0000-1000-8000-00805f9b34fb'); + static final Guid level = Guid('00002a19-0000-1000-8000-00805f9b34fb'); } /// Standard Device Information Service UUIDs (GATT 0x180A) abstract final class DeviceInfoUuids { /// Device Information Service UUID - static final Guid service = - Guid('0000180a-0000-1000-8000-00805f9b34fb'); + static final Guid service = Guid('0000180a-0000-1000-8000-00805f9b34fb'); /// Manufacturer Name String - static final Guid manufacturerName = - Guid('00002a29-0000-1000-8000-00805f9b34fb'); + static final Guid manufacturerName = Guid( + '00002a29-0000-1000-8000-00805f9b34fb', + ); /// Model Number String - static final Guid modelNumber = - Guid('00002a24-0000-1000-8000-00805f9b34fb'); + static final Guid modelNumber = Guid('00002a24-0000-1000-8000-00805f9b34fb'); /// Firmware Revision String - static final Guid firmwareRevision = - Guid('00002a26-0000-1000-8000-00805f9b34fb'); + static final Guid firmwareRevision = Guid( + '00002a26-0000-1000-8000-00805f9b34fb', + ); /// Hardware Revision String - static final Guid hardwareRevision = - Guid('00002a27-0000-1000-8000-00805f9b34fb'); + static final Guid hardwareRevision = Guid( + '00002a27-0000-1000-8000-00805f9b34fb', + ); /// Software Revision String - static final Guid softwareRevision = - Guid('00002a28-0000-1000-8000-00805f9b34fb'); + static final Guid softwareRevision = Guid( + '00002a28-0000-1000-8000-00805f9b34fb', + ); } /// MCUmgr/SMP Service UUIDs for firmware updates abstract final class McumgrUuids { /// SMP Service UUID - static final Guid service = - Guid('8d53dc1d-1db7-4cd3-868b-8a527460aa84'); + static final Guid service = Guid('8d53dc1d-1db7-4cd3-868b-8a527460aa84'); /// SMP Characteristic (bidirectional) - static final Guid characteristic = - Guid('da2e7828-fbce-4e01-ae9e-261174997c48'); + static final Guid characteristic = Guid( + 'da2e7828-fbce-4e01-ae9e-261174997c48', + ); } /// ZSWatch Sensor Service UUIDs abstract final class SensorServiceUuids { /// ZSWatch Sensor Service UUID - static final Guid service = - Guid('e6c90001-2a76-4094-917e-9af7d7a7a5b1'); + static final Guid service = Guid('e6c90001-2a76-4094-917e-9af7d7a7a5b1'); /// Accelerometer Data Characteristic - static final Guid accelerometer = - Guid('e6c90002-2a76-4094-917e-9af7d7a7a5b1'); + static final Guid accelerometer = Guid( + 'e6c90002-2a76-4094-917e-9af7d7a7a5b1', + ); /// Gyroscope Data Characteristic - static final Guid gyroscope = - Guid('e6c90003-2a76-4094-917e-9af7d7a7a5b1'); + static final Guid gyroscope = Guid('e6c90003-2a76-4094-917e-9af7d7a7a5b1'); /// PPG (Photoplethysmography) Data Characteristic - static final Guid ppg = - Guid('e6c90004-2a76-4094-917e-9af7d7a7a5b1'); + static final Guid ppg = Guid('e6c90004-2a76-4094-917e-9af7d7a7a5b1'); /// Temperature Data Characteristic - static final Guid temperature = - Guid('e6c90005-2a76-4094-917e-9af7d7a7a5b1'); + static final Guid temperature = Guid('e6c90005-2a76-4094-917e-9af7d7a7a5b1'); } - diff --git a/zswatch_app/lib/core/constants/filesystem_constants.dart b/zswatch_app/lib/core/constants/filesystem_constants.dart index ce74d58..06b3aef 100644 --- a/zswatch_app/lib/core/constants/filesystem_constants.dart +++ b/zswatch_app/lib/core/constants/filesystem_constants.dart @@ -3,7 +3,7 @@ class FilesystemConstants { FilesystemConstants._(); /// Target path on the watch for the LVGL resources filesystem image - /// + /// /// This is where the lvgl_resources_raw.bin gets uploaded via MCUmgr filesystem commands. /// The path corresponds to the external flash partition used for LVGL resources. static const String targetPath = '/S/full_fs'; @@ -21,7 +21,8 @@ class FilesystemConstants { /// Check if a filename is a filesystem image static bool isFilesystemImage(String filename) { final lower = filename.toLowerCase(); - return alternativeFilenames.any((name) => lower.endsWith(name.toLowerCase())); + return alternativeFilenames.any( + (name) => lower.endsWith(name.toLowerCase()), + ); } } - diff --git a/zswatch_app/lib/core/extensions/datetime_extensions.dart b/zswatch_app/lib/core/extensions/datetime_extensions.dart index 3e684ed..1f71d45 100644 --- a/zswatch_app/lib/core/extensions/datetime_extensions.dart +++ b/zswatch_app/lib/core/extensions/datetime_extensions.dart @@ -217,4 +217,3 @@ extension IntTimestampExtensions on int { /// Convert Unix timestamp (milliseconds) to DateTime DateTime get toDateTimeMs => DateTime.fromMillisecondsSinceEpoch(this); } - diff --git a/zswatch_app/lib/core/theme/app_theme.dart b/zswatch_app/lib/core/theme/app_theme.dart index 9b4de78..1e80cc7 100644 --- a/zswatch_app/lib/core/theme/app_theme.dart +++ b/zswatch_app/lib/core/theme/app_theme.dart @@ -155,16 +155,11 @@ abstract final class AppTheme { horizontal: spacingMd, vertical: spacingSm, ), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ), iconButtonTheme: IconButtonThemeData( - style: IconButton.styleFrom( - foregroundColor: textPrimary, - ), + style: IconButton.styleFrom(foregroundColor: textPrimary), ), floatingActionButtonTheme: const FloatingActionButtonThemeData( backgroundColor: primaryColor, @@ -225,10 +220,7 @@ abstract final class AppTheme { fontWeight: FontWeight.w500, color: textPrimary, ), - subtitleTextStyle: TextStyle( - fontSize: 14, - color: textSecondary, - ), + subtitleTextStyle: TextStyle(fontSize: 14, color: textSecondary), iconColor: textSecondary, ), switchTheme: SwitchThemeData( @@ -308,10 +300,7 @@ abstract final class AppTheme { fontWeight: FontWeight.w600, color: textPrimary, ), - contentTextStyle: const TextStyle( - fontSize: 16, - color: textSecondary, - ), + contentTextStyle: const TextStyle(fontSize: 16, color: textSecondary), ), textTheme: const TextTheme( displayLarge: TextStyle( @@ -407,10 +396,6 @@ abstract final class AppTheme { /// Get gradient for battery ring static List getBatteryGradient(int level) { final color = getBatteryColor(level); - return [ - color.withValues(alpha: 0.8), - color, - ]; + return [color.withValues(alpha: 0.8), color]; } } - diff --git a/zswatch_app/lib/data/database/app_database.dart b/zswatch_app/lib/data/database/app_database.dart index 61e8271..1840d5b 100644 --- a/zswatch_app/lib/data/database/app_database.dart +++ b/zswatch_app/lib/data/database/app_database.dart @@ -8,7 +8,10 @@ import 'package:path_provider/path_provider.dart'; import 'tables/battery_readings_table.dart'; import 'tables/comm_log_entries_table.dart'; import 'tables/connection_events_table.dart'; +import 'tables/crash_reports_table.dart'; +import 'tables/extracted_actions_table.dart'; import 'tables/health_samples_table.dart'; +import 'tables/voice_memos_table.dart'; import 'tables/watches_table.dart'; part 'app_database.g.dart'; @@ -22,30 +25,38 @@ part 'app_database.g.dart'; /// - CommLogEntries: BLE communication logs for debugging /// - ConnectionEvents: Connection/disconnection events for analytics @DriftDatabase( - tables: [Watches, HealthSamples, BatteryReadings, CommLogEntries, ConnectionEvents], + tables: [ + Watches, + HealthSamples, + BatteryReadings, + CommLogEntries, + ConnectionEvents, + VoiceMemos, + ExtractedActions, + CrashReports, + ], ) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); - /// Database schema version - increment when making schema changes + /// Database schema version @override int get schemaVersion => 3; @override MigrationStrategy get migration { return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, + onCreate: (Migrator m) => m.createAll(), onUpgrade: (Migrator m, int from, int to) async { - // Handle migrations here when schema changes if (from < 2) { - // Add custom_name column for watch rename feature (FR-099 to FR-102) - await m.addColumn(watches, watches.customName); + // v1 → v2: added ConnectionEvents, VoiceMemos, ExtractedActions tables. + await m.createTable(connectionEvents); + await m.createTable(voiceMemos); + await m.createTable(extractedActions); } if (from < 3) { - // Add connection events table for analytics (US9) - await m.createTable(connectionEvents); + // v2 → v3: added CrashReports table. + await m.createTable(crashReports); } }, ); @@ -66,8 +77,9 @@ class AppDatabase extends _$AppDatabase { /// Get the primary watch Future getPrimaryWatch() { - return (select(watches)..where((w) => w.isPrimary.equals(true))) - .getSingleOrNull(); + return (select( + watches, + )..where((w) => w.isPrimary.equals(true))).getSingleOrNull(); } /// Insert or update a watch @@ -79,24 +91,28 @@ class AppDatabase extends _$AppDatabase { Future setWatchAsPrimary(String watchId) async { await transaction(() async { // Clear all primary flags - await (update(watches)..where((w) => w.isPrimary.equals(true))) - .write(const WatchesCompanion(isPrimary: Value(false))); + await (update(watches)..where((w) => w.isPrimary.equals(true))).write( + const WatchesCompanion(isPrimary: Value(false)), + ); // Set the specified watch as primary - await (update(watches)..where((w) => w.id.equals(watchId))) - .write(const WatchesCompanion(isPrimary: Value(true))); + await (update(watches)..where((w) => w.id.equals(watchId))).write( + const WatchesCompanion(isPrimary: Value(true)), + ); }); } /// Delete a watch and all its associated data Future deleteWatch(String watchId) async { await transaction(() async { - await (delete(healthSamples)..where((h) => h.watchId.equals(watchId))) - .go(); - await (delete(batteryReadings)..where((b) => b.watchId.equals(watchId))) - .go(); - await (delete(connectionEvents) - ..where((c) => c.watchId.equals(watchId))) - .go(); + await (delete( + healthSamples, + )..where((h) => h.watchId.equals(watchId))).go(); + await (delete( + batteryReadings, + )..where((b) => b.watchId.equals(watchId))).go(); + await (delete( + connectionEvents, + )..where((c) => c.watchId.equals(watchId))).go(); await (delete(watches)..where((w) => w.id.equals(watchId))).go(); }); } @@ -135,18 +151,21 @@ class AppDatabase extends _$AppDatabase { required DateTime to, }) { return (select(healthSamples) - ..where((h) => - h.watchId.equals(watchId) & - h.type.equals(type) & - h.timestamp.isBetweenValues(from, to)) + ..where( + (h) => + h.watchId.equals(watchId) & + h.type.equals(type) & + h.timestamp.isBetweenValues(from, to), + ) ..orderBy([(h) => OrderingTerm.asc(h.timestamp)])) .get(); } /// Delete health samples older than specified date Future deleteOldHealthSamples(DateTime cutoff) { - return (delete(healthSamples)..where((h) => h.timestamp.isSmallerThanValue(cutoff))) - .go(); + return (delete( + healthSamples, + )..where((h) => h.timestamp.isSmallerThanValue(cutoff))).go(); } // ==================== Battery Reading Operations ==================== @@ -163,18 +182,20 @@ class AppDatabase extends _$AppDatabase { required DateTime to, }) { return (select(batteryReadings) - ..where((b) => - b.watchId.equals(watchId) & - b.timestamp.isBetweenValues(from, to)) + ..where( + (b) => + b.watchId.equals(watchId) & + b.timestamp.isBetweenValues(from, to), + ) ..orderBy([(b) => OrderingTerm.asc(b.timestamp)])) .get(); } /// Delete battery readings older than specified date Future deleteOldBatteryReadings(DateTime cutoff) { - return (delete(batteryReadings) - ..where((b) => b.timestamp.isSmallerThanValue(cutoff))) - .go(); + return (delete( + batteryReadings, + )..where((b) => b.timestamp.isSmallerThanValue(cutoff))).go(); } // ==================== Comm Log Operations ==================== @@ -238,9 +259,11 @@ class AppDatabase extends _$AppDatabase { required DateTime to, }) { return (select(connectionEvents) - ..where((e) => - e.watchId.equals(watchId) & - e.timestamp.isBetweenValues(from, to)) + ..where( + (e) => + e.watchId.equals(watchId) & + e.timestamp.isBetweenValues(from, to), + ) ..orderBy([(e) => OrderingTerm.asc(e.timestamp)])) .get(); } @@ -262,9 +285,10 @@ class AppDatabase extends _$AppDatabase { int limit = 20, }) { return (select(connectionEvents) - ..where((e) => - e.watchId.equals(watchId) & - e.eventType.equals('disconnected')) + ..where( + (e) => + e.watchId.equals(watchId) & e.eventType.equals('disconnected'), + ) ..orderBy([(e) => OrderingTerm.desc(e.timestamp)]) ..limit(limit)) .get(); @@ -279,18 +303,43 @@ class AppDatabase extends _$AppDatabase { }) async { final query = selectOnly(connectionEvents) ..addColumns([connectionEvents.id.count()]) - ..where(connectionEvents.watchId.equals(watchId) & - connectionEvents.eventType.equals(eventType) & - connectionEvents.timestamp.isBetweenValues(from, to)); + ..where( + connectionEvents.watchId.equals(watchId) & + connectionEvents.eventType.equals(eventType) & + connectionEvents.timestamp.isBetweenValues(from, to), + ); final result = await query.getSingle(); return result.read(connectionEvents.id.count()) ?? 0; } + /// Get the last connection event for a watch strictly before [before] + Future getLastConnectionEventBefore({ + required String watchId, + required DateTime before, + }) { + return (select(connectionEvents) + ..where( + (e) => + e.watchId.equals(watchId) & + e.timestamp.isSmallerThanValue(before), + ) + ..orderBy([(e) => OrderingTerm.desc(e.timestamp)]) + ..limit(1)) + .getSingleOrNull(); + } + /// Delete connection events older than specified date Future deleteOldConnectionEvents(DateTime cutoff) { - return (delete(connectionEvents) - ..where((e) => e.timestamp.isSmallerThanValue(cutoff))) - .go(); + return (delete( + connectionEvents, + )..where((e) => e.timestamp.isSmallerThanValue(cutoff))).go(); + } + + /// Delete all connection events for a specific watch + Future deleteAllConnectionEventsForWatch(String watchId) { + return (delete( + connectionEvents, + )..where((e) => e.watchId.equals(watchId))).go(); } /// Watch connection events (stream) @@ -305,6 +354,352 @@ class AppDatabase extends _$AppDatabase { .watch(); } + // ==================== Voice Memo Operations ==================== + + /// Get all voice memos, newest first + Future> getAllVoiceMemos() { + return (select( + voiceMemos, + )..orderBy([(v) => OrderingTerm.desc(v.timestampUtc)])).get(); + } + + /// Watch all voice memos (reactive stream), newest first + Stream> watchAllVoiceMemos() { + return (select( + voiceMemos, + )..orderBy([(v) => OrderingTerm.desc(v.timestampUtc)])).watch(); + } + + /// Get voice memo by filename + Future getVoiceMemoByFilename(String filename) async { + final rows = await (select( + voiceMemos, + )..where((v) => v.filename.equals(filename))).get(); + if (rows.length > 1) { + // Clean up stale duplicates (can occur from race conditions on double + // BLE notification delivery). Keep the first row, delete the rest. + for (final extra in rows.skip(1)) { + await (delete(voiceMemos)..where((v) => v.id.equals(extra.id))).go(); + } + } + return rows.isEmpty ? null : rows.first; + } + + /// Get voice memos not yet downloaded + Future> getUndownloadedVoiceMemos() { + return (select(voiceMemos) + ..where((v) => v.syncedFromWatch.equals(false)) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + + /// Get voice memos that are synced but not yet transcribed + Future> getUntranscribedVoiceMemos() { + return (select(voiceMemos) + ..where( + (v) => v.syncedFromWatch.equals(true) & v.transcription.isNull(), + ) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + + /// Get voice memos that are transcribed but not yet AI-processed + Future> getUnprocessedVoiceMemos() { + return (select(voiceMemos) + ..where( + (v) => + v.transcription.isNotNull() & + v.summary.isNull() & + (v.processingStatus.isNull() | + v.processingStatus.equals('failed')), + ) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + + /// Insert or update a voice memo (upsert by filename) + Future upsertVoiceMemo(VoiceMemosCompanion memo) async { + // getVoiceMemoByFilename also deduplicates if stale duplicates exist. + final existing = await getVoiceMemoByFilename(memo.filename.value); + if (existing != null) { + await (update( + voiceMemos, + )..where((v) => v.filename.equals(memo.filename.value))).write(memo); + } else { + await into(voiceMemos).insert(memo); + } + } + + /// Mark a voice memo as downloaded + Future updateVoiceMemoDownloaded({ + required String filename, + required String localFilePath, + }) { + return (update( + voiceMemos, + )..where((v) => v.filename.equals(filename))).write( + VoiceMemosCompanion( + syncedFromWatch: const Value(true), + localFilePath: Value(localFilePath), + downloadedAt: Value(DateTime.now()), + ), + ); + } + + /// Mark a voice memo as deleted on the watch + Future updateVoiceMemoDeletedOnWatch(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion(deletedOnWatch: Value(true))); + } + + /// Update transcription for a voice memo + Future updateVoiceMemoTranscription({ + required String filename, + required String transcription, + }) { + return (update( + voiceMemos, + )..where((v) => v.filename.equals(filename))).write( + VoiceMemosCompanion( + transcription: Value(transcription), + transcribedAt: Value(DateTime.now()), + ), + ); + } + + /// Update converted file path for a voice memo + Future updateVoiceMemoConvertedPath({ + required String filename, + required String convertedFilePath, + }) { + return (update( + voiceMemos, + )..where((v) => v.filename.equals(filename))).write( + VoiceMemosCompanion(convertedFilePath: Value(convertedFilePath)), + ); + } + + /// Update AI processing results for a voice memo + Future updateVoiceMemoAiResults({ + required String filename, + required String summary, + required String category, + required String aiModel, + }) { + return (update( + voiceMemos, + )..where((v) => v.filename.equals(filename))).write( + VoiceMemosCompanion( + summary: Value(summary), + category: Value(category), + processingStatus: const Value('ready'), + aiModel: Value(aiModel), + aiProcessedAt: Value(DateTime.now()), + ), + ); + } + + /// Update AI processing status + Future updateVoiceMemoProcessingStatus({ + required String filename, + required String status, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion(processingStatus: Value(status))); + } + + /// Mark task created for a voice memo + Future updateVoiceMemoTaskCreated(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion(taskCreated: Value(true))); + } + + /// Mark calendar event created for a voice memo + Future updateVoiceMemoCalendarEventCreated(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion(calendarEventCreated: Value(true))); + } + + /// Delete a voice memo by filename + Future deleteVoiceMemo(String filename) { + return (delete(voiceMemos)..where((v) => v.filename.equals(filename))).go(); + } + + // ==================== Extracted Action Operations ==================== + + /// Get all extracted actions for a voice memo + Future> getActionsForMemo(int memoId) { + return (select(extractedActions) + ..where((a) => a.memoId.equals(memoId)) + ..orderBy([(a) => OrderingTerm.asc(a.id)])) + .get(); + } + + /// Watch extracted actions for a voice memo (reactive stream) + Stream> watchActionsForMemo(int memoId) { + return (select(extractedActions) + ..where((a) => a.memoId.equals(memoId)) + ..orderBy([(a) => OrderingTerm.asc(a.id)])) + .watch(); + } + + /// Insert an extracted action + Future insertExtractedAction(ExtractedActionsCompanion action) { + return into(extractedActions).insert(action); + } + + /// Update an extracted action as created + Future markExtractedActionCreated({ + required int actionId, + String? platformTargetId, + }) { + return (update( + extractedActions, + )..where((a) => a.id.equals(actionId))).write( + ExtractedActionsCompanion( + created: const Value(true), + platformTargetId: Value(platformTargetId), + createdAt: Value(DateTime.now()), + ), + ); + } + + /// Dismiss an extracted action + Future dismissExtractedAction(int actionId) { + return (update(extractedActions)..where((a) => a.id.equals(actionId))) + .write(const ExtractedActionsCompanion(dismissed: Value(true))); + } + + /// Delete all extracted actions for a memo + Future deleteActionsForMemo(int memoId) { + return (delete( + extractedActions, + )..where((a) => a.memoId.equals(memoId))).go(); + } + + /// Get all pending (not created, not dismissed) extracted actions + Future> getPendingActions() { + return (select( + extractedActions, + )..where((a) => a.created.equals(false) & a.dismissed.equals(false))).get(); + } + + // ==================== Crash Report Operations ==================== + + /// Insert a crash report + Future insertCrashReport(CrashReportsCompanion report) { + return into(crashReports).insert(report); + } + + /// Get all crash reports for a watch, newest first + Future> getCrashReports({ + required String watchId, + int limit = 50, + }) { + return (select(crashReports) + ..where((c) => c.watchId.equals(watchId)) + ..orderBy([(c) => OrderingTerm.desc(c.receivedAt)]) + ..limit(limit)) + .get(); + } + + /// Watch crash reports for a watch (reactive stream), newest first + Stream> watchCrashReports({ + required String watchId, + int limit = 50, + }) { + return (select(crashReports) + ..where((c) => c.watchId.equals(watchId)) + ..orderBy([(c) => OrderingTerm.desc(c.receivedAt)]) + ..limit(limit)) + .watch(); + } + + /// Get all crash reports across all watches, newest first + Stream> watchAllCrashReports({int limit = 50}) { + return (select(crashReports) + ..orderBy([(c) => OrderingTerm.desc(c.receivedAt)]) + ..limit(limit)) + .watch(); + } + + /// Update a crash report with analysis results + Future updateCrashReportAnalysis({ + required int reportId, + required bool success, + String? backtrace, + String? registers, + String? rawOutput, + String? error, + bool elfAvailable = false, + }) { + return (update(crashReports)..where((c) => c.id.equals(reportId))).write( + CrashReportsCompanion( + analyzed: const Value(true), + backtrace: Value(backtrace), + registers: Value(registers), + rawOutput: Value(rawOutput), + analysisError: Value(error), + elfAvailable: Value(elfAvailable), + ), + ); + } + + /// Check if a crash report already exists (dedup by file+line+crashTime+watchId) + Future findExistingCrashReport({ + required String watchId, + required String file, + required int line, + required String crashTime, + }) { + return (select(crashReports) + ..where( + (c) => + c.watchId.equals(watchId) & + c.file.equals(file) & + c.line.equals(line) & + c.crashTime.equals(crashTime), + ) + ..limit(1)) + .getSingleOrNull(); + } + + /// Get crash count per file (top crashers) + Future> getCrashFileStats({String? watchId}) async { + final query = customSelect( + 'SELECT file, COUNT(*) as crash_count, MAX(received_at) as last_crash ' + 'FROM crash_reports ' + '${watchId != null ? "WHERE watch_id = ?" : ""} ' + 'GROUP BY file ORDER BY crash_count DESC LIMIT 10', + variables: [if (watchId != null) Variable.withString(watchId)], + readsFrom: {crashReports}, + ); + final rows = await query.get(); + return rows + .map( + (row) => CrashFileStats( + file: row.read('file'), + count: row.read('crash_count'), + lastCrash: row.read('last_crash'), + ), + ) + .toList(); + } + + /// Delete all crash reports. + Future deleteAllCrashReports() { + return delete(crashReports).go(); + } + + /// Delete old crash reports (keep last N) + Future deleteOldCrashReports({int keep = 100}) async { + await customStatement( + 'DELETE FROM crash_reports WHERE id NOT IN ' + '(SELECT id FROM crash_reports ORDER BY received_at DESC LIMIT ?)', + [keep], + ); + } + // ==================== Data Retention ==================== /// Clean up old data (60-day retention) @@ -316,6 +711,19 @@ class AppDatabase extends _$AppDatabase { } } +/// Stats for crash frequency per file +class CrashFileStats { + final String file; + final int count; + final DateTime lastCrash; + + const CrashFileStats({ + required this.file, + required this.count, + required this.lastCrash, + }); +} + /// Opens the database connection LazyDatabase _openConnection() { return LazyDatabase(() async { @@ -324,4 +732,3 @@ LazyDatabase _openConnection() { return NativeDatabase.createInBackground(file); }); } - diff --git a/zswatch_app/lib/data/database/app_database.g.dart b/zswatch_app/lib/data/database/app_database.g.dart index 9da9e7f..531e2f7 100644 --- a/zswatch_app/lib/data/database/app_database.g.dart +++ b/zswatch_app/lib/data/database/app_database.g.dart @@ -2457,668 +2457,4705 @@ class ConnectionEventsCompanion extends UpdateCompanion { } } -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $WatchesTable watches = $WatchesTable(this); - late final $HealthSamplesTable healthSamples = $HealthSamplesTable(this); - late final $BatteryReadingsTable batteryReadings = $BatteryReadingsTable( - this, +class $VoiceMemosTable extends VoiceMemos + with TableInfo<$VoiceMemosTable, VoiceMemoEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $VoiceMemosTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), ); - late final $CommLogEntriesTable commLogEntries = $CommLogEntriesTable(this); - late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable( - this, + static const VerificationMeta _filenameMeta = const VerificationMeta( + 'filename', ); @override - Iterable> get allTables => - allSchemaEntities.whereType>(); + late final GeneratedColumn filename = GeneratedColumn( + 'filename', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _timestampUtcMeta = const VerificationMeta( + 'timestampUtc', + ); @override - List get allSchemaEntities => [ - watches, - healthSamples, - batteryReadings, - commLogEntries, - connectionEvents, + late final GeneratedColumn timestampUtc = GeneratedColumn( + 'timestamp_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _durationMsMeta = const VerificationMeta( + 'durationMs', + ); + @override + late final GeneratedColumn durationMs = GeneratedColumn( + 'duration_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _sizeBytesMeta = const VerificationMeta( + 'sizeBytes', + ); + @override + late final GeneratedColumn sizeBytes = GeneratedColumn( + 'size_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _localFilePathMeta = const VerificationMeta( + 'localFilePath', + ); + @override + late final GeneratedColumn localFilePath = GeneratedColumn( + 'local_file_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _transcriptionMeta = const VerificationMeta( + 'transcription', + ); + @override + late final GeneratedColumn transcription = GeneratedColumn( + 'transcription', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _syncedFromWatchMeta = const VerificationMeta( + 'syncedFromWatch', + ); + @override + late final GeneratedColumn syncedFromWatch = GeneratedColumn( + 'synced_from_watch', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("synced_from_watch" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _deletedOnWatchMeta = const VerificationMeta( + 'deletedOnWatch', + ); + @override + late final GeneratedColumn deletedOnWatch = GeneratedColumn( + 'deleted_on_watch', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("deleted_on_watch" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _downloadedAtMeta = const VerificationMeta( + 'downloadedAt', + ); + @override + late final GeneratedColumn downloadedAt = GeneratedColumn( + 'downloaded_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _transcribedAtMeta = const VerificationMeta( + 'transcribedAt', + ); + @override + late final GeneratedColumn transcribedAt = + GeneratedColumn( + 'transcribed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _convertedFilePathMeta = const VerificationMeta( + 'convertedFilePath', + ); + @override + late final GeneratedColumn convertedFilePath = + GeneratedColumn( + 'converted_file_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _summaryMeta = const VerificationMeta( + 'summary', + ); + @override + late final GeneratedColumn summary = GeneratedColumn( + 'summary', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _processingStatusMeta = const VerificationMeta( + 'processingStatus', + ); + @override + late final GeneratedColumn processingStatus = GeneratedColumn( + 'processing_status', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _aiModelMeta = const VerificationMeta( + 'aiModel', + ); + @override + late final GeneratedColumn aiModel = GeneratedColumn( + 'ai_model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _aiProcessedAtMeta = const VerificationMeta( + 'aiProcessedAt', + ); + @override + late final GeneratedColumn aiProcessedAt = + GeneratedColumn( + 'ai_processed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _taskCreatedMeta = const VerificationMeta( + 'taskCreated', + ); + @override + late final GeneratedColumn taskCreated = GeneratedColumn( + 'task_created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("task_created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _calendarEventCreatedMeta = + const VerificationMeta('calendarEventCreated'); + @override + late final GeneratedColumn calendarEventCreated = GeneratedColumn( + 'calendar_event_created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("calendar_event_created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _actionReviewStateMeta = const VerificationMeta( + 'actionReviewState', + ); + @override + late final GeneratedColumn actionReviewState = + GeneratedColumn( + 'action_review_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + filename, + timestampUtc, + durationMs, + sizeBytes, + localFilePath, + transcription, + syncedFromWatch, + deletedOnWatch, + downloadedAt, + transcribedAt, + convertedFilePath, + summary, + category, + processingStatus, + aiModel, + aiProcessedAt, + taskCreated, + calendarEventCreated, + actionReviewState, ]; -} - -typedef $$WatchesTableCreateCompanionBuilder = - WatchesCompanion Function({ - required String id, - required String name, - Value customName, - Value firmwareVersion, - Value hardwareVersion, - Value batteryLevel, - Value isPrimary, - Value supportsExtendedApi, - Value lastConnectedAt, - required DateTime createdAt, - Value rowid, - }); -typedef $$WatchesTableUpdateCompanionBuilder = - WatchesCompanion Function({ - Value id, - Value name, - Value customName, - Value firmwareVersion, - Value hardwareVersion, - Value batteryLevel, - Value isPrimary, - Value supportsExtendedApi, - Value lastConnectedAt, - Value createdAt, - Value rowid, - }); + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'voice_memos'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('filename')) { + context.handle( + _filenameMeta, + filename.isAcceptableOrUnknown(data['filename']!, _filenameMeta), + ); + } else if (isInserting) { + context.missing(_filenameMeta); + } + if (data.containsKey('timestamp_utc')) { + context.handle( + _timestampUtcMeta, + timestampUtc.isAcceptableOrUnknown( + data['timestamp_utc']!, + _timestampUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_timestampUtcMeta); + } + if (data.containsKey('duration_ms')) { + context.handle( + _durationMsMeta, + durationMs.isAcceptableOrUnknown(data['duration_ms']!, _durationMsMeta), + ); + } else if (isInserting) { + context.missing(_durationMsMeta); + } + if (data.containsKey('size_bytes')) { + context.handle( + _sizeBytesMeta, + sizeBytes.isAcceptableOrUnknown(data['size_bytes']!, _sizeBytesMeta), + ); + } else if (isInserting) { + context.missing(_sizeBytesMeta); + } + if (data.containsKey('local_file_path')) { + context.handle( + _localFilePathMeta, + localFilePath.isAcceptableOrUnknown( + data['local_file_path']!, + _localFilePathMeta, + ), + ); + } + if (data.containsKey('transcription')) { + context.handle( + _transcriptionMeta, + transcription.isAcceptableOrUnknown( + data['transcription']!, + _transcriptionMeta, + ), + ); + } + if (data.containsKey('synced_from_watch')) { + context.handle( + _syncedFromWatchMeta, + syncedFromWatch.isAcceptableOrUnknown( + data['synced_from_watch']!, + _syncedFromWatchMeta, + ), + ); + } + if (data.containsKey('deleted_on_watch')) { + context.handle( + _deletedOnWatchMeta, + deletedOnWatch.isAcceptableOrUnknown( + data['deleted_on_watch']!, + _deletedOnWatchMeta, + ), + ); + } + if (data.containsKey('downloaded_at')) { + context.handle( + _downloadedAtMeta, + downloadedAt.isAcceptableOrUnknown( + data['downloaded_at']!, + _downloadedAtMeta, + ), + ); + } + if (data.containsKey('transcribed_at')) { + context.handle( + _transcribedAtMeta, + transcribedAt.isAcceptableOrUnknown( + data['transcribed_at']!, + _transcribedAtMeta, + ), + ); + } + if (data.containsKey('converted_file_path')) { + context.handle( + _convertedFilePathMeta, + convertedFilePath.isAcceptableOrUnknown( + data['converted_file_path']!, + _convertedFilePathMeta, + ), + ); + } + if (data.containsKey('summary')) { + context.handle( + _summaryMeta, + summary.isAcceptableOrUnknown(data['summary']!, _summaryMeta), + ); + } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } + if (data.containsKey('processing_status')) { + context.handle( + _processingStatusMeta, + processingStatus.isAcceptableOrUnknown( + data['processing_status']!, + _processingStatusMeta, + ), + ); + } + if (data.containsKey('ai_model')) { + context.handle( + _aiModelMeta, + aiModel.isAcceptableOrUnknown(data['ai_model']!, _aiModelMeta), + ); + } + if (data.containsKey('ai_processed_at')) { + context.handle( + _aiProcessedAtMeta, + aiProcessedAt.isAcceptableOrUnknown( + data['ai_processed_at']!, + _aiProcessedAtMeta, + ), + ); + } + if (data.containsKey('task_created')) { + context.handle( + _taskCreatedMeta, + taskCreated.isAcceptableOrUnknown( + data['task_created']!, + _taskCreatedMeta, + ), + ); + } + if (data.containsKey('calendar_event_created')) { + context.handle( + _calendarEventCreatedMeta, + calendarEventCreated.isAcceptableOrUnknown( + data['calendar_event_created']!, + _calendarEventCreatedMeta, + ), + ); + } + if (data.containsKey('action_review_state')) { + context.handle( + _actionReviewStateMeta, + actionReviewState.isAcceptableOrUnknown( + data['action_review_state']!, + _actionReviewStateMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + VoiceMemoEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return VoiceMemoEntity( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + filename: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}filename'], + )!, + timestampUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp_utc'], + )!, + durationMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_ms'], + )!, + sizeBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}size_bytes'], + )!, + localFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}local_file_path'], + ), + transcription: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}transcription'], + ), + syncedFromWatch: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}synced_from_watch'], + )!, + deletedOnWatch: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}deleted_on_watch'], + )!, + downloadedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}downloaded_at'], + ), + transcribedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}transcribed_at'], + ), + convertedFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}converted_file_path'], + ), + summary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summary'], + ), + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + ), + processingStatus: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}processing_status'], + ), + aiModel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}ai_model'], + ), + aiProcessedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}ai_processed_at'], + ), + taskCreated: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}task_created'], + )!, + calendarEventCreated: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}calendar_event_created'], + )!, + actionReviewState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_review_state'], + ), + ); + } + + @override + $VoiceMemosTable createAlias(String alias) { + return $VoiceMemosTable(attachedDatabase, alias); + } +} + +class VoiceMemoEntity extends DataClass implements Insertable { + /// Auto-incrementing row identifier + final int id; + + /// Original filename on the watch (e.g., "20260304_143022") + final String filename; + + /// Recording timestamp as Unix epoch seconds (UTC) + final int timestampUtc; + + /// Recording duration in milliseconds + final int durationMs; + + /// File size in bytes (Opus-encoded .zsw_opus) + final int sizeBytes; + + /// Local file path after download (null = not yet downloaded) + final String? localFilePath; + + /// Transcription text (null = not yet transcribed) + final String? transcription; + + /// Whether the file has been synced (downloaded) from the watch + final bool syncedFromWatch; + + /// Whether the file has been deleted on the watch after sync + final bool deletedOnWatch; + + /// When the file was downloaded to the phone + final DateTime? downloadedAt; + + /// When the transcription was completed + final DateTime? transcribedAt; + + /// Path to converted audio file (WAV/Ogg) for playback/transcription + final String? convertedFilePath; + + /// AI-generated summary of the voice note + final String? summary; + + /// AI-assigned category: 'idea', 'task', 'reminder', 'meeting', 'note' + final String? category; + + /// Current AI processing status: 'pending', 'summarizing', 'categorizing', + /// 'extractingActions', 'ready', 'failed' + final String? processingStatus; + + /// Which AI model was used for processing + final String? aiModel; + + /// When AI processing completed + final DateTime? aiProcessedAt; + + /// Whether a task has been created from this memo's suggestions + final bool taskCreated; + + /// Whether a calendar event has been created from this memo's suggestions + final bool calendarEventCreated; + + /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' + final String? actionReviewState; + const VoiceMemoEntity({ + required this.id, + required this.filename, + required this.timestampUtc, + required this.durationMs, + required this.sizeBytes, + this.localFilePath, + this.transcription, + required this.syncedFromWatch, + required this.deletedOnWatch, + this.downloadedAt, + this.transcribedAt, + this.convertedFilePath, + this.summary, + this.category, + this.processingStatus, + this.aiModel, + this.aiProcessedAt, + required this.taskCreated, + required this.calendarEventCreated, + this.actionReviewState, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['filename'] = Variable(filename); + map['timestamp_utc'] = Variable(timestampUtc); + map['duration_ms'] = Variable(durationMs); + map['size_bytes'] = Variable(sizeBytes); + if (!nullToAbsent || localFilePath != null) { + map['local_file_path'] = Variable(localFilePath); + } + if (!nullToAbsent || transcription != null) { + map['transcription'] = Variable(transcription); + } + map['synced_from_watch'] = Variable(syncedFromWatch); + map['deleted_on_watch'] = Variable(deletedOnWatch); + if (!nullToAbsent || downloadedAt != null) { + map['downloaded_at'] = Variable(downloadedAt); + } + if (!nullToAbsent || transcribedAt != null) { + map['transcribed_at'] = Variable(transcribedAt); + } + if (!nullToAbsent || convertedFilePath != null) { + map['converted_file_path'] = Variable(convertedFilePath); + } + if (!nullToAbsent || summary != null) { + map['summary'] = Variable(summary); + } + if (!nullToAbsent || category != null) { + map['category'] = Variable(category); + } + if (!nullToAbsent || processingStatus != null) { + map['processing_status'] = Variable(processingStatus); + } + if (!nullToAbsent || aiModel != null) { + map['ai_model'] = Variable(aiModel); + } + if (!nullToAbsent || aiProcessedAt != null) { + map['ai_processed_at'] = Variable(aiProcessedAt); + } + map['task_created'] = Variable(taskCreated); + map['calendar_event_created'] = Variable(calendarEventCreated); + if (!nullToAbsent || actionReviewState != null) { + map['action_review_state'] = Variable(actionReviewState); + } + return map; + } + + VoiceMemosCompanion toCompanion(bool nullToAbsent) { + return VoiceMemosCompanion( + id: Value(id), + filename: Value(filename), + timestampUtc: Value(timestampUtc), + durationMs: Value(durationMs), + sizeBytes: Value(sizeBytes), + localFilePath: localFilePath == null && nullToAbsent + ? const Value.absent() + : Value(localFilePath), + transcription: transcription == null && nullToAbsent + ? const Value.absent() + : Value(transcription), + syncedFromWatch: Value(syncedFromWatch), + deletedOnWatch: Value(deletedOnWatch), + downloadedAt: downloadedAt == null && nullToAbsent + ? const Value.absent() + : Value(downloadedAt), + transcribedAt: transcribedAt == null && nullToAbsent + ? const Value.absent() + : Value(transcribedAt), + convertedFilePath: convertedFilePath == null && nullToAbsent + ? const Value.absent() + : Value(convertedFilePath), + summary: summary == null && nullToAbsent + ? const Value.absent() + : Value(summary), + category: category == null && nullToAbsent + ? const Value.absent() + : Value(category), + processingStatus: processingStatus == null && nullToAbsent + ? const Value.absent() + : Value(processingStatus), + aiModel: aiModel == null && nullToAbsent + ? const Value.absent() + : Value(aiModel), + aiProcessedAt: aiProcessedAt == null && nullToAbsent + ? const Value.absent() + : Value(aiProcessedAt), + taskCreated: Value(taskCreated), + calendarEventCreated: Value(calendarEventCreated), + actionReviewState: actionReviewState == null && nullToAbsent + ? const Value.absent() + : Value(actionReviewState), + ); + } + + factory VoiceMemoEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return VoiceMemoEntity( + id: serializer.fromJson(json['id']), + filename: serializer.fromJson(json['filename']), + timestampUtc: serializer.fromJson(json['timestampUtc']), + durationMs: serializer.fromJson(json['durationMs']), + sizeBytes: serializer.fromJson(json['sizeBytes']), + localFilePath: serializer.fromJson(json['localFilePath']), + transcription: serializer.fromJson(json['transcription']), + syncedFromWatch: serializer.fromJson(json['syncedFromWatch']), + deletedOnWatch: serializer.fromJson(json['deletedOnWatch']), + downloadedAt: serializer.fromJson(json['downloadedAt']), + transcribedAt: serializer.fromJson(json['transcribedAt']), + convertedFilePath: serializer.fromJson( + json['convertedFilePath'], + ), + summary: serializer.fromJson(json['summary']), + category: serializer.fromJson(json['category']), + processingStatus: serializer.fromJson(json['processingStatus']), + aiModel: serializer.fromJson(json['aiModel']), + aiProcessedAt: serializer.fromJson(json['aiProcessedAt']), + taskCreated: serializer.fromJson(json['taskCreated']), + calendarEventCreated: serializer.fromJson( + json['calendarEventCreated'], + ), + actionReviewState: serializer.fromJson( + json['actionReviewState'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'filename': serializer.toJson(filename), + 'timestampUtc': serializer.toJson(timestampUtc), + 'durationMs': serializer.toJson(durationMs), + 'sizeBytes': serializer.toJson(sizeBytes), + 'localFilePath': serializer.toJson(localFilePath), + 'transcription': serializer.toJson(transcription), + 'syncedFromWatch': serializer.toJson(syncedFromWatch), + 'deletedOnWatch': serializer.toJson(deletedOnWatch), + 'downloadedAt': serializer.toJson(downloadedAt), + 'transcribedAt': serializer.toJson(transcribedAt), + 'convertedFilePath': serializer.toJson(convertedFilePath), + 'summary': serializer.toJson(summary), + 'category': serializer.toJson(category), + 'processingStatus': serializer.toJson(processingStatus), + 'aiModel': serializer.toJson(aiModel), + 'aiProcessedAt': serializer.toJson(aiProcessedAt), + 'taskCreated': serializer.toJson(taskCreated), + 'calendarEventCreated': serializer.toJson(calendarEventCreated), + 'actionReviewState': serializer.toJson(actionReviewState), + }; + } + + VoiceMemoEntity copyWith({ + int? id, + String? filename, + int? timestampUtc, + int? durationMs, + int? sizeBytes, + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + bool? syncedFromWatch, + bool? deletedOnWatch, + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + bool? taskCreated, + bool? calendarEventCreated, + Value actionReviewState = const Value.absent(), + }) => VoiceMemoEntity( + id: id ?? this.id, + filename: filename ?? this.filename, + timestampUtc: timestampUtc ?? this.timestampUtc, + durationMs: durationMs ?? this.durationMs, + sizeBytes: sizeBytes ?? this.sizeBytes, + localFilePath: localFilePath.present + ? localFilePath.value + : this.localFilePath, + transcription: transcription.present + ? transcription.value + : this.transcription, + syncedFromWatch: syncedFromWatch ?? this.syncedFromWatch, + deletedOnWatch: deletedOnWatch ?? this.deletedOnWatch, + downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt, + transcribedAt: transcribedAt.present + ? transcribedAt.value + : this.transcribedAt, + convertedFilePath: convertedFilePath.present + ? convertedFilePath.value + : this.convertedFilePath, + summary: summary.present ? summary.value : this.summary, + category: category.present ? category.value : this.category, + processingStatus: processingStatus.present + ? processingStatus.value + : this.processingStatus, + aiModel: aiModel.present ? aiModel.value : this.aiModel, + aiProcessedAt: aiProcessedAt.present + ? aiProcessedAt.value + : this.aiProcessedAt, + taskCreated: taskCreated ?? this.taskCreated, + calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, + actionReviewState: actionReviewState.present + ? actionReviewState.value + : this.actionReviewState, + ); + VoiceMemoEntity copyWithCompanion(VoiceMemosCompanion data) { + return VoiceMemoEntity( + id: data.id.present ? data.id.value : this.id, + filename: data.filename.present ? data.filename.value : this.filename, + timestampUtc: data.timestampUtc.present + ? data.timestampUtc.value + : this.timestampUtc, + durationMs: data.durationMs.present + ? data.durationMs.value + : this.durationMs, + sizeBytes: data.sizeBytes.present ? data.sizeBytes.value : this.sizeBytes, + localFilePath: data.localFilePath.present + ? data.localFilePath.value + : this.localFilePath, + transcription: data.transcription.present + ? data.transcription.value + : this.transcription, + syncedFromWatch: data.syncedFromWatch.present + ? data.syncedFromWatch.value + : this.syncedFromWatch, + deletedOnWatch: data.deletedOnWatch.present + ? data.deletedOnWatch.value + : this.deletedOnWatch, + downloadedAt: data.downloadedAt.present + ? data.downloadedAt.value + : this.downloadedAt, + transcribedAt: data.transcribedAt.present + ? data.transcribedAt.value + : this.transcribedAt, + convertedFilePath: data.convertedFilePath.present + ? data.convertedFilePath.value + : this.convertedFilePath, + summary: data.summary.present ? data.summary.value : this.summary, + category: data.category.present ? data.category.value : this.category, + processingStatus: data.processingStatus.present + ? data.processingStatus.value + : this.processingStatus, + aiModel: data.aiModel.present ? data.aiModel.value : this.aiModel, + aiProcessedAt: data.aiProcessedAt.present + ? data.aiProcessedAt.value + : this.aiProcessedAt, + taskCreated: data.taskCreated.present + ? data.taskCreated.value + : this.taskCreated, + calendarEventCreated: data.calendarEventCreated.present + ? data.calendarEventCreated.value + : this.calendarEventCreated, + actionReviewState: data.actionReviewState.present + ? data.actionReviewState.value + : this.actionReviewState, + ); + } + + @override + String toString() { + return (StringBuffer('VoiceMemoEntity(') + ..write('id: $id, ') + ..write('filename: $filename, ') + ..write('timestampUtc: $timestampUtc, ') + ..write('durationMs: $durationMs, ') + ..write('sizeBytes: $sizeBytes, ') + ..write('localFilePath: $localFilePath, ') + ..write('transcription: $transcription, ') + ..write('syncedFromWatch: $syncedFromWatch, ') + ..write('deletedOnWatch: $deletedOnWatch, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('transcribedAt: $transcribedAt, ') + ..write('convertedFilePath: $convertedFilePath, ') + ..write('summary: $summary, ') + ..write('category: $category, ') + ..write('processingStatus: $processingStatus, ') + ..write('aiModel: $aiModel, ') + ..write('aiProcessedAt: $aiProcessedAt, ') + ..write('taskCreated: $taskCreated, ') + ..write('calendarEventCreated: $calendarEventCreated, ') + ..write('actionReviewState: $actionReviewState') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + filename, + timestampUtc, + durationMs, + sizeBytes, + localFilePath, + transcription, + syncedFromWatch, + deletedOnWatch, + downloadedAt, + transcribedAt, + convertedFilePath, + summary, + category, + processingStatus, + aiModel, + aiProcessedAt, + taskCreated, + calendarEventCreated, + actionReviewState, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is VoiceMemoEntity && + other.id == this.id && + other.filename == this.filename && + other.timestampUtc == this.timestampUtc && + other.durationMs == this.durationMs && + other.sizeBytes == this.sizeBytes && + other.localFilePath == this.localFilePath && + other.transcription == this.transcription && + other.syncedFromWatch == this.syncedFromWatch && + other.deletedOnWatch == this.deletedOnWatch && + other.downloadedAt == this.downloadedAt && + other.transcribedAt == this.transcribedAt && + other.convertedFilePath == this.convertedFilePath && + other.summary == this.summary && + other.category == this.category && + other.processingStatus == this.processingStatus && + other.aiModel == this.aiModel && + other.aiProcessedAt == this.aiProcessedAt && + other.taskCreated == this.taskCreated && + other.calendarEventCreated == this.calendarEventCreated && + other.actionReviewState == this.actionReviewState); +} + +class VoiceMemosCompanion extends UpdateCompanion { + final Value id; + final Value filename; + final Value timestampUtc; + final Value durationMs; + final Value sizeBytes; + final Value localFilePath; + final Value transcription; + final Value syncedFromWatch; + final Value deletedOnWatch; + final Value downloadedAt; + final Value transcribedAt; + final Value convertedFilePath; + final Value summary; + final Value category; + final Value processingStatus; + final Value aiModel; + final Value aiProcessedAt; + final Value taskCreated; + final Value calendarEventCreated; + final Value actionReviewState; + const VoiceMemosCompanion({ + this.id = const Value.absent(), + this.filename = const Value.absent(), + this.timestampUtc = const Value.absent(), + this.durationMs = const Value.absent(), + this.sizeBytes = const Value.absent(), + this.localFilePath = const Value.absent(), + this.transcription = const Value.absent(), + this.syncedFromWatch = const Value.absent(), + this.deletedOnWatch = const Value.absent(), + this.downloadedAt = const Value.absent(), + this.transcribedAt = const Value.absent(), + this.convertedFilePath = const Value.absent(), + this.summary = const Value.absent(), + this.category = const Value.absent(), + this.processingStatus = const Value.absent(), + this.aiModel = const Value.absent(), + this.aiProcessedAt = const Value.absent(), + this.taskCreated = const Value.absent(), + this.calendarEventCreated = const Value.absent(), + this.actionReviewState = const Value.absent(), + }); + VoiceMemosCompanion.insert({ + this.id = const Value.absent(), + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + this.localFilePath = const Value.absent(), + this.transcription = const Value.absent(), + this.syncedFromWatch = const Value.absent(), + this.deletedOnWatch = const Value.absent(), + this.downloadedAt = const Value.absent(), + this.transcribedAt = const Value.absent(), + this.convertedFilePath = const Value.absent(), + this.summary = const Value.absent(), + this.category = const Value.absent(), + this.processingStatus = const Value.absent(), + this.aiModel = const Value.absent(), + this.aiProcessedAt = const Value.absent(), + this.taskCreated = const Value.absent(), + this.calendarEventCreated = const Value.absent(), + this.actionReviewState = const Value.absent(), + }) : filename = Value(filename), + timestampUtc = Value(timestampUtc), + durationMs = Value(durationMs), + sizeBytes = Value(sizeBytes); + static Insertable custom({ + Expression? id, + Expression? filename, + Expression? timestampUtc, + Expression? durationMs, + Expression? sizeBytes, + Expression? localFilePath, + Expression? transcription, + Expression? syncedFromWatch, + Expression? deletedOnWatch, + Expression? downloadedAt, + Expression? transcribedAt, + Expression? convertedFilePath, + Expression? summary, + Expression? category, + Expression? processingStatus, + Expression? aiModel, + Expression? aiProcessedAt, + Expression? taskCreated, + Expression? calendarEventCreated, + Expression? actionReviewState, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (filename != null) 'filename': filename, + if (timestampUtc != null) 'timestamp_utc': timestampUtc, + if (durationMs != null) 'duration_ms': durationMs, + if (sizeBytes != null) 'size_bytes': sizeBytes, + if (localFilePath != null) 'local_file_path': localFilePath, + if (transcription != null) 'transcription': transcription, + if (syncedFromWatch != null) 'synced_from_watch': syncedFromWatch, + if (deletedOnWatch != null) 'deleted_on_watch': deletedOnWatch, + if (downloadedAt != null) 'downloaded_at': downloadedAt, + if (transcribedAt != null) 'transcribed_at': transcribedAt, + if (convertedFilePath != null) 'converted_file_path': convertedFilePath, + if (summary != null) 'summary': summary, + if (category != null) 'category': category, + if (processingStatus != null) 'processing_status': processingStatus, + if (aiModel != null) 'ai_model': aiModel, + if (aiProcessedAt != null) 'ai_processed_at': aiProcessedAt, + if (taskCreated != null) 'task_created': taskCreated, + if (calendarEventCreated != null) + 'calendar_event_created': calendarEventCreated, + if (actionReviewState != null) 'action_review_state': actionReviewState, + }); + } + + VoiceMemosCompanion copyWith({ + Value? id, + Value? filename, + Value? timestampUtc, + Value? durationMs, + Value? sizeBytes, + Value? localFilePath, + Value? transcription, + Value? syncedFromWatch, + Value? deletedOnWatch, + Value? downloadedAt, + Value? transcribedAt, + Value? convertedFilePath, + Value? summary, + Value? category, + Value? processingStatus, + Value? aiModel, + Value? aiProcessedAt, + Value? taskCreated, + Value? calendarEventCreated, + Value? actionReviewState, + }) { + return VoiceMemosCompanion( + id: id ?? this.id, + filename: filename ?? this.filename, + timestampUtc: timestampUtc ?? this.timestampUtc, + durationMs: durationMs ?? this.durationMs, + sizeBytes: sizeBytes ?? this.sizeBytes, + localFilePath: localFilePath ?? this.localFilePath, + transcription: transcription ?? this.transcription, + syncedFromWatch: syncedFromWatch ?? this.syncedFromWatch, + deletedOnWatch: deletedOnWatch ?? this.deletedOnWatch, + downloadedAt: downloadedAt ?? this.downloadedAt, + transcribedAt: transcribedAt ?? this.transcribedAt, + convertedFilePath: convertedFilePath ?? this.convertedFilePath, + summary: summary ?? this.summary, + category: category ?? this.category, + processingStatus: processingStatus ?? this.processingStatus, + aiModel: aiModel ?? this.aiModel, + aiProcessedAt: aiProcessedAt ?? this.aiProcessedAt, + taskCreated: taskCreated ?? this.taskCreated, + calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, + actionReviewState: actionReviewState ?? this.actionReviewState, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (filename.present) { + map['filename'] = Variable(filename.value); + } + if (timestampUtc.present) { + map['timestamp_utc'] = Variable(timestampUtc.value); + } + if (durationMs.present) { + map['duration_ms'] = Variable(durationMs.value); + } + if (sizeBytes.present) { + map['size_bytes'] = Variable(sizeBytes.value); + } + if (localFilePath.present) { + map['local_file_path'] = Variable(localFilePath.value); + } + if (transcription.present) { + map['transcription'] = Variable(transcription.value); + } + if (syncedFromWatch.present) { + map['synced_from_watch'] = Variable(syncedFromWatch.value); + } + if (deletedOnWatch.present) { + map['deleted_on_watch'] = Variable(deletedOnWatch.value); + } + if (downloadedAt.present) { + map['downloaded_at'] = Variable(downloadedAt.value); + } + if (transcribedAt.present) { + map['transcribed_at'] = Variable(transcribedAt.value); + } + if (convertedFilePath.present) { + map['converted_file_path'] = Variable(convertedFilePath.value); + } + if (summary.present) { + map['summary'] = Variable(summary.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (processingStatus.present) { + map['processing_status'] = Variable(processingStatus.value); + } + if (aiModel.present) { + map['ai_model'] = Variable(aiModel.value); + } + if (aiProcessedAt.present) { + map['ai_processed_at'] = Variable(aiProcessedAt.value); + } + if (taskCreated.present) { + map['task_created'] = Variable(taskCreated.value); + } + if (calendarEventCreated.present) { + map['calendar_event_created'] = Variable( + calendarEventCreated.value, + ); + } + if (actionReviewState.present) { + map['action_review_state'] = Variable(actionReviewState.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('VoiceMemosCompanion(') + ..write('id: $id, ') + ..write('filename: $filename, ') + ..write('timestampUtc: $timestampUtc, ') + ..write('durationMs: $durationMs, ') + ..write('sizeBytes: $sizeBytes, ') + ..write('localFilePath: $localFilePath, ') + ..write('transcription: $transcription, ') + ..write('syncedFromWatch: $syncedFromWatch, ') + ..write('deletedOnWatch: $deletedOnWatch, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('transcribedAt: $transcribedAt, ') + ..write('convertedFilePath: $convertedFilePath, ') + ..write('summary: $summary, ') + ..write('category: $category, ') + ..write('processingStatus: $processingStatus, ') + ..write('aiModel: $aiModel, ') + ..write('aiProcessedAt: $aiProcessedAt, ') + ..write('taskCreated: $taskCreated, ') + ..write('calendarEventCreated: $calendarEventCreated, ') + ..write('actionReviewState: $actionReviewState') + ..write(')')) + .toString(); + } +} + +class $ExtractedActionsTable extends ExtractedActions + with TableInfo<$ExtractedActionsTable, ExtractedActionEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ExtractedActionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _memoIdMeta = const VerificationMeta('memoId'); + @override + late final GeneratedColumn memoId = GeneratedColumn( + 'memo_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _actionTypeMeta = const VerificationMeta( + 'actionType', + ); + @override + late final GeneratedColumn actionType = GeneratedColumn( + 'action_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _notesMeta = const VerificationMeta('notes'); + @override + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _startTimeMeta = const VerificationMeta( + 'startTime', + ); + @override + late final GeneratedColumn startTime = GeneratedColumn( + 'start_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _endTimeMeta = const VerificationMeta( + 'endTime', + ); + @override + late final GeneratedColumn endTime = GeneratedColumn( + 'end_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _dueDateMeta = const VerificationMeta( + 'dueDate', + ); + @override + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _locationMeta = const VerificationMeta( + 'location', + ); + @override + late final GeneratedColumn location = GeneratedColumn( + 'location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _reminderMinutesMeta = const VerificationMeta( + 'reminderMinutes', + ); + @override + late final GeneratedColumn reminderMinutes = GeneratedColumn( + 'reminder_minutes', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdMeta = const VerificationMeta( + 'created', + ); + @override + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _dismissedMeta = const VerificationMeta( + 'dismissed', + ); + @override + late final GeneratedColumn dismissed = GeneratedColumn( + 'dismissed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("dismissed" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _platformTargetIdMeta = const VerificationMeta( + 'platformTargetId', + ); + @override + late final GeneratedColumn platformTargetId = GeneratedColumn( + 'platform_target_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + memoId, + actionType, + title, + notes, + startTime, + endTime, + dueDate, + location, + reminderMinutes, + created, + dismissed, + platformTargetId, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'extracted_actions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('memo_id')) { + context.handle( + _memoIdMeta, + memoId.isAcceptableOrUnknown(data['memo_id']!, _memoIdMeta), + ); + } else if (isInserting) { + context.missing(_memoIdMeta); + } + if (data.containsKey('action_type')) { + context.handle( + _actionTypeMeta, + actionType.isAcceptableOrUnknown(data['action_type']!, _actionTypeMeta), + ); + } else if (isInserting) { + context.missing(_actionTypeMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('notes')) { + context.handle( + _notesMeta, + notes.isAcceptableOrUnknown(data['notes']!, _notesMeta), + ); + } + if (data.containsKey('start_time')) { + context.handle( + _startTimeMeta, + startTime.isAcceptableOrUnknown(data['start_time']!, _startTimeMeta), + ); + } + if (data.containsKey('end_time')) { + context.handle( + _endTimeMeta, + endTime.isAcceptableOrUnknown(data['end_time']!, _endTimeMeta), + ); + } + if (data.containsKey('due_date')) { + context.handle( + _dueDateMeta, + dueDate.isAcceptableOrUnknown(data['due_date']!, _dueDateMeta), + ); + } + if (data.containsKey('location')) { + context.handle( + _locationMeta, + location.isAcceptableOrUnknown(data['location']!, _locationMeta), + ); + } + if (data.containsKey('reminder_minutes')) { + context.handle( + _reminderMinutesMeta, + reminderMinutes.isAcceptableOrUnknown( + data['reminder_minutes']!, + _reminderMinutesMeta, + ), + ); + } + if (data.containsKey('created')) { + context.handle( + _createdMeta, + created.isAcceptableOrUnknown(data['created']!, _createdMeta), + ); + } + if (data.containsKey('dismissed')) { + context.handle( + _dismissedMeta, + dismissed.isAcceptableOrUnknown(data['dismissed']!, _dismissedMeta), + ); + } + if (data.containsKey('platform_target_id')) { + context.handle( + _platformTargetIdMeta, + platformTargetId.isAcceptableOrUnknown( + data['platform_target_id']!, + _platformTargetIdMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ExtractedActionEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ExtractedActionEntity( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + memoId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}memo_id'], + )!, + actionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_type'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + notes: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}notes'], + ), + startTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}start_time'], + ), + endTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}end_time'], + ), + dueDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}due_date'], + ), + location: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}location'], + ), + reminderMinutes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}reminder_minutes'], + ), + created: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}created'], + )!, + dismissed: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}dismissed'], + )!, + platformTargetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}platform_target_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + ); + } + + @override + $ExtractedActionsTable createAlias(String alias) { + return $ExtractedActionsTable(attachedDatabase, alias); + } +} + +class ExtractedActionEntity extends DataClass + implements Insertable { + /// Auto-incrementing row identifier + final int id; + + /// Foreign key to the parent voice memo + final int memoId; + + /// Action type: 'task', 'calendar_event', 'reminder' + final String actionType; + + /// AI-generated title for the action + final String title; + + /// Optional notes / body text + final String? notes; + + /// Suggested start time (for calendar events) + final DateTime? startTime; + + /// Suggested end time (for calendar events) + final DateTime? endTime; + + /// Suggested due date (for tasks / reminders) + final DateTime? dueDate; + + /// Optional location + final String? location; + + /// Reminder offset in minutes before the event + final int? reminderMinutes; + + /// Whether this action has been created in the OS (calendar / reminders) + final bool created; + + /// Whether the user dismissed this suggestion + final bool dismissed; + + /// Platform-specific ID after creation (e.g. calendar event ID) + final String? platformTargetId; + + /// When this action was created in the OS + final DateTime? createdAt; + const ExtractedActionEntity({ + required this.id, + required this.memoId, + required this.actionType, + required this.title, + this.notes, + this.startTime, + this.endTime, + this.dueDate, + this.location, + this.reminderMinutes, + required this.created, + required this.dismissed, + this.platformTargetId, + this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['memo_id'] = Variable(memoId); + map['action_type'] = Variable(actionType); + map['title'] = Variable(title); + if (!nullToAbsent || notes != null) { + map['notes'] = Variable(notes); + } + if (!nullToAbsent || startTime != null) { + map['start_time'] = Variable(startTime); + } + if (!nullToAbsent || endTime != null) { + map['end_time'] = Variable(endTime); + } + if (!nullToAbsent || dueDate != null) { + map['due_date'] = Variable(dueDate); + } + if (!nullToAbsent || location != null) { + map['location'] = Variable(location); + } + if (!nullToAbsent || reminderMinutes != null) { + map['reminder_minutes'] = Variable(reminderMinutes); + } + map['created'] = Variable(created); + map['dismissed'] = Variable(dismissed); + if (!nullToAbsent || platformTargetId != null) { + map['platform_target_id'] = Variable(platformTargetId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + return map; + } + + ExtractedActionsCompanion toCompanion(bool nullToAbsent) { + return ExtractedActionsCompanion( + id: Value(id), + memoId: Value(memoId), + actionType: Value(actionType), + title: Value(title), + notes: notes == null && nullToAbsent + ? const Value.absent() + : Value(notes), + startTime: startTime == null && nullToAbsent + ? const Value.absent() + : Value(startTime), + endTime: endTime == null && nullToAbsent + ? const Value.absent() + : Value(endTime), + dueDate: dueDate == null && nullToAbsent + ? const Value.absent() + : Value(dueDate), + location: location == null && nullToAbsent + ? const Value.absent() + : Value(location), + reminderMinutes: reminderMinutes == null && nullToAbsent + ? const Value.absent() + : Value(reminderMinutes), + created: Value(created), + dismissed: Value(dismissed), + platformTargetId: platformTargetId == null && nullToAbsent + ? const Value.absent() + : Value(platformTargetId), + createdAt: createdAt == null && nullToAbsent + ? const Value.absent() + : Value(createdAt), + ); + } + + factory ExtractedActionEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ExtractedActionEntity( + id: serializer.fromJson(json['id']), + memoId: serializer.fromJson(json['memoId']), + actionType: serializer.fromJson(json['actionType']), + title: serializer.fromJson(json['title']), + notes: serializer.fromJson(json['notes']), + startTime: serializer.fromJson(json['startTime']), + endTime: serializer.fromJson(json['endTime']), + dueDate: serializer.fromJson(json['dueDate']), + location: serializer.fromJson(json['location']), + reminderMinutes: serializer.fromJson(json['reminderMinutes']), + created: serializer.fromJson(json['created']), + dismissed: serializer.fromJson(json['dismissed']), + platformTargetId: serializer.fromJson(json['platformTargetId']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'memoId': serializer.toJson(memoId), + 'actionType': serializer.toJson(actionType), + 'title': serializer.toJson(title), + 'notes': serializer.toJson(notes), + 'startTime': serializer.toJson(startTime), + 'endTime': serializer.toJson(endTime), + 'dueDate': serializer.toJson(dueDate), + 'location': serializer.toJson(location), + 'reminderMinutes': serializer.toJson(reminderMinutes), + 'created': serializer.toJson(created), + 'dismissed': serializer.toJson(dismissed), + 'platformTargetId': serializer.toJson(platformTargetId), + 'createdAt': serializer.toJson(createdAt), + }; + } + + ExtractedActionEntity copyWith({ + int? id, + int? memoId, + String? actionType, + String? title, + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + bool? created, + bool? dismissed, + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionEntity( + id: id ?? this.id, + memoId: memoId ?? this.memoId, + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes.present ? notes.value : this.notes, + startTime: startTime.present ? startTime.value : this.startTime, + endTime: endTime.present ? endTime.value : this.endTime, + dueDate: dueDate.present ? dueDate.value : this.dueDate, + location: location.present ? location.value : this.location, + reminderMinutes: reminderMinutes.present + ? reminderMinutes.value + : this.reminderMinutes, + created: created ?? this.created, + dismissed: dismissed ?? this.dismissed, + platformTargetId: platformTargetId.present + ? platformTargetId.value + : this.platformTargetId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + ); + ExtractedActionEntity copyWithCompanion(ExtractedActionsCompanion data) { + return ExtractedActionEntity( + id: data.id.present ? data.id.value : this.id, + memoId: data.memoId.present ? data.memoId.value : this.memoId, + actionType: data.actionType.present + ? data.actionType.value + : this.actionType, + title: data.title.present ? data.title.value : this.title, + notes: data.notes.present ? data.notes.value : this.notes, + startTime: data.startTime.present ? data.startTime.value : this.startTime, + endTime: data.endTime.present ? data.endTime.value : this.endTime, + dueDate: data.dueDate.present ? data.dueDate.value : this.dueDate, + location: data.location.present ? data.location.value : this.location, + reminderMinutes: data.reminderMinutes.present + ? data.reminderMinutes.value + : this.reminderMinutes, + created: data.created.present ? data.created.value : this.created, + dismissed: data.dismissed.present ? data.dismissed.value : this.dismissed, + platformTargetId: data.platformTargetId.present + ? data.platformTargetId.value + : this.platformTargetId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('ExtractedActionEntity(') + ..write('id: $id, ') + ..write('memoId: $memoId, ') + ..write('actionType: $actionType, ') + ..write('title: $title, ') + ..write('notes: $notes, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('dueDate: $dueDate, ') + ..write('location: $location, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('created: $created, ') + ..write('dismissed: $dismissed, ') + ..write('platformTargetId: $platformTargetId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + memoId, + actionType, + title, + notes, + startTime, + endTime, + dueDate, + location, + reminderMinutes, + created, + dismissed, + platformTargetId, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ExtractedActionEntity && + other.id == this.id && + other.memoId == this.memoId && + other.actionType == this.actionType && + other.title == this.title && + other.notes == this.notes && + other.startTime == this.startTime && + other.endTime == this.endTime && + other.dueDate == this.dueDate && + other.location == this.location && + other.reminderMinutes == this.reminderMinutes && + other.created == this.created && + other.dismissed == this.dismissed && + other.platformTargetId == this.platformTargetId && + other.createdAt == this.createdAt); +} + +class ExtractedActionsCompanion extends UpdateCompanion { + final Value id; + final Value memoId; + final Value actionType; + final Value title; + final Value notes; + final Value startTime; + final Value endTime; + final Value dueDate; + final Value location; + final Value reminderMinutes; + final Value created; + final Value dismissed; + final Value platformTargetId; + final Value createdAt; + const ExtractedActionsCompanion({ + this.id = const Value.absent(), + this.memoId = const Value.absent(), + this.actionType = const Value.absent(), + this.title = const Value.absent(), + this.notes = const Value.absent(), + this.startTime = const Value.absent(), + this.endTime = const Value.absent(), + this.dueDate = const Value.absent(), + this.location = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.created = const Value.absent(), + this.dismissed = const Value.absent(), + this.platformTargetId = const Value.absent(), + this.createdAt = const Value.absent(), + }); + ExtractedActionsCompanion.insert({ + this.id = const Value.absent(), + required int memoId, + required String actionType, + required String title, + this.notes = const Value.absent(), + this.startTime = const Value.absent(), + this.endTime = const Value.absent(), + this.dueDate = const Value.absent(), + this.location = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.created = const Value.absent(), + this.dismissed = const Value.absent(), + this.platformTargetId = const Value.absent(), + this.createdAt = const Value.absent(), + }) : memoId = Value(memoId), + actionType = Value(actionType), + title = Value(title); + static Insertable custom({ + Expression? id, + Expression? memoId, + Expression? actionType, + Expression? title, + Expression? notes, + Expression? startTime, + Expression? endTime, + Expression? dueDate, + Expression? location, + Expression? reminderMinutes, + Expression? created, + Expression? dismissed, + Expression? platformTargetId, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (memoId != null) 'memo_id': memoId, + if (actionType != null) 'action_type': actionType, + if (title != null) 'title': title, + if (notes != null) 'notes': notes, + if (startTime != null) 'start_time': startTime, + if (endTime != null) 'end_time': endTime, + if (dueDate != null) 'due_date': dueDate, + if (location != null) 'location': location, + if (reminderMinutes != null) 'reminder_minutes': reminderMinutes, + if (created != null) 'created': created, + if (dismissed != null) 'dismissed': dismissed, + if (platformTargetId != null) 'platform_target_id': platformTargetId, + if (createdAt != null) 'created_at': createdAt, + }); + } + + ExtractedActionsCompanion copyWith({ + Value? id, + Value? memoId, + Value? actionType, + Value? title, + Value? notes, + Value? startTime, + Value? endTime, + Value? dueDate, + Value? location, + Value? reminderMinutes, + Value? created, + Value? dismissed, + Value? platformTargetId, + Value? createdAt, + }) { + return ExtractedActionsCompanion( + id: id ?? this.id, + memoId: memoId ?? this.memoId, + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes ?? this.notes, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + dueDate: dueDate ?? this.dueDate, + location: location ?? this.location, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + created: created ?? this.created, + dismissed: dismissed ?? this.dismissed, + platformTargetId: platformTargetId ?? this.platformTargetId, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (memoId.present) { + map['memo_id'] = Variable(memoId.value); + } + if (actionType.present) { + map['action_type'] = Variable(actionType.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (notes.present) { + map['notes'] = Variable(notes.value); + } + if (startTime.present) { + map['start_time'] = Variable(startTime.value); + } + if (endTime.present) { + map['end_time'] = Variable(endTime.value); + } + if (dueDate.present) { + map['due_date'] = Variable(dueDate.value); + } + if (location.present) { + map['location'] = Variable(location.value); + } + if (reminderMinutes.present) { + map['reminder_minutes'] = Variable(reminderMinutes.value); + } + if (created.present) { + map['created'] = Variable(created.value); + } + if (dismissed.present) { + map['dismissed'] = Variable(dismissed.value); + } + if (platformTargetId.present) { + map['platform_target_id'] = Variable(platformTargetId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ExtractedActionsCompanion(') + ..write('id: $id, ') + ..write('memoId: $memoId, ') + ..write('actionType: $actionType, ') + ..write('title: $title, ') + ..write('notes: $notes, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('dueDate: $dueDate, ') + ..write('location: $location, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('created: $created, ') + ..write('dismissed: $dismissed, ') + ..write('platformTargetId: $platformTargetId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $CrashReportsTable extends CrashReports + with TableInfo<$CrashReportsTable, CrashReportEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CrashReportsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _watchIdMeta = const VerificationMeta( + 'watchId', + ); + @override + late final GeneratedColumn watchId = GeneratedColumn( + 'watch_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES watches (id)', + ), + ); + static const VerificationMeta _fileMeta = const VerificationMeta('file'); + @override + late final GeneratedColumn file = GeneratedColumn( + 'file', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lineMeta = const VerificationMeta('line'); + @override + late final GeneratedColumn line = GeneratedColumn( + 'line', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _crashTimeMeta = const VerificationMeta( + 'crashTime', + ); + @override + late final GeneratedColumn crashTime = GeneratedColumn( + 'crash_time', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fwVersionMeta = const VerificationMeta( + 'fwVersion', + ); + @override + late final GeneratedColumn fwVersion = GeneratedColumn( + 'fw_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fwCommitShaMeta = const VerificationMeta( + 'fwCommitSha', + ); + @override + late final GeneratedColumn fwCommitSha = GeneratedColumn( + 'fw_commit_sha', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _boardMeta = const VerificationMeta('board'); + @override + late final GeneratedColumn board = GeneratedColumn( + 'board', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _buildTypeMeta = const VerificationMeta( + 'buildType', + ); + @override + late final GeneratedColumn buildType = GeneratedColumn( + 'build_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _receivedAtMeta = const VerificationMeta( + 'receivedAt', + ); + @override + late final GeneratedColumn receivedAt = GeneratedColumn( + 'received_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _analyzedMeta = const VerificationMeta( + 'analyzed', + ); + @override + late final GeneratedColumn analyzed = GeneratedColumn( + 'analyzed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("analyzed" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _backtraceMeta = const VerificationMeta( + 'backtrace', + ); + @override + late final GeneratedColumn backtrace = GeneratedColumn( + 'backtrace', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _registersMeta = const VerificationMeta( + 'registers', + ); + @override + late final GeneratedColumn registers = GeneratedColumn( + 'registers', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _rawOutputMeta = const VerificationMeta( + 'rawOutput', + ); + @override + late final GeneratedColumn rawOutput = GeneratedColumn( + 'raw_output', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _analysisErrorMeta = const VerificationMeta( + 'analysisError', + ); + @override + late final GeneratedColumn analysisError = GeneratedColumn( + 'analysis_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _elfAvailableMeta = const VerificationMeta( + 'elfAvailable', + ); + @override + late final GeneratedColumn elfAvailable = GeneratedColumn( + 'elf_available', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("elf_available" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + @override + List get $columns => [ + id, + watchId, + file, + line, + crashTime, + fwVersion, + fwCommitSha, + board, + buildType, + receivedAt, + analyzed, + backtrace, + registers, + rawOutput, + analysisError, + elfAvailable, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'crash_reports'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('watch_id')) { + context.handle( + _watchIdMeta, + watchId.isAcceptableOrUnknown(data['watch_id']!, _watchIdMeta), + ); + } else if (isInserting) { + context.missing(_watchIdMeta); + } + if (data.containsKey('file')) { + context.handle( + _fileMeta, + file.isAcceptableOrUnknown(data['file']!, _fileMeta), + ); + } else if (isInserting) { + context.missing(_fileMeta); + } + if (data.containsKey('line')) { + context.handle( + _lineMeta, + line.isAcceptableOrUnknown(data['line']!, _lineMeta), + ); + } else if (isInserting) { + context.missing(_lineMeta); + } + if (data.containsKey('crash_time')) { + context.handle( + _crashTimeMeta, + crashTime.isAcceptableOrUnknown(data['crash_time']!, _crashTimeMeta), + ); + } else if (isInserting) { + context.missing(_crashTimeMeta); + } + if (data.containsKey('fw_version')) { + context.handle( + _fwVersionMeta, + fwVersion.isAcceptableOrUnknown(data['fw_version']!, _fwVersionMeta), + ); + } else if (isInserting) { + context.missing(_fwVersionMeta); + } + if (data.containsKey('fw_commit_sha')) { + context.handle( + _fwCommitShaMeta, + fwCommitSha.isAcceptableOrUnknown( + data['fw_commit_sha']!, + _fwCommitShaMeta, + ), + ); + } else if (isInserting) { + context.missing(_fwCommitShaMeta); + } + if (data.containsKey('board')) { + context.handle( + _boardMeta, + board.isAcceptableOrUnknown(data['board']!, _boardMeta), + ); + } else if (isInserting) { + context.missing(_boardMeta); + } + if (data.containsKey('build_type')) { + context.handle( + _buildTypeMeta, + buildType.isAcceptableOrUnknown(data['build_type']!, _buildTypeMeta), + ); + } else if (isInserting) { + context.missing(_buildTypeMeta); + } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown(data['received_at']!, _receivedAtMeta), + ); + } else if (isInserting) { + context.missing(_receivedAtMeta); + } + if (data.containsKey('analyzed')) { + context.handle( + _analyzedMeta, + analyzed.isAcceptableOrUnknown(data['analyzed']!, _analyzedMeta), + ); + } + if (data.containsKey('backtrace')) { + context.handle( + _backtraceMeta, + backtrace.isAcceptableOrUnknown(data['backtrace']!, _backtraceMeta), + ); + } + if (data.containsKey('registers')) { + context.handle( + _registersMeta, + registers.isAcceptableOrUnknown(data['registers']!, _registersMeta), + ); + } + if (data.containsKey('raw_output')) { + context.handle( + _rawOutputMeta, + rawOutput.isAcceptableOrUnknown(data['raw_output']!, _rawOutputMeta), + ); + } + if (data.containsKey('analysis_error')) { + context.handle( + _analysisErrorMeta, + analysisError.isAcceptableOrUnknown( + data['analysis_error']!, + _analysisErrorMeta, + ), + ); + } + if (data.containsKey('elf_available')) { + context.handle( + _elfAvailableMeta, + elfAvailable.isAcceptableOrUnknown( + data['elf_available']!, + _elfAvailableMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CrashReportEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CrashReportEntity( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + watchId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}watch_id'], + )!, + file: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}file'], + )!, + line: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}line'], + )!, + crashTime: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}crash_time'], + )!, + fwVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fw_version'], + )!, + fwCommitSha: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fw_commit_sha'], + )!, + board: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}board'], + )!, + buildType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}build_type'], + )!, + receivedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}received_at'], + )!, + analyzed: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}analyzed'], + )!, + backtrace: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}backtrace'], + ), + registers: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}registers'], + ), + rawOutput: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_output'], + ), + analysisError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}analysis_error'], + ), + elfAvailable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}elf_available'], + )!, + ); + } + + @override + $CrashReportsTable createAlias(String alias) { + return $CrashReportsTable(attachedDatabase, alias); + } +} + +class CrashReportEntity extends DataClass + implements Insertable { + /// Auto-incrementing row identifier + final int id; + + /// Foreign key to source watch + final String watchId; + + /// Source file that crashed + final String file; + + /// Line number of the crash + final int line; + + /// Crash timestamp as reported by the watch + final String crashTime; + + /// Firmware version at time of crash + final String fwVersion; + + /// Firmware commit SHA at time of crash + final String fwCommitSha; + + /// Board identifier + final String board; + + /// Build type (debug/release) + final String buildType; + + /// When this crash was first received by the app + final DateTime receivedAt; + + /// Whether analysis has been performed + final bool analyzed; + + /// Decoded backtrace from server (null if not analyzed) + final String? backtrace; + + /// Decoded registers from server (null if not analyzed) + final String? registers; + + /// Raw GDB output from server (null if not analyzed) + final String? rawOutput; + + /// Error message if analysis failed + final String? analysisError; + + /// Whether ELF was available for analysis + final bool elfAvailable; + const CrashReportEntity({ + required this.id, + required this.watchId, + required this.file, + required this.line, + required this.crashTime, + required this.fwVersion, + required this.fwCommitSha, + required this.board, + required this.buildType, + required this.receivedAt, + required this.analyzed, + this.backtrace, + this.registers, + this.rawOutput, + this.analysisError, + required this.elfAvailable, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['watch_id'] = Variable(watchId); + map['file'] = Variable(file); + map['line'] = Variable(line); + map['crash_time'] = Variable(crashTime); + map['fw_version'] = Variable(fwVersion); + map['fw_commit_sha'] = Variable(fwCommitSha); + map['board'] = Variable(board); + map['build_type'] = Variable(buildType); + map['received_at'] = Variable(receivedAt); + map['analyzed'] = Variable(analyzed); + if (!nullToAbsent || backtrace != null) { + map['backtrace'] = Variable(backtrace); + } + if (!nullToAbsent || registers != null) { + map['registers'] = Variable(registers); + } + if (!nullToAbsent || rawOutput != null) { + map['raw_output'] = Variable(rawOutput); + } + if (!nullToAbsent || analysisError != null) { + map['analysis_error'] = Variable(analysisError); + } + map['elf_available'] = Variable(elfAvailable); + return map; + } + + CrashReportsCompanion toCompanion(bool nullToAbsent) { + return CrashReportsCompanion( + id: Value(id), + watchId: Value(watchId), + file: Value(file), + line: Value(line), + crashTime: Value(crashTime), + fwVersion: Value(fwVersion), + fwCommitSha: Value(fwCommitSha), + board: Value(board), + buildType: Value(buildType), + receivedAt: Value(receivedAt), + analyzed: Value(analyzed), + backtrace: backtrace == null && nullToAbsent + ? const Value.absent() + : Value(backtrace), + registers: registers == null && nullToAbsent + ? const Value.absent() + : Value(registers), + rawOutput: rawOutput == null && nullToAbsent + ? const Value.absent() + : Value(rawOutput), + analysisError: analysisError == null && nullToAbsent + ? const Value.absent() + : Value(analysisError), + elfAvailable: Value(elfAvailable), + ); + } + + factory CrashReportEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CrashReportEntity( + id: serializer.fromJson(json['id']), + watchId: serializer.fromJson(json['watchId']), + file: serializer.fromJson(json['file']), + line: serializer.fromJson(json['line']), + crashTime: serializer.fromJson(json['crashTime']), + fwVersion: serializer.fromJson(json['fwVersion']), + fwCommitSha: serializer.fromJson(json['fwCommitSha']), + board: serializer.fromJson(json['board']), + buildType: serializer.fromJson(json['buildType']), + receivedAt: serializer.fromJson(json['receivedAt']), + analyzed: serializer.fromJson(json['analyzed']), + backtrace: serializer.fromJson(json['backtrace']), + registers: serializer.fromJson(json['registers']), + rawOutput: serializer.fromJson(json['rawOutput']), + analysisError: serializer.fromJson(json['analysisError']), + elfAvailable: serializer.fromJson(json['elfAvailable']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'watchId': serializer.toJson(watchId), + 'file': serializer.toJson(file), + 'line': serializer.toJson(line), + 'crashTime': serializer.toJson(crashTime), + 'fwVersion': serializer.toJson(fwVersion), + 'fwCommitSha': serializer.toJson(fwCommitSha), + 'board': serializer.toJson(board), + 'buildType': serializer.toJson(buildType), + 'receivedAt': serializer.toJson(receivedAt), + 'analyzed': serializer.toJson(analyzed), + 'backtrace': serializer.toJson(backtrace), + 'registers': serializer.toJson(registers), + 'rawOutput': serializer.toJson(rawOutput), + 'analysisError': serializer.toJson(analysisError), + 'elfAvailable': serializer.toJson(elfAvailable), + }; + } + + CrashReportEntity copyWith({ + int? id, + String? watchId, + String? file, + int? line, + String? crashTime, + String? fwVersion, + String? fwCommitSha, + String? board, + String? buildType, + DateTime? receivedAt, + bool? analyzed, + Value backtrace = const Value.absent(), + Value registers = const Value.absent(), + Value rawOutput = const Value.absent(), + Value analysisError = const Value.absent(), + bool? elfAvailable, + }) => CrashReportEntity( + id: id ?? this.id, + watchId: watchId ?? this.watchId, + file: file ?? this.file, + line: line ?? this.line, + crashTime: crashTime ?? this.crashTime, + fwVersion: fwVersion ?? this.fwVersion, + fwCommitSha: fwCommitSha ?? this.fwCommitSha, + board: board ?? this.board, + buildType: buildType ?? this.buildType, + receivedAt: receivedAt ?? this.receivedAt, + analyzed: analyzed ?? this.analyzed, + backtrace: backtrace.present ? backtrace.value : this.backtrace, + registers: registers.present ? registers.value : this.registers, + rawOutput: rawOutput.present ? rawOutput.value : this.rawOutput, + analysisError: analysisError.present + ? analysisError.value + : this.analysisError, + elfAvailable: elfAvailable ?? this.elfAvailable, + ); + CrashReportEntity copyWithCompanion(CrashReportsCompanion data) { + return CrashReportEntity( + id: data.id.present ? data.id.value : this.id, + watchId: data.watchId.present ? data.watchId.value : this.watchId, + file: data.file.present ? data.file.value : this.file, + line: data.line.present ? data.line.value : this.line, + crashTime: data.crashTime.present ? data.crashTime.value : this.crashTime, + fwVersion: data.fwVersion.present ? data.fwVersion.value : this.fwVersion, + fwCommitSha: data.fwCommitSha.present + ? data.fwCommitSha.value + : this.fwCommitSha, + board: data.board.present ? data.board.value : this.board, + buildType: data.buildType.present ? data.buildType.value : this.buildType, + receivedAt: data.receivedAt.present + ? data.receivedAt.value + : this.receivedAt, + analyzed: data.analyzed.present ? data.analyzed.value : this.analyzed, + backtrace: data.backtrace.present ? data.backtrace.value : this.backtrace, + registers: data.registers.present ? data.registers.value : this.registers, + rawOutput: data.rawOutput.present ? data.rawOutput.value : this.rawOutput, + analysisError: data.analysisError.present + ? data.analysisError.value + : this.analysisError, + elfAvailable: data.elfAvailable.present + ? data.elfAvailable.value + : this.elfAvailable, + ); + } + + @override + String toString() { + return (StringBuffer('CrashReportEntity(') + ..write('id: $id, ') + ..write('watchId: $watchId, ') + ..write('file: $file, ') + ..write('line: $line, ') + ..write('crashTime: $crashTime, ') + ..write('fwVersion: $fwVersion, ') + ..write('fwCommitSha: $fwCommitSha, ') + ..write('board: $board, ') + ..write('buildType: $buildType, ') + ..write('receivedAt: $receivedAt, ') + ..write('analyzed: $analyzed, ') + ..write('backtrace: $backtrace, ') + ..write('registers: $registers, ') + ..write('rawOutput: $rawOutput, ') + ..write('analysisError: $analysisError, ') + ..write('elfAvailable: $elfAvailable') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + watchId, + file, + line, + crashTime, + fwVersion, + fwCommitSha, + board, + buildType, + receivedAt, + analyzed, + backtrace, + registers, + rawOutput, + analysisError, + elfAvailable, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CrashReportEntity && + other.id == this.id && + other.watchId == this.watchId && + other.file == this.file && + other.line == this.line && + other.crashTime == this.crashTime && + other.fwVersion == this.fwVersion && + other.fwCommitSha == this.fwCommitSha && + other.board == this.board && + other.buildType == this.buildType && + other.receivedAt == this.receivedAt && + other.analyzed == this.analyzed && + other.backtrace == this.backtrace && + other.registers == this.registers && + other.rawOutput == this.rawOutput && + other.analysisError == this.analysisError && + other.elfAvailable == this.elfAvailable); +} + +class CrashReportsCompanion extends UpdateCompanion { + final Value id; + final Value watchId; + final Value file; + final Value line; + final Value crashTime; + final Value fwVersion; + final Value fwCommitSha; + final Value board; + final Value buildType; + final Value receivedAt; + final Value analyzed; + final Value backtrace; + final Value registers; + final Value rawOutput; + final Value analysisError; + final Value elfAvailable; + const CrashReportsCompanion({ + this.id = const Value.absent(), + this.watchId = const Value.absent(), + this.file = const Value.absent(), + this.line = const Value.absent(), + this.crashTime = const Value.absent(), + this.fwVersion = const Value.absent(), + this.fwCommitSha = const Value.absent(), + this.board = const Value.absent(), + this.buildType = const Value.absent(), + this.receivedAt = const Value.absent(), + this.analyzed = const Value.absent(), + this.backtrace = const Value.absent(), + this.registers = const Value.absent(), + this.rawOutput = const Value.absent(), + this.analysisError = const Value.absent(), + this.elfAvailable = const Value.absent(), + }); + CrashReportsCompanion.insert({ + this.id = const Value.absent(), + required String watchId, + required String file, + required int line, + required String crashTime, + required String fwVersion, + required String fwCommitSha, + required String board, + required String buildType, + required DateTime receivedAt, + this.analyzed = const Value.absent(), + this.backtrace = const Value.absent(), + this.registers = const Value.absent(), + this.rawOutput = const Value.absent(), + this.analysisError = const Value.absent(), + this.elfAvailable = const Value.absent(), + }) : watchId = Value(watchId), + file = Value(file), + line = Value(line), + crashTime = Value(crashTime), + fwVersion = Value(fwVersion), + fwCommitSha = Value(fwCommitSha), + board = Value(board), + buildType = Value(buildType), + receivedAt = Value(receivedAt); + static Insertable custom({ + Expression? id, + Expression? watchId, + Expression? file, + Expression? line, + Expression? crashTime, + Expression? fwVersion, + Expression? fwCommitSha, + Expression? board, + Expression? buildType, + Expression? receivedAt, + Expression? analyzed, + Expression? backtrace, + Expression? registers, + Expression? rawOutput, + Expression? analysisError, + Expression? elfAvailable, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (watchId != null) 'watch_id': watchId, + if (file != null) 'file': file, + if (line != null) 'line': line, + if (crashTime != null) 'crash_time': crashTime, + if (fwVersion != null) 'fw_version': fwVersion, + if (fwCommitSha != null) 'fw_commit_sha': fwCommitSha, + if (board != null) 'board': board, + if (buildType != null) 'build_type': buildType, + if (receivedAt != null) 'received_at': receivedAt, + if (analyzed != null) 'analyzed': analyzed, + if (backtrace != null) 'backtrace': backtrace, + if (registers != null) 'registers': registers, + if (rawOutput != null) 'raw_output': rawOutput, + if (analysisError != null) 'analysis_error': analysisError, + if (elfAvailable != null) 'elf_available': elfAvailable, + }); + } + + CrashReportsCompanion copyWith({ + Value? id, + Value? watchId, + Value? file, + Value? line, + Value? crashTime, + Value? fwVersion, + Value? fwCommitSha, + Value? board, + Value? buildType, + Value? receivedAt, + Value? analyzed, + Value? backtrace, + Value? registers, + Value? rawOutput, + Value? analysisError, + Value? elfAvailable, + }) { + return CrashReportsCompanion( + id: id ?? this.id, + watchId: watchId ?? this.watchId, + file: file ?? this.file, + line: line ?? this.line, + crashTime: crashTime ?? this.crashTime, + fwVersion: fwVersion ?? this.fwVersion, + fwCommitSha: fwCommitSha ?? this.fwCommitSha, + board: board ?? this.board, + buildType: buildType ?? this.buildType, + receivedAt: receivedAt ?? this.receivedAt, + analyzed: analyzed ?? this.analyzed, + backtrace: backtrace ?? this.backtrace, + registers: registers ?? this.registers, + rawOutput: rawOutput ?? this.rawOutput, + analysisError: analysisError ?? this.analysisError, + elfAvailable: elfAvailable ?? this.elfAvailable, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (watchId.present) { + map['watch_id'] = Variable(watchId.value); + } + if (file.present) { + map['file'] = Variable(file.value); + } + if (line.present) { + map['line'] = Variable(line.value); + } + if (crashTime.present) { + map['crash_time'] = Variable(crashTime.value); + } + if (fwVersion.present) { + map['fw_version'] = Variable(fwVersion.value); + } + if (fwCommitSha.present) { + map['fw_commit_sha'] = Variable(fwCommitSha.value); + } + if (board.present) { + map['board'] = Variable(board.value); + } + if (buildType.present) { + map['build_type'] = Variable(buildType.value); + } + if (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); + } + if (analyzed.present) { + map['analyzed'] = Variable(analyzed.value); + } + if (backtrace.present) { + map['backtrace'] = Variable(backtrace.value); + } + if (registers.present) { + map['registers'] = Variable(registers.value); + } + if (rawOutput.present) { + map['raw_output'] = Variable(rawOutput.value); + } + if (analysisError.present) { + map['analysis_error'] = Variable(analysisError.value); + } + if (elfAvailable.present) { + map['elf_available'] = Variable(elfAvailable.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CrashReportsCompanion(') + ..write('id: $id, ') + ..write('watchId: $watchId, ') + ..write('file: $file, ') + ..write('line: $line, ') + ..write('crashTime: $crashTime, ') + ..write('fwVersion: $fwVersion, ') + ..write('fwCommitSha: $fwCommitSha, ') + ..write('board: $board, ') + ..write('buildType: $buildType, ') + ..write('receivedAt: $receivedAt, ') + ..write('analyzed: $analyzed, ') + ..write('backtrace: $backtrace, ') + ..write('registers: $registers, ') + ..write('rawOutput: $rawOutput, ') + ..write('analysisError: $analysisError, ') + ..write('elfAvailable: $elfAvailable') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $WatchesTable watches = $WatchesTable(this); + late final $HealthSamplesTable healthSamples = $HealthSamplesTable(this); + late final $BatteryReadingsTable batteryReadings = $BatteryReadingsTable( + this, + ); + late final $CommLogEntriesTable commLogEntries = $CommLogEntriesTable(this); + late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable( + this, + ); + late final $VoiceMemosTable voiceMemos = $VoiceMemosTable(this); + late final $ExtractedActionsTable extractedActions = $ExtractedActionsTable( + this, + ); + late final $CrashReportsTable crashReports = $CrashReportsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + watches, + healthSamples, + batteryReadings, + commLogEntries, + connectionEvents, + voiceMemos, + extractedActions, + crashReports, + ]; +} + +typedef $$WatchesTableCreateCompanionBuilder = + WatchesCompanion Function({ + required String id, + required String name, + Value customName, + Value firmwareVersion, + Value hardwareVersion, + Value batteryLevel, + Value isPrimary, + Value supportsExtendedApi, + Value lastConnectedAt, + required DateTime createdAt, + Value rowid, + }); +typedef $$WatchesTableUpdateCompanionBuilder = + WatchesCompanion Function({ + Value id, + Value name, + Value customName, + Value firmwareVersion, + Value hardwareVersion, + Value batteryLevel, + Value isPrimary, + Value supportsExtendedApi, + Value lastConnectedAt, + Value createdAt, + Value rowid, + }); + +final class $$WatchesTableReferences + extends BaseReferences<_$AppDatabase, $WatchesTable, WatchEntity> { + $$WatchesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$HealthSamplesTable, List> + _healthSamplesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.healthSamples, + aliasName: $_aliasNameGenerator(db.watches.id, db.healthSamples.watchId), + ); + + $$HealthSamplesTableProcessedTableManager get healthSamplesRefs { + final manager = $$HealthSamplesTableTableManager( + $_db, + $_db.healthSamples, + ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_healthSamplesRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$BatteryReadingsTable, List> + _batteryReadingsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.batteryReadings, + aliasName: $_aliasNameGenerator(db.watches.id, db.batteryReadings.watchId), + ); + + $$BatteryReadingsTableProcessedTableManager get batteryReadingsRefs { + final manager = $$BatteryReadingsTableTableManager( + $_db, + $_db.batteryReadings, + ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _batteryReadingsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $ConnectionEventsTable, + List + > + _connectionEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.connectionEvents, + aliasName: $_aliasNameGenerator(db.watches.id, db.connectionEvents.watchId), + ); + + $$ConnectionEventsTableProcessedTableManager get connectionEventsRefs { + final manager = $$ConnectionEventsTableTableManager( + $_db, + $_db.connectionEvents, + ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _connectionEventsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$CrashReportsTable, List> + _crashReportsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.crashReports, + aliasName: $_aliasNameGenerator(db.watches.id, db.crashReports.watchId), + ); + + $$CrashReportsTableProcessedTableManager get crashReportsRefs { + final manager = $$CrashReportsTableTableManager( + $_db, + $_db.crashReports, + ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_crashReportsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$WatchesTableFilterComposer + extends Composer<_$AppDatabase, $WatchesTable> { + $$WatchesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get customName => $composableBuilder( + column: $table.customName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get firmwareVersion => $composableBuilder( + column: $table.firmwareVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hardwareVersion => $composableBuilder( + column: $table.hardwareVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get batteryLevel => $composableBuilder( + column: $table.batteryLevel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isPrimary => $composableBuilder( + column: $table.isPrimary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get supportsExtendedApi => $composableBuilder( + column: $table.supportsExtendedApi, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + Expression healthSamplesRefs( + Expression Function($$HealthSamplesTableFilterComposer f) f, + ) { + final $$HealthSamplesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.healthSamples, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$HealthSamplesTableFilterComposer( + $db: $db, + $table: $db.healthSamples, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression batteryReadingsRefs( + Expression Function($$BatteryReadingsTableFilterComposer f) f, + ) { + final $$BatteryReadingsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.batteryReadings, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$BatteryReadingsTableFilterComposer( + $db: $db, + $table: $db.batteryReadings, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression connectionEventsRefs( + Expression Function($$ConnectionEventsTableFilterComposer f) f, + ) { + final $$ConnectionEventsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.connectionEvents, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ConnectionEventsTableFilterComposer( + $db: $db, + $table: $db.connectionEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression crashReportsRefs( + Expression Function($$CrashReportsTableFilterComposer f) f, + ) { + final $$CrashReportsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.crashReports, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CrashReportsTableFilterComposer( + $db: $db, + $table: $db.crashReports, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$WatchesTableOrderingComposer + extends Composer<_$AppDatabase, $WatchesTable> { + $$WatchesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get customName => $composableBuilder( + column: $table.customName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get firmwareVersion => $composableBuilder( + column: $table.firmwareVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hardwareVersion => $composableBuilder( + column: $table.hardwareVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get batteryLevel => $composableBuilder( + column: $table.batteryLevel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isPrimary => $composableBuilder( + column: $table.isPrimary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get supportsExtendedApi => $composableBuilder( + column: $table.supportsExtendedApi, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$WatchesTableAnnotationComposer + extends Composer<_$AppDatabase, $WatchesTable> { + $$WatchesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get customName => $composableBuilder( + column: $table.customName, + builder: (column) => column, + ); + + GeneratedColumn get firmwareVersion => $composableBuilder( + column: $table.firmwareVersion, + builder: (column) => column, + ); + + GeneratedColumn get hardwareVersion => $composableBuilder( + column: $table.hardwareVersion, + builder: (column) => column, + ); + + GeneratedColumn get batteryLevel => $composableBuilder( + column: $table.batteryLevel, + builder: (column) => column, + ); + + GeneratedColumn get isPrimary => + $composableBuilder(column: $table.isPrimary, builder: (column) => column); + + GeneratedColumn get supportsExtendedApi => $composableBuilder( + column: $table.supportsExtendedApi, + builder: (column) => column, + ); + + GeneratedColumn get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + Expression healthSamplesRefs( + Expression Function($$HealthSamplesTableAnnotationComposer a) f, + ) { + final $$HealthSamplesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.healthSamples, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$HealthSamplesTableAnnotationComposer( + $db: $db, + $table: $db.healthSamples, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression batteryReadingsRefs( + Expression Function($$BatteryReadingsTableAnnotationComposer a) f, + ) { + final $$BatteryReadingsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.batteryReadings, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$BatteryReadingsTableAnnotationComposer( + $db: $db, + $table: $db.batteryReadings, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression connectionEventsRefs( + Expression Function($$ConnectionEventsTableAnnotationComposer a) f, + ) { + final $$ConnectionEventsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.connectionEvents, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ConnectionEventsTableAnnotationComposer( + $db: $db, + $table: $db.connectionEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression crashReportsRefs( + Expression Function($$CrashReportsTableAnnotationComposer a) f, + ) { + final $$CrashReportsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.crashReports, + getReferencedColumn: (t) => t.watchId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CrashReportsTableAnnotationComposer( + $db: $db, + $table: $db.crashReports, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$WatchesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $WatchesTable, + WatchEntity, + $$WatchesTableFilterComposer, + $$WatchesTableOrderingComposer, + $$WatchesTableAnnotationComposer, + $$WatchesTableCreateCompanionBuilder, + $$WatchesTableUpdateCompanionBuilder, + (WatchEntity, $$WatchesTableReferences), + WatchEntity, + PrefetchHooks Function({ + bool healthSamplesRefs, + bool batteryReadingsRefs, + bool connectionEventsRefs, + bool crashReportsRefs, + }) + > { + $$WatchesTableTableManager(_$AppDatabase db, $WatchesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WatchesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$WatchesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$WatchesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value customName = const Value.absent(), + Value firmwareVersion = const Value.absent(), + Value hardwareVersion = const Value.absent(), + Value batteryLevel = const Value.absent(), + Value isPrimary = const Value.absent(), + Value supportsExtendedApi = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => WatchesCompanion( + id: id, + name: name, + customName: customName, + firmwareVersion: firmwareVersion, + hardwareVersion: hardwareVersion, + batteryLevel: batteryLevel, + isPrimary: isPrimary, + supportsExtendedApi: supportsExtendedApi, + lastConnectedAt: lastConnectedAt, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + Value customName = const Value.absent(), + Value firmwareVersion = const Value.absent(), + Value hardwareVersion = const Value.absent(), + Value batteryLevel = const Value.absent(), + Value isPrimary = const Value.absent(), + Value supportsExtendedApi = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => WatchesCompanion.insert( + id: id, + name: name, + customName: customName, + firmwareVersion: firmwareVersion, + hardwareVersion: hardwareVersion, + batteryLevel: batteryLevel, + isPrimary: isPrimary, + supportsExtendedApi: supportsExtendedApi, + lastConnectedAt: lastConnectedAt, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$WatchesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({ + healthSamplesRefs = false, + batteryReadingsRefs = false, + connectionEventsRefs = false, + crashReportsRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (healthSamplesRefs) db.healthSamples, + if (batteryReadingsRefs) db.batteryReadings, + if (connectionEventsRefs) db.connectionEvents, + if (crashReportsRefs) db.crashReports, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (healthSamplesRefs) + await $_getPrefetchedData< + WatchEntity, + $WatchesTable, + HealthSampleEntity + >( + currentTable: table, + referencedTable: $$WatchesTableReferences + ._healthSamplesRefsTable(db), + managerFromTypedResult: (p0) => + $$WatchesTableReferences( + db, + table, + p0, + ).healthSamplesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.watchId == item.id, + ), + typedResults: items, + ), + if (batteryReadingsRefs) + await $_getPrefetchedData< + WatchEntity, + $WatchesTable, + BatteryReadingEntity + >( + currentTable: table, + referencedTable: $$WatchesTableReferences + ._batteryReadingsRefsTable(db), + managerFromTypedResult: (p0) => + $$WatchesTableReferences( + db, + table, + p0, + ).batteryReadingsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.watchId == item.id, + ), + typedResults: items, + ), + if (connectionEventsRefs) + await $_getPrefetchedData< + WatchEntity, + $WatchesTable, + ConnectionEventEntity + >( + currentTable: table, + referencedTable: $$WatchesTableReferences + ._connectionEventsRefsTable(db), + managerFromTypedResult: (p0) => + $$WatchesTableReferences( + db, + table, + p0, + ).connectionEventsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.watchId == item.id, + ), + typedResults: items, + ), + if (crashReportsRefs) + await $_getPrefetchedData< + WatchEntity, + $WatchesTable, + CrashReportEntity + >( + currentTable: table, + referencedTable: $$WatchesTableReferences + ._crashReportsRefsTable(db), + managerFromTypedResult: (p0) => + $$WatchesTableReferences( + db, + table, + p0, + ).crashReportsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.watchId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$WatchesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $WatchesTable, + WatchEntity, + $$WatchesTableFilterComposer, + $$WatchesTableOrderingComposer, + $$WatchesTableAnnotationComposer, + $$WatchesTableCreateCompanionBuilder, + $$WatchesTableUpdateCompanionBuilder, + (WatchEntity, $$WatchesTableReferences), + WatchEntity, + PrefetchHooks Function({ + bool healthSamplesRefs, + bool batteryReadingsRefs, + bool connectionEventsRefs, + bool crashReportsRefs, + }) + >; +typedef $$HealthSamplesTableCreateCompanionBuilder = + HealthSamplesCompanion Function({ + Value id, + required String watchId, + required String type, + required double value, + required DateTime timestamp, + required String granularity, + required DateTime syncedAt, + }); +typedef $$HealthSamplesTableUpdateCompanionBuilder = + HealthSamplesCompanion Function({ + Value id, + Value watchId, + Value type, + Value value, + Value timestamp, + Value granularity, + Value syncedAt, + }); + +final class $$HealthSamplesTableReferences + extends + BaseReferences<_$AppDatabase, $HealthSamplesTable, HealthSampleEntity> { + $$HealthSamplesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $WatchesTable _watchIdTable(_$AppDatabase db) => + db.watches.createAlias( + $_aliasNameGenerator(db.healthSamples.watchId, db.watches.id), + ); + + $$WatchesTableProcessedTableManager get watchId { + final $_column = $_itemColumn('watch_id')!; + + final manager = $$WatchesTableTableManager( + $_db, + $_db.watches, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_watchIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$HealthSamplesTableFilterComposer + extends Composer<_$AppDatabase, $HealthSamplesTable> { + $$HealthSamplesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get granularity => $composableBuilder( + column: $table.granularity, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncedAt => $composableBuilder( + column: $table.syncedAt, + builder: (column) => ColumnFilters(column), + ); + + $$WatchesTableFilterComposer get watchId { + final $$WatchesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableFilterComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$HealthSamplesTableOrderingComposer + extends Composer<_$AppDatabase, $HealthSamplesTable> { + $$HealthSamplesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get granularity => $composableBuilder( + column: $table.granularity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncedAt => $composableBuilder( + column: $table.syncedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$WatchesTableOrderingComposer get watchId { + final $$WatchesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableOrderingComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$HealthSamplesTableAnnotationComposer + extends Composer<_$AppDatabase, $HealthSamplesTable> { + $$HealthSamplesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get granularity => $composableBuilder( + column: $table.granularity, + builder: (column) => column, + ); + + GeneratedColumn get syncedAt => + $composableBuilder(column: $table.syncedAt, builder: (column) => column); + + $$WatchesTableAnnotationComposer get watchId { + final $$WatchesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableAnnotationComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$HealthSamplesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $HealthSamplesTable, + HealthSampleEntity, + $$HealthSamplesTableFilterComposer, + $$HealthSamplesTableOrderingComposer, + $$HealthSamplesTableAnnotationComposer, + $$HealthSamplesTableCreateCompanionBuilder, + $$HealthSamplesTableUpdateCompanionBuilder, + (HealthSampleEntity, $$HealthSamplesTableReferences), + HealthSampleEntity, + PrefetchHooks Function({bool watchId}) + > { + $$HealthSamplesTableTableManager(_$AppDatabase db, $HealthSamplesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$HealthSamplesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$HealthSamplesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$HealthSamplesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value watchId = const Value.absent(), + Value type = const Value.absent(), + Value value = const Value.absent(), + Value timestamp = const Value.absent(), + Value granularity = const Value.absent(), + Value syncedAt = const Value.absent(), + }) => HealthSamplesCompanion( + id: id, + watchId: watchId, + type: type, + value: value, + timestamp: timestamp, + granularity: granularity, + syncedAt: syncedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String watchId, + required String type, + required double value, + required DateTime timestamp, + required String granularity, + required DateTime syncedAt, + }) => HealthSamplesCompanion.insert( + id: id, + watchId: watchId, + type: type, + value: value, + timestamp: timestamp, + granularity: granularity, + syncedAt: syncedAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$HealthSamplesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({watchId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (watchId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.watchId, + referencedTable: $$HealthSamplesTableReferences + ._watchIdTable(db), + referencedColumn: $$HealthSamplesTableReferences + ._watchIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$HealthSamplesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $HealthSamplesTable, + HealthSampleEntity, + $$HealthSamplesTableFilterComposer, + $$HealthSamplesTableOrderingComposer, + $$HealthSamplesTableAnnotationComposer, + $$HealthSamplesTableCreateCompanionBuilder, + $$HealthSamplesTableUpdateCompanionBuilder, + (HealthSampleEntity, $$HealthSamplesTableReferences), + HealthSampleEntity, + PrefetchHooks Function({bool watchId}) + >; +typedef $$BatteryReadingsTableCreateCompanionBuilder = + BatteryReadingsCompanion Function({ + Value id, + required String watchId, + required int level, + Value isCharging, + required DateTime timestamp, + }); +typedef $$BatteryReadingsTableUpdateCompanionBuilder = + BatteryReadingsCompanion Function({ + Value id, + Value watchId, + Value level, + Value isCharging, + Value timestamp, + }); + +final class $$BatteryReadingsTableReferences + extends + BaseReferences< + _$AppDatabase, + $BatteryReadingsTable, + BatteryReadingEntity + > { + $$BatteryReadingsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $WatchesTable _watchIdTable(_$AppDatabase db) => + db.watches.createAlias( + $_aliasNameGenerator(db.batteryReadings.watchId, db.watches.id), + ); + + $$WatchesTableProcessedTableManager get watchId { + final $_column = $_itemColumn('watch_id')!; + + final manager = $$WatchesTableTableManager( + $_db, + $_db.watches, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_watchIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$BatteryReadingsTableFilterComposer + extends Composer<_$AppDatabase, $BatteryReadingsTable> { + $$BatteryReadingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get level => $composableBuilder( + column: $table.level, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isCharging => $composableBuilder( + column: $table.isCharging, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); + + $$WatchesTableFilterComposer get watchId { + final $$WatchesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableFilterComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$BatteryReadingsTableOrderingComposer + extends Composer<_$AppDatabase, $BatteryReadingsTable> { + $$BatteryReadingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); -final class $$WatchesTableReferences - extends BaseReferences<_$AppDatabase, $WatchesTable, WatchEntity> { - $$WatchesTableReferences(super.$_db, super.$_table, super.$_typedResult); + ColumnOrderings get level => $composableBuilder( + column: $table.level, + builder: (column) => ColumnOrderings(column), + ); - static MultiTypedResultKey<$HealthSamplesTable, List> - _healthSamplesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.healthSamples, - aliasName: $_aliasNameGenerator(db.watches.id, db.healthSamples.watchId), + ColumnOrderings get isCharging => $composableBuilder( + column: $table.isCharging, + builder: (column) => ColumnOrderings(column), ); - $$HealthSamplesTableProcessedTableManager get healthSamplesRefs { - final manager = $$HealthSamplesTableTableManager( - $_db, - $_db.healthSamples, - ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); - final cache = $_typedResult.readTableOrNull(_healthSamplesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + $$WatchesTableOrderingComposer get watchId { + final $$WatchesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableOrderingComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } +} - static MultiTypedResultKey<$BatteryReadingsTable, List> - _batteryReadingsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.batteryReadings, - aliasName: $_aliasNameGenerator(db.watches.id, db.batteryReadings.watchId), - ); - - $$BatteryReadingsTableProcessedTableManager get batteryReadingsRefs { - final manager = $$BatteryReadingsTableTableManager( - $_db, - $_db.batteryReadings, - ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); +class $$BatteryReadingsTableAnnotationComposer + extends Composer<_$AppDatabase, $BatteryReadingsTable> { + $$BatteryReadingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - final cache = $_typedResult.readTableOrNull( - _batteryReadingsRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } + GeneratedColumn get level => + $composableBuilder(column: $table.level, builder: (column) => column); - static MultiTypedResultKey< - $ConnectionEventsTable, - List - > - _connectionEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.connectionEvents, - aliasName: $_aliasNameGenerator(db.watches.id, db.connectionEvents.watchId), + GeneratedColumn get isCharging => $composableBuilder( + column: $table.isCharging, + builder: (column) => column, ); - $$ConnectionEventsTableProcessedTableManager get connectionEventsRefs { - final manager = $$ConnectionEventsTableTableManager( - $_db, - $_db.connectionEvents, - ).filter((f) => f.watchId.id.sqlEquals($_itemColumn('id')!)); + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); - final cache = $_typedResult.readTableOrNull( - _connectionEventsRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + $$WatchesTableAnnotationComposer get watchId { + final $$WatchesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.watchId, + referencedTable: $db.watches, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WatchesTableAnnotationComposer( + $db: $db, + $table: $db.watches, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } } -class $$WatchesTableFilterComposer - extends Composer<_$AppDatabase, $WatchesTable> { - $$WatchesTableFilterComposer({ +class $$BatteryReadingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $BatteryReadingsTable, + BatteryReadingEntity, + $$BatteryReadingsTableFilterComposer, + $$BatteryReadingsTableOrderingComposer, + $$BatteryReadingsTableAnnotationComposer, + $$BatteryReadingsTableCreateCompanionBuilder, + $$BatteryReadingsTableUpdateCompanionBuilder, + (BatteryReadingEntity, $$BatteryReadingsTableReferences), + BatteryReadingEntity, + PrefetchHooks Function({bool watchId}) + > { + $$BatteryReadingsTableTableManager( + _$AppDatabase db, + $BatteryReadingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$BatteryReadingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BatteryReadingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BatteryReadingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value watchId = const Value.absent(), + Value level = const Value.absent(), + Value isCharging = const Value.absent(), + Value timestamp = const Value.absent(), + }) => BatteryReadingsCompanion( + id: id, + watchId: watchId, + level: level, + isCharging: isCharging, + timestamp: timestamp, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String watchId, + required int level, + Value isCharging = const Value.absent(), + required DateTime timestamp, + }) => BatteryReadingsCompanion.insert( + id: id, + watchId: watchId, + level: level, + isCharging: isCharging, + timestamp: timestamp, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$BatteryReadingsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({watchId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (watchId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.watchId, + referencedTable: + $$BatteryReadingsTableReferences + ._watchIdTable(db), + referencedColumn: + $$BatteryReadingsTableReferences + ._watchIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$BatteryReadingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $BatteryReadingsTable, + BatteryReadingEntity, + $$BatteryReadingsTableFilterComposer, + $$BatteryReadingsTableOrderingComposer, + $$BatteryReadingsTableAnnotationComposer, + $$BatteryReadingsTableCreateCompanionBuilder, + $$BatteryReadingsTableUpdateCompanionBuilder, + (BatteryReadingEntity, $$BatteryReadingsTableReferences), + BatteryReadingEntity, + PrefetchHooks Function({bool watchId}) + >; +typedef $$CommLogEntriesTableCreateCompanionBuilder = + CommLogEntriesCompanion Function({ + Value id, + required String direction, + required String protocol, + Value characteristic, + required String payload, + required int payloadSize, + required DateTime timestamp, + }); +typedef $$CommLogEntriesTableUpdateCompanionBuilder = + CommLogEntriesCompanion Function({ + Value id, + Value direction, + Value protocol, + Value characteristic, + Value payload, + Value payloadSize, + Value timestamp, + }); + +class $$CommLogEntriesTableFilterComposer + extends Composer<_$AppDatabase, $CommLogEntriesTable> { + $$CommLogEntriesTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( + ColumnFilters get id => $composableBuilder( column: $table.id, builder: (column) => ColumnFilters(column), ); - ColumnFilters get name => $composableBuilder( - column: $table.name, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get customName => $composableBuilder( - column: $table.customName, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get firmwareVersion => $composableBuilder( - column: $table.firmwareVersion, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get hardwareVersion => $composableBuilder( - column: $table.hardwareVersion, + ColumnFilters get direction => $composableBuilder( + column: $table.direction, builder: (column) => ColumnFilters(column), ); - ColumnFilters get batteryLevel => $composableBuilder( - column: $table.batteryLevel, + ColumnFilters get protocol => $composableBuilder( + column: $table.protocol, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isPrimary => $composableBuilder( - column: $table.isPrimary, + ColumnFilters get characteristic => $composableBuilder( + column: $table.characteristic, builder: (column) => ColumnFilters(column), ); - ColumnFilters get supportsExtendedApi => $composableBuilder( - column: $table.supportsExtendedApi, + ColumnFilters get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastConnectedAt => $composableBuilder( - column: $table.lastConnectedAt, + ColumnFilters get payloadSize => $composableBuilder( + column: $table.payloadSize, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnFilters(column), ); - - Expression healthSamplesRefs( - Expression Function($$HealthSamplesTableFilterComposer f) f, - ) { - final $$HealthSamplesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.healthSamples, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$HealthSamplesTableFilterComposer( - $db: $db, - $table: $db.healthSamples, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression batteryReadingsRefs( - Expression Function($$BatteryReadingsTableFilterComposer f) f, - ) { - final $$BatteryReadingsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.batteryReadings, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$BatteryReadingsTableFilterComposer( - $db: $db, - $table: $db.batteryReadings, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression connectionEventsRefs( - Expression Function($$ConnectionEventsTableFilterComposer f) f, - ) { - final $$ConnectionEventsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.connectionEvents, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$ConnectionEventsTableFilterComposer( - $db: $db, - $table: $db.connectionEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$WatchesTableOrderingComposer - extends Composer<_$AppDatabase, $WatchesTable> { - $$WatchesTableOrderingComposer({ +class $$CommLogEntriesTableOrderingComposer + extends Composer<_$AppDatabase, $CommLogEntriesTable> { + $$CommLogEntriesTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( + ColumnOrderings get id => $composableBuilder( column: $table.id, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get name => $composableBuilder( - column: $table.name, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get customName => $composableBuilder( - column: $table.customName, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get firmwareVersion => $composableBuilder( - column: $table.firmwareVersion, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get hardwareVersion => $composableBuilder( - column: $table.hardwareVersion, + ColumnOrderings get direction => $composableBuilder( + column: $table.direction, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get batteryLevel => $composableBuilder( - column: $table.batteryLevel, + ColumnOrderings get protocol => $composableBuilder( + column: $table.protocol, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isPrimary => $composableBuilder( - column: $table.isPrimary, + ColumnOrderings get characteristic => $composableBuilder( + column: $table.characteristic, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get supportsExtendedApi => $composableBuilder( - column: $table.supportsExtendedApi, + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastConnectedAt => $composableBuilder( - column: $table.lastConnectedAt, + ColumnOrderings get payloadSize => $composableBuilder( + column: $table.payloadSize, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnOrderings(column), ); } -class $$WatchesTableAnnotationComposer - extends Composer<_$AppDatabase, $WatchesTable> { - $$WatchesTableAnnotationComposer({ +class $$CommLogEntriesTableAnnotationComposer + extends Composer<_$AppDatabase, $CommLogEntriesTable> { + $$CommLogEntriesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get name => - $composableBuilder(column: $table.name, builder: (column) => column); - - GeneratedColumn get customName => $composableBuilder( - column: $table.customName, - builder: (column) => column, - ); - - GeneratedColumn get firmwareVersion => $composableBuilder( - column: $table.firmwareVersion, - builder: (column) => column, - ); - - GeneratedColumn get hardwareVersion => $composableBuilder( - column: $table.hardwareVersion, - builder: (column) => column, - ); - - GeneratedColumn get batteryLevel => $composableBuilder( - column: $table.batteryLevel, - builder: (column) => column, - ); - - GeneratedColumn get isPrimary => - $composableBuilder(column: $table.isPrimary, builder: (column) => column); + GeneratedColumn get direction => + $composableBuilder(column: $table.direction, builder: (column) => column); - GeneratedColumn get supportsExtendedApi => $composableBuilder( - column: $table.supportsExtendedApi, - builder: (column) => column, - ); + GeneratedColumn get protocol => + $composableBuilder(column: $table.protocol, builder: (column) => column); - GeneratedColumn get lastConnectedAt => $composableBuilder( - column: $table.lastConnectedAt, + GeneratedColumn get characteristic => $composableBuilder( + column: $table.characteristic, builder: (column) => column, ); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - Expression healthSamplesRefs( - Expression Function($$HealthSamplesTableAnnotationComposer a) f, - ) { - final $$HealthSamplesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.healthSamples, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$HealthSamplesTableAnnotationComposer( - $db: $db, - $table: $db.healthSamples, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); - Expression batteryReadingsRefs( - Expression Function($$BatteryReadingsTableAnnotationComposer a) f, - ) { - final $$BatteryReadingsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.batteryReadings, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$BatteryReadingsTableAnnotationComposer( - $db: $db, - $table: $db.batteryReadings, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } + GeneratedColumn get payloadSize => $composableBuilder( + column: $table.payloadSize, + builder: (column) => column, + ); - Expression connectionEventsRefs( - Expression Function($$ConnectionEventsTableAnnotationComposer a) f, - ) { - final $$ConnectionEventsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.connectionEvents, - getReferencedColumn: (t) => t.watchId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$ConnectionEventsTableAnnotationComposer( - $db: $db, - $table: $db.connectionEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); } -class $$WatchesTableTableManager +class $$CommLogEntriesTableTableManager extends RootTableManager< _$AppDatabase, - $WatchesTable, - WatchEntity, - $$WatchesTableFilterComposer, - $$WatchesTableOrderingComposer, - $$WatchesTableAnnotationComposer, - $$WatchesTableCreateCompanionBuilder, - $$WatchesTableUpdateCompanionBuilder, - (WatchEntity, $$WatchesTableReferences), - WatchEntity, - PrefetchHooks Function({ - bool healthSamplesRefs, - bool batteryReadingsRefs, - bool connectionEventsRefs, - }) + $CommLogEntriesTable, + CommLogEntryEntity, + $$CommLogEntriesTableFilterComposer, + $$CommLogEntriesTableOrderingComposer, + $$CommLogEntriesTableAnnotationComposer, + $$CommLogEntriesTableCreateCompanionBuilder, + $$CommLogEntriesTableUpdateCompanionBuilder, + ( + CommLogEntryEntity, + BaseReferences< + _$AppDatabase, + $CommLogEntriesTable, + CommLogEntryEntity + >, + ), + CommLogEntryEntity, + PrefetchHooks Function() > { - $$WatchesTableTableManager(_$AppDatabase db, $WatchesTable table) - : super( + $$CommLogEntriesTableTableManager( + _$AppDatabase db, + $CommLogEntriesTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$WatchesTableFilterComposer($db: db, $table: table), + $$CommLogEntriesTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$WatchesTableOrderingComposer($db: db, $table: table), + $$CommLogEntriesTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$WatchesTableAnnotationComposer($db: db, $table: table), + $$CommLogEntriesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value customName = const Value.absent(), - Value firmwareVersion = const Value.absent(), - Value hardwareVersion = const Value.absent(), - Value batteryLevel = const Value.absent(), - Value isPrimary = const Value.absent(), - Value supportsExtendedApi = const Value.absent(), - Value lastConnectedAt = const Value.absent(), - Value createdAt = const Value.absent(), - Value rowid = const Value.absent(), - }) => WatchesCompanion( + Value id = const Value.absent(), + Value direction = const Value.absent(), + Value protocol = const Value.absent(), + Value characteristic = const Value.absent(), + Value payload = const Value.absent(), + Value payloadSize = const Value.absent(), + Value timestamp = const Value.absent(), + }) => CommLogEntriesCompanion( id: id, - name: name, - customName: customName, - firmwareVersion: firmwareVersion, - hardwareVersion: hardwareVersion, - batteryLevel: batteryLevel, - isPrimary: isPrimary, - supportsExtendedApi: supportsExtendedApi, - lastConnectedAt: lastConnectedAt, - createdAt: createdAt, - rowid: rowid, + direction: direction, + protocol: protocol, + characteristic: characteristic, + payload: payload, + payloadSize: payloadSize, + timestamp: timestamp, ), createCompanionCallback: ({ - required String id, - required String name, - Value customName = const Value.absent(), - Value firmwareVersion = const Value.absent(), - Value hardwareVersion = const Value.absent(), - Value batteryLevel = const Value.absent(), - Value isPrimary = const Value.absent(), - Value supportsExtendedApi = const Value.absent(), - Value lastConnectedAt = const Value.absent(), - required DateTime createdAt, - Value rowid = const Value.absent(), - }) => WatchesCompanion.insert( - id: id, - name: name, - customName: customName, - firmwareVersion: firmwareVersion, - hardwareVersion: hardwareVersion, - batteryLevel: batteryLevel, - isPrimary: isPrimary, - supportsExtendedApi: supportsExtendedApi, - lastConnectedAt: lastConnectedAt, - createdAt: createdAt, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map( - (e) => ( - e.readTable(table), - $$WatchesTableReferences(db, table, e), - ), - ) - .toList(), - prefetchHooksCallback: - ({ - healthSamplesRefs = false, - batteryReadingsRefs = false, - connectionEventsRefs = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (healthSamplesRefs) db.healthSamples, - if (batteryReadingsRefs) db.batteryReadings, - if (connectionEventsRefs) db.connectionEvents, - ], - addJoins: null, - getPrefetchedDataCallback: (items) async { - return [ - if (healthSamplesRefs) - await $_getPrefetchedData< - WatchEntity, - $WatchesTable, - HealthSampleEntity - >( - currentTable: table, - referencedTable: $$WatchesTableReferences - ._healthSamplesRefsTable(db), - managerFromTypedResult: (p0) => - $$WatchesTableReferences( - db, - table, - p0, - ).healthSamplesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.watchId == item.id, - ), - typedResults: items, - ), - if (batteryReadingsRefs) - await $_getPrefetchedData< - WatchEntity, - $WatchesTable, - BatteryReadingEntity - >( - currentTable: table, - referencedTable: $$WatchesTableReferences - ._batteryReadingsRefsTable(db), - managerFromTypedResult: (p0) => - $$WatchesTableReferences( - db, - table, - p0, - ).batteryReadingsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.watchId == item.id, - ), - typedResults: items, - ), - if (connectionEventsRefs) - await $_getPrefetchedData< - WatchEntity, - $WatchesTable, - ConnectionEventEntity - >( - currentTable: table, - referencedTable: $$WatchesTableReferences - ._connectionEventsRefsTable(db), - managerFromTypedResult: (p0) => - $$WatchesTableReferences( - db, - table, - p0, - ).connectionEventsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.watchId == item.id, - ), - typedResults: items, - ), - ]; - }, - ); - }, + Value id = const Value.absent(), + required String direction, + required String protocol, + Value characteristic = const Value.absent(), + required String payload, + required int payloadSize, + required DateTime timestamp, + }) => CommLogEntriesCompanion.insert( + id: id, + direction: direction, + protocol: protocol, + characteristic: characteristic, + payload: payload, + payloadSize: payloadSize, + timestamp: timestamp, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, ), ); } -typedef $$WatchesTableProcessedTableManager = +typedef $$CommLogEntriesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $WatchesTable, - WatchEntity, - $$WatchesTableFilterComposer, - $$WatchesTableOrderingComposer, - $$WatchesTableAnnotationComposer, - $$WatchesTableCreateCompanionBuilder, - $$WatchesTableUpdateCompanionBuilder, - (WatchEntity, $$WatchesTableReferences), - WatchEntity, - PrefetchHooks Function({ - bool healthSamplesRefs, - bool batteryReadingsRefs, - bool connectionEventsRefs, - }) + $CommLogEntriesTable, + CommLogEntryEntity, + $$CommLogEntriesTableFilterComposer, + $$CommLogEntriesTableOrderingComposer, + $$CommLogEntriesTableAnnotationComposer, + $$CommLogEntriesTableCreateCompanionBuilder, + $$CommLogEntriesTableUpdateCompanionBuilder, + ( + CommLogEntryEntity, + BaseReferences<_$AppDatabase, $CommLogEntriesTable, CommLogEntryEntity>, + ), + CommLogEntryEntity, + PrefetchHooks Function() >; -typedef $$HealthSamplesTableCreateCompanionBuilder = - HealthSamplesCompanion Function({ +typedef $$ConnectionEventsTableCreateCompanionBuilder = + ConnectionEventsCompanion Function({ Value id, required String watchId, - required String type, - required double value, + required String eventType, required DateTime timestamp, - required String granularity, - required DateTime syncedAt, + Value reason, + Value details, + Value sessionId, }); -typedef $$HealthSamplesTableUpdateCompanionBuilder = - HealthSamplesCompanion Function({ +typedef $$ConnectionEventsTableUpdateCompanionBuilder = + ConnectionEventsCompanion Function({ Value id, Value watchId, - Value type, - Value value, + Value eventType, Value timestamp, - Value granularity, - Value syncedAt, + Value reason, + Value details, + Value sessionId, }); -final class $$HealthSamplesTableReferences +final class $$ConnectionEventsTableReferences extends - BaseReferences<_$AppDatabase, $HealthSamplesTable, HealthSampleEntity> { - $$HealthSamplesTableReferences( + BaseReferences< + _$AppDatabase, + $ConnectionEventsTable, + ConnectionEventEntity + > { + $$ConnectionEventsTableReferences( super.$_db, super.$_table, super.$_typedResult, @@ -3126,7 +7163,7 @@ final class $$HealthSamplesTableReferences static $WatchesTable _watchIdTable(_$AppDatabase db) => db.watches.createAlias( - $_aliasNameGenerator(db.healthSamples.watchId, db.watches.id), + $_aliasNameGenerator(db.connectionEvents.watchId, db.watches.id), ); $$WatchesTableProcessedTableManager get watchId { @@ -3144,9 +7181,9 @@ final class $$HealthSamplesTableReferences } } -class $$HealthSamplesTableFilterComposer - extends Composer<_$AppDatabase, $HealthSamplesTable> { - $$HealthSamplesTableFilterComposer({ +class $$ConnectionEventsTableFilterComposer + extends Composer<_$AppDatabase, $ConnectionEventsTable> { + $$ConnectionEventsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3158,28 +7195,28 @@ class $$HealthSamplesTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get type => $composableBuilder( - column: $table.type, + ColumnFilters get eventType => $composableBuilder( + column: $table.eventType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get value => $composableBuilder( - column: $table.value, + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnFilters(column), ); - ColumnFilters get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnFilters get reason => $composableBuilder( + column: $table.reason, builder: (column) => ColumnFilters(column), ); - ColumnFilters get granularity => $composableBuilder( - column: $table.granularity, + ColumnFilters get details => $composableBuilder( + column: $table.details, builder: (column) => ColumnFilters(column), ); - ColumnFilters get syncedAt => $composableBuilder( - column: $table.syncedAt, + ColumnFilters get sessionId => $composableBuilder( + column: $table.sessionId, builder: (column) => ColumnFilters(column), ); @@ -3207,9 +7244,9 @@ class $$HealthSamplesTableFilterComposer } } -class $$HealthSamplesTableOrderingComposer - extends Composer<_$AppDatabase, $HealthSamplesTable> { - $$HealthSamplesTableOrderingComposer({ +class $$ConnectionEventsTableOrderingComposer + extends Composer<_$AppDatabase, $ConnectionEventsTable> { + $$ConnectionEventsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3221,28 +7258,28 @@ class $$HealthSamplesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get type => $composableBuilder( - column: $table.type, + ColumnOrderings get eventType => $composableBuilder( + column: $table.eventType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get value => $composableBuilder( - column: $table.value, + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnOrderings get reason => $composableBuilder( + column: $table.reason, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get granularity => $composableBuilder( - column: $table.granularity, + ColumnOrderings get details => $composableBuilder( + column: $table.details, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get syncedAt => $composableBuilder( - column: $table.syncedAt, + ColumnOrderings get sessionId => $composableBuilder( + column: $table.sessionId, builder: (column) => ColumnOrderings(column), ); @@ -3270,9 +7307,9 @@ class $$HealthSamplesTableOrderingComposer } } -class $$HealthSamplesTableAnnotationComposer - extends Composer<_$AppDatabase, $HealthSamplesTable> { - $$HealthSamplesTableAnnotationComposer({ +class $$ConnectionEventsTableAnnotationComposer + extends Composer<_$AppDatabase, $ConnectionEventsTable> { + $$ConnectionEventsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3282,22 +7319,20 @@ class $$HealthSamplesTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumn get value => - $composableBuilder(column: $table.value, builder: (column) => column); + GeneratedColumn get eventType => + $composableBuilder(column: $table.eventType, builder: (column) => column); GeneratedColumn get timestamp => $composableBuilder(column: $table.timestamp, builder: (column) => column); - GeneratedColumn get granularity => $composableBuilder( - column: $table.granularity, - builder: (column) => column, - ); + GeneratedColumn get reason => + $composableBuilder(column: $table.reason, builder: (column) => column); - GeneratedColumn get syncedAt => - $composableBuilder(column: $table.syncedAt, builder: (column) => column); + GeneratedColumn get details => + $composableBuilder(column: $table.details, builder: (column) => column); + + GeneratedColumn get sessionId => + $composableBuilder(column: $table.sessionId, builder: (column) => column); $$WatchesTableAnnotationComposer get watchId { final $$WatchesTableAnnotationComposer composer = $composerBuilder( @@ -3323,73 +7358,75 @@ class $$HealthSamplesTableAnnotationComposer } } -class $$HealthSamplesTableTableManager +class $$ConnectionEventsTableTableManager extends RootTableManager< _$AppDatabase, - $HealthSamplesTable, - HealthSampleEntity, - $$HealthSamplesTableFilterComposer, - $$HealthSamplesTableOrderingComposer, - $$HealthSamplesTableAnnotationComposer, - $$HealthSamplesTableCreateCompanionBuilder, - $$HealthSamplesTableUpdateCompanionBuilder, - (HealthSampleEntity, $$HealthSamplesTableReferences), - HealthSampleEntity, + $ConnectionEventsTable, + ConnectionEventEntity, + $$ConnectionEventsTableFilterComposer, + $$ConnectionEventsTableOrderingComposer, + $$ConnectionEventsTableAnnotationComposer, + $$ConnectionEventsTableCreateCompanionBuilder, + $$ConnectionEventsTableUpdateCompanionBuilder, + (ConnectionEventEntity, $$ConnectionEventsTableReferences), + ConnectionEventEntity, PrefetchHooks Function({bool watchId}) > { - $$HealthSamplesTableTableManager(_$AppDatabase db, $HealthSamplesTable table) - : super( + $$ConnectionEventsTableTableManager( + _$AppDatabase db, + $ConnectionEventsTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$HealthSamplesTableFilterComposer($db: db, $table: table), + $$ConnectionEventsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$HealthSamplesTableOrderingComposer($db: db, $table: table), + $$ConnectionEventsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$HealthSamplesTableAnnotationComposer($db: db, $table: table), + $$ConnectionEventsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value watchId = const Value.absent(), - Value type = const Value.absent(), - Value value = const Value.absent(), + Value eventType = const Value.absent(), Value timestamp = const Value.absent(), - Value granularity = const Value.absent(), - Value syncedAt = const Value.absent(), - }) => HealthSamplesCompanion( + Value reason = const Value.absent(), + Value details = const Value.absent(), + Value sessionId = const Value.absent(), + }) => ConnectionEventsCompanion( id: id, watchId: watchId, - type: type, - value: value, + eventType: eventType, timestamp: timestamp, - granularity: granularity, - syncedAt: syncedAt, + reason: reason, + details: details, + sessionId: sessionId, ), createCompanionCallback: ({ Value id = const Value.absent(), required String watchId, - required String type, - required double value, + required String eventType, required DateTime timestamp, - required String granularity, - required DateTime syncedAt, - }) => HealthSamplesCompanion.insert( + Value reason = const Value.absent(), + Value details = const Value.absent(), + Value sessionId = const Value.absent(), + }) => ConnectionEventsCompanion.insert( id: id, watchId: watchId, - type: type, - value: value, + eventType: eventType, timestamp: timestamp, - granularity: granularity, - syncedAt: syncedAt, + reason: reason, + details: details, + sessionId: sessionId, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$HealthSamplesTableReferences(db, table, e), + $$ConnectionEventsTableReferences(db, table, e), ), ) .toList(), @@ -3418,11 +7455,13 @@ class $$HealthSamplesTableTableManager state.withJoin( currentTable: table, currentColumn: table.watchId, - referencedTable: $$HealthSamplesTableReferences - ._watchIdTable(db), - referencedColumn: $$HealthSamplesTableReferences - ._watchIdTable(db) - .id, + referencedTable: + $$ConnectionEventsTableReferences + ._watchIdTable(db), + referencedColumn: + $$ConnectionEventsTableReferences + ._watchIdTable(db) + .id, ) as T; } @@ -3433,78 +7472,75 @@ class $$HealthSamplesTableTableManager return []; }, ); - }, - ), - ); -} - -typedef $$HealthSamplesTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $HealthSamplesTable, - HealthSampleEntity, - $$HealthSamplesTableFilterComposer, - $$HealthSamplesTableOrderingComposer, - $$HealthSamplesTableAnnotationComposer, - $$HealthSamplesTableCreateCompanionBuilder, - $$HealthSamplesTableUpdateCompanionBuilder, - (HealthSampleEntity, $$HealthSamplesTableReferences), - HealthSampleEntity, + }, + ), + ); +} + +typedef $$ConnectionEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ConnectionEventsTable, + ConnectionEventEntity, + $$ConnectionEventsTableFilterComposer, + $$ConnectionEventsTableOrderingComposer, + $$ConnectionEventsTableAnnotationComposer, + $$ConnectionEventsTableCreateCompanionBuilder, + $$ConnectionEventsTableUpdateCompanionBuilder, + (ConnectionEventEntity, $$ConnectionEventsTableReferences), + ConnectionEventEntity, PrefetchHooks Function({bool watchId}) >; -typedef $$BatteryReadingsTableCreateCompanionBuilder = - BatteryReadingsCompanion Function({ +typedef $$VoiceMemosTableCreateCompanionBuilder = + VoiceMemosCompanion Function({ Value id, - required String watchId, - required int level, - Value isCharging, - required DateTime timestamp, + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + Value localFilePath, + Value transcription, + Value syncedFromWatch, + Value deletedOnWatch, + Value downloadedAt, + Value transcribedAt, + Value convertedFilePath, + Value summary, + Value category, + Value processingStatus, + Value aiModel, + Value aiProcessedAt, + Value taskCreated, + Value calendarEventCreated, + Value actionReviewState, }); -typedef $$BatteryReadingsTableUpdateCompanionBuilder = - BatteryReadingsCompanion Function({ +typedef $$VoiceMemosTableUpdateCompanionBuilder = + VoiceMemosCompanion Function({ Value id, - Value watchId, - Value level, - Value isCharging, - Value timestamp, + Value filename, + Value timestampUtc, + Value durationMs, + Value sizeBytes, + Value localFilePath, + Value transcription, + Value syncedFromWatch, + Value deletedOnWatch, + Value downloadedAt, + Value transcribedAt, + Value convertedFilePath, + Value summary, + Value category, + Value processingStatus, + Value aiModel, + Value aiProcessedAt, + Value taskCreated, + Value calendarEventCreated, + Value actionReviewState, }); -final class $$BatteryReadingsTableReferences - extends - BaseReferences< - _$AppDatabase, - $BatteryReadingsTable, - BatteryReadingEntity - > { - $$BatteryReadingsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); - - static $WatchesTable _watchIdTable(_$AppDatabase db) => - db.watches.createAlias( - $_aliasNameGenerator(db.batteryReadings.watchId, db.watches.id), - ); - - $$WatchesTableProcessedTableManager get watchId { - final $_column = $_itemColumn('watch_id')!; - - final manager = $$WatchesTableTableManager( - $_db, - $_db.watches, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_watchIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} - -class $$BatteryReadingsTableFilterComposer - extends Composer<_$AppDatabase, $BatteryReadingsTable> { - $$BatteryReadingsTableFilterComposer({ +class $$VoiceMemosTableFilterComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3516,48 +7552,105 @@ class $$BatteryReadingsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get level => $composableBuilder( - column: $table.level, + ColumnFilters get filename => $composableBuilder( + column: $table.filename, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isCharging => $composableBuilder( - column: $table.isCharging, + ColumnFilters get timestampUtc => $composableBuilder( + column: $table.timestampUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnFilters get durationMs => $composableBuilder( + column: $table.durationMs, builder: (column) => ColumnFilters(column), ); - $$WatchesTableFilterComposer get watchId { - final $$WatchesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.watchId, - referencedTable: $db.watches, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$WatchesTableFilterComposer( - $db: $db, - $table: $db.watches, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + ColumnFilters get sizeBytes => $composableBuilder( + column: $table.sizeBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get aiModel => $composableBuilder( + column: $table.aiModel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskCreated => $composableBuilder( + column: $table.taskCreated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => ColumnFilters(column), + ); } -class $$BatteryReadingsTableOrderingComposer - extends Composer<_$AppDatabase, $BatteryReadingsTable> { - $$BatteryReadingsTableOrderingComposer({ +class $$VoiceMemosTableOrderingComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3569,48 +7662,105 @@ class $$BatteryReadingsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get level => $composableBuilder( - column: $table.level, + ColumnOrderings get filename => $composableBuilder( + column: $table.filename, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isCharging => $composableBuilder( - column: $table.isCharging, + ColumnOrderings get timestampUtc => $composableBuilder( + column: $table.timestampUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnOrderings get durationMs => $composableBuilder( + column: $table.durationMs, builder: (column) => ColumnOrderings(column), ); - $$WatchesTableOrderingComposer get watchId { - final $$WatchesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.watchId, - referencedTable: $db.watches, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$WatchesTableOrderingComposer( - $db: $db, - $table: $db.watches, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + ColumnOrderings get sizeBytes => $composableBuilder( + column: $table.sizeBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get aiModel => $composableBuilder( + column: $table.aiModel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskCreated => $composableBuilder( + column: $table.taskCreated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => ColumnOrderings(column), + ); } -class $$BatteryReadingsTableAnnotationComposer - extends Composer<_$AppDatabase, $BatteryReadingsTable> { - $$BatteryReadingsTableAnnotationComposer({ +class $$VoiceMemosTableAnnotationComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3620,190 +7770,272 @@ class $$BatteryReadingsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get level => - $composableBuilder(column: $table.level, builder: (column) => column); + GeneratedColumn get filename => + $composableBuilder(column: $table.filename, builder: (column) => column); + + GeneratedColumn get timestampUtc => $composableBuilder( + column: $table.timestampUtc, + builder: (column) => column, + ); + + GeneratedColumn get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => column, + ); + + GeneratedColumn get sizeBytes => + $composableBuilder(column: $table.sizeBytes, builder: (column) => column); + + GeneratedColumn get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => column, + ); + + GeneratedColumn get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => column, + ); + + GeneratedColumn get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => column, + ); + + GeneratedColumn get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => column, + ); + + GeneratedColumn get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => column, + ); + + GeneratedColumn get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => column, + ); + + GeneratedColumn get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => column, + ); + + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => column, + ); + + GeneratedColumn get aiModel => + $composableBuilder(column: $table.aiModel, builder: (column) => column); + + GeneratedColumn get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => column, + ); - GeneratedColumn get isCharging => $composableBuilder( - column: $table.isCharging, + GeneratedColumn get taskCreated => $composableBuilder( + column: $table.taskCreated, builder: (column) => column, ); - GeneratedColumn get timestamp => - $composableBuilder(column: $table.timestamp, builder: (column) => column); + GeneratedColumn get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => column, + ); - $$WatchesTableAnnotationComposer get watchId { - final $$WatchesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.watchId, - referencedTable: $db.watches, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$WatchesTableAnnotationComposer( - $db: $db, - $table: $db.watches, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + GeneratedColumn get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => column, + ); } -class $$BatteryReadingsTableTableManager +class $$VoiceMemosTableTableManager extends RootTableManager< _$AppDatabase, - $BatteryReadingsTable, - BatteryReadingEntity, - $$BatteryReadingsTableFilterComposer, - $$BatteryReadingsTableOrderingComposer, - $$BatteryReadingsTableAnnotationComposer, - $$BatteryReadingsTableCreateCompanionBuilder, - $$BatteryReadingsTableUpdateCompanionBuilder, - (BatteryReadingEntity, $$BatteryReadingsTableReferences), - BatteryReadingEntity, - PrefetchHooks Function({bool watchId}) + $VoiceMemosTable, + VoiceMemoEntity, + $$VoiceMemosTableFilterComposer, + $$VoiceMemosTableOrderingComposer, + $$VoiceMemosTableAnnotationComposer, + $$VoiceMemosTableCreateCompanionBuilder, + $$VoiceMemosTableUpdateCompanionBuilder, + ( + VoiceMemoEntity, + BaseReferences<_$AppDatabase, $VoiceMemosTable, VoiceMemoEntity>, + ), + VoiceMemoEntity, + PrefetchHooks Function() > { - $$BatteryReadingsTableTableManager( - _$AppDatabase db, - $BatteryReadingsTable table, - ) : super( + $$VoiceMemosTableTableManager(_$AppDatabase db, $VoiceMemosTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$BatteryReadingsTableFilterComposer($db: db, $table: table), + $$VoiceMemosTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$BatteryReadingsTableOrderingComposer($db: db, $table: table), + $$VoiceMemosTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$BatteryReadingsTableAnnotationComposer($db: db, $table: table), + $$VoiceMemosTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), - Value watchId = const Value.absent(), - Value level = const Value.absent(), - Value isCharging = const Value.absent(), - Value timestamp = const Value.absent(), - }) => BatteryReadingsCompanion( + Value filename = const Value.absent(), + Value timestampUtc = const Value.absent(), + Value durationMs = const Value.absent(), + Value sizeBytes = const Value.absent(), + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + Value syncedFromWatch = const Value.absent(), + Value deletedOnWatch = const Value.absent(), + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + Value taskCreated = const Value.absent(), + Value calendarEventCreated = const Value.absent(), + Value actionReviewState = const Value.absent(), + }) => VoiceMemosCompanion( id: id, - watchId: watchId, - level: level, - isCharging: isCharging, - timestamp: timestamp, + filename: filename, + timestampUtc: timestampUtc, + durationMs: durationMs, + sizeBytes: sizeBytes, + localFilePath: localFilePath, + transcription: transcription, + syncedFromWatch: syncedFromWatch, + deletedOnWatch: deletedOnWatch, + downloadedAt: downloadedAt, + transcribedAt: transcribedAt, + convertedFilePath: convertedFilePath, + summary: summary, + category: category, + processingStatus: processingStatus, + aiModel: aiModel, + aiProcessedAt: aiProcessedAt, + taskCreated: taskCreated, + calendarEventCreated: calendarEventCreated, + actionReviewState: actionReviewState, ), createCompanionCallback: ({ Value id = const Value.absent(), - required String watchId, - required int level, - Value isCharging = const Value.absent(), - required DateTime timestamp, - }) => BatteryReadingsCompanion.insert( + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + Value syncedFromWatch = const Value.absent(), + Value deletedOnWatch = const Value.absent(), + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + Value taskCreated = const Value.absent(), + Value calendarEventCreated = const Value.absent(), + Value actionReviewState = const Value.absent(), + }) => VoiceMemosCompanion.insert( id: id, - watchId: watchId, - level: level, - isCharging: isCharging, - timestamp: timestamp, + filename: filename, + timestampUtc: timestampUtc, + durationMs: durationMs, + sizeBytes: sizeBytes, + localFilePath: localFilePath, + transcription: transcription, + syncedFromWatch: syncedFromWatch, + deletedOnWatch: deletedOnWatch, + downloadedAt: downloadedAt, + transcribedAt: transcribedAt, + convertedFilePath: convertedFilePath, + summary: summary, + category: category, + processingStatus: processingStatus, + aiModel: aiModel, + aiProcessedAt: aiProcessedAt, + taskCreated: taskCreated, + calendarEventCreated: calendarEventCreated, + actionReviewState: actionReviewState, ), withReferenceMapper: (p0) => p0 - .map( - (e) => ( - e.readTable(table), - $$BatteryReadingsTableReferences(db, table, e), - ), - ) + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) .toList(), - prefetchHooksCallback: ({watchId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (watchId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.watchId, - referencedTable: - $$BatteryReadingsTableReferences - ._watchIdTable(db), - referencedColumn: - $$BatteryReadingsTableReferences - ._watchIdTable(db) - .id, - ) - as T; - } - - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, + prefetchHooksCallback: null, ), ); } -typedef $$BatteryReadingsTableProcessedTableManager = +typedef $$VoiceMemosTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $BatteryReadingsTable, - BatteryReadingEntity, - $$BatteryReadingsTableFilterComposer, - $$BatteryReadingsTableOrderingComposer, - $$BatteryReadingsTableAnnotationComposer, - $$BatteryReadingsTableCreateCompanionBuilder, - $$BatteryReadingsTableUpdateCompanionBuilder, - (BatteryReadingEntity, $$BatteryReadingsTableReferences), - BatteryReadingEntity, - PrefetchHooks Function({bool watchId}) + $VoiceMemosTable, + VoiceMemoEntity, + $$VoiceMemosTableFilterComposer, + $$VoiceMemosTableOrderingComposer, + $$VoiceMemosTableAnnotationComposer, + $$VoiceMemosTableCreateCompanionBuilder, + $$VoiceMemosTableUpdateCompanionBuilder, + ( + VoiceMemoEntity, + BaseReferences<_$AppDatabase, $VoiceMemosTable, VoiceMemoEntity>, + ), + VoiceMemoEntity, + PrefetchHooks Function() >; -typedef $$CommLogEntriesTableCreateCompanionBuilder = - CommLogEntriesCompanion Function({ +typedef $$ExtractedActionsTableCreateCompanionBuilder = + ExtractedActionsCompanion Function({ Value id, - required String direction, - required String protocol, - Value characteristic, - required String payload, - required int payloadSize, - required DateTime timestamp, + required int memoId, + required String actionType, + required String title, + Value notes, + Value startTime, + Value endTime, + Value dueDate, + Value location, + Value reminderMinutes, + Value created, + Value dismissed, + Value platformTargetId, + Value createdAt, }); -typedef $$CommLogEntriesTableUpdateCompanionBuilder = - CommLogEntriesCompanion Function({ +typedef $$ExtractedActionsTableUpdateCompanionBuilder = + ExtractedActionsCompanion Function({ Value id, - Value direction, - Value protocol, - Value characteristic, - Value payload, - Value payloadSize, - Value timestamp, + Value memoId, + Value actionType, + Value title, + Value notes, + Value startTime, + Value endTime, + Value dueDate, + Value location, + Value reminderMinutes, + Value created, + Value dismissed, + Value platformTargetId, + Value createdAt, }); -class $$CommLogEntriesTableFilterComposer - extends Composer<_$AppDatabase, $CommLogEntriesTable> { - $$CommLogEntriesTableFilterComposer({ +class $$ExtractedActionsTableFilterComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3815,40 +8047,75 @@ class $$CommLogEntriesTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get direction => $composableBuilder( - column: $table.direction, + ColumnFilters get memoId => $composableBuilder( + column: $table.memoId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get protocol => $composableBuilder( - column: $table.protocol, + ColumnFilters get actionType => $composableBuilder( + column: $table.actionType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get characteristic => $composableBuilder( - column: $table.characteristic, + ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnFilters(column), ); - ColumnFilters get payload => $composableBuilder( - column: $table.payload, + ColumnFilters get notes => $composableBuilder( + column: $table.notes, builder: (column) => ColumnFilters(column), ); - ColumnFilters get payloadSize => $composableBuilder( - column: $table.payloadSize, + ColumnFilters get startTime => $composableBuilder( + column: $table.startTime, builder: (column) => ColumnFilters(column), ); - ColumnFilters get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnFilters get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dismissed => $composableBuilder( + column: $table.dismissed, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get platformTargetId => $composableBuilder( + column: $table.platformTargetId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column), ); } -class $$CommLogEntriesTableOrderingComposer - extends Composer<_$AppDatabase, $CommLogEntriesTable> { - $$CommLogEntriesTableOrderingComposer({ +class $$ExtractedActionsTableOrderingComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3860,40 +8127,75 @@ class $$CommLogEntriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get direction => $composableBuilder( - column: $table.direction, + ColumnOrderings get memoId => $composableBuilder( + column: $table.memoId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get protocol => $composableBuilder( - column: $table.protocol, + ColumnOrderings get actionType => $composableBuilder( + column: $table.actionType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get characteristic => $composableBuilder( - column: $table.characteristic, + ColumnOrderings get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get payload => $composableBuilder( - column: $table.payload, + ColumnOrderings get notes => $composableBuilder( + column: $table.notes, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get payloadSize => $composableBuilder( - column: $table.payloadSize, + ColumnOrderings get startTime => $composableBuilder( + column: $table.startTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnOrderings get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dismissed => $composableBuilder( + column: $table.dismissed, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get platformTargetId => $composableBuilder( + column: $table.platformTargetId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column), ); } -class $$CommLogEntriesTableAnnotationComposer - extends Composer<_$AppDatabase, $CommLogEntriesTable> { - $$CommLogEntriesTableAnnotationComposer({ +class $$ExtractedActionsTableAnnotationComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -3903,99 +8205,150 @@ class $$CommLogEntriesTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get direction => - $composableBuilder(column: $table.direction, builder: (column) => column); + GeneratedColumn get memoId => + $composableBuilder(column: $table.memoId, builder: (column) => column); - GeneratedColumn get protocol => - $composableBuilder(column: $table.protocol, builder: (column) => column); + GeneratedColumn get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => column, + ); - GeneratedColumn get characteristic => $composableBuilder( - column: $table.characteristic, + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get notes => + $composableBuilder(column: $table.notes, builder: (column) => column); + + GeneratedColumn get startTime => + $composableBuilder(column: $table.startTime, builder: (column) => column); + + GeneratedColumn get endTime => + $composableBuilder(column: $table.endTime, builder: (column) => column); + + GeneratedColumn get dueDate => + $composableBuilder(column: $table.dueDate, builder: (column) => column); + + GeneratedColumn get location => + $composableBuilder(column: $table.location, builder: (column) => column); + + GeneratedColumn get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, builder: (column) => column, ); - GeneratedColumn get payload => - $composableBuilder(column: $table.payload, builder: (column) => column); + GeneratedColumn get created => + $composableBuilder(column: $table.created, builder: (column) => column); - GeneratedColumn get payloadSize => $composableBuilder( - column: $table.payloadSize, + GeneratedColumn get dismissed => + $composableBuilder(column: $table.dismissed, builder: (column) => column); + + GeneratedColumn get platformTargetId => $composableBuilder( + column: $table.platformTargetId, builder: (column) => column, ); - GeneratedColumn get timestamp => - $composableBuilder(column: $table.timestamp, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); } -class $$CommLogEntriesTableTableManager +class $$ExtractedActionsTableTableManager extends RootTableManager< _$AppDatabase, - $CommLogEntriesTable, - CommLogEntryEntity, - $$CommLogEntriesTableFilterComposer, - $$CommLogEntriesTableOrderingComposer, - $$CommLogEntriesTableAnnotationComposer, - $$CommLogEntriesTableCreateCompanionBuilder, - $$CommLogEntriesTableUpdateCompanionBuilder, + $ExtractedActionsTable, + ExtractedActionEntity, + $$ExtractedActionsTableFilterComposer, + $$ExtractedActionsTableOrderingComposer, + $$ExtractedActionsTableAnnotationComposer, + $$ExtractedActionsTableCreateCompanionBuilder, + $$ExtractedActionsTableUpdateCompanionBuilder, ( - CommLogEntryEntity, + ExtractedActionEntity, BaseReferences< _$AppDatabase, - $CommLogEntriesTable, - CommLogEntryEntity + $ExtractedActionsTable, + ExtractedActionEntity >, ), - CommLogEntryEntity, + ExtractedActionEntity, PrefetchHooks Function() > { - $$CommLogEntriesTableTableManager( + $$ExtractedActionsTableTableManager( _$AppDatabase db, - $CommLogEntriesTable table, + $ExtractedActionsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CommLogEntriesTableFilterComposer($db: db, $table: table), + $$ExtractedActionsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CommLogEntriesTableOrderingComposer($db: db, $table: table), + $$ExtractedActionsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$CommLogEntriesTableAnnotationComposer($db: db, $table: table), + $$ExtractedActionsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), - Value direction = const Value.absent(), - Value protocol = const Value.absent(), - Value characteristic = const Value.absent(), - Value payload = const Value.absent(), - Value payloadSize = const Value.absent(), - Value timestamp = const Value.absent(), - }) => CommLogEntriesCompanion( + Value memoId = const Value.absent(), + Value actionType = const Value.absent(), + Value title = const Value.absent(), + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value created = const Value.absent(), + Value dismissed = const Value.absent(), + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionsCompanion( id: id, - direction: direction, - protocol: protocol, - characteristic: characteristic, - payload: payload, - payloadSize: payloadSize, - timestamp: timestamp, + memoId: memoId, + actionType: actionType, + title: title, + notes: notes, + startTime: startTime, + endTime: endTime, + dueDate: dueDate, + location: location, + reminderMinutes: reminderMinutes, + created: created, + dismissed: dismissed, + platformTargetId: platformTargetId, + createdAt: createdAt, ), createCompanionCallback: ({ Value id = const Value.absent(), - required String direction, - required String protocol, - Value characteristic = const Value.absent(), - required String payload, - required int payloadSize, - required DateTime timestamp, - }) => CommLogEntriesCompanion.insert( + required int memoId, + required String actionType, + required String title, + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value created = const Value.absent(), + Value dismissed = const Value.absent(), + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionsCompanion.insert( id: id, - direction: direction, - protocol: protocol, - characteristic: characteristic, - payload: payload, - payloadSize: payloadSize, - timestamp: timestamp, + memoId: memoId, + actionType: actionType, + title: title, + notes: notes, + startTime: startTime, + endTime: endTime, + dueDate: dueDate, + location: location, + reminderMinutes: reminderMinutes, + created: created, + dismissed: dismissed, + platformTargetId: platformTargetId, + createdAt: createdAt, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -4005,60 +8358,74 @@ class $$CommLogEntriesTableTableManager ); } -typedef $$CommLogEntriesTableProcessedTableManager = +typedef $$ExtractedActionsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CommLogEntriesTable, - CommLogEntryEntity, - $$CommLogEntriesTableFilterComposer, - $$CommLogEntriesTableOrderingComposer, - $$CommLogEntriesTableAnnotationComposer, - $$CommLogEntriesTableCreateCompanionBuilder, - $$CommLogEntriesTableUpdateCompanionBuilder, + $ExtractedActionsTable, + ExtractedActionEntity, + $$ExtractedActionsTableFilterComposer, + $$ExtractedActionsTableOrderingComposer, + $$ExtractedActionsTableAnnotationComposer, + $$ExtractedActionsTableCreateCompanionBuilder, + $$ExtractedActionsTableUpdateCompanionBuilder, ( - CommLogEntryEntity, - BaseReferences<_$AppDatabase, $CommLogEntriesTable, CommLogEntryEntity>, + ExtractedActionEntity, + BaseReferences< + _$AppDatabase, + $ExtractedActionsTable, + ExtractedActionEntity + >, ), - CommLogEntryEntity, + ExtractedActionEntity, PrefetchHooks Function() >; -typedef $$ConnectionEventsTableCreateCompanionBuilder = - ConnectionEventsCompanion Function({ +typedef $$CrashReportsTableCreateCompanionBuilder = + CrashReportsCompanion Function({ Value id, required String watchId, - required String eventType, - required DateTime timestamp, - Value reason, - Value details, - Value sessionId, + required String file, + required int line, + required String crashTime, + required String fwVersion, + required String fwCommitSha, + required String board, + required String buildType, + required DateTime receivedAt, + Value analyzed, + Value backtrace, + Value registers, + Value rawOutput, + Value analysisError, + Value elfAvailable, }); -typedef $$ConnectionEventsTableUpdateCompanionBuilder = - ConnectionEventsCompanion Function({ +typedef $$CrashReportsTableUpdateCompanionBuilder = + CrashReportsCompanion Function({ Value id, Value watchId, - Value eventType, - Value timestamp, - Value reason, - Value details, - Value sessionId, + Value file, + Value line, + Value crashTime, + Value fwVersion, + Value fwCommitSha, + Value board, + Value buildType, + Value receivedAt, + Value analyzed, + Value backtrace, + Value registers, + Value rawOutput, + Value analysisError, + Value elfAvailable, }); -final class $$ConnectionEventsTableReferences +final class $$CrashReportsTableReferences extends - BaseReferences< - _$AppDatabase, - $ConnectionEventsTable, - ConnectionEventEntity - > { - $$ConnectionEventsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); + BaseReferences<_$AppDatabase, $CrashReportsTable, CrashReportEntity> { + $$CrashReportsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $WatchesTable _watchIdTable(_$AppDatabase db) => db.watches.createAlias( - $_aliasNameGenerator(db.connectionEvents.watchId, db.watches.id), + $_aliasNameGenerator(db.crashReports.watchId, db.watches.id), ); $$WatchesTableProcessedTableManager get watchId { @@ -4076,9 +8443,9 @@ final class $$ConnectionEventsTableReferences } } -class $$ConnectionEventsTableFilterComposer - extends Composer<_$AppDatabase, $ConnectionEventsTable> { - $$ConnectionEventsTableFilterComposer({ +class $$CrashReportsTableFilterComposer + extends Composer<_$AppDatabase, $CrashReportsTable> { + $$CrashReportsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -4090,28 +8457,73 @@ class $$ConnectionEventsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get eventType => $composableBuilder( - column: $table.eventType, + ColumnFilters get file => $composableBuilder( + column: $table.file, builder: (column) => ColumnFilters(column), ); - ColumnFilters get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnFilters get line => $composableBuilder( + column: $table.line, builder: (column) => ColumnFilters(column), ); - ColumnFilters get reason => $composableBuilder( - column: $table.reason, + ColumnFilters get crashTime => $composableBuilder( + column: $table.crashTime, builder: (column) => ColumnFilters(column), ); - ColumnFilters get details => $composableBuilder( - column: $table.details, + ColumnFilters get fwVersion => $composableBuilder( + column: $table.fwVersion, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sessionId => $composableBuilder( - column: $table.sessionId, + ColumnFilters get fwCommitSha => $composableBuilder( + column: $table.fwCommitSha, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get board => $composableBuilder( + column: $table.board, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get buildType => $composableBuilder( + column: $table.buildType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get analyzed => $composableBuilder( + column: $table.analyzed, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get backtrace => $composableBuilder( + column: $table.backtrace, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get registers => $composableBuilder( + column: $table.registers, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rawOutput => $composableBuilder( + column: $table.rawOutput, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get analysisError => $composableBuilder( + column: $table.analysisError, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get elfAvailable => $composableBuilder( + column: $table.elfAvailable, builder: (column) => ColumnFilters(column), ); @@ -4139,9 +8551,9 @@ class $$ConnectionEventsTableFilterComposer } } -class $$ConnectionEventsTableOrderingComposer - extends Composer<_$AppDatabase, $ConnectionEventsTable> { - $$ConnectionEventsTableOrderingComposer({ +class $$CrashReportsTableOrderingComposer + extends Composer<_$AppDatabase, $CrashReportsTable> { + $$CrashReportsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -4153,28 +8565,73 @@ class $$ConnectionEventsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get eventType => $composableBuilder( - column: $table.eventType, + ColumnOrderings get file => $composableBuilder( + column: $table.file, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get timestamp => $composableBuilder( - column: $table.timestamp, + ColumnOrderings get line => $composableBuilder( + column: $table.line, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get reason => $composableBuilder( - column: $table.reason, + ColumnOrderings get crashTime => $composableBuilder( + column: $table.crashTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get details => $composableBuilder( - column: $table.details, + ColumnOrderings get fwVersion => $composableBuilder( + column: $table.fwVersion, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sessionId => $composableBuilder( - column: $table.sessionId, + ColumnOrderings get fwCommitSha => $composableBuilder( + column: $table.fwCommitSha, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get board => $composableBuilder( + column: $table.board, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get buildType => $composableBuilder( + column: $table.buildType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get analyzed => $composableBuilder( + column: $table.analyzed, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get backtrace => $composableBuilder( + column: $table.backtrace, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get registers => $composableBuilder( + column: $table.registers, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get rawOutput => $composableBuilder( + column: $table.rawOutput, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get analysisError => $composableBuilder( + column: $table.analysisError, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get elfAvailable => $composableBuilder( + column: $table.elfAvailable, builder: (column) => ColumnOrderings(column), ); @@ -4202,9 +8659,9 @@ class $$ConnectionEventsTableOrderingComposer } } -class $$ConnectionEventsTableAnnotationComposer - extends Composer<_$AppDatabase, $ConnectionEventsTable> { - $$ConnectionEventsTableAnnotationComposer({ +class $$CrashReportsTableAnnotationComposer + extends Composer<_$AppDatabase, $CrashReportsTable> { + $$CrashReportsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -4214,20 +8671,55 @@ class $$ConnectionEventsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get eventType => - $composableBuilder(column: $table.eventType, builder: (column) => column); + GeneratedColumn get file => + $composableBuilder(column: $table.file, builder: (column) => column); - GeneratedColumn get timestamp => - $composableBuilder(column: $table.timestamp, builder: (column) => column); + GeneratedColumn get line => + $composableBuilder(column: $table.line, builder: (column) => column); - GeneratedColumn get reason => - $composableBuilder(column: $table.reason, builder: (column) => column); + GeneratedColumn get crashTime => + $composableBuilder(column: $table.crashTime, builder: (column) => column); - GeneratedColumn get details => - $composableBuilder(column: $table.details, builder: (column) => column); + GeneratedColumn get fwVersion => + $composableBuilder(column: $table.fwVersion, builder: (column) => column); - GeneratedColumn get sessionId => - $composableBuilder(column: $table.sessionId, builder: (column) => column); + GeneratedColumn get fwCommitSha => $composableBuilder( + column: $table.fwCommitSha, + builder: (column) => column, + ); + + GeneratedColumn get board => + $composableBuilder(column: $table.board, builder: (column) => column); + + GeneratedColumn get buildType => + $composableBuilder(column: $table.buildType, builder: (column) => column); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => column, + ); + + GeneratedColumn get analyzed => + $composableBuilder(column: $table.analyzed, builder: (column) => column); + + GeneratedColumn get backtrace => + $composableBuilder(column: $table.backtrace, builder: (column) => column); + + GeneratedColumn get registers => + $composableBuilder(column: $table.registers, builder: (column) => column); + + GeneratedColumn get rawOutput => + $composableBuilder(column: $table.rawOutput, builder: (column) => column); + + GeneratedColumn get analysisError => $composableBuilder( + column: $table.analysisError, + builder: (column) => column, + ); + + GeneratedColumn get elfAvailable => $composableBuilder( + column: $table.elfAvailable, + builder: (column) => column, + ); $$WatchesTableAnnotationComposer get watchId { final $$WatchesTableAnnotationComposer composer = $composerBuilder( @@ -4253,75 +8745,109 @@ class $$ConnectionEventsTableAnnotationComposer } } -class $$ConnectionEventsTableTableManager +class $$CrashReportsTableTableManager extends RootTableManager< _$AppDatabase, - $ConnectionEventsTable, - ConnectionEventEntity, - $$ConnectionEventsTableFilterComposer, - $$ConnectionEventsTableOrderingComposer, - $$ConnectionEventsTableAnnotationComposer, - $$ConnectionEventsTableCreateCompanionBuilder, - $$ConnectionEventsTableUpdateCompanionBuilder, - (ConnectionEventEntity, $$ConnectionEventsTableReferences), - ConnectionEventEntity, + $CrashReportsTable, + CrashReportEntity, + $$CrashReportsTableFilterComposer, + $$CrashReportsTableOrderingComposer, + $$CrashReportsTableAnnotationComposer, + $$CrashReportsTableCreateCompanionBuilder, + $$CrashReportsTableUpdateCompanionBuilder, + (CrashReportEntity, $$CrashReportsTableReferences), + CrashReportEntity, PrefetchHooks Function({bool watchId}) > { - $$ConnectionEventsTableTableManager( - _$AppDatabase db, - $ConnectionEventsTable table, - ) : super( + $$CrashReportsTableTableManager(_$AppDatabase db, $CrashReportsTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$ConnectionEventsTableFilterComposer($db: db, $table: table), + $$CrashReportsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$ConnectionEventsTableOrderingComposer($db: db, $table: table), + $$CrashReportsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$ConnectionEventsTableAnnotationComposer($db: db, $table: table), + $$CrashReportsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value watchId = const Value.absent(), - Value eventType = const Value.absent(), - Value timestamp = const Value.absent(), - Value reason = const Value.absent(), - Value details = const Value.absent(), - Value sessionId = const Value.absent(), - }) => ConnectionEventsCompanion( + Value file = const Value.absent(), + Value line = const Value.absent(), + Value crashTime = const Value.absent(), + Value fwVersion = const Value.absent(), + Value fwCommitSha = const Value.absent(), + Value board = const Value.absent(), + Value buildType = const Value.absent(), + Value receivedAt = const Value.absent(), + Value analyzed = const Value.absent(), + Value backtrace = const Value.absent(), + Value registers = const Value.absent(), + Value rawOutput = const Value.absent(), + Value analysisError = const Value.absent(), + Value elfAvailable = const Value.absent(), + }) => CrashReportsCompanion( id: id, watchId: watchId, - eventType: eventType, - timestamp: timestamp, - reason: reason, - details: details, - sessionId: sessionId, + file: file, + line: line, + crashTime: crashTime, + fwVersion: fwVersion, + fwCommitSha: fwCommitSha, + board: board, + buildType: buildType, + receivedAt: receivedAt, + analyzed: analyzed, + backtrace: backtrace, + registers: registers, + rawOutput: rawOutput, + analysisError: analysisError, + elfAvailable: elfAvailable, ), createCompanionCallback: ({ Value id = const Value.absent(), required String watchId, - required String eventType, - required DateTime timestamp, - Value reason = const Value.absent(), - Value details = const Value.absent(), - Value sessionId = const Value.absent(), - }) => ConnectionEventsCompanion.insert( + required String file, + required int line, + required String crashTime, + required String fwVersion, + required String fwCommitSha, + required String board, + required String buildType, + required DateTime receivedAt, + Value analyzed = const Value.absent(), + Value backtrace = const Value.absent(), + Value registers = const Value.absent(), + Value rawOutput = const Value.absent(), + Value analysisError = const Value.absent(), + Value elfAvailable = const Value.absent(), + }) => CrashReportsCompanion.insert( id: id, watchId: watchId, - eventType: eventType, - timestamp: timestamp, - reason: reason, - details: details, - sessionId: sessionId, + file: file, + line: line, + crashTime: crashTime, + fwVersion: fwVersion, + fwCommitSha: fwCommitSha, + board: board, + buildType: buildType, + receivedAt: receivedAt, + analyzed: analyzed, + backtrace: backtrace, + registers: registers, + rawOutput: rawOutput, + analysisError: analysisError, + elfAvailable: elfAvailable, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$ConnectionEventsTableReferences(db, table, e), + $$CrashReportsTableReferences(db, table, e), ), ) .toList(), @@ -4350,13 +8876,11 @@ class $$ConnectionEventsTableTableManager state.withJoin( currentTable: table, currentColumn: table.watchId, - referencedTable: - $$ConnectionEventsTableReferences - ._watchIdTable(db), - referencedColumn: - $$ConnectionEventsTableReferences - ._watchIdTable(db) - .id, + referencedTable: $$CrashReportsTableReferences + ._watchIdTable(db), + referencedColumn: $$CrashReportsTableReferences + ._watchIdTable(db) + .id, ) as T; } @@ -4372,18 +8896,18 @@ class $$ConnectionEventsTableTableManager ); } -typedef $$ConnectionEventsTableProcessedTableManager = +typedef $$CrashReportsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $ConnectionEventsTable, - ConnectionEventEntity, - $$ConnectionEventsTableFilterComposer, - $$ConnectionEventsTableOrderingComposer, - $$ConnectionEventsTableAnnotationComposer, - $$ConnectionEventsTableCreateCompanionBuilder, - $$ConnectionEventsTableUpdateCompanionBuilder, - (ConnectionEventEntity, $$ConnectionEventsTableReferences), - ConnectionEventEntity, + $CrashReportsTable, + CrashReportEntity, + $$CrashReportsTableFilterComposer, + $$CrashReportsTableOrderingComposer, + $$CrashReportsTableAnnotationComposer, + $$CrashReportsTableCreateCompanionBuilder, + $$CrashReportsTableUpdateCompanionBuilder, + (CrashReportEntity, $$CrashReportsTableReferences), + CrashReportEntity, PrefetchHooks Function({bool watchId}) >; @@ -4400,4 +8924,10 @@ class $AppDatabaseManager { $$CommLogEntriesTableTableManager(_db, _db.commLogEntries); $$ConnectionEventsTableTableManager get connectionEvents => $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); + $$VoiceMemosTableTableManager get voiceMemos => + $$VoiceMemosTableTableManager(_db, _db.voiceMemos); + $$ExtractedActionsTableTableManager get extractedActions => + $$ExtractedActionsTableTableManager(_db, _db.extractedActions); + $$CrashReportsTableTableManager get crashReports => + $$CrashReportsTableTableManager(_db, _db.crashReports); } diff --git a/zswatch_app/lib/data/database/tables/battery_readings_table.dart b/zswatch_app/lib/data/database/tables/battery_readings_table.dart index 4148916..e55e17a 100644 --- a/zswatch_app/lib/data/database/tables/battery_readings_table.dart +++ b/zswatch_app/lib/data/database/tables/battery_readings_table.dart @@ -12,8 +12,7 @@ class BatteryReadings extends Table { IntColumn get id => integer().autoIncrement()(); /// Foreign key to source watch - TextColumn get watchId => - text().references(Watches, #id).named('watch_id')(); + TextColumn get watchId => text().references(Watches, #id).named('watch_id')(); /// Battery percentage (0-100) IntColumn get level => integer()(); @@ -25,4 +24,3 @@ class BatteryReadings extends Table { /// When the sample was taken DateTimeColumn get timestamp => dateTime()(); } - diff --git a/zswatch_app/lib/data/database/tables/comm_log_entries_table.dart b/zswatch_app/lib/data/database/tables/comm_log_entries_table.dart index 7994d4c..1e6cd25 100644 --- a/zswatch_app/lib/data/database/tables/comm_log_entries_table.dart +++ b/zswatch_app/lib/data/database/tables/comm_log_entries_table.dart @@ -27,4 +27,3 @@ class CommLogEntries extends Table { /// When the message was sent/received DateTimeColumn get timestamp => dateTime()(); } - diff --git a/zswatch_app/lib/data/database/tables/connection_events_table.dart b/zswatch_app/lib/data/database/tables/connection_events_table.dart index 59564c6..43260cf 100644 --- a/zswatch_app/lib/data/database/tables/connection_events_table.dart +++ b/zswatch_app/lib/data/database/tables/connection_events_table.dart @@ -15,8 +15,7 @@ class ConnectionEvents extends Table { IntColumn get id => integer().autoIncrement()(); /// Foreign key to source watch - TextColumn get watchId => - text().references(Watches, #id).named('watch_id')(); + TextColumn get watchId => text().references(Watches, #id).named('watch_id')(); /// Type of event: connected, disconnected, reconnect_attempt, reconnect_failed TextColumn get eventType => text().named('event_type')(); @@ -25,7 +24,7 @@ class ConnectionEvents extends Table { DateTimeColumn get timestamp => dateTime()(); /// Reason for disconnection (only for disconnect events) - /// Values: user_requested, connection_lost, device_unavailable, + /// Values: user_requested, connection_lost, device_unavailable, /// bluetooth_disabled, app_terminated, unknown TextColumn get reason => text().nullable()(); diff --git a/zswatch_app/lib/data/database/tables/crash_reports_table.dart b/zswatch_app/lib/data/database/tables/crash_reports_table.dart new file mode 100644 index 0000000..667edbb --- /dev/null +++ b/zswatch_app/lib/data/database/tables/crash_reports_table.dart @@ -0,0 +1,59 @@ +import 'package:drift/drift.dart'; + +import 'watches_table.dart'; + +/// Crash reports table - persists crash summaries and analysis results +/// +/// Each row represents one crash event received from the watch. +/// Analysis results (backtrace, registers) are stored after server decode. +@DataClassName('CrashReportEntity') +class CrashReports extends Table { + /// Auto-incrementing row identifier + IntColumn get id => integer().autoIncrement()(); + + /// Foreign key to source watch + TextColumn get watchId => text().references(Watches, #id).named('watch_id')(); + + /// Source file that crashed + TextColumn get file => text()(); + + /// Line number of the crash + IntColumn get line => integer()(); + + /// Crash timestamp as reported by the watch + TextColumn get crashTime => text().named('crash_time')(); + + /// Firmware version at time of crash + TextColumn get fwVersion => text().named('fw_version')(); + + /// Firmware commit SHA at time of crash + TextColumn get fwCommitSha => text().named('fw_commit_sha')(); + + /// Board identifier + TextColumn get board => text()(); + + /// Build type (debug/release) + TextColumn get buildType => text().named('build_type')(); + + /// When this crash was first received by the app + DateTimeColumn get receivedAt => dateTime().named('received_at')(); + + /// Whether analysis has been performed + BoolColumn get analyzed => boolean().withDefault(const Constant(false))(); + + /// Decoded backtrace from server (null if not analyzed) + TextColumn get backtrace => text().nullable()(); + + /// Decoded registers from server (null if not analyzed) + TextColumn get registers => text().nullable()(); + + /// Raw GDB output from server (null if not analyzed) + TextColumn get rawOutput => text().nullable().named('raw_output')(); + + /// Error message if analysis failed + TextColumn get analysisError => text().nullable().named('analysis_error')(); + + /// Whether ELF was available for analysis + BoolColumn get elfAvailable => + boolean().withDefault(const Constant(false)).named('elf_available')(); +} diff --git a/zswatch_app/lib/data/database/tables/extracted_actions_table.dart b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart new file mode 100644 index 0000000..ea95553 --- /dev/null +++ b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart @@ -0,0 +1,52 @@ +import 'package:drift/drift.dart'; + +/// Extracted actions from AI processing of voice memos. +/// +/// Each row represents a single task, reminder, or calendar event +/// suggestion produced by the local LLM from a parent voice memo. +@DataClassName('ExtractedActionEntity') +class ExtractedActions extends Table { + /// Auto-incrementing row identifier + IntColumn get id => integer().autoIncrement()(); + + /// Foreign key to the parent voice memo + IntColumn get memoId => integer().named('memo_id')(); + + /// Action type: 'task', 'calendar_event', 'reminder' + TextColumn get actionType => text().named('action_type')(); + + /// AI-generated title for the action + TextColumn get title => text()(); + + /// Optional notes / body text + TextColumn get notes => text().nullable()(); + + /// Suggested start time (for calendar events) + DateTimeColumn get startTime => dateTime().nullable().named('start_time')(); + + /// Suggested end time (for calendar events) + DateTimeColumn get endTime => dateTime().nullable().named('end_time')(); + + /// Suggested due date (for tasks / reminders) + DateTimeColumn get dueDate => dateTime().nullable().named('due_date')(); + + /// Optional location + TextColumn get location => text().nullable()(); + + /// Reminder offset in minutes before the event + IntColumn get reminderMinutes => + integer().nullable().named('reminder_minutes')(); + + /// Whether this action has been created in the OS (calendar / reminders) + BoolColumn get created => boolean().withDefault(const Constant(false))(); + + /// Whether the user dismissed this suggestion + BoolColumn get dismissed => boolean().withDefault(const Constant(false))(); + + /// Platform-specific ID after creation (e.g. calendar event ID) + TextColumn get platformTargetId => + text().nullable().named('platform_target_id')(); + + /// When this action was created in the OS + DateTimeColumn get createdAt => dateTime().nullable().named('created_at')(); +} diff --git a/zswatch_app/lib/data/database/tables/health_samples_table.dart b/zswatch_app/lib/data/database/tables/health_samples_table.dart index 36fd8df..9ac7cca 100644 --- a/zswatch_app/lib/data/database/tables/health_samples_table.dart +++ b/zswatch_app/lib/data/database/tables/health_samples_table.dart @@ -12,8 +12,7 @@ class HealthSamples extends Table { IntColumn get id => integer().autoIncrement()(); /// Foreign key to source watch - TextColumn get watchId => - text().references(Watches, #id).named('watch_id')(); + TextColumn get watchId => text().references(Watches, #id).named('watch_id')(); /// Type of health data (steps, heartRate, sleep) TextColumn get type => text()(); @@ -30,4 +29,3 @@ class HealthSamples extends Table { /// When the data was received by the app DateTimeColumn get syncedAt => dateTime().named('synced_at')(); } - diff --git a/zswatch_app/lib/data/database/tables/voice_memos_table.dart b/zswatch_app/lib/data/database/tables/voice_memos_table.dart new file mode 100644 index 0000000..670fb5a --- /dev/null +++ b/zswatch_app/lib/data/database/tables/voice_memos_table.dart @@ -0,0 +1,83 @@ +import 'package:drift/drift.dart'; + +/// Voice memos table - recordings synced from ZSWatch +/// +/// Stores metadata about voice recordings captured on the watch, +/// their sync status, local file paths after download, and +/// transcription results. +@DataClassName('VoiceMemoEntity') +class VoiceMemos extends Table { + /// Auto-incrementing row identifier + IntColumn get id => integer().autoIncrement()(); + + /// Original filename on the watch (e.g., "20260304_143022") + TextColumn get filename => text()(); + + /// Recording timestamp as Unix epoch seconds (UTC) + IntColumn get timestampUtc => integer().named('timestamp_utc')(); + + /// Recording duration in milliseconds + IntColumn get durationMs => integer().named('duration_ms')(); + + /// File size in bytes (Opus-encoded .zsw_opus) + IntColumn get sizeBytes => integer().named('size_bytes')(); + + /// Local file path after download (null = not yet downloaded) + TextColumn get localFilePath => text().nullable().named('local_file_path')(); + + /// Transcription text (null = not yet transcribed) + TextColumn get transcription => text().nullable()(); + + /// Whether the file has been synced (downloaded) from the watch + BoolColumn get syncedFromWatch => + boolean().withDefault(const Constant(false)).named('synced_from_watch')(); + + /// Whether the file has been deleted on the watch after sync + BoolColumn get deletedOnWatch => + boolean().withDefault(const Constant(false)).named('deleted_on_watch')(); + + /// When the file was downloaded to the phone + DateTimeColumn get downloadedAt => + dateTime().nullable().named('downloaded_at')(); + + /// When the transcription was completed + DateTimeColumn get transcribedAt => + dateTime().nullable().named('transcribed_at')(); + + /// Path to converted audio file (WAV/Ogg) for playback/transcription + TextColumn get convertedFilePath => + text().nullable().named('converted_file_path')(); + + // ==================== AI-Enhanced Fields ==================== + + /// AI-generated summary of the voice note + TextColumn get summary => text().nullable()(); + + /// AI-assigned category: 'idea', 'task', 'reminder', 'meeting', 'note' + TextColumn get category => text().nullable()(); + + /// Current AI processing status: 'pending', 'summarizing', 'categorizing', + /// 'extractingActions', 'ready', 'failed' + TextColumn get processingStatus => + text().nullable().named('processing_status')(); + + /// Which AI model was used for processing + TextColumn get aiModel => text().nullable().named('ai_model')(); + + /// When AI processing completed + DateTimeColumn get aiProcessedAt => + dateTime().nullable().named('ai_processed_at')(); + + /// Whether a task has been created from this memo's suggestions + BoolColumn get taskCreated => + boolean().withDefault(const Constant(false)).named('task_created')(); + + /// Whether a calendar event has been created from this memo's suggestions + BoolColumn get calendarEventCreated => boolean() + .withDefault(const Constant(false)) + .named('calendar_event_created')(); + + /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' + TextColumn get actionReviewState => + text().nullable().named('action_review_state')(); +} diff --git a/zswatch_app/lib/data/database/tables/watches_table.dart b/zswatch_app/lib/data/database/tables/watches_table.dart index 3303406..3bed47d 100644 --- a/zswatch_app/lib/data/database/tables/watches_table.dart +++ b/zswatch_app/lib/data/database/tables/watches_table.dart @@ -43,4 +43,3 @@ class Watches extends Table { @override Set> get primaryKey => {id}; } - diff --git a/zswatch_app/lib/data/models/comm_log_entry.dart b/zswatch_app/lib/data/models/comm_log_entry.dart index 2f77661..e60f7d9 100644 --- a/zswatch_app/lib/data/models/comm_log_entry.dart +++ b/zswatch_app/lib/data/models/comm_log_entry.dart @@ -1,4 +1,6 @@ -import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'comm_log_entry.freezed.dart'; /// Direction of BLE communication enum CommDirection { @@ -10,42 +12,35 @@ enum CommDirection { } /// A single communication log entry for debugging BLE traffic -@immutable -class CommLogEntry { - /// Unique identifier for this entry - final int id; +@freezed +abstract class CommLogEntry with _$CommLogEntry { + const CommLogEntry._(); - /// Timestamp when the entry was recorded - final DateTime timestamp; + const factory CommLogEntry({ + /// Unique identifier for this entry + required int id, - /// The raw data content - final String data; + /// Timestamp when the entry was recorded + required DateTime timestamp, - /// Direction of communication - final CommDirection direction; + /// The raw data content + required String data, - /// Size in bytes - final int sizeBytes; + /// Direction of communication + required CommDirection direction, - /// Optional parsed message type (from 't' field in JSON) - final String? messageType; + /// Size in bytes + required int sizeBytes, - /// Whether the data was chunked across multiple BLE packets - final bool wasChunked; + /// Optional parsed message type (from 't' field in JSON) + String? messageType, - /// Number of chunks if chunked - final int? chunkCount; + /// Whether the data was chunked across multiple BLE packets + @Default(false) bool wasChunked, - const CommLogEntry({ - required this.id, - required this.timestamp, - required this.data, - required this.direction, - required this.sizeBytes, - this.messageType, - this.wasChunked = false, - this.chunkCount, - }); + /// Number of chunks if chunked + int? chunkCount, + }) = _CommLogEntry; /// Create a TX (outgoing) entry factory CommLogEntry.tx({ @@ -109,56 +104,35 @@ class CommLogEntry { if (sizeBytes < 1024) return '$sizeBytes B'; return '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CommLogEntry && - runtimeType == other.runtimeType && - id == other.id && - timestamp == other.timestamp; - - @override - int get hashCode => Object.hash(id, timestamp); - - @override - String toString() => - 'CommLogEntry(id: $id, direction: $direction, size: $sizeBytes, type: $messageType)'; } /// Statistics for communication log -@immutable -class CommLogStats { - /// Total entries in the log - final int totalEntries; +@freezed +abstract class CommLogStats with _$CommLogStats { + const CommLogStats._(); - /// Total TX entries - final int txCount; + const factory CommLogStats({ + /// Total entries in the log + @Default(0) int totalEntries, - /// Total RX entries - final int rxCount; + /// Total TX entries + @Default(0) int txCount, - /// Total bytes sent - final int totalTxBytes; + /// Total RX entries + @Default(0) int rxCount, - /// Total bytes received - final int totalRxBytes; + /// Total bytes sent + @Default(0) int totalTxBytes, - /// Oldest entry timestamp - final DateTime? oldestEntry; + /// Total bytes received + @Default(0) int totalRxBytes, - /// Newest entry timestamp - final DateTime? newestEntry; + /// Oldest entry timestamp + DateTime? oldestEntry, - const CommLogStats({ - this.totalEntries = 0, - this.txCount = 0, - this.rxCount = 0, - this.totalTxBytes = 0, - this.totalRxBytes = 0, - this.oldestEntry, - this.newestEntry, - }); + /// Newest entry timestamp + DateTime? newestEntry, + }) = _CommLogStats; /// Total bytes (TX + RX) int get totalBytes => totalTxBytes + totalRxBytes; @@ -177,16 +151,4 @@ class CommLogStats { if (oldestEntry == null || newestEntry == null) return null; return newestEntry!.difference(oldestEntry!); } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CommLogStats && - runtimeType == other.runtimeType && - totalEntries == other.totalEntries && - totalTxBytes == other.totalTxBytes && - totalRxBytes == other.totalRxBytes; - - @override - int get hashCode => Object.hash(totalEntries, totalTxBytes, totalRxBytes); } diff --git a/zswatch_app/lib/data/models/comm_log_entry.freezed.dart b/zswatch_app/lib/data/models/comm_log_entry.freezed.dart new file mode 100644 index 0000000..7ef5927 --- /dev/null +++ b/zswatch_app/lib/data/models/comm_log_entry.freezed.dart @@ -0,0 +1,597 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'comm_log_entry.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CommLogEntry { + +/// Unique identifier for this entry + int get id;/// Timestamp when the entry was recorded + DateTime get timestamp;/// The raw data content + String get data;/// Direction of communication + CommDirection get direction;/// Size in bytes + int get sizeBytes;/// Optional parsed message type (from 't' field in JSON) + String? get messageType;/// Whether the data was chunked across multiple BLE packets + bool get wasChunked;/// Number of chunks if chunked + int? get chunkCount; +/// Create a copy of CommLogEntry +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CommLogEntryCopyWith get copyWith => _$CommLogEntryCopyWithImpl(this as CommLogEntry, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CommLogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.data, data) || other.data == data)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.sizeBytes, sizeBytes) || other.sizeBytes == sizeBytes)&&(identical(other.messageType, messageType) || other.messageType == messageType)&&(identical(other.wasChunked, wasChunked) || other.wasChunked == wasChunked)&&(identical(other.chunkCount, chunkCount) || other.chunkCount == chunkCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,data,direction,sizeBytes,messageType,wasChunked,chunkCount); + +@override +String toString() { + return 'CommLogEntry(id: $id, timestamp: $timestamp, data: $data, direction: $direction, sizeBytes: $sizeBytes, messageType: $messageType, wasChunked: $wasChunked, chunkCount: $chunkCount)'; +} + + +} + +/// @nodoc +abstract mixin class $CommLogEntryCopyWith<$Res> { + factory $CommLogEntryCopyWith(CommLogEntry value, $Res Function(CommLogEntry) _then) = _$CommLogEntryCopyWithImpl; +@useResult +$Res call({ + int id, DateTime timestamp, String data, CommDirection direction, int sizeBytes, String? messageType, bool wasChunked, int? chunkCount +}); + + + + +} +/// @nodoc +class _$CommLogEntryCopyWithImpl<$Res> + implements $CommLogEntryCopyWith<$Res> { + _$CommLogEntryCopyWithImpl(this._self, this._then); + + final CommLogEntry _self; + final $Res Function(CommLogEntry) _then; + +/// Create a copy of CommLogEntry +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? data = null,Object? direction = null,Object? sizeBytes = null,Object? messageType = freezed,Object? wasChunked = null,Object? chunkCount = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as String,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as CommDirection,sizeBytes: null == sizeBytes ? _self.sizeBytes : sizeBytes // ignore: cast_nullable_to_non_nullable +as int,messageType: freezed == messageType ? _self.messageType : messageType // ignore: cast_nullable_to_non_nullable +as String?,wasChunked: null == wasChunked ? _self.wasChunked : wasChunked // ignore: cast_nullable_to_non_nullable +as bool,chunkCount: freezed == chunkCount ? _self.chunkCount : chunkCount // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CommLogEntry]. +extension CommLogEntryPatterns on CommLogEntry { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CommLogEntry value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CommLogEntry() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CommLogEntry value) $default,){ +final _that = this; +switch (_that) { +case _CommLogEntry(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CommLogEntry value)? $default,){ +final _that = this; +switch (_that) { +case _CommLogEntry() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, DateTime timestamp, String data, CommDirection direction, int sizeBytes, String? messageType, bool wasChunked, int? chunkCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CommLogEntry() when $default != null: +return $default(_that.id,_that.timestamp,_that.data,_that.direction,_that.sizeBytes,_that.messageType,_that.wasChunked,_that.chunkCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, DateTime timestamp, String data, CommDirection direction, int sizeBytes, String? messageType, bool wasChunked, int? chunkCount) $default,) {final _that = this; +switch (_that) { +case _CommLogEntry(): +return $default(_that.id,_that.timestamp,_that.data,_that.direction,_that.sizeBytes,_that.messageType,_that.wasChunked,_that.chunkCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, DateTime timestamp, String data, CommDirection direction, int sizeBytes, String? messageType, bool wasChunked, int? chunkCount)? $default,) {final _that = this; +switch (_that) { +case _CommLogEntry() when $default != null: +return $default(_that.id,_that.timestamp,_that.data,_that.direction,_that.sizeBytes,_that.messageType,_that.wasChunked,_that.chunkCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CommLogEntry extends CommLogEntry { + const _CommLogEntry({required this.id, required this.timestamp, required this.data, required this.direction, required this.sizeBytes, this.messageType, this.wasChunked = false, this.chunkCount}): super._(); + + +/// Unique identifier for this entry +@override final int id; +/// Timestamp when the entry was recorded +@override final DateTime timestamp; +/// The raw data content +@override final String data; +/// Direction of communication +@override final CommDirection direction; +/// Size in bytes +@override final int sizeBytes; +/// Optional parsed message type (from 't' field in JSON) +@override final String? messageType; +/// Whether the data was chunked across multiple BLE packets +@override@JsonKey() final bool wasChunked; +/// Number of chunks if chunked +@override final int? chunkCount; + +/// Create a copy of CommLogEntry +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CommLogEntryCopyWith<_CommLogEntry> get copyWith => __$CommLogEntryCopyWithImpl<_CommLogEntry>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CommLogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.data, data) || other.data == data)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.sizeBytes, sizeBytes) || other.sizeBytes == sizeBytes)&&(identical(other.messageType, messageType) || other.messageType == messageType)&&(identical(other.wasChunked, wasChunked) || other.wasChunked == wasChunked)&&(identical(other.chunkCount, chunkCount) || other.chunkCount == chunkCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,data,direction,sizeBytes,messageType,wasChunked,chunkCount); + +@override +String toString() { + return 'CommLogEntry(id: $id, timestamp: $timestamp, data: $data, direction: $direction, sizeBytes: $sizeBytes, messageType: $messageType, wasChunked: $wasChunked, chunkCount: $chunkCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$CommLogEntryCopyWith<$Res> implements $CommLogEntryCopyWith<$Res> { + factory _$CommLogEntryCopyWith(_CommLogEntry value, $Res Function(_CommLogEntry) _then) = __$CommLogEntryCopyWithImpl; +@override @useResult +$Res call({ + int id, DateTime timestamp, String data, CommDirection direction, int sizeBytes, String? messageType, bool wasChunked, int? chunkCount +}); + + + + +} +/// @nodoc +class __$CommLogEntryCopyWithImpl<$Res> + implements _$CommLogEntryCopyWith<$Res> { + __$CommLogEntryCopyWithImpl(this._self, this._then); + + final _CommLogEntry _self; + final $Res Function(_CommLogEntry) _then; + +/// Create a copy of CommLogEntry +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? data = null,Object? direction = null,Object? sizeBytes = null,Object? messageType = freezed,Object? wasChunked = null,Object? chunkCount = freezed,}) { + return _then(_CommLogEntry( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as String,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as CommDirection,sizeBytes: null == sizeBytes ? _self.sizeBytes : sizeBytes // ignore: cast_nullable_to_non_nullable +as int,messageType: freezed == messageType ? _self.messageType : messageType // ignore: cast_nullable_to_non_nullable +as String?,wasChunked: null == wasChunked ? _self.wasChunked : wasChunked // ignore: cast_nullable_to_non_nullable +as bool,chunkCount: freezed == chunkCount ? _self.chunkCount : chunkCount // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +/// @nodoc +mixin _$CommLogStats { + +/// Total entries in the log + int get totalEntries;/// Total TX entries + int get txCount;/// Total RX entries + int get rxCount;/// Total bytes sent + int get totalTxBytes;/// Total bytes received + int get totalRxBytes;/// Oldest entry timestamp + DateTime? get oldestEntry;/// Newest entry timestamp + DateTime? get newestEntry; +/// Create a copy of CommLogStats +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CommLogStatsCopyWith get copyWith => _$CommLogStatsCopyWithImpl(this as CommLogStats, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CommLogStats&&(identical(other.totalEntries, totalEntries) || other.totalEntries == totalEntries)&&(identical(other.txCount, txCount) || other.txCount == txCount)&&(identical(other.rxCount, rxCount) || other.rxCount == rxCount)&&(identical(other.totalTxBytes, totalTxBytes) || other.totalTxBytes == totalTxBytes)&&(identical(other.totalRxBytes, totalRxBytes) || other.totalRxBytes == totalRxBytes)&&(identical(other.oldestEntry, oldestEntry) || other.oldestEntry == oldestEntry)&&(identical(other.newestEntry, newestEntry) || other.newestEntry == newestEntry)); +} + + +@override +int get hashCode => Object.hash(runtimeType,totalEntries,txCount,rxCount,totalTxBytes,totalRxBytes,oldestEntry,newestEntry); + +@override +String toString() { + return 'CommLogStats(totalEntries: $totalEntries, txCount: $txCount, rxCount: $rxCount, totalTxBytes: $totalTxBytes, totalRxBytes: $totalRxBytes, oldestEntry: $oldestEntry, newestEntry: $newestEntry)'; +} + + +} + +/// @nodoc +abstract mixin class $CommLogStatsCopyWith<$Res> { + factory $CommLogStatsCopyWith(CommLogStats value, $Res Function(CommLogStats) _then) = _$CommLogStatsCopyWithImpl; +@useResult +$Res call({ + int totalEntries, int txCount, int rxCount, int totalTxBytes, int totalRxBytes, DateTime? oldestEntry, DateTime? newestEntry +}); + + + + +} +/// @nodoc +class _$CommLogStatsCopyWithImpl<$Res> + implements $CommLogStatsCopyWith<$Res> { + _$CommLogStatsCopyWithImpl(this._self, this._then); + + final CommLogStats _self; + final $Res Function(CommLogStats) _then; + +/// Create a copy of CommLogStats +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? totalEntries = null,Object? txCount = null,Object? rxCount = null,Object? totalTxBytes = null,Object? totalRxBytes = null,Object? oldestEntry = freezed,Object? newestEntry = freezed,}) { + return _then(_self.copyWith( +totalEntries: null == totalEntries ? _self.totalEntries : totalEntries // ignore: cast_nullable_to_non_nullable +as int,txCount: null == txCount ? _self.txCount : txCount // ignore: cast_nullable_to_non_nullable +as int,rxCount: null == rxCount ? _self.rxCount : rxCount // ignore: cast_nullable_to_non_nullable +as int,totalTxBytes: null == totalTxBytes ? _self.totalTxBytes : totalTxBytes // ignore: cast_nullable_to_non_nullable +as int,totalRxBytes: null == totalRxBytes ? _self.totalRxBytes : totalRxBytes // ignore: cast_nullable_to_non_nullable +as int,oldestEntry: freezed == oldestEntry ? _self.oldestEntry : oldestEntry // ignore: cast_nullable_to_non_nullable +as DateTime?,newestEntry: freezed == newestEntry ? _self.newestEntry : newestEntry // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CommLogStats]. +extension CommLogStatsPatterns on CommLogStats { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CommLogStats value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CommLogStats() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CommLogStats value) $default,){ +final _that = this; +switch (_that) { +case _CommLogStats(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CommLogStats value)? $default,){ +final _that = this; +switch (_that) { +case _CommLogStats() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int totalEntries, int txCount, int rxCount, int totalTxBytes, int totalRxBytes, DateTime? oldestEntry, DateTime? newestEntry)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CommLogStats() when $default != null: +return $default(_that.totalEntries,_that.txCount,_that.rxCount,_that.totalTxBytes,_that.totalRxBytes,_that.oldestEntry,_that.newestEntry);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int totalEntries, int txCount, int rxCount, int totalTxBytes, int totalRxBytes, DateTime? oldestEntry, DateTime? newestEntry) $default,) {final _that = this; +switch (_that) { +case _CommLogStats(): +return $default(_that.totalEntries,_that.txCount,_that.rxCount,_that.totalTxBytes,_that.totalRxBytes,_that.oldestEntry,_that.newestEntry);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int totalEntries, int txCount, int rxCount, int totalTxBytes, int totalRxBytes, DateTime? oldestEntry, DateTime? newestEntry)? $default,) {final _that = this; +switch (_that) { +case _CommLogStats() when $default != null: +return $default(_that.totalEntries,_that.txCount,_that.rxCount,_that.totalTxBytes,_that.totalRxBytes,_that.oldestEntry,_that.newestEntry);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CommLogStats extends CommLogStats { + const _CommLogStats({this.totalEntries = 0, this.txCount = 0, this.rxCount = 0, this.totalTxBytes = 0, this.totalRxBytes = 0, this.oldestEntry, this.newestEntry}): super._(); + + +/// Total entries in the log +@override@JsonKey() final int totalEntries; +/// Total TX entries +@override@JsonKey() final int txCount; +/// Total RX entries +@override@JsonKey() final int rxCount; +/// Total bytes sent +@override@JsonKey() final int totalTxBytes; +/// Total bytes received +@override@JsonKey() final int totalRxBytes; +/// Oldest entry timestamp +@override final DateTime? oldestEntry; +/// Newest entry timestamp +@override final DateTime? newestEntry; + +/// Create a copy of CommLogStats +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CommLogStatsCopyWith<_CommLogStats> get copyWith => __$CommLogStatsCopyWithImpl<_CommLogStats>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CommLogStats&&(identical(other.totalEntries, totalEntries) || other.totalEntries == totalEntries)&&(identical(other.txCount, txCount) || other.txCount == txCount)&&(identical(other.rxCount, rxCount) || other.rxCount == rxCount)&&(identical(other.totalTxBytes, totalTxBytes) || other.totalTxBytes == totalTxBytes)&&(identical(other.totalRxBytes, totalRxBytes) || other.totalRxBytes == totalRxBytes)&&(identical(other.oldestEntry, oldestEntry) || other.oldestEntry == oldestEntry)&&(identical(other.newestEntry, newestEntry) || other.newestEntry == newestEntry)); +} + + +@override +int get hashCode => Object.hash(runtimeType,totalEntries,txCount,rxCount,totalTxBytes,totalRxBytes,oldestEntry,newestEntry); + +@override +String toString() { + return 'CommLogStats(totalEntries: $totalEntries, txCount: $txCount, rxCount: $rxCount, totalTxBytes: $totalTxBytes, totalRxBytes: $totalRxBytes, oldestEntry: $oldestEntry, newestEntry: $newestEntry)'; +} + + +} + +/// @nodoc +abstract mixin class _$CommLogStatsCopyWith<$Res> implements $CommLogStatsCopyWith<$Res> { + factory _$CommLogStatsCopyWith(_CommLogStats value, $Res Function(_CommLogStats) _then) = __$CommLogStatsCopyWithImpl; +@override @useResult +$Res call({ + int totalEntries, int txCount, int rxCount, int totalTxBytes, int totalRxBytes, DateTime? oldestEntry, DateTime? newestEntry +}); + + + + +} +/// @nodoc +class __$CommLogStatsCopyWithImpl<$Res> + implements _$CommLogStatsCopyWith<$Res> { + __$CommLogStatsCopyWithImpl(this._self, this._then); + + final _CommLogStats _self; + final $Res Function(_CommLogStats) _then; + +/// Create a copy of CommLogStats +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? totalEntries = null,Object? txCount = null,Object? rxCount = null,Object? totalTxBytes = null,Object? totalRxBytes = null,Object? oldestEntry = freezed,Object? newestEntry = freezed,}) { + return _then(_CommLogStats( +totalEntries: null == totalEntries ? _self.totalEntries : totalEntries // ignore: cast_nullable_to_non_nullable +as int,txCount: null == txCount ? _self.txCount : txCount // ignore: cast_nullable_to_non_nullable +as int,rxCount: null == rxCount ? _self.rxCount : rxCount // ignore: cast_nullable_to_non_nullable +as int,totalTxBytes: null == totalTxBytes ? _self.totalTxBytes : totalTxBytes // ignore: cast_nullable_to_non_nullable +as int,totalRxBytes: null == totalRxBytes ? _self.totalRxBytes : totalRxBytes // ignore: cast_nullable_to_non_nullable +as int,oldestEntry: freezed == oldestEntry ? _self.oldestEntry : oldestEntry // ignore: cast_nullable_to_non_nullable +as DateTime?,newestEntry: freezed == newestEntry ? _self.newestEntry : newestEntry // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/connection.dart b/zswatch_app/lib/data/models/connection.dart index 4e574b6..8cd9fdb 100644 --- a/zswatch_app/lib/data/models/connection.dart +++ b/zswatch_app/lib/data/models/connection.dart @@ -1,15 +1,17 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'connection_state.dart'; +part 'connection.freezed.dart'; + /// PHY mode for BLE connection enum PhyMode { /// 1 Mbps PHY (default) phy1M, - + /// 2 Mbps PHY (preferred for higher throughput) phy2M, - + /// Coded PHY (long range, not typically used for ZSWatch) coded, } @@ -18,61 +20,50 @@ enum PhyMode { /// /// This is an in-memory model that holds the current connection state /// and metadata. It's not persisted to the database. -class Connection extends Equatable { - /// ID of the connected watch - final String watchId; +@freezed +abstract class Connection with _$Connection { + const Connection._(); - /// Name of the connected watch - final String? watchName; + const factory Connection({ + /// ID of the connected watch + required String watchId, - /// Current connection state - final WatchConnectionState state; + /// Name of the connected watch + String? watchName, - /// Signal strength in dBm (negative value, closer to 0 is stronger) - final int? rssi; + /// Current connection state + required WatchConnectionState state, - /// Negotiated MTU size - final int? mtu; + /// Signal strength in dBm (negative value, closer to 0 is stronger) + int? rssi, - /// Current PHY mode - final PhyMode? phyMode; + /// Negotiated MTU size + int? mtu, - /// Whether Data Length Extension is enabled - final bool dleEnabled; + /// Current PHY mode + PhyMode? phyMode, - /// Whether watch is currently charging - final bool isCharging; + /// Whether Data Length Extension is enabled + @Default(false) bool dleEnabled, - /// Number of reconnection attempts in current session - final int reconnectionCount; + /// Whether watch is currently charging + @Default(false) bool isCharging, - /// When the current connection was established - final DateTime? connectedAt; + /// Number of reconnection attempts in current session + @Default(0) int reconnectionCount, - /// Last data exchange timestamp - final DateTime? lastActivityAt; + /// When the current connection was established + DateTime? connectedAt, - /// Error information if state is error - final ConnectionErrorType? errorType; + /// Last data exchange timestamp + DateTime? lastActivityAt, - /// Additional error details - final String? errorDetails; + /// Error information if state is error + ConnectionErrorType? errorType, - const Connection({ - required this.watchId, - this.watchName, - required this.state, - this.rssi, - this.mtu, - this.phyMode, - this.dleEnabled = false, - this.isCharging = false, - this.reconnectionCount = 0, - this.connectedAt, - this.lastActivityAt, - this.errorType, - this.errorDetails, - }); + /// Additional error details + String? errorDetails, + }) = _Connection; /// Create initial disconnected connection state factory Connection.disconnected(String watchId) { @@ -84,10 +75,7 @@ class Connection extends Equatable { /// Create connection in connecting state factory Connection.connecting(String watchId) { - return Connection( - watchId: watchId, - state: WatchConnectionState.connecting, - ); + return Connection(watchId: watchId, state: WatchConnectionState.connecting); } /// Create connection in error state @@ -104,39 +92,6 @@ class Connection extends Equatable { ); } - /// Copy with modified fields - Connection copyWith({ - String? watchId, - String? watchName, - WatchConnectionState? state, - int? rssi, - int? mtu, - PhyMode? phyMode, - bool? dleEnabled, - bool? isCharging, - int? reconnectionCount, - DateTime? connectedAt, - DateTime? lastActivityAt, - ConnectionErrorType? errorType, - String? errorDetails, - }) { - return Connection( - watchId: watchId ?? this.watchId, - watchName: watchName ?? this.watchName, - state: state ?? this.state, - rssi: rssi ?? this.rssi, - mtu: mtu ?? this.mtu, - phyMode: phyMode ?? this.phyMode, - dleEnabled: dleEnabled ?? this.dleEnabled, - isCharging: isCharging ?? this.isCharging, - reconnectionCount: reconnectionCount ?? this.reconnectionCount, - connectedAt: connectedAt ?? this.connectedAt, - lastActivityAt: lastActivityAt ?? this.lastActivityAt, - errorType: errorType ?? this.errorType, - errorDetails: errorDetails ?? this.errorDetails, - ); - } - /// Whether currently connected bool get isConnected => state.isConnected; @@ -183,27 +138,4 @@ class Connection extends Equatable { final clamped = rssi!.clamp(-100, -30); return ((clamped + 100) * 100 ~/ 70).clamp(0, 100); } - - @override - List get props => [ - watchId, - watchName, - state, - rssi, - mtu, - phyMode, - dleEnabled, - isCharging, - reconnectionCount, - connectedAt, - lastActivityAt, - errorType, - errorDetails, - ]; - - @override - String toString() { - return 'Connection(watchId: $watchId, state: ${state.name}, rssi: $rssi, mtu: $mtu)'; - } } - diff --git a/zswatch_app/lib/data/models/connection.freezed.dart b/zswatch_app/lib/data/models/connection.freezed.dart new file mode 100644 index 0000000..1e1e909 --- /dev/null +++ b/zswatch_app/lib/data/models/connection.freezed.dart @@ -0,0 +1,333 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connection.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$Connection { + +/// ID of the connected watch + String get watchId;/// Name of the connected watch + String? get watchName;/// Current connection state + WatchConnectionState get state;/// Signal strength in dBm (negative value, closer to 0 is stronger) + int? get rssi;/// Negotiated MTU size + int? get mtu;/// Current PHY mode + PhyMode? get phyMode;/// Whether Data Length Extension is enabled + bool get dleEnabled;/// Whether watch is currently charging + bool get isCharging;/// Number of reconnection attempts in current session + int get reconnectionCount;/// When the current connection was established + DateTime? get connectedAt;/// Last data exchange timestamp + DateTime? get lastActivityAt;/// Error information if state is error + ConnectionErrorType? get errorType;/// Additional error details + String? get errorDetails; +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConnectionCopyWith get copyWith => _$ConnectionCopyWithImpl(this as Connection, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Connection&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.watchName, watchName) || other.watchName == watchName)&&(identical(other.state, state) || other.state == state)&&(identical(other.rssi, rssi) || other.rssi == rssi)&&(identical(other.mtu, mtu) || other.mtu == mtu)&&(identical(other.phyMode, phyMode) || other.phyMode == phyMode)&&(identical(other.dleEnabled, dleEnabled) || other.dleEnabled == dleEnabled)&&(identical(other.isCharging, isCharging) || other.isCharging == isCharging)&&(identical(other.reconnectionCount, reconnectionCount) || other.reconnectionCount == reconnectionCount)&&(identical(other.connectedAt, connectedAt) || other.connectedAt == connectedAt)&&(identical(other.lastActivityAt, lastActivityAt) || other.lastActivityAt == lastActivityAt)&&(identical(other.errorType, errorType) || other.errorType == errorType)&&(identical(other.errorDetails, errorDetails) || other.errorDetails == errorDetails)); +} + + +@override +int get hashCode => Object.hash(runtimeType,watchId,watchName,state,rssi,mtu,phyMode,dleEnabled,isCharging,reconnectionCount,connectedAt,lastActivityAt,errorType,errorDetails); + +@override +String toString() { + return 'Connection(watchId: $watchId, watchName: $watchName, state: $state, rssi: $rssi, mtu: $mtu, phyMode: $phyMode, dleEnabled: $dleEnabled, isCharging: $isCharging, reconnectionCount: $reconnectionCount, connectedAt: $connectedAt, lastActivityAt: $lastActivityAt, errorType: $errorType, errorDetails: $errorDetails)'; +} + + +} + +/// @nodoc +abstract mixin class $ConnectionCopyWith<$Res> { + factory $ConnectionCopyWith(Connection value, $Res Function(Connection) _then) = _$ConnectionCopyWithImpl; +@useResult +$Res call({ + String watchId, String? watchName, WatchConnectionState state, int? rssi, int? mtu, PhyMode? phyMode, bool dleEnabled, bool isCharging, int reconnectionCount, DateTime? connectedAt, DateTime? lastActivityAt, ConnectionErrorType? errorType, String? errorDetails +}); + + + + +} +/// @nodoc +class _$ConnectionCopyWithImpl<$Res> + implements $ConnectionCopyWith<$Res> { + _$ConnectionCopyWithImpl(this._self, this._then); + + final Connection _self; + final $Res Function(Connection) _then; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? watchId = null,Object? watchName = freezed,Object? state = null,Object? rssi = freezed,Object? mtu = freezed,Object? phyMode = freezed,Object? dleEnabled = null,Object? isCharging = null,Object? reconnectionCount = null,Object? connectedAt = freezed,Object? lastActivityAt = freezed,Object? errorType = freezed,Object? errorDetails = freezed,}) { + return _then(_self.copyWith( +watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,watchName: freezed == watchName ? _self.watchName : watchName // ignore: cast_nullable_to_non_nullable +as String?,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable +as WatchConnectionState,rssi: freezed == rssi ? _self.rssi : rssi // ignore: cast_nullable_to_non_nullable +as int?,mtu: freezed == mtu ? _self.mtu : mtu // ignore: cast_nullable_to_non_nullable +as int?,phyMode: freezed == phyMode ? _self.phyMode : phyMode // ignore: cast_nullable_to_non_nullable +as PhyMode?,dleEnabled: null == dleEnabled ? _self.dleEnabled : dleEnabled // ignore: cast_nullable_to_non_nullable +as bool,isCharging: null == isCharging ? _self.isCharging : isCharging // ignore: cast_nullable_to_non_nullable +as bool,reconnectionCount: null == reconnectionCount ? _self.reconnectionCount : reconnectionCount // ignore: cast_nullable_to_non_nullable +as int,connectedAt: freezed == connectedAt ? _self.connectedAt : connectedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastActivityAt: freezed == lastActivityAt ? _self.lastActivityAt : lastActivityAt // ignore: cast_nullable_to_non_nullable +as DateTime?,errorType: freezed == errorType ? _self.errorType : errorType // ignore: cast_nullable_to_non_nullable +as ConnectionErrorType?,errorDetails: freezed == errorDetails ? _self.errorDetails : errorDetails // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Connection]. +extension ConnectionPatterns on Connection { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Connection value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Connection value) $default,){ +final _that = this; +switch (_that) { +case _Connection(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Connection value)? $default,){ +final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String watchId, String? watchName, WatchConnectionState state, int? rssi, int? mtu, PhyMode? phyMode, bool dleEnabled, bool isCharging, int reconnectionCount, DateTime? connectedAt, DateTime? lastActivityAt, ConnectionErrorType? errorType, String? errorDetails)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that.watchId,_that.watchName,_that.state,_that.rssi,_that.mtu,_that.phyMode,_that.dleEnabled,_that.isCharging,_that.reconnectionCount,_that.connectedAt,_that.lastActivityAt,_that.errorType,_that.errorDetails);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String watchId, String? watchName, WatchConnectionState state, int? rssi, int? mtu, PhyMode? phyMode, bool dleEnabled, bool isCharging, int reconnectionCount, DateTime? connectedAt, DateTime? lastActivityAt, ConnectionErrorType? errorType, String? errorDetails) $default,) {final _that = this; +switch (_that) { +case _Connection(): +return $default(_that.watchId,_that.watchName,_that.state,_that.rssi,_that.mtu,_that.phyMode,_that.dleEnabled,_that.isCharging,_that.reconnectionCount,_that.connectedAt,_that.lastActivityAt,_that.errorType,_that.errorDetails);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String watchId, String? watchName, WatchConnectionState state, int? rssi, int? mtu, PhyMode? phyMode, bool dleEnabled, bool isCharging, int reconnectionCount, DateTime? connectedAt, DateTime? lastActivityAt, ConnectionErrorType? errorType, String? errorDetails)? $default,) {final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that.watchId,_that.watchName,_that.state,_that.rssi,_that.mtu,_that.phyMode,_that.dleEnabled,_that.isCharging,_that.reconnectionCount,_that.connectedAt,_that.lastActivityAt,_that.errorType,_that.errorDetails);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _Connection extends Connection { + const _Connection({required this.watchId, this.watchName, required this.state, this.rssi, this.mtu, this.phyMode, this.dleEnabled = false, this.isCharging = false, this.reconnectionCount = 0, this.connectedAt, this.lastActivityAt, this.errorType, this.errorDetails}): super._(); + + +/// ID of the connected watch +@override final String watchId; +/// Name of the connected watch +@override final String? watchName; +/// Current connection state +@override final WatchConnectionState state; +/// Signal strength in dBm (negative value, closer to 0 is stronger) +@override final int? rssi; +/// Negotiated MTU size +@override final int? mtu; +/// Current PHY mode +@override final PhyMode? phyMode; +/// Whether Data Length Extension is enabled +@override@JsonKey() final bool dleEnabled; +/// Whether watch is currently charging +@override@JsonKey() final bool isCharging; +/// Number of reconnection attempts in current session +@override@JsonKey() final int reconnectionCount; +/// When the current connection was established +@override final DateTime? connectedAt; +/// Last data exchange timestamp +@override final DateTime? lastActivityAt; +/// Error information if state is error +@override final ConnectionErrorType? errorType; +/// Additional error details +@override final String? errorDetails; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ConnectionCopyWith<_Connection> get copyWith => __$ConnectionCopyWithImpl<_Connection>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Connection&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.watchName, watchName) || other.watchName == watchName)&&(identical(other.state, state) || other.state == state)&&(identical(other.rssi, rssi) || other.rssi == rssi)&&(identical(other.mtu, mtu) || other.mtu == mtu)&&(identical(other.phyMode, phyMode) || other.phyMode == phyMode)&&(identical(other.dleEnabled, dleEnabled) || other.dleEnabled == dleEnabled)&&(identical(other.isCharging, isCharging) || other.isCharging == isCharging)&&(identical(other.reconnectionCount, reconnectionCount) || other.reconnectionCount == reconnectionCount)&&(identical(other.connectedAt, connectedAt) || other.connectedAt == connectedAt)&&(identical(other.lastActivityAt, lastActivityAt) || other.lastActivityAt == lastActivityAt)&&(identical(other.errorType, errorType) || other.errorType == errorType)&&(identical(other.errorDetails, errorDetails) || other.errorDetails == errorDetails)); +} + + +@override +int get hashCode => Object.hash(runtimeType,watchId,watchName,state,rssi,mtu,phyMode,dleEnabled,isCharging,reconnectionCount,connectedAt,lastActivityAt,errorType,errorDetails); + +@override +String toString() { + return 'Connection(watchId: $watchId, watchName: $watchName, state: $state, rssi: $rssi, mtu: $mtu, phyMode: $phyMode, dleEnabled: $dleEnabled, isCharging: $isCharging, reconnectionCount: $reconnectionCount, connectedAt: $connectedAt, lastActivityAt: $lastActivityAt, errorType: $errorType, errorDetails: $errorDetails)'; +} + + +} + +/// @nodoc +abstract mixin class _$ConnectionCopyWith<$Res> implements $ConnectionCopyWith<$Res> { + factory _$ConnectionCopyWith(_Connection value, $Res Function(_Connection) _then) = __$ConnectionCopyWithImpl; +@override @useResult +$Res call({ + String watchId, String? watchName, WatchConnectionState state, int? rssi, int? mtu, PhyMode? phyMode, bool dleEnabled, bool isCharging, int reconnectionCount, DateTime? connectedAt, DateTime? lastActivityAt, ConnectionErrorType? errorType, String? errorDetails +}); + + + + +} +/// @nodoc +class __$ConnectionCopyWithImpl<$Res> + implements _$ConnectionCopyWith<$Res> { + __$ConnectionCopyWithImpl(this._self, this._then); + + final _Connection _self; + final $Res Function(_Connection) _then; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? watchId = null,Object? watchName = freezed,Object? state = null,Object? rssi = freezed,Object? mtu = freezed,Object? phyMode = freezed,Object? dleEnabled = null,Object? isCharging = null,Object? reconnectionCount = null,Object? connectedAt = freezed,Object? lastActivityAt = freezed,Object? errorType = freezed,Object? errorDetails = freezed,}) { + return _then(_Connection( +watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,watchName: freezed == watchName ? _self.watchName : watchName // ignore: cast_nullable_to_non_nullable +as String?,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable +as WatchConnectionState,rssi: freezed == rssi ? _self.rssi : rssi // ignore: cast_nullable_to_non_nullable +as int?,mtu: freezed == mtu ? _self.mtu : mtu // ignore: cast_nullable_to_non_nullable +as int?,phyMode: freezed == phyMode ? _self.phyMode : phyMode // ignore: cast_nullable_to_non_nullable +as PhyMode?,dleEnabled: null == dleEnabled ? _self.dleEnabled : dleEnabled // ignore: cast_nullable_to_non_nullable +as bool,isCharging: null == isCharging ? _self.isCharging : isCharging // ignore: cast_nullable_to_non_nullable +as bool,reconnectionCount: null == reconnectionCount ? _self.reconnectionCount : reconnectionCount // ignore: cast_nullable_to_non_nullable +as int,connectedAt: freezed == connectedAt ? _self.connectedAt : connectedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastActivityAt: freezed == lastActivityAt ? _self.lastActivityAt : lastActivityAt // ignore: cast_nullable_to_non_nullable +as DateTime?,errorType: freezed == errorType ? _self.errorType : errorType // ignore: cast_nullable_to_non_nullable +as ConnectionErrorType?,errorDetails: freezed == errorDetails ? _self.errorDetails : errorDetails // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/connection_event.dart b/zswatch_app/lib/data/models/connection_event.dart index ea92cc5..4ed1fc8 100644 --- a/zswatch_app/lib/data/models/connection_event.dart +++ b/zswatch_app/lib/data/models/connection_event.dart @@ -1,4 +1,7 @@ import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'connection_event.freezed.dart'; /// Type of connection event enum ConnectionEventType { @@ -128,38 +131,32 @@ extension DisconnectReasonExtension on DisconnectReason { } /// A connection event record for analytics -@immutable -class ConnectionEvent { - /// Unique identifier - final int? id; +@freezed +abstract class ConnectionEvent with _$ConnectionEvent { + const ConnectionEvent._(); - /// Watch device ID - final String watchId; + const factory ConnectionEvent({ + /// Unique identifier + int? id, - /// Type of event - final ConnectionEventType eventType; + /// Watch device ID + required String watchId, - /// When the event occurred - final DateTime timestamp; + /// Type of event + required ConnectionEventType eventType, - /// Reason for disconnection (only for disconnect events) - final DisconnectReason? reason; + /// When the event occurred + required DateTime timestamp, - /// Additional details (e.g., error message) - final String? details; + /// Reason for disconnection (only for disconnect events) + DisconnectReason? reason, - /// Session ID to group connect/disconnect pairs - final String? sessionId; + /// Additional details (e.g., error message) + String? details, - const ConnectionEvent({ - this.id, - required this.watchId, - required this.eventType, - required this.timestamp, - this.reason, - this.details, - this.sessionId, - }); + /// Session ID to group connect/disconnect pairs + String? sessionId, + }) = _ConnectionEvent; /// Create a connected event factory ConnectionEvent.connected({ @@ -222,56 +219,4 @@ class ConnectionEvent { sessionId: sessionId, ); } - - ConnectionEvent copyWith({ - int? id, - String? watchId, - ConnectionEventType? eventType, - DateTime? timestamp, - DisconnectReason? reason, - String? details, - String? sessionId, - }) { - return ConnectionEvent( - id: id ?? this.id, - watchId: watchId ?? this.watchId, - eventType: eventType ?? this.eventType, - timestamp: timestamp ?? this.timestamp, - reason: reason ?? this.reason, - details: details ?? this.details, - sessionId: sessionId ?? this.sessionId, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is ConnectionEvent && - other.id == id && - other.watchId == watchId && - other.eventType == eventType && - other.timestamp == timestamp && - other.reason == reason && - other.details == details && - other.sessionId == sessionId; - } - - @override - int get hashCode { - return Object.hash( - id, - watchId, - eventType, - timestamp, - reason, - details, - sessionId, - ); - } - - @override - String toString() { - return 'ConnectionEvent(id: $id, watchId: $watchId, eventType: $eventType, ' - 'timestamp: $timestamp, reason: $reason, sessionId: $sessionId)'; - } } diff --git a/zswatch_app/lib/data/models/connection_event.freezed.dart b/zswatch_app/lib/data/models/connection_event.freezed.dart new file mode 100644 index 0000000..f134724 --- /dev/null +++ b/zswatch_app/lib/data/models/connection_event.freezed.dart @@ -0,0 +1,315 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connection_event.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ConnectionEvent implements DiagnosticableTreeMixin { + +/// Unique identifier + int? get id;/// Watch device ID + String get watchId;/// Type of event + ConnectionEventType get eventType;/// When the event occurred + DateTime get timestamp;/// Reason for disconnection (only for disconnect events) + DisconnectReason? get reason;/// Additional details (e.g., error message) + String? get details;/// Session ID to group connect/disconnect pairs + String? get sessionId; +/// Create a copy of ConnectionEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConnectionEventCopyWith get copyWith => _$ConnectionEventCopyWithImpl(this as ConnectionEvent, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'ConnectionEvent')) + ..add(DiagnosticsProperty('id', id))..add(DiagnosticsProperty('watchId', watchId))..add(DiagnosticsProperty('eventType', eventType))..add(DiagnosticsProperty('timestamp', timestamp))..add(DiagnosticsProperty('reason', reason))..add(DiagnosticsProperty('details', details))..add(DiagnosticsProperty('sessionId', sessionId)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectionEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.eventType, eventType) || other.eventType == eventType)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.details, details) || other.details == details)&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,watchId,eventType,timestamp,reason,details,sessionId); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'ConnectionEvent(id: $id, watchId: $watchId, eventType: $eventType, timestamp: $timestamp, reason: $reason, details: $details, sessionId: $sessionId)'; +} + + +} + +/// @nodoc +abstract mixin class $ConnectionEventCopyWith<$Res> { + factory $ConnectionEventCopyWith(ConnectionEvent value, $Res Function(ConnectionEvent) _then) = _$ConnectionEventCopyWithImpl; +@useResult +$Res call({ + int? id, String watchId, ConnectionEventType eventType, DateTime timestamp, DisconnectReason? reason, String? details, String? sessionId +}); + + + + +} +/// @nodoc +class _$ConnectionEventCopyWithImpl<$Res> + implements $ConnectionEventCopyWith<$Res> { + _$ConnectionEventCopyWithImpl(this._self, this._then); + + final ConnectionEvent _self; + final $Res Function(ConnectionEvent) _then; + +/// Create a copy of ConnectionEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? watchId = null,Object? eventType = null,Object? timestamp = null,Object? reason = freezed,Object? details = freezed,Object? sessionId = freezed,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int?,watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,eventType: null == eventType ? _self.eventType : eventType // ignore: cast_nullable_to_non_nullable +as ConnectionEventType,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,reason: freezed == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as DisconnectReason?,details: freezed == details ? _self.details : details // ignore: cast_nullable_to_non_nullable +as String?,sessionId: freezed == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ConnectionEvent]. +extension ConnectionEventPatterns on ConnectionEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ConnectionEvent value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ConnectionEvent() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ConnectionEvent value) $default,){ +final _that = this; +switch (_that) { +case _ConnectionEvent(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ConnectionEvent value)? $default,){ +final _that = this; +switch (_that) { +case _ConnectionEvent() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int? id, String watchId, ConnectionEventType eventType, DateTime timestamp, DisconnectReason? reason, String? details, String? sessionId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ConnectionEvent() when $default != null: +return $default(_that.id,_that.watchId,_that.eventType,_that.timestamp,_that.reason,_that.details,_that.sessionId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int? id, String watchId, ConnectionEventType eventType, DateTime timestamp, DisconnectReason? reason, String? details, String? sessionId) $default,) {final _that = this; +switch (_that) { +case _ConnectionEvent(): +return $default(_that.id,_that.watchId,_that.eventType,_that.timestamp,_that.reason,_that.details,_that.sessionId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? id, String watchId, ConnectionEventType eventType, DateTime timestamp, DisconnectReason? reason, String? details, String? sessionId)? $default,) {final _that = this; +switch (_that) { +case _ConnectionEvent() when $default != null: +return $default(_that.id,_that.watchId,_that.eventType,_that.timestamp,_that.reason,_that.details,_that.sessionId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ConnectionEvent extends ConnectionEvent with DiagnosticableTreeMixin { + const _ConnectionEvent({this.id, required this.watchId, required this.eventType, required this.timestamp, this.reason, this.details, this.sessionId}): super._(); + + +/// Unique identifier +@override final int? id; +/// Watch device ID +@override final String watchId; +/// Type of event +@override final ConnectionEventType eventType; +/// When the event occurred +@override final DateTime timestamp; +/// Reason for disconnection (only for disconnect events) +@override final DisconnectReason? reason; +/// Additional details (e.g., error message) +@override final String? details; +/// Session ID to group connect/disconnect pairs +@override final String? sessionId; + +/// Create a copy of ConnectionEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ConnectionEventCopyWith<_ConnectionEvent> get copyWith => __$ConnectionEventCopyWithImpl<_ConnectionEvent>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'ConnectionEvent')) + ..add(DiagnosticsProperty('id', id))..add(DiagnosticsProperty('watchId', watchId))..add(DiagnosticsProperty('eventType', eventType))..add(DiagnosticsProperty('timestamp', timestamp))..add(DiagnosticsProperty('reason', reason))..add(DiagnosticsProperty('details', details))..add(DiagnosticsProperty('sessionId', sessionId)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ConnectionEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.eventType, eventType) || other.eventType == eventType)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.details, details) || other.details == details)&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,watchId,eventType,timestamp,reason,details,sessionId); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'ConnectionEvent(id: $id, watchId: $watchId, eventType: $eventType, timestamp: $timestamp, reason: $reason, details: $details, sessionId: $sessionId)'; +} + + +} + +/// @nodoc +abstract mixin class _$ConnectionEventCopyWith<$Res> implements $ConnectionEventCopyWith<$Res> { + factory _$ConnectionEventCopyWith(_ConnectionEvent value, $Res Function(_ConnectionEvent) _then) = __$ConnectionEventCopyWithImpl; +@override @useResult +$Res call({ + int? id, String watchId, ConnectionEventType eventType, DateTime timestamp, DisconnectReason? reason, String? details, String? sessionId +}); + + + + +} +/// @nodoc +class __$ConnectionEventCopyWithImpl<$Res> + implements _$ConnectionEventCopyWith<$Res> { + __$ConnectionEventCopyWithImpl(this._self, this._then); + + final _ConnectionEvent _self; + final $Res Function(_ConnectionEvent) _then; + +/// Create a copy of ConnectionEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? watchId = null,Object? eventType = null,Object? timestamp = null,Object? reason = freezed,Object? details = freezed,Object? sessionId = freezed,}) { + return _then(_ConnectionEvent( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int?,watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,eventType: null == eventType ? _self.eventType : eventType // ignore: cast_nullable_to_non_nullable +as ConnectionEventType,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,reason: freezed == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as DisconnectReason?,details: freezed == details ? _self.details : details // ignore: cast_nullable_to_non_nullable +as String?,sessionId: freezed == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/connection_phase.dart b/zswatch_app/lib/data/models/connection_phase.dart new file mode 100644 index 0000000..b7c202e --- /dev/null +++ b/zswatch_app/lib/data/models/connection_phase.dart @@ -0,0 +1,75 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'connection_state.dart'; + +part 'connection_phase.freezed.dart'; + +/// Connection lifecycle phase — replaces boolean flags with explicit states. +/// +/// This is the single source of truth for where the connection is in its +/// lifecycle. All providers derive their state from this. +@freezed +sealed class ConnectionPhase with _$ConnectionPhase { + const ConnectionPhase._(); + + /// Not connected to any device + const factory ConnectionPhase.disconnected() = Disconnected; + + /// Actively scanning for devices + const factory ConnectionPhase.scanning() = Scanning; + + /// Attempting to establish BLE connection + const factory ConnectionPhase.connecting() = Connecting; + + /// BLE connected, running post-connection setup (bonding, service discovery, + /// MTU negotiation, NUS setup, initial sync) + const factory ConnectionPhase.settingUp({ + /// Which sub-step of setup we're in (for UI status text) + @Default(SetupStep.bonding) SetupStep step, + }) = SettingUp; + + /// Fully connected, synced, and ready for communication + const factory ConnectionPhase.connected() = Connected; + + /// Connection lost, attempting to reconnect + const factory ConnectionPhase.reconnecting({ + required int attempt, + @Default(false) bool isBackground, + }) = Reconnecting; + + /// Unrecoverable error + const factory ConnectionPhase.error({ + required ConnectionErrorType type, + String? details, + }) = PhaseError; + + /// Whether operations can be performed on the watch + bool get canOperate => this is Connected; + + /// Whether we're in any "trying to connect" state + bool get isTryingToConnect => + this is Connecting || this is SettingUp || this is Reconnecting; + + /// Whether disconnected (including error) + bool get isDisconnected => this is Disconnected || this is PhaseError; + + /// Map to the existing WatchConnectionState enum for backwards compatibility + /// with UI code that reads the Connection model. + WatchConnectionState get watchConnectionState => switch (this) { + Disconnected() => WatchConnectionState.disconnected, + Scanning() => WatchConnectionState.scanning, + Connecting() => WatchConnectionState.connecting, + SettingUp(step: final step) => switch (step) { + SetupStep.bonding => WatchConnectionState.bonding, + SetupStep.discoveringServices => WatchConnectionState.discoveringServices, + SetupStep.negotiating => WatchConnectionState.negotiating, + SetupStep.syncing => WatchConnectionState.syncing, + }, + Connected() => WatchConnectionState.connected, + Reconnecting() => WatchConnectionState.reconnecting, + PhaseError() => WatchConnectionState.error, + }; +} + +/// Sub-steps within the SettingUp phase, for UI status display. +enum SetupStep { bonding, discoveringServices, negotiating, syncing } diff --git a/zswatch_app/lib/data/models/connection_phase.freezed.dart b/zswatch_app/lib/data/models/connection_phase.freezed.dart new file mode 100644 index 0000000..2091f20 --- /dev/null +++ b/zswatch_app/lib/data/models/connection_phase.freezed.dart @@ -0,0 +1,535 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connection_phase.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ConnectionPhase { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectionPhase); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ConnectionPhase()'; +} + + +} + +/// @nodoc +class $ConnectionPhaseCopyWith<$Res> { +$ConnectionPhaseCopyWith(ConnectionPhase _, $Res Function(ConnectionPhase) __); +} + + +/// Adds pattern-matching-related methods to [ConnectionPhase]. +extension ConnectionPhasePatterns on ConnectionPhase { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( Disconnected value)? disconnected,TResult Function( Scanning value)? scanning,TResult Function( Connecting value)? connecting,TResult Function( SettingUp value)? settingUp,TResult Function( Connected value)? connected,TResult Function( Reconnecting value)? reconnecting,TResult Function( PhaseError value)? error,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case Disconnected() when disconnected != null: +return disconnected(_that);case Scanning() when scanning != null: +return scanning(_that);case Connecting() when connecting != null: +return connecting(_that);case SettingUp() when settingUp != null: +return settingUp(_that);case Connected() when connected != null: +return connected(_that);case Reconnecting() when reconnecting != null: +return reconnecting(_that);case PhaseError() when error != null: +return error(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( Disconnected value) disconnected,required TResult Function( Scanning value) scanning,required TResult Function( Connecting value) connecting,required TResult Function( SettingUp value) settingUp,required TResult Function( Connected value) connected,required TResult Function( Reconnecting value) reconnecting,required TResult Function( PhaseError value) error,}){ +final _that = this; +switch (_that) { +case Disconnected(): +return disconnected(_that);case Scanning(): +return scanning(_that);case Connecting(): +return connecting(_that);case SettingUp(): +return settingUp(_that);case Connected(): +return connected(_that);case Reconnecting(): +return reconnecting(_that);case PhaseError(): +return error(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( Disconnected value)? disconnected,TResult? Function( Scanning value)? scanning,TResult? Function( Connecting value)? connecting,TResult? Function( SettingUp value)? settingUp,TResult? Function( Connected value)? connected,TResult? Function( Reconnecting value)? reconnecting,TResult? Function( PhaseError value)? error,}){ +final _that = this; +switch (_that) { +case Disconnected() when disconnected != null: +return disconnected(_that);case Scanning() when scanning != null: +return scanning(_that);case Connecting() when connecting != null: +return connecting(_that);case SettingUp() when settingUp != null: +return settingUp(_that);case Connected() when connected != null: +return connected(_that);case Reconnecting() when reconnecting != null: +return reconnecting(_that);case PhaseError() when error != null: +return error(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? disconnected,TResult Function()? scanning,TResult Function()? connecting,TResult Function( SetupStep step)? settingUp,TResult Function()? connected,TResult Function( int attempt, bool isBackground)? reconnecting,TResult Function( ConnectionErrorType type, String? details)? error,required TResult orElse(),}) {final _that = this; +switch (_that) { +case Disconnected() when disconnected != null: +return disconnected();case Scanning() when scanning != null: +return scanning();case Connecting() when connecting != null: +return connecting();case SettingUp() when settingUp != null: +return settingUp(_that.step);case Connected() when connected != null: +return connected();case Reconnecting() when reconnecting != null: +return reconnecting(_that.attempt,_that.isBackground);case PhaseError() when error != null: +return error(_that.type,_that.details);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() disconnected,required TResult Function() scanning,required TResult Function() connecting,required TResult Function( SetupStep step) settingUp,required TResult Function() connected,required TResult Function( int attempt, bool isBackground) reconnecting,required TResult Function( ConnectionErrorType type, String? details) error,}) {final _that = this; +switch (_that) { +case Disconnected(): +return disconnected();case Scanning(): +return scanning();case Connecting(): +return connecting();case SettingUp(): +return settingUp(_that.step);case Connected(): +return connected();case Reconnecting(): +return reconnecting(_that.attempt,_that.isBackground);case PhaseError(): +return error(_that.type,_that.details);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? disconnected,TResult? Function()? scanning,TResult? Function()? connecting,TResult? Function( SetupStep step)? settingUp,TResult? Function()? connected,TResult? Function( int attempt, bool isBackground)? reconnecting,TResult? Function( ConnectionErrorType type, String? details)? error,}) {final _that = this; +switch (_that) { +case Disconnected() when disconnected != null: +return disconnected();case Scanning() when scanning != null: +return scanning();case Connecting() when connecting != null: +return connecting();case SettingUp() when settingUp != null: +return settingUp(_that.step);case Connected() when connected != null: +return connected();case Reconnecting() when reconnecting != null: +return reconnecting(_that.attempt,_that.isBackground);case PhaseError() when error != null: +return error(_that.type,_that.details);case _: + return null; + +} +} + +} + +/// @nodoc + + +class Disconnected extends ConnectionPhase { + const Disconnected(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Disconnected); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ConnectionPhase.disconnected()'; +} + + +} + + + + +/// @nodoc + + +class Scanning extends ConnectionPhase { + const Scanning(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Scanning); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ConnectionPhase.scanning()'; +} + + +} + + + + +/// @nodoc + + +class Connecting extends ConnectionPhase { + const Connecting(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Connecting); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ConnectionPhase.connecting()'; +} + + +} + + + + +/// @nodoc + + +class SettingUp extends ConnectionPhase { + const SettingUp({this.step = SetupStep.bonding}): super._(); + + +/// Which sub-step of setup we're in (for UI status text) +@JsonKey() final SetupStep step; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SettingUpCopyWith get copyWith => _$SettingUpCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SettingUp&&(identical(other.step, step) || other.step == step)); +} + + +@override +int get hashCode => Object.hash(runtimeType,step); + +@override +String toString() { + return 'ConnectionPhase.settingUp(step: $step)'; +} + + +} + +/// @nodoc +abstract mixin class $SettingUpCopyWith<$Res> implements $ConnectionPhaseCopyWith<$Res> { + factory $SettingUpCopyWith(SettingUp value, $Res Function(SettingUp) _then) = _$SettingUpCopyWithImpl; +@useResult +$Res call({ + SetupStep step +}); + + + + +} +/// @nodoc +class _$SettingUpCopyWithImpl<$Res> + implements $SettingUpCopyWith<$Res> { + _$SettingUpCopyWithImpl(this._self, this._then); + + final SettingUp _self; + final $Res Function(SettingUp) _then; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? step = null,}) { + return _then(SettingUp( +step: null == step ? _self.step : step // ignore: cast_nullable_to_non_nullable +as SetupStep, + )); +} + + +} + +/// @nodoc + + +class Connected extends ConnectionPhase { + const Connected(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Connected); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ConnectionPhase.connected()'; +} + + +} + + + + +/// @nodoc + + +class Reconnecting extends ConnectionPhase { + const Reconnecting({required this.attempt, this.isBackground = false}): super._(); + + + final int attempt; +@JsonKey() final bool isBackground; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ReconnectingCopyWith get copyWith => _$ReconnectingCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Reconnecting&&(identical(other.attempt, attempt) || other.attempt == attempt)&&(identical(other.isBackground, isBackground) || other.isBackground == isBackground)); +} + + +@override +int get hashCode => Object.hash(runtimeType,attempt,isBackground); + +@override +String toString() { + return 'ConnectionPhase.reconnecting(attempt: $attempt, isBackground: $isBackground)'; +} + + +} + +/// @nodoc +abstract mixin class $ReconnectingCopyWith<$Res> implements $ConnectionPhaseCopyWith<$Res> { + factory $ReconnectingCopyWith(Reconnecting value, $Res Function(Reconnecting) _then) = _$ReconnectingCopyWithImpl; +@useResult +$Res call({ + int attempt, bool isBackground +}); + + + + +} +/// @nodoc +class _$ReconnectingCopyWithImpl<$Res> + implements $ReconnectingCopyWith<$Res> { + _$ReconnectingCopyWithImpl(this._self, this._then); + + final Reconnecting _self; + final $Res Function(Reconnecting) _then; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? attempt = null,Object? isBackground = null,}) { + return _then(Reconnecting( +attempt: null == attempt ? _self.attempt : attempt // ignore: cast_nullable_to_non_nullable +as int,isBackground: null == isBackground ? _self.isBackground : isBackground // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc + + +class PhaseError extends ConnectionPhase { + const PhaseError({required this.type, this.details}): super._(); + + + final ConnectionErrorType type; + final String? details; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PhaseErrorCopyWith get copyWith => _$PhaseErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PhaseError&&(identical(other.type, type) || other.type == type)&&(identical(other.details, details) || other.details == details)); +} + + +@override +int get hashCode => Object.hash(runtimeType,type,details); + +@override +String toString() { + return 'ConnectionPhase.error(type: $type, details: $details)'; +} + + +} + +/// @nodoc +abstract mixin class $PhaseErrorCopyWith<$Res> implements $ConnectionPhaseCopyWith<$Res> { + factory $PhaseErrorCopyWith(PhaseError value, $Res Function(PhaseError) _then) = _$PhaseErrorCopyWithImpl; +@useResult +$Res call({ + ConnectionErrorType type, String? details +}); + + + + +} +/// @nodoc +class _$PhaseErrorCopyWithImpl<$Res> + implements $PhaseErrorCopyWith<$Res> { + _$PhaseErrorCopyWithImpl(this._self, this._then); + + final PhaseError _self; + final $Res Function(PhaseError) _then; + +/// Create a copy of ConnectionPhase +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? type = null,Object? details = freezed,}) { + return _then(PhaseError( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as ConnectionErrorType,details: freezed == details ? _self.details : details // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/connection_state.dart b/zswatch_app/lib/data/models/connection_state.dart index 028d3ec..4048ac5 100644 --- a/zswatch_app/lib/data/models/connection_state.dart +++ b/zswatch_app/lib/data/models/connection_state.dart @@ -193,4 +193,3 @@ extension ConnectionErrorTypeExtension on ConnectionErrorType { } } } - diff --git a/zswatch_app/lib/data/models/coredump_analysis.dart b/zswatch_app/lib/data/models/coredump_analysis.dart new file mode 100644 index 0000000..e8e9789 --- /dev/null +++ b/zswatch_app/lib/data/models/coredump_analysis.dart @@ -0,0 +1,32 @@ +/// Result of a coredump analysis from the backend server. +class CoredumpAnalysis { + final bool success; + final String? backtrace; + final String? registers; + final String rawOutput; + final String? error; + final bool elfAvailable; + final String? elfHash; + + const CoredumpAnalysis({ + required this.success, + this.backtrace, + this.registers, + this.rawOutput = '', + this.error, + this.elfAvailable = false, + this.elfHash, + }); + + factory CoredumpAnalysis.fromJson(Map json) { + return CoredumpAnalysis( + success: json['success'] as bool? ?? false, + backtrace: json['backtrace'] as String?, + registers: json['registers'] as String?, + rawOutput: json['raw_output'] as String? ?? '', + error: json['error'] as String?, + elfAvailable: json['elf_available'] as bool? ?? false, + elfHash: json['elf_hash'] as String?, + ); + } +} diff --git a/zswatch_app/lib/data/models/crash_summary.dart b/zswatch_app/lib/data/models/crash_summary.dart new file mode 100644 index 0000000..c4d57b8 --- /dev/null +++ b/zswatch_app/lib/data/models/crash_summary.dart @@ -0,0 +1,23 @@ +/// Lightweight crash summary received via Gadgetbridge protocol on connect. +class CrashSummary { + final String file; + final int line; + final String time; + final String fwVersion; + final String fwCommitSha; + final String board; + final String buildType; + + const CrashSummary({ + required this.file, + required this.line, + required this.time, + required this.fwVersion, + required this.fwCommitSha, + required this.board, + required this.buildType, + }); + + @override + String toString() => 'CrashSummary($file:$line @ $time, sha=$fwCommitSha)'; +} diff --git a/zswatch_app/lib/data/models/dfu_state.dart b/zswatch_app/lib/data/models/dfu_state.dart index 0906272..2dc88b4 100644 --- a/zswatch_app/lib/data/models/dfu_state.dart +++ b/zswatch_app/lib/data/models/dfu_state.dart @@ -1,4 +1,6 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'dfu_state.freezed.dart'; /// DFU (Device Firmware Update) process states enum DfuStatus { @@ -119,53 +121,47 @@ extension DfuStatusExtension on DfuStatus { } /// Represents the current state of a DFU operation -class DfuState extends Equatable { - /// Current status of the DFU process - final DfuStatus status; +@freezed +abstract class DfuState with _$DfuState { + const DfuState._(); - /// Overall progress (0.0 to 1.0) - final double progress; + const factory DfuState({ + /// Current status of the DFU process + @Default(DfuStatus.idle) DfuStatus status, - /// Bytes transferred so far - final int bytesTransferred; + /// Overall progress (0.0 to 1.0) + @Default(0.0) double progress, - /// Total bytes to transfer - final int totalBytes; + /// Bytes transferred so far + @Default(0) int bytesTransferred, - /// Current upload speed in bytes per second - final int speedBytesPerSecond; + /// Total bytes to transfer + @Default(0) int totalBytes, - /// Current image being uploaded (for multi-image updates) - final int currentImage; + /// Current upload speed in bytes per second + @Default(0) int speedBytesPerSecond, - /// Total number of images to upload - final int totalImages; + /// Speed history for chart (bytes per second samples) + @Default([]) List speedHistory, - /// Name of the current image being processed - final String? currentImageName; + /// Current image being uploaded (for multi-image updates) + @Default(0) int currentImage, - /// Error message if status is failed - final String? errorMessage; + /// Total number of images to upload + @Default(1) int totalImages, - /// When the DFU started - final DateTime? startedAt; + /// Name of the current image being processed + String? currentImageName, - /// When the DFU completed/failed - final DateTime? completedAt; + /// Error message if status is failed + String? errorMessage, + + /// When the DFU started + DateTime? startedAt, - const DfuState({ - this.status = DfuStatus.idle, - this.progress = 0.0, - this.bytesTransferred = 0, - this.totalBytes = 0, - this.speedBytesPerSecond = 0, - this.currentImage = 0, - this.totalImages = 1, - this.currentImageName, - this.errorMessage, - this.startedAt, - this.completedAt, - }); + /// When the DFU completed/failed + DateTime? completedAt, + }) = _DfuState; /// Initial idle state static const idle = DfuState(); @@ -174,14 +170,10 @@ class DfuState extends Equatable { int get progressPercent => (progress * 100).round(); /// Human-readable bytes transferred - String get formattedBytesTransferred { - return _formatBytes(bytesTransferred); - } + String get formattedBytesTransferred => _formatBytes(bytesTransferred); /// Human-readable total bytes - String get formattedTotalBytes { - return _formatBytes(totalBytes); - } + String get formattedTotalBytes => _formatBytes(totalBytes); /// Human-readable speed String get formattedSpeed { @@ -207,7 +199,9 @@ class DfuState extends Equatable { final seconds = estimatedSecondsRemaining; if (seconds == null) { // During active upload, show "calculating..." instead of "--" - if (status == DfuStatus.uploading && bytesTransferred > 0 && speedBytesPerSecond == 0) { + if (status == DfuStatus.uploading && + bytesTransferred > 0 && + speedBytesPerSecond == 0) { return 'calculating...'; } return '--'; @@ -246,35 +240,6 @@ class DfuState extends Equatable { } } - /// Copy with modified fields - DfuState copyWith({ - DfuStatus? status, - double? progress, - int? bytesTransferred, - int? totalBytes, - int? speedBytesPerSecond, - int? currentImage, - int? totalImages, - String? currentImageName, - String? errorMessage, - DateTime? startedAt, - DateTime? completedAt, - }) { - return DfuState( - status: status ?? this.status, - progress: progress ?? this.progress, - bytesTransferred: bytesTransferred ?? this.bytesTransferred, - totalBytes: totalBytes ?? this.totalBytes, - speedBytesPerSecond: speedBytesPerSecond ?? this.speedBytesPerSecond, - currentImage: currentImage ?? this.currentImage, - totalImages: totalImages ?? this.totalImages, - currentImageName: currentImageName ?? this.currentImageName, - errorMessage: errorMessage ?? this.errorMessage, - startedAt: startedAt ?? this.startedAt, - completedAt: completedAt ?? this.completedAt, - ); - } - /// Create a preparing state factory DfuState.preparing({String? imageName}) { return DfuState( @@ -295,6 +260,7 @@ class DfuState extends Equatable { status: DfuStatus.uploading, totalBytes: totalBytes, totalImages: totalImages, + currentImage: 1, currentImageName: currentImageName, startedAt: startedAt ?? DateTime.now(), ); @@ -328,27 +294,6 @@ class DfuState extends Equatable { completedAt: DateTime.now(), ); } - - @override - List get props => [ - status, - progress, - bytesTransferred, - totalBytes, - speedBytesPerSecond, - currentImage, - totalImages, - currentImageName, - errorMessage, - startedAt, - completedAt, - ]; - - @override - String toString() { - return 'DfuState(status: ${status.name}, progress: $progressPercent%, ' - '$formattedBytesTransferred/$formattedTotalBytes, speed: $formattedSpeed)'; - } } /// DFU error types @@ -430,4 +375,3 @@ extension DfuErrorTypeExtension on DfuErrorType { } } } - diff --git a/zswatch_app/lib/data/models/dfu_state.freezed.dart b/zswatch_app/lib/data/models/dfu_state.freezed.dart new file mode 100644 index 0000000..1de2413 --- /dev/null +++ b/zswatch_app/lib/data/models/dfu_state.freezed.dart @@ -0,0 +1,335 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'dfu_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DfuState { + +/// Current status of the DFU process + DfuStatus get status;/// Overall progress (0.0 to 1.0) + double get progress;/// Bytes transferred so far + int get bytesTransferred;/// Total bytes to transfer + int get totalBytes;/// Current upload speed in bytes per second + int get speedBytesPerSecond;/// Speed history for chart (bytes per second samples) + List get speedHistory;/// Current image being uploaded (for multi-image updates) + int get currentImage;/// Total number of images to upload + int get totalImages;/// Name of the current image being processed + String? get currentImageName;/// Error message if status is failed + String? get errorMessage;/// When the DFU started + DateTime? get startedAt;/// When the DFU completed/failed + DateTime? get completedAt; +/// Create a copy of DfuState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DfuStateCopyWith get copyWith => _$DfuStateCopyWithImpl(this as DfuState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DfuState&&(identical(other.status, status) || other.status == status)&&(identical(other.progress, progress) || other.progress == progress)&&(identical(other.bytesTransferred, bytesTransferred) || other.bytesTransferred == bytesTransferred)&&(identical(other.totalBytes, totalBytes) || other.totalBytes == totalBytes)&&(identical(other.speedBytesPerSecond, speedBytesPerSecond) || other.speedBytesPerSecond == speedBytesPerSecond)&&const DeepCollectionEquality().equals(other.speedHistory, speedHistory)&&(identical(other.currentImage, currentImage) || other.currentImage == currentImage)&&(identical(other.totalImages, totalImages) || other.totalImages == totalImages)&&(identical(other.currentImageName, currentImageName) || other.currentImageName == currentImageName)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.completedAt, completedAt) || other.completedAt == completedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,progress,bytesTransferred,totalBytes,speedBytesPerSecond,const DeepCollectionEquality().hash(speedHistory),currentImage,totalImages,currentImageName,errorMessage,startedAt,completedAt); + +@override +String toString() { + return 'DfuState(status: $status, progress: $progress, bytesTransferred: $bytesTransferred, totalBytes: $totalBytes, speedBytesPerSecond: $speedBytesPerSecond, speedHistory: $speedHistory, currentImage: $currentImage, totalImages: $totalImages, currentImageName: $currentImageName, errorMessage: $errorMessage, startedAt: $startedAt, completedAt: $completedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $DfuStateCopyWith<$Res> { + factory $DfuStateCopyWith(DfuState value, $Res Function(DfuState) _then) = _$DfuStateCopyWithImpl; +@useResult +$Res call({ + DfuStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, int currentImage, int totalImages, String? currentImageName, String? errorMessage, DateTime? startedAt, DateTime? completedAt +}); + + + + +} +/// @nodoc +class _$DfuStateCopyWithImpl<$Res> + implements $DfuStateCopyWith<$Res> { + _$DfuStateCopyWithImpl(this._self, this._then); + + final DfuState _self; + final $Res Function(DfuState) _then; + +/// Create a copy of DfuState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? progress = null,Object? bytesTransferred = null,Object? totalBytes = null,Object? speedBytesPerSecond = null,Object? speedHistory = null,Object? currentImage = null,Object? totalImages = null,Object? currentImageName = freezed,Object? errorMessage = freezed,Object? startedAt = freezed,Object? completedAt = freezed,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as DfuStatus,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as double,bytesTransferred: null == bytesTransferred ? _self.bytesTransferred : bytesTransferred // ignore: cast_nullable_to_non_nullable +as int,totalBytes: null == totalBytes ? _self.totalBytes : totalBytes // ignore: cast_nullable_to_non_nullable +as int,speedBytesPerSecond: null == speedBytesPerSecond ? _self.speedBytesPerSecond : speedBytesPerSecond // ignore: cast_nullable_to_non_nullable +as int,speedHistory: null == speedHistory ? _self.speedHistory : speedHistory // ignore: cast_nullable_to_non_nullable +as List,currentImage: null == currentImage ? _self.currentImage : currentImage // ignore: cast_nullable_to_non_nullable +as int,totalImages: null == totalImages ? _self.totalImages : totalImages // ignore: cast_nullable_to_non_nullable +as int,currentImageName: freezed == currentImageName ? _self.currentImageName : currentImageName // ignore: cast_nullable_to_non_nullable +as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,completedAt: freezed == completedAt ? _self.completedAt : completedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DfuState]. +extension DfuStatePatterns on DfuState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DfuState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DfuState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DfuState value) $default,){ +final _that = this; +switch (_that) { +case _DfuState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DfuState value)? $default,){ +final _that = this; +switch (_that) { +case _DfuState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( DfuStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, int currentImage, int totalImages, String? currentImageName, String? errorMessage, DateTime? startedAt, DateTime? completedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DfuState() when $default != null: +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.currentImage,_that.totalImages,_that.currentImageName,_that.errorMessage,_that.startedAt,_that.completedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( DfuStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, int currentImage, int totalImages, String? currentImageName, String? errorMessage, DateTime? startedAt, DateTime? completedAt) $default,) {final _that = this; +switch (_that) { +case _DfuState(): +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.currentImage,_that.totalImages,_that.currentImageName,_that.errorMessage,_that.startedAt,_that.completedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DfuStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, int currentImage, int totalImages, String? currentImageName, String? errorMessage, DateTime? startedAt, DateTime? completedAt)? $default,) {final _that = this; +switch (_that) { +case _DfuState() when $default != null: +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.currentImage,_that.totalImages,_that.currentImageName,_that.errorMessage,_that.startedAt,_that.completedAt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _DfuState extends DfuState { + const _DfuState({this.status = DfuStatus.idle, this.progress = 0.0, this.bytesTransferred = 0, this.totalBytes = 0, this.speedBytesPerSecond = 0, final List speedHistory = const [], this.currentImage = 0, this.totalImages = 1, this.currentImageName, this.errorMessage, this.startedAt, this.completedAt}): _speedHistory = speedHistory,super._(); + + +/// Current status of the DFU process +@override@JsonKey() final DfuStatus status; +/// Overall progress (0.0 to 1.0) +@override@JsonKey() final double progress; +/// Bytes transferred so far +@override@JsonKey() final int bytesTransferred; +/// Total bytes to transfer +@override@JsonKey() final int totalBytes; +/// Current upload speed in bytes per second +@override@JsonKey() final int speedBytesPerSecond; +/// Speed history for chart (bytes per second samples) + final List _speedHistory; +/// Speed history for chart (bytes per second samples) +@override@JsonKey() List get speedHistory { + if (_speedHistory is EqualUnmodifiableListView) return _speedHistory; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_speedHistory); +} + +/// Current image being uploaded (for multi-image updates) +@override@JsonKey() final int currentImage; +/// Total number of images to upload +@override@JsonKey() final int totalImages; +/// Name of the current image being processed +@override final String? currentImageName; +/// Error message if status is failed +@override final String? errorMessage; +/// When the DFU started +@override final DateTime? startedAt; +/// When the DFU completed/failed +@override final DateTime? completedAt; + +/// Create a copy of DfuState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DfuStateCopyWith<_DfuState> get copyWith => __$DfuStateCopyWithImpl<_DfuState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DfuState&&(identical(other.status, status) || other.status == status)&&(identical(other.progress, progress) || other.progress == progress)&&(identical(other.bytesTransferred, bytesTransferred) || other.bytesTransferred == bytesTransferred)&&(identical(other.totalBytes, totalBytes) || other.totalBytes == totalBytes)&&(identical(other.speedBytesPerSecond, speedBytesPerSecond) || other.speedBytesPerSecond == speedBytesPerSecond)&&const DeepCollectionEquality().equals(other._speedHistory, _speedHistory)&&(identical(other.currentImage, currentImage) || other.currentImage == currentImage)&&(identical(other.totalImages, totalImages) || other.totalImages == totalImages)&&(identical(other.currentImageName, currentImageName) || other.currentImageName == currentImageName)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.completedAt, completedAt) || other.completedAt == completedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,progress,bytesTransferred,totalBytes,speedBytesPerSecond,const DeepCollectionEquality().hash(_speedHistory),currentImage,totalImages,currentImageName,errorMessage,startedAt,completedAt); + +@override +String toString() { + return 'DfuState(status: $status, progress: $progress, bytesTransferred: $bytesTransferred, totalBytes: $totalBytes, speedBytesPerSecond: $speedBytesPerSecond, speedHistory: $speedHistory, currentImage: $currentImage, totalImages: $totalImages, currentImageName: $currentImageName, errorMessage: $errorMessage, startedAt: $startedAt, completedAt: $completedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$DfuStateCopyWith<$Res> implements $DfuStateCopyWith<$Res> { + factory _$DfuStateCopyWith(_DfuState value, $Res Function(_DfuState) _then) = __$DfuStateCopyWithImpl; +@override @useResult +$Res call({ + DfuStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, int currentImage, int totalImages, String? currentImageName, String? errorMessage, DateTime? startedAt, DateTime? completedAt +}); + + + + +} +/// @nodoc +class __$DfuStateCopyWithImpl<$Res> + implements _$DfuStateCopyWith<$Res> { + __$DfuStateCopyWithImpl(this._self, this._then); + + final _DfuState _self; + final $Res Function(_DfuState) _then; + +/// Create a copy of DfuState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? progress = null,Object? bytesTransferred = null,Object? totalBytes = null,Object? speedBytesPerSecond = null,Object? speedHistory = null,Object? currentImage = null,Object? totalImages = null,Object? currentImageName = freezed,Object? errorMessage = freezed,Object? startedAt = freezed,Object? completedAt = freezed,}) { + return _then(_DfuState( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as DfuStatus,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as double,bytesTransferred: null == bytesTransferred ? _self.bytesTransferred : bytesTransferred // ignore: cast_nullable_to_non_nullable +as int,totalBytes: null == totalBytes ? _self.totalBytes : totalBytes // ignore: cast_nullable_to_non_nullable +as int,speedBytesPerSecond: null == speedBytesPerSecond ? _self.speedBytesPerSecond : speedBytesPerSecond // ignore: cast_nullable_to_non_nullable +as int,speedHistory: null == speedHistory ? _self._speedHistory : speedHistory // ignore: cast_nullable_to_non_nullable +as List,currentImage: null == currentImage ? _self.currentImage : currentImage // ignore: cast_nullable_to_non_nullable +as int,totalImages: null == totalImages ? _self.totalImages : totalImages // ignore: cast_nullable_to_non_nullable +as int,currentImageName: freezed == currentImageName ? _self.currentImageName : currentImageName // ignore: cast_nullable_to_non_nullable +as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,completedAt: freezed == completedAt ? _self.completedAt : completedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/extracted_action.dart b/zswatch_app/lib/data/models/extracted_action.dart new file mode 100644 index 0000000..d306309 --- /dev/null +++ b/zswatch_app/lib/data/models/extracted_action.dart @@ -0,0 +1,55 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'extracted_action.freezed.dart'; + +/// Type of extracted action from AI processing +enum ExtractedActionType { task, calendarEvent, reminder } + +/// Domain model for an AI-extracted action from a voice memo +@freezed +abstract class ExtractedAction with _$ExtractedAction { + const ExtractedAction._(); + + const factory ExtractedAction({ + required int id, + required int memoId, + required ExtractedActionType actionType, + required String title, + String? notes, + DateTime? startTime, + DateTime? endTime, + DateTime? dueDate, + String? location, + int? reminderMinutes, + @Default(false) bool created, + @Default(false) bool dismissed, + String? platformTargetId, + DateTime? createdAt, + }) = _ExtractedAction; + + /// Convert action type string from DB to enum + static ExtractedActionType typeFromString(String value) { + switch (value) { + case 'task': + return ExtractedActionType.task; + case 'calendar_event': + return ExtractedActionType.calendarEvent; + case 'reminder': + return ExtractedActionType.reminder; + default: + return ExtractedActionType.task; + } + } + + /// Convert enum to DB string + static String typeToString(ExtractedActionType type) { + switch (type) { + case ExtractedActionType.task: + return 'task'; + case ExtractedActionType.calendarEvent: + return 'calendar_event'; + case ExtractedActionType.reminder: + return 'reminder'; + } + } +} diff --git a/zswatch_app/lib/data/models/extracted_action.freezed.dart b/zswatch_app/lib/data/models/extracted_action.freezed.dart new file mode 100644 index 0000000..b8e1288 --- /dev/null +++ b/zswatch_app/lib/data/models/extracted_action.freezed.dart @@ -0,0 +1,310 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'extracted_action.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ExtractedAction { + + int get id; int get memoId; ExtractedActionType get actionType; String get title; String? get notes; DateTime? get startTime; DateTime? get endTime; DateTime? get dueDate; String? get location; int? get reminderMinutes; bool get created; bool get dismissed; String? get platformTargetId; DateTime? get createdAt; +/// Create a copy of ExtractedAction +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ExtractedActionCopyWith get copyWith => _$ExtractedActionCopyWithImpl(this as ExtractedAction, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ExtractedAction&&(identical(other.id, id) || other.id == id)&&(identical(other.memoId, memoId) || other.memoId == memoId)&&(identical(other.actionType, actionType) || other.actionType == actionType)&&(identical(other.title, title) || other.title == title)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.startTime, startTime) || other.startTime == startTime)&&(identical(other.endTime, endTime) || other.endTime == endTime)&&(identical(other.dueDate, dueDate) || other.dueDate == dueDate)&&(identical(other.location, location) || other.location == location)&&(identical(other.reminderMinutes, reminderMinutes) || other.reminderMinutes == reminderMinutes)&&(identical(other.created, created) || other.created == created)&&(identical(other.dismissed, dismissed) || other.dismissed == dismissed)&&(identical(other.platformTargetId, platformTargetId) || other.platformTargetId == platformTargetId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,memoId,actionType,title,notes,startTime,endTime,dueDate,location,reminderMinutes,created,dismissed,platformTargetId,createdAt); + +@override +String toString() { + return 'ExtractedAction(id: $id, memoId: $memoId, actionType: $actionType, title: $title, notes: $notes, startTime: $startTime, endTime: $endTime, dueDate: $dueDate, location: $location, reminderMinutes: $reminderMinutes, created: $created, dismissed: $dismissed, platformTargetId: $platformTargetId, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class $ExtractedActionCopyWith<$Res> { + factory $ExtractedActionCopyWith(ExtractedAction value, $Res Function(ExtractedAction) _then) = _$ExtractedActionCopyWithImpl; +@useResult +$Res call({ + int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt +}); + + + + +} +/// @nodoc +class _$ExtractedActionCopyWithImpl<$Res> + implements $ExtractedActionCopyWith<$Res> { + _$ExtractedActionCopyWithImpl(this._self, this._then); + + final ExtractedAction _self; + final $Res Function(ExtractedAction) _then; + +/// Create a copy of ExtractedAction +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? memoId = null,Object? actionType = null,Object? title = null,Object? notes = freezed,Object? startTime = freezed,Object? endTime = freezed,Object? dueDate = freezed,Object? location = freezed,Object? reminderMinutes = freezed,Object? created = null,Object? dismissed = null,Object? platformTargetId = freezed,Object? createdAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,memoId: null == memoId ? _self.memoId : memoId // ignore: cast_nullable_to_non_nullable +as int,actionType: null == actionType ? _self.actionType : actionType // ignore: cast_nullable_to_non_nullable +as ExtractedActionType,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,notes: freezed == notes ? _self.notes : notes // ignore: cast_nullable_to_non_nullable +as String?,startTime: freezed == startTime ? _self.startTime : startTime // ignore: cast_nullable_to_non_nullable +as DateTime?,endTime: freezed == endTime ? _self.endTime : endTime // ignore: cast_nullable_to_non_nullable +as DateTime?,dueDate: freezed == dueDate ? _self.dueDate : dueDate // ignore: cast_nullable_to_non_nullable +as DateTime?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,reminderMinutes: freezed == reminderMinutes ? _self.reminderMinutes : reminderMinutes // ignore: cast_nullable_to_non_nullable +as int?,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable +as bool,dismissed: null == dismissed ? _self.dismissed : dismissed // ignore: cast_nullable_to_non_nullable +as bool,platformTargetId: freezed == platformTargetId ? _self.platformTargetId : platformTargetId // ignore: cast_nullable_to_non_nullable +as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ExtractedAction]. +extension ExtractedActionPatterns on ExtractedAction { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ExtractedAction value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ExtractedAction() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ExtractedAction value) $default,){ +final _that = this; +switch (_that) { +case _ExtractedAction(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ExtractedAction value)? $default,){ +final _that = this; +switch (_that) { +case _ExtractedAction() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ExtractedAction() when $default != null: +return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt) $default,) {final _that = this; +switch (_that) { +case _ExtractedAction(): +return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt)? $default,) {final _that = this; +switch (_that) { +case _ExtractedAction() when $default != null: +return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ExtractedAction extends ExtractedAction { + const _ExtractedAction({required this.id, required this.memoId, required this.actionType, required this.title, this.notes, this.startTime, this.endTime, this.dueDate, this.location, this.reminderMinutes, this.created = false, this.dismissed = false, this.platformTargetId, this.createdAt}): super._(); + + +@override final int id; +@override final int memoId; +@override final ExtractedActionType actionType; +@override final String title; +@override final String? notes; +@override final DateTime? startTime; +@override final DateTime? endTime; +@override final DateTime? dueDate; +@override final String? location; +@override final int? reminderMinutes; +@override@JsonKey() final bool created; +@override@JsonKey() final bool dismissed; +@override final String? platformTargetId; +@override final DateTime? createdAt; + +/// Create a copy of ExtractedAction +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ExtractedActionCopyWith<_ExtractedAction> get copyWith => __$ExtractedActionCopyWithImpl<_ExtractedAction>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ExtractedAction&&(identical(other.id, id) || other.id == id)&&(identical(other.memoId, memoId) || other.memoId == memoId)&&(identical(other.actionType, actionType) || other.actionType == actionType)&&(identical(other.title, title) || other.title == title)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.startTime, startTime) || other.startTime == startTime)&&(identical(other.endTime, endTime) || other.endTime == endTime)&&(identical(other.dueDate, dueDate) || other.dueDate == dueDate)&&(identical(other.location, location) || other.location == location)&&(identical(other.reminderMinutes, reminderMinutes) || other.reminderMinutes == reminderMinutes)&&(identical(other.created, created) || other.created == created)&&(identical(other.dismissed, dismissed) || other.dismissed == dismissed)&&(identical(other.platformTargetId, platformTargetId) || other.platformTargetId == platformTargetId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,memoId,actionType,title,notes,startTime,endTime,dueDate,location,reminderMinutes,created,dismissed,platformTargetId,createdAt); + +@override +String toString() { + return 'ExtractedAction(id: $id, memoId: $memoId, actionType: $actionType, title: $title, notes: $notes, startTime: $startTime, endTime: $endTime, dueDate: $dueDate, location: $location, reminderMinutes: $reminderMinutes, created: $created, dismissed: $dismissed, platformTargetId: $platformTargetId, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$ExtractedActionCopyWith<$Res> implements $ExtractedActionCopyWith<$Res> { + factory _$ExtractedActionCopyWith(_ExtractedAction value, $Res Function(_ExtractedAction) _then) = __$ExtractedActionCopyWithImpl; +@override @useResult +$Res call({ + int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt +}); + + + + +} +/// @nodoc +class __$ExtractedActionCopyWithImpl<$Res> + implements _$ExtractedActionCopyWith<$Res> { + __$ExtractedActionCopyWithImpl(this._self, this._then); + + final _ExtractedAction _self; + final $Res Function(_ExtractedAction) _then; + +/// Create a copy of ExtractedAction +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? memoId = null,Object? actionType = null,Object? title = null,Object? notes = freezed,Object? startTime = freezed,Object? endTime = freezed,Object? dueDate = freezed,Object? location = freezed,Object? reminderMinutes = freezed,Object? created = null,Object? dismissed = null,Object? platformTargetId = freezed,Object? createdAt = freezed,}) { + return _then(_ExtractedAction( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,memoId: null == memoId ? _self.memoId : memoId // ignore: cast_nullable_to_non_nullable +as int,actionType: null == actionType ? _self.actionType : actionType // ignore: cast_nullable_to_non_nullable +as ExtractedActionType,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,notes: freezed == notes ? _self.notes : notes // ignore: cast_nullable_to_non_nullable +as String?,startTime: freezed == startTime ? _self.startTime : startTime // ignore: cast_nullable_to_non_nullable +as DateTime?,endTime: freezed == endTime ? _self.endTime : endTime // ignore: cast_nullable_to_non_nullable +as DateTime?,dueDate: freezed == dueDate ? _self.dueDate : dueDate // ignore: cast_nullable_to_non_nullable +as DateTime?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,reminderMinutes: freezed == reminderMinutes ? _self.reminderMinutes : reminderMinutes // ignore: cast_nullable_to_non_nullable +as int?,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable +as bool,dismissed: null == dismissed ? _self.dismissed : dismissed // ignore: cast_nullable_to_non_nullable +as bool,platformTargetId: freezed == platformTargetId ? _self.platformTargetId : platformTargetId // ignore: cast_nullable_to_non_nullable +as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/filesystem_image.dart b/zswatch_app/lib/data/models/filesystem_image.dart index 137ffdd..0df7028 100644 --- a/zswatch_app/lib/data/models/filesystem_image.dart +++ b/zswatch_app/lib/data/models/filesystem_image.dart @@ -1,19 +1,25 @@ import 'dart:io'; -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import '../../core/constants/filesystem_constants.dart'; +part 'filesystem_image.freezed.dart'; + /// Status of a filesystem upload operation enum FilesystemUploadStatus { /// Not started idle, + /// Upload is in progress uploading, + /// Upload completed successfully completed, + /// Upload failed failed, + /// Upload was cancelled cancelled, } @@ -21,10 +27,11 @@ enum FilesystemUploadStatus { /// Extension methods for FilesystemUploadStatus extension FilesystemUploadStatusExtension on FilesystemUploadStatus { bool get isInProgress => this == FilesystemUploadStatus.uploading; - bool get isTerminal => this == FilesystemUploadStatus.completed || + bool get isTerminal => + this == FilesystemUploadStatus.completed || this == FilesystemUploadStatus.failed || this == FilesystemUploadStatus.cancelled; - + String get statusText { switch (this) { case FilesystemUploadStatus.idle: @@ -42,36 +49,32 @@ extension FilesystemUploadStatusExtension on FilesystemUploadStatus { } /// Represents a filesystem image that can be uploaded to the watch -/// +/// /// This is typically the lvgl_resources_raw.bin file found alongside /// dfu_application.zip in release archives. -class FilesystemImage extends Equatable { - /// Display name for the image - final String name; +@freezed +abstract class FilesystemImage with _$FilesystemImage { + const FilesystemImage._(); - /// Local file path to the image - final String filePath; + const factory FilesystemImage({ + /// Display name for the image + required String name, - /// Target path on the device where the file will be uploaded - final String targetPath; + /// Local file path to the image + required String filePath, - /// File size in bytes - final int size; + /// Target path on the device where the file will be uploaded + required String targetPath, - /// Optional source URL (if downloaded from GitHub) - final String? sourceUrl; + /// File size in bytes + required int size, - /// Version string (if known) - final String? version; + /// Optional source URL (if downloaded from GitHub) + String? sourceUrl, - const FilesystemImage({ - required this.name, - required this.filePath, - required this.targetPath, - required this.size, - this.sourceUrl, - this.version, - }); + /// Version string (if known) + String? version, + }) = _FilesystemImage; /// Create from a local file with default target path factory FilesystemImage.fromFile({ @@ -82,7 +85,7 @@ class FilesystemImage extends Equatable { }) { final file = File(filePath); final fileName = file.uri.pathSegments.last; - + return FilesystemImage( name: fileName, filePath: filePath, @@ -106,60 +109,48 @@ class FilesystemImage extends Equatable { /// Check if the file exists Future exists() async { - return File(filePath).exists(); + return File(filePath).existsSync(); } /// Read the file contents Future> readBytes() async { return File(filePath).readAsBytes(); } - - @override - List get props => [name, filePath, targetPath, size, sourceUrl, version]; - - @override - String toString() => 'FilesystemImage(name: $name, size: $formattedSize, target: $targetPath)'; } /// State for filesystem upload operations -class FilesystemUploadState extends Equatable { - /// Current status - final FilesystemUploadStatus status; +@freezed +abstract class FilesystemUploadState with _$FilesystemUploadState { + const FilesystemUploadState._(); - /// Progress (0.0 to 1.0) - final double progress; + const factory FilesystemUploadState({ + /// Current status + @Default(FilesystemUploadStatus.idle) FilesystemUploadStatus status, - /// Bytes transferred - final int bytesTransferred; + /// Progress (0.0 to 1.0) + @Default(0.0) double progress, - /// Total bytes to transfer - final int totalBytes; + /// Bytes transferred + @Default(0) int bytesTransferred, - /// Upload speed in bytes per second - final int speedBytesPerSecond; + /// Total bytes to transfer + @Default(0) int totalBytes, - /// When the upload started - final DateTime? startedAt; + /// Upload speed in bytes per second + @Default(0) int speedBytesPerSecond, - /// Error message (if failed) - final String? errorMessage; + /// Speed history for chart (bytes per second samples) + @Default([]) List speedHistory, - /// Current image being uploaded - final String? imageName; + /// When the upload started + DateTime? startedAt, - const FilesystemUploadState({ - this.status = FilesystemUploadStatus.idle, - this.progress = 0.0, - this.bytesTransferred = 0, - this.totalBytes = 0, - this.speedBytesPerSecond = 0, - this.startedAt, - this.errorMessage, - this.imageName, - }); + /// Error message (if failed) + String? errorMessage, - /// Idle state - static const idle = FilesystemUploadState(); + /// Current image being uploaded + String? imageName, + }) = _FilesystemUploadState; /// Create uploading state factory FilesystemUploadState.uploading({ @@ -226,10 +217,10 @@ class FilesystemUploadState extends Equatable { if (speedBytesPerSecond <= 0 || bytesTransferred >= totalBytes) { return '--:--'; } - + final remaining = totalBytes - bytesTransferred; final seconds = remaining ~/ speedBytesPerSecond; - + if (seconds < 60) { return '${seconds}s'; } else if (seconds < 3600) { @@ -258,39 +249,4 @@ class FilesystemUploadState extends Equatable { return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; } } - - FilesystemUploadState copyWith({ - FilesystemUploadStatus? status, - double? progress, - int? bytesTransferred, - int? totalBytes, - int? speedBytesPerSecond, - DateTime? startedAt, - String? errorMessage, - String? imageName, - }) { - return FilesystemUploadState( - status: status ?? this.status, - progress: progress ?? this.progress, - bytesTransferred: bytesTransferred ?? this.bytesTransferred, - totalBytes: totalBytes ?? this.totalBytes, - speedBytesPerSecond: speedBytesPerSecond ?? this.speedBytesPerSecond, - startedAt: startedAt ?? this.startedAt, - errorMessage: errorMessage ?? this.errorMessage, - imageName: imageName ?? this.imageName, - ); - } - - @override - List get props => [ - status, - progress, - bytesTransferred, - totalBytes, - speedBytesPerSecond, - startedAt, - errorMessage, - imageName, - ]; } - diff --git a/zswatch_app/lib/data/models/filesystem_image.freezed.dart b/zswatch_app/lib/data/models/filesystem_image.freezed.dart new file mode 100644 index 0000000..77a2843 --- /dev/null +++ b/zswatch_app/lib/data/models/filesystem_image.freezed.dart @@ -0,0 +1,604 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'filesystem_image.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$FilesystemImage { + +/// Display name for the image + String get name;/// Local file path to the image + String get filePath;/// Target path on the device where the file will be uploaded + String get targetPath;/// File size in bytes + int get size;/// Optional source URL (if downloaded from GitHub) + String? get sourceUrl;/// Version string (if known) + String? get version; +/// Create a copy of FilesystemImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FilesystemImageCopyWith get copyWith => _$FilesystemImageCopyWithImpl(this as FilesystemImage, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FilesystemImage&&(identical(other.name, name) || other.name == name)&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.targetPath, targetPath) || other.targetPath == targetPath)&&(identical(other.size, size) || other.size == size)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.version, version) || other.version == version)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,filePath,targetPath,size,sourceUrl,version); + +@override +String toString() { + return 'FilesystemImage(name: $name, filePath: $filePath, targetPath: $targetPath, size: $size, sourceUrl: $sourceUrl, version: $version)'; +} + + +} + +/// @nodoc +abstract mixin class $FilesystemImageCopyWith<$Res> { + factory $FilesystemImageCopyWith(FilesystemImage value, $Res Function(FilesystemImage) _then) = _$FilesystemImageCopyWithImpl; +@useResult +$Res call({ + String name, String filePath, String targetPath, int size, String? sourceUrl, String? version +}); + + + + +} +/// @nodoc +class _$FilesystemImageCopyWithImpl<$Res> + implements $FilesystemImageCopyWith<$Res> { + _$FilesystemImageCopyWithImpl(this._self, this._then); + + final FilesystemImage _self; + final $Res Function(FilesystemImage) _then; + +/// Create a copy of FilesystemImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? filePath = null,Object? targetPath = null,Object? size = null,Object? sourceUrl = freezed,Object? version = freezed,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,targetPath: null == targetPath ? _self.targetPath : targetPath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,sourceUrl: freezed == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FilesystemImage]. +extension FilesystemImagePatterns on FilesystemImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FilesystemImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FilesystemImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FilesystemImage value) $default,){ +final _that = this; +switch (_that) { +case _FilesystemImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FilesystemImage value)? $default,){ +final _that = this; +switch (_that) { +case _FilesystemImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String filePath, String targetPath, int size, String? sourceUrl, String? version)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FilesystemImage() when $default != null: +return $default(_that.name,_that.filePath,_that.targetPath,_that.size,_that.sourceUrl,_that.version);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String filePath, String targetPath, int size, String? sourceUrl, String? version) $default,) {final _that = this; +switch (_that) { +case _FilesystemImage(): +return $default(_that.name,_that.filePath,_that.targetPath,_that.size,_that.sourceUrl,_that.version);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String filePath, String targetPath, int size, String? sourceUrl, String? version)? $default,) {final _that = this; +switch (_that) { +case _FilesystemImage() when $default != null: +return $default(_that.name,_that.filePath,_that.targetPath,_that.size,_that.sourceUrl,_that.version);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FilesystemImage extends FilesystemImage { + const _FilesystemImage({required this.name, required this.filePath, required this.targetPath, required this.size, this.sourceUrl, this.version}): super._(); + + +/// Display name for the image +@override final String name; +/// Local file path to the image +@override final String filePath; +/// Target path on the device where the file will be uploaded +@override final String targetPath; +/// File size in bytes +@override final int size; +/// Optional source URL (if downloaded from GitHub) +@override final String? sourceUrl; +/// Version string (if known) +@override final String? version; + +/// Create a copy of FilesystemImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FilesystemImageCopyWith<_FilesystemImage> get copyWith => __$FilesystemImageCopyWithImpl<_FilesystemImage>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FilesystemImage&&(identical(other.name, name) || other.name == name)&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.targetPath, targetPath) || other.targetPath == targetPath)&&(identical(other.size, size) || other.size == size)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.version, version) || other.version == version)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,filePath,targetPath,size,sourceUrl,version); + +@override +String toString() { + return 'FilesystemImage(name: $name, filePath: $filePath, targetPath: $targetPath, size: $size, sourceUrl: $sourceUrl, version: $version)'; +} + + +} + +/// @nodoc +abstract mixin class _$FilesystemImageCopyWith<$Res> implements $FilesystemImageCopyWith<$Res> { + factory _$FilesystemImageCopyWith(_FilesystemImage value, $Res Function(_FilesystemImage) _then) = __$FilesystemImageCopyWithImpl; +@override @useResult +$Res call({ + String name, String filePath, String targetPath, int size, String? sourceUrl, String? version +}); + + + + +} +/// @nodoc +class __$FilesystemImageCopyWithImpl<$Res> + implements _$FilesystemImageCopyWith<$Res> { + __$FilesystemImageCopyWithImpl(this._self, this._then); + + final _FilesystemImage _self; + final $Res Function(_FilesystemImage) _then; + +/// Create a copy of FilesystemImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? filePath = null,Object? targetPath = null,Object? size = null,Object? sourceUrl = freezed,Object? version = freezed,}) { + return _then(_FilesystemImage( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,targetPath: null == targetPath ? _self.targetPath : targetPath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,sourceUrl: freezed == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$FilesystemUploadState { + +/// Current status + FilesystemUploadStatus get status;/// Progress (0.0 to 1.0) + double get progress;/// Bytes transferred + int get bytesTransferred;/// Total bytes to transfer + int get totalBytes;/// Upload speed in bytes per second + int get speedBytesPerSecond;/// Speed history for chart (bytes per second samples) + List get speedHistory;/// When the upload started + DateTime? get startedAt;/// Error message (if failed) + String? get errorMessage;/// Current image being uploaded + String? get imageName; +/// Create a copy of FilesystemUploadState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FilesystemUploadStateCopyWith get copyWith => _$FilesystemUploadStateCopyWithImpl(this as FilesystemUploadState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FilesystemUploadState&&(identical(other.status, status) || other.status == status)&&(identical(other.progress, progress) || other.progress == progress)&&(identical(other.bytesTransferred, bytesTransferred) || other.bytesTransferred == bytesTransferred)&&(identical(other.totalBytes, totalBytes) || other.totalBytes == totalBytes)&&(identical(other.speedBytesPerSecond, speedBytesPerSecond) || other.speedBytesPerSecond == speedBytesPerSecond)&&const DeepCollectionEquality().equals(other.speedHistory, speedHistory)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.imageName, imageName) || other.imageName == imageName)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,progress,bytesTransferred,totalBytes,speedBytesPerSecond,const DeepCollectionEquality().hash(speedHistory),startedAt,errorMessage,imageName); + +@override +String toString() { + return 'FilesystemUploadState(status: $status, progress: $progress, bytesTransferred: $bytesTransferred, totalBytes: $totalBytes, speedBytesPerSecond: $speedBytesPerSecond, speedHistory: $speedHistory, startedAt: $startedAt, errorMessage: $errorMessage, imageName: $imageName)'; +} + + +} + +/// @nodoc +abstract mixin class $FilesystemUploadStateCopyWith<$Res> { + factory $FilesystemUploadStateCopyWith(FilesystemUploadState value, $Res Function(FilesystemUploadState) _then) = _$FilesystemUploadStateCopyWithImpl; +@useResult +$Res call({ + FilesystemUploadStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, DateTime? startedAt, String? errorMessage, String? imageName +}); + + + + +} +/// @nodoc +class _$FilesystemUploadStateCopyWithImpl<$Res> + implements $FilesystemUploadStateCopyWith<$Res> { + _$FilesystemUploadStateCopyWithImpl(this._self, this._then); + + final FilesystemUploadState _self; + final $Res Function(FilesystemUploadState) _then; + +/// Create a copy of FilesystemUploadState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? progress = null,Object? bytesTransferred = null,Object? totalBytes = null,Object? speedBytesPerSecond = null,Object? speedHistory = null,Object? startedAt = freezed,Object? errorMessage = freezed,Object? imageName = freezed,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as FilesystemUploadStatus,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as double,bytesTransferred: null == bytesTransferred ? _self.bytesTransferred : bytesTransferred // ignore: cast_nullable_to_non_nullable +as int,totalBytes: null == totalBytes ? _self.totalBytes : totalBytes // ignore: cast_nullable_to_non_nullable +as int,speedBytesPerSecond: null == speedBytesPerSecond ? _self.speedBytesPerSecond : speedBytesPerSecond // ignore: cast_nullable_to_non_nullable +as int,speedHistory: null == speedHistory ? _self.speedHistory : speedHistory // ignore: cast_nullable_to_non_nullable +as List,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,imageName: freezed == imageName ? _self.imageName : imageName // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FilesystemUploadState]. +extension FilesystemUploadStatePatterns on FilesystemUploadState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FilesystemUploadState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FilesystemUploadState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FilesystemUploadState value) $default,){ +final _that = this; +switch (_that) { +case _FilesystemUploadState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FilesystemUploadState value)? $default,){ +final _that = this; +switch (_that) { +case _FilesystemUploadState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( FilesystemUploadStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, DateTime? startedAt, String? errorMessage, String? imageName)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FilesystemUploadState() when $default != null: +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.startedAt,_that.errorMessage,_that.imageName);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( FilesystemUploadStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, DateTime? startedAt, String? errorMessage, String? imageName) $default,) {final _that = this; +switch (_that) { +case _FilesystemUploadState(): +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.startedAt,_that.errorMessage,_that.imageName);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( FilesystemUploadStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, DateTime? startedAt, String? errorMessage, String? imageName)? $default,) {final _that = this; +switch (_that) { +case _FilesystemUploadState() when $default != null: +return $default(_that.status,_that.progress,_that.bytesTransferred,_that.totalBytes,_that.speedBytesPerSecond,_that.speedHistory,_that.startedAt,_that.errorMessage,_that.imageName);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FilesystemUploadState extends FilesystemUploadState { + const _FilesystemUploadState({this.status = FilesystemUploadStatus.idle, this.progress = 0.0, this.bytesTransferred = 0, this.totalBytes = 0, this.speedBytesPerSecond = 0, final List speedHistory = const [], this.startedAt, this.errorMessage, this.imageName}): _speedHistory = speedHistory,super._(); + + +/// Current status +@override@JsonKey() final FilesystemUploadStatus status; +/// Progress (0.0 to 1.0) +@override@JsonKey() final double progress; +/// Bytes transferred +@override@JsonKey() final int bytesTransferred; +/// Total bytes to transfer +@override@JsonKey() final int totalBytes; +/// Upload speed in bytes per second +@override@JsonKey() final int speedBytesPerSecond; +/// Speed history for chart (bytes per second samples) + final List _speedHistory; +/// Speed history for chart (bytes per second samples) +@override@JsonKey() List get speedHistory { + if (_speedHistory is EqualUnmodifiableListView) return _speedHistory; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_speedHistory); +} + +/// When the upload started +@override final DateTime? startedAt; +/// Error message (if failed) +@override final String? errorMessage; +/// Current image being uploaded +@override final String? imageName; + +/// Create a copy of FilesystemUploadState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FilesystemUploadStateCopyWith<_FilesystemUploadState> get copyWith => __$FilesystemUploadStateCopyWithImpl<_FilesystemUploadState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FilesystemUploadState&&(identical(other.status, status) || other.status == status)&&(identical(other.progress, progress) || other.progress == progress)&&(identical(other.bytesTransferred, bytesTransferred) || other.bytesTransferred == bytesTransferred)&&(identical(other.totalBytes, totalBytes) || other.totalBytes == totalBytes)&&(identical(other.speedBytesPerSecond, speedBytesPerSecond) || other.speedBytesPerSecond == speedBytesPerSecond)&&const DeepCollectionEquality().equals(other._speedHistory, _speedHistory)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.imageName, imageName) || other.imageName == imageName)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,progress,bytesTransferred,totalBytes,speedBytesPerSecond,const DeepCollectionEquality().hash(_speedHistory),startedAt,errorMessage,imageName); + +@override +String toString() { + return 'FilesystemUploadState(status: $status, progress: $progress, bytesTransferred: $bytesTransferred, totalBytes: $totalBytes, speedBytesPerSecond: $speedBytesPerSecond, speedHistory: $speedHistory, startedAt: $startedAt, errorMessage: $errorMessage, imageName: $imageName)'; +} + + +} + +/// @nodoc +abstract mixin class _$FilesystemUploadStateCopyWith<$Res> implements $FilesystemUploadStateCopyWith<$Res> { + factory _$FilesystemUploadStateCopyWith(_FilesystemUploadState value, $Res Function(_FilesystemUploadState) _then) = __$FilesystemUploadStateCopyWithImpl; +@override @useResult +$Res call({ + FilesystemUploadStatus status, double progress, int bytesTransferred, int totalBytes, int speedBytesPerSecond, List speedHistory, DateTime? startedAt, String? errorMessage, String? imageName +}); + + + + +} +/// @nodoc +class __$FilesystemUploadStateCopyWithImpl<$Res> + implements _$FilesystemUploadStateCopyWith<$Res> { + __$FilesystemUploadStateCopyWithImpl(this._self, this._then); + + final _FilesystemUploadState _self; + final $Res Function(_FilesystemUploadState) _then; + +/// Create a copy of FilesystemUploadState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? progress = null,Object? bytesTransferred = null,Object? totalBytes = null,Object? speedBytesPerSecond = null,Object? speedHistory = null,Object? startedAt = freezed,Object? errorMessage = freezed,Object? imageName = freezed,}) { + return _then(_FilesystemUploadState( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as FilesystemUploadStatus,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as double,bytesTransferred: null == bytesTransferred ? _self.bytesTransferred : bytesTransferred // ignore: cast_nullable_to_non_nullable +as int,totalBytes: null == totalBytes ? _self.totalBytes : totalBytes // ignore: cast_nullable_to_non_nullable +as int,speedBytesPerSecond: null == speedBytesPerSecond ? _self.speedBytesPerSecond : speedBytesPerSecond // ignore: cast_nullable_to_non_nullable +as int,speedHistory: null == speedHistory ? _self._speedHistory : speedHistory // ignore: cast_nullable_to_non_nullable +as List,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,imageName: freezed == imageName ? _self.imageName : imageName // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/firmware_image.dart b/zswatch_app/lib/data/models/firmware_image.dart index 0b5ab82..c4f64e0 100644 --- a/zswatch_app/lib/data/models/firmware_image.dart +++ b/zswatch_app/lib/data/models/firmware_image.dart @@ -1,52 +1,46 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'firmware_image.freezed.dart'; /// Represents a firmware image prepared for upload /// /// This model holds metadata about firmware files that can be uploaded /// to the watch via MCUmgr/SMP protocol. -class FirmwareImage extends Equatable { - /// Display name for the firmware - final String name; +@freezed +abstract class FirmwareImage with _$FirmwareImage { + const FirmwareImage._(); - /// Firmware version string (e.g., "3.0.0", "v3.0.0-rc1") - final String? version; + const factory FirmwareImage({ + /// Display name for the firmware + required String name, - /// Local file path where the firmware is stored - final String filePath; + /// Firmware version string (e.g., "3.0.0", "v3.0.0-rc1") + String? version, - /// File size in bytes - final int size; + /// Local file path where the firmware is stored + required String filePath, - /// MCUmgr image slot from manifest.json (0=app internal, 1=netCore, 2=app external) - final int? slot; + /// File size in bytes + required int size, - /// Board identifier from manifest.json (e.g., "watchdk" or "watchdk@1/nrf5340/cpunet") - final String? board; + /// MCUmgr image slot from manifest.json (0=app internal, 1=netCore, 2=app external) + int? slot, - /// SHA256 hash of the file (optional, for verification) - final String? hash; + /// Board identifier from manifest.json (e.g., "watchdk" or "watchdk@1/nrf5340/cpunet") + String? board, - /// When the firmware was downloaded (null for local files) - final DateTime? downloadedAt; + /// SHA256 hash of the file (optional, for verification) + String? hash, - /// Source URL if downloaded from GitHub - final String? sourceUrl; + /// When the firmware was downloaded (null for local files) + DateTime? downloadedAt, - /// Git branch or tag name - final String? branch; + /// Source URL if downloaded from GitHub + String? sourceUrl, - const FirmwareImage({ - required this.name, - this.version, - required this.filePath, - required this.size, - this.slot, - this.board, - this.hash, - this.downloadedAt, - this.sourceUrl, - this.branch, - }); + /// Git branch or tag name + String? branch, + }) = _FirmwareImage; /// Create a firmware image from a local file factory FirmwareImage.fromLocalFile({ @@ -120,19 +114,19 @@ class FirmwareImage extends Equatable { if (slot == 1) { return 'Network Core'; } - + // Check board field for netCore indicator if (board?.toLowerCase().contains('cpunet') ?? false) { return 'Network Core'; } - + // image_index 0 and 2 are application (main app and external app) if (slot == 0) { return 'Application (Internal)'; } else if (slot == 2) { return 'Application (External)'; } - + return 'Application'; } @@ -147,7 +141,7 @@ class FirmwareImage extends Equatable { lowerPath.contains('filesystem')) { return 'Filesystem'; } - + return 'Application'; } @@ -160,70 +154,23 @@ class FirmwareImage extends Equatable { if (dotIndex == -1) return ''; return filePath.substring(dotIndex + 1).toLowerCase(); } - - /// Copy with modified fields - FirmwareImage copyWith({ - String? name, - String? version, - String? filePath, - int? size, - int? slot, - String? board, - String? hash, - DateTime? downloadedAt, - String? sourceUrl, - String? branch, - }) { - return FirmwareImage( - name: name ?? this.name, - version: version ?? this.version, - filePath: filePath ?? this.filePath, - size: size ?? this.size, - slot: slot ?? this.slot, - board: board ?? this.board, - hash: hash ?? this.hash, - downloadedAt: downloadedAt ?? this.downloadedAt, - sourceUrl: sourceUrl ?? this.sourceUrl, - branch: branch ?? this.branch, - ); - } - - @override - List get props => [ - name, - version, - filePath, - size, - slot, - board, - hash, - downloadedAt, - sourceUrl, - branch, - ]; - - @override - String toString() { - return 'FirmwareImage(name: $name, slot: $slot, version: $version, type: $displayName, size: $formattedSize)'; - } } /// Represents a single firmware asset in a GitHub release -class ReleaseAsset extends Equatable { - /// Asset file name (e.g., "watchdk@1_nrf5340_cpuapp_debug.zip") - final String name; +@freezed +abstract class ReleaseAsset with _$ReleaseAsset { + const ReleaseAsset._(); - /// Download URL - final String downloadUrl; + const factory ReleaseAsset({ + /// Asset file name (e.g., "watchdk@1_nrf5340_cpuapp_debug.zip") + required String name, - /// File size in bytes - final int size; + /// Download URL + required String downloadUrl, - const ReleaseAsset({ - required this.name, - required this.downloadUrl, - required this.size, - }); + /// File size in bytes + required int size, + }) = _ReleaseAsset; /// Human-readable file size String get formattedSize { @@ -243,39 +190,32 @@ class ReleaseAsset extends Equatable { } return name; } - - @override - List get props => [name, downloadUrl, size]; } /// Represents a GitHub release with available firmware -class GitHubRelease extends Equatable { - /// Release tag name (e.g., "v3.0.0") - final String tagName; +@freezed +abstract class GitHubRelease with _$GitHubRelease { + const GitHubRelease._(); - /// Release title - final String name; + const factory GitHubRelease({ + /// Release tag name (e.g., "v3.0.0") + required String tagName, - /// Release description/body - final String? body; + /// Release title + required String name, - /// Whether this is a prerelease - final bool isPrerelease; + /// Release description/body + String? body, - /// When the release was published - final DateTime publishedAt; + /// Whether this is a prerelease + required bool isPrerelease, - /// All available firmware assets in this release - final List assets; + /// When the release was published + required DateTime publishedAt, - const GitHubRelease({ - required this.tagName, - required this.name, - this.body, - required this.isPrerelease, - required this.publishedAt, - required this.assets, - }); + /// All available firmware assets in this release + required List assets, + }) = _GitHubRelease; /// Version string without 'v' prefix String get version { @@ -292,54 +232,38 @@ class GitHubRelease extends Equatable { /// Whether this release has any firmware assets bool get hasFirmware => assets.isNotEmpty; - - @override - List get props => [ - tagName, - name, - body, - isPrerelease, - publishedAt, - assets, - ]; } /// Represents a GitHub Actions workflow artifact -class GitHubArtifact extends Equatable { - /// Branch name - final String branch; +@freezed +abstract class GitHubArtifact with _$GitHubArtifact { + const GitHubArtifact._(); - /// Workflow run ID - final String runId; + const factory GitHubArtifact({ + /// Branch name + required String branch, - /// Artifact name - final String name; + /// Workflow run ID + required String runId, - /// Artifact size in bytes - final int size; + /// Artifact name + required String name, - /// When the artifact was created - final DateTime createdAt; + /// Artifact size in bytes + required int size, - /// Download URL (requires authentication) - final String downloadUrl; + /// When the artifact was created + required DateTime createdAt, - /// Commit SHA - final String? commitSha; + /// Download URL (requires authentication) + required String downloadUrl, - /// Commit message - final String? commitMessage; + /// Commit SHA + String? commitSha, - const GitHubArtifact({ - required this.branch, - required this.runId, - required this.name, - required this.size, - required this.createdAt, - required this.downloadUrl, - this.commitSha, - this.commitMessage, - }); + /// Commit message + String? commitMessage, + }) = _GitHubArtifact; /// Short commit SHA (first 7 chars) String get shortSha => commitSha?.substring(0, 7) ?? ''; @@ -359,17 +283,4 @@ class GitHubArtifact extends Equatable { String get formattedDate { return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; } - - @override - List get props => [ - branch, - runId, - name, - size, - createdAt, - downloadUrl, - commitSha, - commitMessage, - ]; } - diff --git a/zswatch_app/lib/data/models/firmware_image.freezed.dart b/zswatch_app/lib/data/models/firmware_image.freezed.dart new file mode 100644 index 0000000..a3bb3d3 --- /dev/null +++ b/zswatch_app/lib/data/models/firmware_image.freezed.dart @@ -0,0 +1,1172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'firmware_image.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$FirmwareImage { + +/// Display name for the firmware + String get name;/// Firmware version string (e.g., "3.0.0", "v3.0.0-rc1") + String? get version;/// Local file path where the firmware is stored + String get filePath;/// File size in bytes + int get size;/// MCUmgr image slot from manifest.json (0=app internal, 1=netCore, 2=app external) + int? get slot;/// Board identifier from manifest.json (e.g., "watchdk" or "watchdk@1/nrf5340/cpunet") + String? get board;/// SHA256 hash of the file (optional, for verification) + String? get hash;/// When the firmware was downloaded (null for local files) + DateTime? get downloadedAt;/// Source URL if downloaded from GitHub + String? get sourceUrl;/// Git branch or tag name + String? get branch; +/// Create a copy of FirmwareImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FirmwareImageCopyWith get copyWith => _$FirmwareImageCopyWithImpl(this as FirmwareImage, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FirmwareImage&&(identical(other.name, name) || other.name == name)&&(identical(other.version, version) || other.version == version)&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.slot, slot) || other.slot == slot)&&(identical(other.board, board) || other.board == board)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.downloadedAt, downloadedAt) || other.downloadedAt == downloadedAt)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.branch, branch) || other.branch == branch)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,version,filePath,size,slot,board,hash,downloadedAt,sourceUrl,branch); + +@override +String toString() { + return 'FirmwareImage(name: $name, version: $version, filePath: $filePath, size: $size, slot: $slot, board: $board, hash: $hash, downloadedAt: $downloadedAt, sourceUrl: $sourceUrl, branch: $branch)'; +} + + +} + +/// @nodoc +abstract mixin class $FirmwareImageCopyWith<$Res> { + factory $FirmwareImageCopyWith(FirmwareImage value, $Res Function(FirmwareImage) _then) = _$FirmwareImageCopyWithImpl; +@useResult +$Res call({ + String name, String? version, String filePath, int size, int? slot, String? board, String? hash, DateTime? downloadedAt, String? sourceUrl, String? branch +}); + + + + +} +/// @nodoc +class _$FirmwareImageCopyWithImpl<$Res> + implements $FirmwareImageCopyWith<$Res> { + _$FirmwareImageCopyWithImpl(this._self, this._then); + + final FirmwareImage _self; + final $Res Function(FirmwareImage) _then; + +/// Create a copy of FirmwareImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? version = freezed,Object? filePath = null,Object? size = null,Object? slot = freezed,Object? board = freezed,Object? hash = freezed,Object? downloadedAt = freezed,Object? sourceUrl = freezed,Object? branch = freezed,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String?,filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,slot: freezed == slot ? _self.slot : slot // ignore: cast_nullable_to_non_nullable +as int?,board: freezed == board ? _self.board : board // ignore: cast_nullable_to_non_nullable +as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable +as String?,downloadedAt: freezed == downloadedAt ? _self.downloadedAt : downloadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,sourceUrl: freezed == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String?,branch: freezed == branch ? _self.branch : branch // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FirmwareImage]. +extension FirmwareImagePatterns on FirmwareImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FirmwareImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FirmwareImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FirmwareImage value) $default,){ +final _that = this; +switch (_that) { +case _FirmwareImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FirmwareImage value)? $default,){ +final _that = this; +switch (_that) { +case _FirmwareImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String? version, String filePath, int size, int? slot, String? board, String? hash, DateTime? downloadedAt, String? sourceUrl, String? branch)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FirmwareImage() when $default != null: +return $default(_that.name,_that.version,_that.filePath,_that.size,_that.slot,_that.board,_that.hash,_that.downloadedAt,_that.sourceUrl,_that.branch);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String? version, String filePath, int size, int? slot, String? board, String? hash, DateTime? downloadedAt, String? sourceUrl, String? branch) $default,) {final _that = this; +switch (_that) { +case _FirmwareImage(): +return $default(_that.name,_that.version,_that.filePath,_that.size,_that.slot,_that.board,_that.hash,_that.downloadedAt,_that.sourceUrl,_that.branch);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String? version, String filePath, int size, int? slot, String? board, String? hash, DateTime? downloadedAt, String? sourceUrl, String? branch)? $default,) {final _that = this; +switch (_that) { +case _FirmwareImage() when $default != null: +return $default(_that.name,_that.version,_that.filePath,_that.size,_that.slot,_that.board,_that.hash,_that.downloadedAt,_that.sourceUrl,_that.branch);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FirmwareImage extends FirmwareImage { + const _FirmwareImage({required this.name, this.version, required this.filePath, required this.size, this.slot, this.board, this.hash, this.downloadedAt, this.sourceUrl, this.branch}): super._(); + + +/// Display name for the firmware +@override final String name; +/// Firmware version string (e.g., "3.0.0", "v3.0.0-rc1") +@override final String? version; +/// Local file path where the firmware is stored +@override final String filePath; +/// File size in bytes +@override final int size; +/// MCUmgr image slot from manifest.json (0=app internal, 1=netCore, 2=app external) +@override final int? slot; +/// Board identifier from manifest.json (e.g., "watchdk" or "watchdk@1/nrf5340/cpunet") +@override final String? board; +/// SHA256 hash of the file (optional, for verification) +@override final String? hash; +/// When the firmware was downloaded (null for local files) +@override final DateTime? downloadedAt; +/// Source URL if downloaded from GitHub +@override final String? sourceUrl; +/// Git branch or tag name +@override final String? branch; + +/// Create a copy of FirmwareImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FirmwareImageCopyWith<_FirmwareImage> get copyWith => __$FirmwareImageCopyWithImpl<_FirmwareImage>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FirmwareImage&&(identical(other.name, name) || other.name == name)&&(identical(other.version, version) || other.version == version)&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.slot, slot) || other.slot == slot)&&(identical(other.board, board) || other.board == board)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.downloadedAt, downloadedAt) || other.downloadedAt == downloadedAt)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.branch, branch) || other.branch == branch)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,version,filePath,size,slot,board,hash,downloadedAt,sourceUrl,branch); + +@override +String toString() { + return 'FirmwareImage(name: $name, version: $version, filePath: $filePath, size: $size, slot: $slot, board: $board, hash: $hash, downloadedAt: $downloadedAt, sourceUrl: $sourceUrl, branch: $branch)'; +} + + +} + +/// @nodoc +abstract mixin class _$FirmwareImageCopyWith<$Res> implements $FirmwareImageCopyWith<$Res> { + factory _$FirmwareImageCopyWith(_FirmwareImage value, $Res Function(_FirmwareImage) _then) = __$FirmwareImageCopyWithImpl; +@override @useResult +$Res call({ + String name, String? version, String filePath, int size, int? slot, String? board, String? hash, DateTime? downloadedAt, String? sourceUrl, String? branch +}); + + + + +} +/// @nodoc +class __$FirmwareImageCopyWithImpl<$Res> + implements _$FirmwareImageCopyWith<$Res> { + __$FirmwareImageCopyWithImpl(this._self, this._then); + + final _FirmwareImage _self; + final $Res Function(_FirmwareImage) _then; + +/// Create a copy of FirmwareImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? version = freezed,Object? filePath = null,Object? size = null,Object? slot = freezed,Object? board = freezed,Object? hash = freezed,Object? downloadedAt = freezed,Object? sourceUrl = freezed,Object? branch = freezed,}) { + return _then(_FirmwareImage( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String?,filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,slot: freezed == slot ? _self.slot : slot // ignore: cast_nullable_to_non_nullable +as int?,board: freezed == board ? _self.board : board // ignore: cast_nullable_to_non_nullable +as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable +as String?,downloadedAt: freezed == downloadedAt ? _self.downloadedAt : downloadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,sourceUrl: freezed == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String?,branch: freezed == branch ? _self.branch : branch // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$ReleaseAsset { + +/// Asset file name (e.g., "watchdk@1_nrf5340_cpuapp_debug.zip") + String get name;/// Download URL + String get downloadUrl;/// File size in bytes + int get size; +/// Create a copy of ReleaseAsset +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ReleaseAssetCopyWith get copyWith => _$ReleaseAssetCopyWithImpl(this as ReleaseAsset, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ReleaseAsset&&(identical(other.name, name) || other.name == name)&&(identical(other.downloadUrl, downloadUrl) || other.downloadUrl == downloadUrl)&&(identical(other.size, size) || other.size == size)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,downloadUrl,size); + +@override +String toString() { + return 'ReleaseAsset(name: $name, downloadUrl: $downloadUrl, size: $size)'; +} + + +} + +/// @nodoc +abstract mixin class $ReleaseAssetCopyWith<$Res> { + factory $ReleaseAssetCopyWith(ReleaseAsset value, $Res Function(ReleaseAsset) _then) = _$ReleaseAssetCopyWithImpl; +@useResult +$Res call({ + String name, String downloadUrl, int size +}); + + + + +} +/// @nodoc +class _$ReleaseAssetCopyWithImpl<$Res> + implements $ReleaseAssetCopyWith<$Res> { + _$ReleaseAssetCopyWithImpl(this._self, this._then); + + final ReleaseAsset _self; + final $Res Function(ReleaseAsset) _then; + +/// Create a copy of ReleaseAsset +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? downloadUrl = null,Object? size = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,downloadUrl: null == downloadUrl ? _self.downloadUrl : downloadUrl // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ReleaseAsset]. +extension ReleaseAssetPatterns on ReleaseAsset { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ReleaseAsset value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ReleaseAsset() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ReleaseAsset value) $default,){ +final _that = this; +switch (_that) { +case _ReleaseAsset(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ReleaseAsset value)? $default,){ +final _that = this; +switch (_that) { +case _ReleaseAsset() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String downloadUrl, int size)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ReleaseAsset() when $default != null: +return $default(_that.name,_that.downloadUrl,_that.size);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String downloadUrl, int size) $default,) {final _that = this; +switch (_that) { +case _ReleaseAsset(): +return $default(_that.name,_that.downloadUrl,_that.size);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String downloadUrl, int size)? $default,) {final _that = this; +switch (_that) { +case _ReleaseAsset() when $default != null: +return $default(_that.name,_that.downloadUrl,_that.size);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ReleaseAsset extends ReleaseAsset { + const _ReleaseAsset({required this.name, required this.downloadUrl, required this.size}): super._(); + + +/// Asset file name (e.g., "watchdk@1_nrf5340_cpuapp_debug.zip") +@override final String name; +/// Download URL +@override final String downloadUrl; +/// File size in bytes +@override final int size; + +/// Create a copy of ReleaseAsset +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ReleaseAssetCopyWith<_ReleaseAsset> get copyWith => __$ReleaseAssetCopyWithImpl<_ReleaseAsset>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReleaseAsset&&(identical(other.name, name) || other.name == name)&&(identical(other.downloadUrl, downloadUrl) || other.downloadUrl == downloadUrl)&&(identical(other.size, size) || other.size == size)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,downloadUrl,size); + +@override +String toString() { + return 'ReleaseAsset(name: $name, downloadUrl: $downloadUrl, size: $size)'; +} + + +} + +/// @nodoc +abstract mixin class _$ReleaseAssetCopyWith<$Res> implements $ReleaseAssetCopyWith<$Res> { + factory _$ReleaseAssetCopyWith(_ReleaseAsset value, $Res Function(_ReleaseAsset) _then) = __$ReleaseAssetCopyWithImpl; +@override @useResult +$Res call({ + String name, String downloadUrl, int size +}); + + + + +} +/// @nodoc +class __$ReleaseAssetCopyWithImpl<$Res> + implements _$ReleaseAssetCopyWith<$Res> { + __$ReleaseAssetCopyWithImpl(this._self, this._then); + + final _ReleaseAsset _self; + final $Res Function(_ReleaseAsset) _then; + +/// Create a copy of ReleaseAsset +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? downloadUrl = null,Object? size = null,}) { + return _then(_ReleaseAsset( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,downloadUrl: null == downloadUrl ? _self.downloadUrl : downloadUrl // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$GitHubRelease { + +/// Release tag name (e.g., "v3.0.0") + String get tagName;/// Release title + String get name;/// Release description/body + String? get body;/// Whether this is a prerelease + bool get isPrerelease;/// When the release was published + DateTime get publishedAt;/// All available firmware assets in this release + List get assets; +/// Create a copy of GitHubRelease +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GitHubReleaseCopyWith get copyWith => _$GitHubReleaseCopyWithImpl(this as GitHubRelease, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GitHubRelease&&(identical(other.tagName, tagName) || other.tagName == tagName)&&(identical(other.name, name) || other.name == name)&&(identical(other.body, body) || other.body == body)&&(identical(other.isPrerelease, isPrerelease) || other.isPrerelease == isPrerelease)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&const DeepCollectionEquality().equals(other.assets, assets)); +} + + +@override +int get hashCode => Object.hash(runtimeType,tagName,name,body,isPrerelease,publishedAt,const DeepCollectionEquality().hash(assets)); + +@override +String toString() { + return 'GitHubRelease(tagName: $tagName, name: $name, body: $body, isPrerelease: $isPrerelease, publishedAt: $publishedAt, assets: $assets)'; +} + + +} + +/// @nodoc +abstract mixin class $GitHubReleaseCopyWith<$Res> { + factory $GitHubReleaseCopyWith(GitHubRelease value, $Res Function(GitHubRelease) _then) = _$GitHubReleaseCopyWithImpl; +@useResult +$Res call({ + String tagName, String name, String? body, bool isPrerelease, DateTime publishedAt, List assets +}); + + + + +} +/// @nodoc +class _$GitHubReleaseCopyWithImpl<$Res> + implements $GitHubReleaseCopyWith<$Res> { + _$GitHubReleaseCopyWithImpl(this._self, this._then); + + final GitHubRelease _self; + final $Res Function(GitHubRelease) _then; + +/// Create a copy of GitHubRelease +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? tagName = null,Object? name = null,Object? body = freezed,Object? isPrerelease = null,Object? publishedAt = null,Object? assets = null,}) { + return _then(_self.copyWith( +tagName: null == tagName ? _self.tagName : tagName // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,isPrerelease: null == isPrerelease ? _self.isPrerelease : isPrerelease // ignore: cast_nullable_to_non_nullable +as bool,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as DateTime,assets: null == assets ? _self.assets : assets // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GitHubRelease]. +extension GitHubReleasePatterns on GitHubRelease { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GitHubRelease value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GitHubRelease() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GitHubRelease value) $default,){ +final _that = this; +switch (_that) { +case _GitHubRelease(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GitHubRelease value)? $default,){ +final _that = this; +switch (_that) { +case _GitHubRelease() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String tagName, String name, String? body, bool isPrerelease, DateTime publishedAt, List assets)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GitHubRelease() when $default != null: +return $default(_that.tagName,_that.name,_that.body,_that.isPrerelease,_that.publishedAt,_that.assets);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String tagName, String name, String? body, bool isPrerelease, DateTime publishedAt, List assets) $default,) {final _that = this; +switch (_that) { +case _GitHubRelease(): +return $default(_that.tagName,_that.name,_that.body,_that.isPrerelease,_that.publishedAt,_that.assets);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String tagName, String name, String? body, bool isPrerelease, DateTime publishedAt, List assets)? $default,) {final _that = this; +switch (_that) { +case _GitHubRelease() when $default != null: +return $default(_that.tagName,_that.name,_that.body,_that.isPrerelease,_that.publishedAt,_that.assets);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GitHubRelease extends GitHubRelease { + const _GitHubRelease({required this.tagName, required this.name, this.body, required this.isPrerelease, required this.publishedAt, required final List assets}): _assets = assets,super._(); + + +/// Release tag name (e.g., "v3.0.0") +@override final String tagName; +/// Release title +@override final String name; +/// Release description/body +@override final String? body; +/// Whether this is a prerelease +@override final bool isPrerelease; +/// When the release was published +@override final DateTime publishedAt; +/// All available firmware assets in this release + final List _assets; +/// All available firmware assets in this release +@override List get assets { + if (_assets is EqualUnmodifiableListView) return _assets; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_assets); +} + + +/// Create a copy of GitHubRelease +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GitHubReleaseCopyWith<_GitHubRelease> get copyWith => __$GitHubReleaseCopyWithImpl<_GitHubRelease>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GitHubRelease&&(identical(other.tagName, tagName) || other.tagName == tagName)&&(identical(other.name, name) || other.name == name)&&(identical(other.body, body) || other.body == body)&&(identical(other.isPrerelease, isPrerelease) || other.isPrerelease == isPrerelease)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&const DeepCollectionEquality().equals(other._assets, _assets)); +} + + +@override +int get hashCode => Object.hash(runtimeType,tagName,name,body,isPrerelease,publishedAt,const DeepCollectionEquality().hash(_assets)); + +@override +String toString() { + return 'GitHubRelease(tagName: $tagName, name: $name, body: $body, isPrerelease: $isPrerelease, publishedAt: $publishedAt, assets: $assets)'; +} + + +} + +/// @nodoc +abstract mixin class _$GitHubReleaseCopyWith<$Res> implements $GitHubReleaseCopyWith<$Res> { + factory _$GitHubReleaseCopyWith(_GitHubRelease value, $Res Function(_GitHubRelease) _then) = __$GitHubReleaseCopyWithImpl; +@override @useResult +$Res call({ + String tagName, String name, String? body, bool isPrerelease, DateTime publishedAt, List assets +}); + + + + +} +/// @nodoc +class __$GitHubReleaseCopyWithImpl<$Res> + implements _$GitHubReleaseCopyWith<$Res> { + __$GitHubReleaseCopyWithImpl(this._self, this._then); + + final _GitHubRelease _self; + final $Res Function(_GitHubRelease) _then; + +/// Create a copy of GitHubRelease +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? tagName = null,Object? name = null,Object? body = freezed,Object? isPrerelease = null,Object? publishedAt = null,Object? assets = null,}) { + return _then(_GitHubRelease( +tagName: null == tagName ? _self.tagName : tagName // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,isPrerelease: null == isPrerelease ? _self.isPrerelease : isPrerelease // ignore: cast_nullable_to_non_nullable +as bool,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as DateTime,assets: null == assets ? _self._assets : assets // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$GitHubArtifact { + +/// Branch name + String get branch;/// Workflow run ID + String get runId;/// Artifact name + String get name;/// Artifact size in bytes + int get size;/// When the artifact was created + DateTime get createdAt;/// Download URL (requires authentication) + String get downloadUrl;/// Commit SHA + String? get commitSha;/// Commit message + String? get commitMessage; +/// Create a copy of GitHubArtifact +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GitHubArtifactCopyWith get copyWith => _$GitHubArtifactCopyWithImpl(this as GitHubArtifact, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GitHubArtifact&&(identical(other.branch, branch) || other.branch == branch)&&(identical(other.runId, runId) || other.runId == runId)&&(identical(other.name, name) || other.name == name)&&(identical(other.size, size) || other.size == size)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.downloadUrl, downloadUrl) || other.downloadUrl == downloadUrl)&&(identical(other.commitSha, commitSha) || other.commitSha == commitSha)&&(identical(other.commitMessage, commitMessage) || other.commitMessage == commitMessage)); +} + + +@override +int get hashCode => Object.hash(runtimeType,branch,runId,name,size,createdAt,downloadUrl,commitSha,commitMessage); + +@override +String toString() { + return 'GitHubArtifact(branch: $branch, runId: $runId, name: $name, size: $size, createdAt: $createdAt, downloadUrl: $downloadUrl, commitSha: $commitSha, commitMessage: $commitMessage)'; +} + + +} + +/// @nodoc +abstract mixin class $GitHubArtifactCopyWith<$Res> { + factory $GitHubArtifactCopyWith(GitHubArtifact value, $Res Function(GitHubArtifact) _then) = _$GitHubArtifactCopyWithImpl; +@useResult +$Res call({ + String branch, String runId, String name, int size, DateTime createdAt, String downloadUrl, String? commitSha, String? commitMessage +}); + + + + +} +/// @nodoc +class _$GitHubArtifactCopyWithImpl<$Res> + implements $GitHubArtifactCopyWith<$Res> { + _$GitHubArtifactCopyWithImpl(this._self, this._then); + + final GitHubArtifact _self; + final $Res Function(GitHubArtifact) _then; + +/// Create a copy of GitHubArtifact +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? branch = null,Object? runId = null,Object? name = null,Object? size = null,Object? createdAt = null,Object? downloadUrl = null,Object? commitSha = freezed,Object? commitMessage = freezed,}) { + return _then(_self.copyWith( +branch: null == branch ? _self.branch : branch // ignore: cast_nullable_to_non_nullable +as String,runId: null == runId ? _self.runId : runId // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,downloadUrl: null == downloadUrl ? _self.downloadUrl : downloadUrl // ignore: cast_nullable_to_non_nullable +as String,commitSha: freezed == commitSha ? _self.commitSha : commitSha // ignore: cast_nullable_to_non_nullable +as String?,commitMessage: freezed == commitMessage ? _self.commitMessage : commitMessage // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GitHubArtifact]. +extension GitHubArtifactPatterns on GitHubArtifact { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GitHubArtifact value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GitHubArtifact() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GitHubArtifact value) $default,){ +final _that = this; +switch (_that) { +case _GitHubArtifact(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GitHubArtifact value)? $default,){ +final _that = this; +switch (_that) { +case _GitHubArtifact() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String branch, String runId, String name, int size, DateTime createdAt, String downloadUrl, String? commitSha, String? commitMessage)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GitHubArtifact() when $default != null: +return $default(_that.branch,_that.runId,_that.name,_that.size,_that.createdAt,_that.downloadUrl,_that.commitSha,_that.commitMessage);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String branch, String runId, String name, int size, DateTime createdAt, String downloadUrl, String? commitSha, String? commitMessage) $default,) {final _that = this; +switch (_that) { +case _GitHubArtifact(): +return $default(_that.branch,_that.runId,_that.name,_that.size,_that.createdAt,_that.downloadUrl,_that.commitSha,_that.commitMessage);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String branch, String runId, String name, int size, DateTime createdAt, String downloadUrl, String? commitSha, String? commitMessage)? $default,) {final _that = this; +switch (_that) { +case _GitHubArtifact() when $default != null: +return $default(_that.branch,_that.runId,_that.name,_that.size,_that.createdAt,_that.downloadUrl,_that.commitSha,_that.commitMessage);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GitHubArtifact extends GitHubArtifact { + const _GitHubArtifact({required this.branch, required this.runId, required this.name, required this.size, required this.createdAt, required this.downloadUrl, this.commitSha, this.commitMessage}): super._(); + + +/// Branch name +@override final String branch; +/// Workflow run ID +@override final String runId; +/// Artifact name +@override final String name; +/// Artifact size in bytes +@override final int size; +/// When the artifact was created +@override final DateTime createdAt; +/// Download URL (requires authentication) +@override final String downloadUrl; +/// Commit SHA +@override final String? commitSha; +/// Commit message +@override final String? commitMessage; + +/// Create a copy of GitHubArtifact +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GitHubArtifactCopyWith<_GitHubArtifact> get copyWith => __$GitHubArtifactCopyWithImpl<_GitHubArtifact>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GitHubArtifact&&(identical(other.branch, branch) || other.branch == branch)&&(identical(other.runId, runId) || other.runId == runId)&&(identical(other.name, name) || other.name == name)&&(identical(other.size, size) || other.size == size)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.downloadUrl, downloadUrl) || other.downloadUrl == downloadUrl)&&(identical(other.commitSha, commitSha) || other.commitSha == commitSha)&&(identical(other.commitMessage, commitMessage) || other.commitMessage == commitMessage)); +} + + +@override +int get hashCode => Object.hash(runtimeType,branch,runId,name,size,createdAt,downloadUrl,commitSha,commitMessage); + +@override +String toString() { + return 'GitHubArtifact(branch: $branch, runId: $runId, name: $name, size: $size, createdAt: $createdAt, downloadUrl: $downloadUrl, commitSha: $commitSha, commitMessage: $commitMessage)'; +} + + +} + +/// @nodoc +abstract mixin class _$GitHubArtifactCopyWith<$Res> implements $GitHubArtifactCopyWith<$Res> { + factory _$GitHubArtifactCopyWith(_GitHubArtifact value, $Res Function(_GitHubArtifact) _then) = __$GitHubArtifactCopyWithImpl; +@override @useResult +$Res call({ + String branch, String runId, String name, int size, DateTime createdAt, String downloadUrl, String? commitSha, String? commitMessage +}); + + + + +} +/// @nodoc +class __$GitHubArtifactCopyWithImpl<$Res> + implements _$GitHubArtifactCopyWith<$Res> { + __$GitHubArtifactCopyWithImpl(this._self, this._then); + + final _GitHubArtifact _self; + final $Res Function(_GitHubArtifact) _then; + +/// Create a copy of GitHubArtifact +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? branch = null,Object? runId = null,Object? name = null,Object? size = null,Object? createdAt = null,Object? downloadUrl = null,Object? commitSha = freezed,Object? commitMessage = freezed,}) { + return _then(_GitHubArtifact( +branch: null == branch ? _self.branch : branch // ignore: cast_nullable_to_non_nullable +as String,runId: null == runId ? _self.runId : runId // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,downloadUrl: null == downloadUrl ? _self.downloadUrl : downloadUrl // ignore: cast_nullable_to_non_nullable +as String,commitSha: freezed == commitSha ? _self.commitSha : commitSha // ignore: cast_nullable_to_non_nullable +as String?,commitMessage: freezed == commitMessage ? _self.commitMessage : commitMessage // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/health_sample.dart b/zswatch_app/lib/data/models/health_sample.dart index 46f00f7..f3eb9b7 100644 --- a/zswatch_app/lib/data/models/health_sample.dart +++ b/zswatch_app/lib/data/models/health_sample.dart @@ -1,16 +1,18 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'health_sample.freezed.dart'; /// Types of health data that can be stored enum HealthType { /// Daily/hourly step count steps, - + /// Heart rate in BPM heartRate, - + /// Sleep duration in minutes (future) sleep, - + /// Activity state (walking, running, etc.) activity, } @@ -19,16 +21,16 @@ enum HealthType { enum Granularity { /// Live streaming data realtime, - + /// Per-hour aggregates hourly, - + /// Per-day totals daily, - + /// Per-week totals weekly, - + /// Per-month totals monthly, } @@ -36,7 +38,7 @@ enum Granularity { /// Extension methods for HealthType enum extension HealthTypeExtension on HealthType { /// Convert to database string - String get name { + String get dbName { switch (this) { case HealthType.steps: return 'steps'; @@ -97,7 +99,7 @@ extension HealthTypeExtension on HealthType { /// Extension methods for Granularity enum extension GranularityExtension on Granularity { /// Convert to database string - String get name { + String get dbName { switch (this) { case Granularity.realtime: return 'realtime'; @@ -152,37 +154,32 @@ extension GranularityExtension on Granularity { /// Used to store and retrieve health metrics like steps, heart rate, /// and sleep data. Samples are stored in the local SQLite database /// with 60-day retention. -class HealthSample extends Equatable { - /// Database row identifier (null for new samples) - final int? id; +@freezed +abstract class HealthSample with _$HealthSample { + const HealthSample._(); - /// Foreign key to source watch - final String watchId; + const factory HealthSample({ + /// Database row identifier (null for new samples) + int? id, - /// Type of health data - final HealthType type; + /// Foreign key to source watch + required String watchId, - /// Measured value (steps count, BPM, minutes, etc.) - final double value; + /// Type of health data + required HealthType type, - /// When the measurement was taken on the watch - final DateTime timestamp; + /// Measured value (steps count, BPM, minutes, etc.) + required double value, - /// Time granularity of this sample - final Granularity granularity; + /// When the measurement was taken on the watch + required DateTime timestamp, - /// When the data was received by the app - final DateTime syncedAt; + /// Time granularity of this sample + required Granularity granularity, - const HealthSample({ - this.id, - required this.watchId, - required this.type, - required this.value, - required this.timestamp, - required this.granularity, - required this.syncedAt, - }); + /// When the data was received by the app + required DateTime syncedAt, + }) = _HealthSample; /// Create a new health sample (not yet persisted) factory HealthSample.create({ @@ -237,7 +234,7 @@ class HealthSample extends Equatable { } /// Create an activity state sample - /// + /// /// The value stores the activity state as an integer: /// 0=unknown, 1=notWorn, 2=deepSleep, 3=lightSleep, 4=remSleep, /// 5=still(activity), 6=running, 7=walking, 8=swimming, 9=cycling, 10=exercise @@ -257,27 +254,6 @@ class HealthSample extends Equatable { ); } - /// Copy with modified fields - HealthSample copyWith({ - int? id, - String? watchId, - HealthType? type, - double? value, - DateTime? timestamp, - Granularity? granularity, - DateTime? syncedAt, - }) { - return HealthSample( - id: id ?? this.id, - watchId: watchId ?? this.watchId, - type: type ?? this.type, - value: value ?? this.value, - timestamp: timestamp ?? this.timestamp, - granularity: granularity ?? this.granularity, - syncedAt: syncedAt ?? this.syncedAt, - ); - } - /// Value as integer (for steps) int get intValue => value.round(); @@ -311,67 +287,44 @@ class HealthSample extends Equatable { if (type != HealthType.steps) return true; return value >= 0; } - - @override - List get props => [ - id, - watchId, - type, - value, - timestamp, - granularity, - syncedAt, - ]; - - @override - String toString() { - return 'HealthSample(type: ${type.name}, value: $value, timestamp: $timestamp)'; - } } /// Aggregated health data for a time period -class HealthAggregate extends Equatable { - /// Type of health data - final HealthType type; +@freezed +abstract class HealthAggregate with _$HealthAggregate { + const HealthAggregate._(); - /// Start of the period - final DateTime periodStart; + const factory HealthAggregate({ + /// Type of health data + required HealthType type, - /// End of the period - final DateTime periodEnd; + /// Start of the period + required DateTime periodStart, - /// Granularity of the aggregate - final Granularity granularity; + /// End of the period + required DateTime periodEnd, - /// Total/sum value (for steps) - final double total; + /// Granularity of the aggregate + required Granularity granularity, - /// Average value (for heart rate) - final double average; + /// Total/sum value (for steps) + required double total, - /// Minimum value in the period - final double min; + /// Average value (for heart rate) + required double average, - /// Maximum value in the period - final double max; + /// Minimum value in the period + required double min, - /// Number of samples in the aggregate - final int sampleCount; + /// Maximum value in the period + required double max, - const HealthAggregate({ - required this.type, - required this.periodStart, - required this.periodEnd, - required this.granularity, - required this.total, - required this.average, - required this.min, - required this.max, - required this.sampleCount, - }); + /// Number of samples in the aggregate + required int sampleCount, + }) = _HealthAggregate; /// Create from a list of samples - /// + /// /// Note: For steps, we use the maximum value since the watch sends cumulative /// daily steps, not incremental counts. factory HealthAggregate.fromSamples({ @@ -381,7 +334,7 @@ class HealthAggregate extends Equatable { required Granularity granularity, }) { if (samples.isEmpty) { - final type = HealthType.steps; // Default + const type = HealthType.steps; // Default return HealthAggregate( type: type, periodStart: periodStart, @@ -401,7 +354,7 @@ class HealthAggregate extends Equatable { final average = sum / values.length; final min = values.reduce((a, b) => a < b ? a : b); final max = values.reduce((a, b) => a > b ? a : b); - + // For steps, use max value (cumulative) instead of sum final total = type == HealthType.steps ? max : sum; @@ -447,20 +400,7 @@ class HealthAggregate extends Equatable { } return '${minutes}m'; case HealthType.activity: - return '${sampleCount} samples'; + return '$sampleCount samples'; } } - - @override - List get props => [ - type, - periodStart, - periodEnd, - granularity, - total, - average, - min, - max, - sampleCount, - ]; } diff --git a/zswatch_app/lib/data/models/health_sample.freezed.dart b/zswatch_app/lib/data/models/health_sample.freezed.dart new file mode 100644 index 0000000..d7e3c4e --- /dev/null +++ b/zswatch_app/lib/data/models/health_sample.freezed.dart @@ -0,0 +1,602 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'health_sample.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$HealthSample { + +/// Database row identifier (null for new samples) + int? get id;/// Foreign key to source watch + String get watchId;/// Type of health data + HealthType get type;/// Measured value (steps count, BPM, minutes, etc.) + double get value;/// When the measurement was taken on the watch + DateTime get timestamp;/// Time granularity of this sample + Granularity get granularity;/// When the data was received by the app + DateTime get syncedAt; +/// Create a copy of HealthSample +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HealthSampleCopyWith get copyWith => _$HealthSampleCopyWithImpl(this as HealthSample, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HealthSample&&(identical(other.id, id) || other.id == id)&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.type, type) || other.type == type)&&(identical(other.value, value) || other.value == value)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.granularity, granularity) || other.granularity == granularity)&&(identical(other.syncedAt, syncedAt) || other.syncedAt == syncedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,watchId,type,value,timestamp,granularity,syncedAt); + +@override +String toString() { + return 'HealthSample(id: $id, watchId: $watchId, type: $type, value: $value, timestamp: $timestamp, granularity: $granularity, syncedAt: $syncedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $HealthSampleCopyWith<$Res> { + factory $HealthSampleCopyWith(HealthSample value, $Res Function(HealthSample) _then) = _$HealthSampleCopyWithImpl; +@useResult +$Res call({ + int? id, String watchId, HealthType type, double value, DateTime timestamp, Granularity granularity, DateTime syncedAt +}); + + + + +} +/// @nodoc +class _$HealthSampleCopyWithImpl<$Res> + implements $HealthSampleCopyWith<$Res> { + _$HealthSampleCopyWithImpl(this._self, this._then); + + final HealthSample _self; + final $Res Function(HealthSample) _then; + +/// Create a copy of HealthSample +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? watchId = null,Object? type = null,Object? value = null,Object? timestamp = null,Object? granularity = null,Object? syncedAt = null,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int?,watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as HealthType,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable +as double,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,granularity: null == granularity ? _self.granularity : granularity // ignore: cast_nullable_to_non_nullable +as Granularity,syncedAt: null == syncedAt ? _self.syncedAt : syncedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HealthSample]. +extension HealthSamplePatterns on HealthSample { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HealthSample value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HealthSample() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HealthSample value) $default,){ +final _that = this; +switch (_that) { +case _HealthSample(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HealthSample value)? $default,){ +final _that = this; +switch (_that) { +case _HealthSample() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int? id, String watchId, HealthType type, double value, DateTime timestamp, Granularity granularity, DateTime syncedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HealthSample() when $default != null: +return $default(_that.id,_that.watchId,_that.type,_that.value,_that.timestamp,_that.granularity,_that.syncedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int? id, String watchId, HealthType type, double value, DateTime timestamp, Granularity granularity, DateTime syncedAt) $default,) {final _that = this; +switch (_that) { +case _HealthSample(): +return $default(_that.id,_that.watchId,_that.type,_that.value,_that.timestamp,_that.granularity,_that.syncedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? id, String watchId, HealthType type, double value, DateTime timestamp, Granularity granularity, DateTime syncedAt)? $default,) {final _that = this; +switch (_that) { +case _HealthSample() when $default != null: +return $default(_that.id,_that.watchId,_that.type,_that.value,_that.timestamp,_that.granularity,_that.syncedAt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HealthSample extends HealthSample { + const _HealthSample({this.id, required this.watchId, required this.type, required this.value, required this.timestamp, required this.granularity, required this.syncedAt}): super._(); + + +/// Database row identifier (null for new samples) +@override final int? id; +/// Foreign key to source watch +@override final String watchId; +/// Type of health data +@override final HealthType type; +/// Measured value (steps count, BPM, minutes, etc.) +@override final double value; +/// When the measurement was taken on the watch +@override final DateTime timestamp; +/// Time granularity of this sample +@override final Granularity granularity; +/// When the data was received by the app +@override final DateTime syncedAt; + +/// Create a copy of HealthSample +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HealthSampleCopyWith<_HealthSample> get copyWith => __$HealthSampleCopyWithImpl<_HealthSample>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HealthSample&&(identical(other.id, id) || other.id == id)&&(identical(other.watchId, watchId) || other.watchId == watchId)&&(identical(other.type, type) || other.type == type)&&(identical(other.value, value) || other.value == value)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.granularity, granularity) || other.granularity == granularity)&&(identical(other.syncedAt, syncedAt) || other.syncedAt == syncedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,watchId,type,value,timestamp,granularity,syncedAt); + +@override +String toString() { + return 'HealthSample(id: $id, watchId: $watchId, type: $type, value: $value, timestamp: $timestamp, granularity: $granularity, syncedAt: $syncedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$HealthSampleCopyWith<$Res> implements $HealthSampleCopyWith<$Res> { + factory _$HealthSampleCopyWith(_HealthSample value, $Res Function(_HealthSample) _then) = __$HealthSampleCopyWithImpl; +@override @useResult +$Res call({ + int? id, String watchId, HealthType type, double value, DateTime timestamp, Granularity granularity, DateTime syncedAt +}); + + + + +} +/// @nodoc +class __$HealthSampleCopyWithImpl<$Res> + implements _$HealthSampleCopyWith<$Res> { + __$HealthSampleCopyWithImpl(this._self, this._then); + + final _HealthSample _self; + final $Res Function(_HealthSample) _then; + +/// Create a copy of HealthSample +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? watchId = null,Object? type = null,Object? value = null,Object? timestamp = null,Object? granularity = null,Object? syncedAt = null,}) { + return _then(_HealthSample( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int?,watchId: null == watchId ? _self.watchId : watchId // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as HealthType,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable +as double,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,granularity: null == granularity ? _self.granularity : granularity // ignore: cast_nullable_to_non_nullable +as Granularity,syncedAt: null == syncedAt ? _self.syncedAt : syncedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + +/// @nodoc +mixin _$HealthAggregate { + +/// Type of health data + HealthType get type;/// Start of the period + DateTime get periodStart;/// End of the period + DateTime get periodEnd;/// Granularity of the aggregate + Granularity get granularity;/// Total/sum value (for steps) + double get total;/// Average value (for heart rate) + double get average;/// Minimum value in the period + double get min;/// Maximum value in the period + double get max;/// Number of samples in the aggregate + int get sampleCount; +/// Create a copy of HealthAggregate +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HealthAggregateCopyWith get copyWith => _$HealthAggregateCopyWithImpl(this as HealthAggregate, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HealthAggregate&&(identical(other.type, type) || other.type == type)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.granularity, granularity) || other.granularity == granularity)&&(identical(other.total, total) || other.total == total)&&(identical(other.average, average) || other.average == average)&&(identical(other.min, min) || other.min == min)&&(identical(other.max, max) || other.max == max)&&(identical(other.sampleCount, sampleCount) || other.sampleCount == sampleCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,type,periodStart,periodEnd,granularity,total,average,min,max,sampleCount); + +@override +String toString() { + return 'HealthAggregate(type: $type, periodStart: $periodStart, periodEnd: $periodEnd, granularity: $granularity, total: $total, average: $average, min: $min, max: $max, sampleCount: $sampleCount)'; +} + + +} + +/// @nodoc +abstract mixin class $HealthAggregateCopyWith<$Res> { + factory $HealthAggregateCopyWith(HealthAggregate value, $Res Function(HealthAggregate) _then) = _$HealthAggregateCopyWithImpl; +@useResult +$Res call({ + HealthType type, DateTime periodStart, DateTime periodEnd, Granularity granularity, double total, double average, double min, double max, int sampleCount +}); + + + + +} +/// @nodoc +class _$HealthAggregateCopyWithImpl<$Res> + implements $HealthAggregateCopyWith<$Res> { + _$HealthAggregateCopyWithImpl(this._self, this._then); + + final HealthAggregate _self; + final $Res Function(HealthAggregate) _then; + +/// Create a copy of HealthAggregate +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? periodStart = null,Object? periodEnd = null,Object? granularity = null,Object? total = null,Object? average = null,Object? min = null,Object? max = null,Object? sampleCount = null,}) { + return _then(_self.copyWith( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as HealthType,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable +as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable +as DateTime,granularity: null == granularity ? _self.granularity : granularity // ignore: cast_nullable_to_non_nullable +as Granularity,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable +as double,average: null == average ? _self.average : average // ignore: cast_nullable_to_non_nullable +as double,min: null == min ? _self.min : min // ignore: cast_nullable_to_non_nullable +as double,max: null == max ? _self.max : max // ignore: cast_nullable_to_non_nullable +as double,sampleCount: null == sampleCount ? _self.sampleCount : sampleCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HealthAggregate]. +extension HealthAggregatePatterns on HealthAggregate { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HealthAggregate value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HealthAggregate() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HealthAggregate value) $default,){ +final _that = this; +switch (_that) { +case _HealthAggregate(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HealthAggregate value)? $default,){ +final _that = this; +switch (_that) { +case _HealthAggregate() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( HealthType type, DateTime periodStart, DateTime periodEnd, Granularity granularity, double total, double average, double min, double max, int sampleCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HealthAggregate() when $default != null: +return $default(_that.type,_that.periodStart,_that.periodEnd,_that.granularity,_that.total,_that.average,_that.min,_that.max,_that.sampleCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( HealthType type, DateTime periodStart, DateTime periodEnd, Granularity granularity, double total, double average, double min, double max, int sampleCount) $default,) {final _that = this; +switch (_that) { +case _HealthAggregate(): +return $default(_that.type,_that.periodStart,_that.periodEnd,_that.granularity,_that.total,_that.average,_that.min,_that.max,_that.sampleCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( HealthType type, DateTime periodStart, DateTime periodEnd, Granularity granularity, double total, double average, double min, double max, int sampleCount)? $default,) {final _that = this; +switch (_that) { +case _HealthAggregate() when $default != null: +return $default(_that.type,_that.periodStart,_that.periodEnd,_that.granularity,_that.total,_that.average,_that.min,_that.max,_that.sampleCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HealthAggregate extends HealthAggregate { + const _HealthAggregate({required this.type, required this.periodStart, required this.periodEnd, required this.granularity, required this.total, required this.average, required this.min, required this.max, required this.sampleCount}): super._(); + + +/// Type of health data +@override final HealthType type; +/// Start of the period +@override final DateTime periodStart; +/// End of the period +@override final DateTime periodEnd; +/// Granularity of the aggregate +@override final Granularity granularity; +/// Total/sum value (for steps) +@override final double total; +/// Average value (for heart rate) +@override final double average; +/// Minimum value in the period +@override final double min; +/// Maximum value in the period +@override final double max; +/// Number of samples in the aggregate +@override final int sampleCount; + +/// Create a copy of HealthAggregate +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HealthAggregateCopyWith<_HealthAggregate> get copyWith => __$HealthAggregateCopyWithImpl<_HealthAggregate>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HealthAggregate&&(identical(other.type, type) || other.type == type)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.granularity, granularity) || other.granularity == granularity)&&(identical(other.total, total) || other.total == total)&&(identical(other.average, average) || other.average == average)&&(identical(other.min, min) || other.min == min)&&(identical(other.max, max) || other.max == max)&&(identical(other.sampleCount, sampleCount) || other.sampleCount == sampleCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,type,periodStart,periodEnd,granularity,total,average,min,max,sampleCount); + +@override +String toString() { + return 'HealthAggregate(type: $type, periodStart: $periodStart, periodEnd: $periodEnd, granularity: $granularity, total: $total, average: $average, min: $min, max: $max, sampleCount: $sampleCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$HealthAggregateCopyWith<$Res> implements $HealthAggregateCopyWith<$Res> { + factory _$HealthAggregateCopyWith(_HealthAggregate value, $Res Function(_HealthAggregate) _then) = __$HealthAggregateCopyWithImpl; +@override @useResult +$Res call({ + HealthType type, DateTime periodStart, DateTime periodEnd, Granularity granularity, double total, double average, double min, double max, int sampleCount +}); + + + + +} +/// @nodoc +class __$HealthAggregateCopyWithImpl<$Res> + implements _$HealthAggregateCopyWith<$Res> { + __$HealthAggregateCopyWithImpl(this._self, this._then); + + final _HealthAggregate _self; + final $Res Function(_HealthAggregate) _then; + +/// Create a copy of HealthAggregate +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? periodStart = null,Object? periodEnd = null,Object? granularity = null,Object? total = null,Object? average = null,Object? min = null,Object? max = null,Object? sampleCount = null,}) { + return _then(_HealthAggregate( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as HealthType,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable +as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable +as DateTime,granularity: null == granularity ? _self.granularity : granularity // ignore: cast_nullable_to_non_nullable +as Granularity,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable +as double,average: null == average ? _self.average : average // ignore: cast_nullable_to_non_nullable +as double,min: null == min ? _self.min : min // ignore: cast_nullable_to_non_nullable +as double,max: null == max ? _self.max : max // ignore: cast_nullable_to_non_nullable +as double,sampleCount: null == sampleCount ? _self.sampleCount : sampleCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/http_request.dart b/zswatch_app/lib/data/models/http_request.dart index 80c0f24..edf7dd8 100644 --- a/zswatch_app/lib/data/models/http_request.dart +++ b/zswatch_app/lib/data/models/http_request.dart @@ -1,44 +1,40 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'http_request.freezed.dart'; /// HTTP relay request from watch. /// /// Represents a request from the watch for the app to fetch a URL /// and return the response (or XPath-evaluated result). -class HttpRequest extends Equatable { - /// The URL to fetch - final String url; +@freezed +abstract class HttpRequest with _$HttpRequest { + const HttpRequest._(); - /// Optional XPath expression to evaluate against XML response - final String? xpath; + const factory HttpRequest({ + /// The URL to fetch + required String url, - /// Whether to disable TLS certificate validation (default: false) - final bool insecure; + /// Optional XPath expression to evaluate against XML response + String? xpath, - /// Request ID from watch (echoed back in response for concurrent request handling) - final String? id; + /// Whether to disable TLS certificate validation (default: false) + @Default(false) bool insecure, - /// Response body or XPath result (populated after successful fetch) - final String? response; + /// Request ID from watch (echoed back in response for concurrent request handling) + String? id, - /// Error message (populated on failure) - final String? error; + /// Response body or XPath result (populated after successful fetch) + String? response, - /// When the request was received from the watch - final DateTime? startedAt; + /// Error message (populated on failure) + String? error, - /// When the request completed (success or failure) - final DateTime? completedAt; + /// When the request was received from the watch + DateTime? startedAt, - const HttpRequest({ - required this.url, - this.xpath, - this.insecure = false, - this.id, - this.response, - this.error, - this.startedAt, - this.completedAt, - }); + /// When the request completed (success or failure) + DateTime? completedAt, + }) = _HttpRequest; /// Create from incoming watch message factory HttpRequest.fromWatchMessage(Map json) { @@ -52,7 +48,8 @@ class HttpRequest extends Equatable { } /// Whether the request is currently pending - bool get isPending => completedAt == null && error == null && response == null; + bool get isPending => + completedAt == null && error == null && response == null; /// Whether the request completed successfully bool get isSuccess => response != null && error == null; @@ -65,36 +62,4 @@ class HttpRequest extends Equatable { if (startedAt == null || completedAt == null) return null; return completedAt!.difference(startedAt!); } - - HttpRequest copyWith({ - String? url, - String? xpath, - bool? insecure, - String? id, - String? response, - String? error, - DateTime? startedAt, - DateTime? completedAt, - }) { - return HttpRequest( - url: url ?? this.url, - xpath: xpath ?? this.xpath, - insecure: insecure ?? this.insecure, - id: id ?? this.id, - response: response ?? this.response, - error: error ?? this.error, - startedAt: startedAt ?? this.startedAt, - completedAt: completedAt ?? this.completedAt, - ); - } - - @override - List get props => [url, xpath, insecure, id, response, error, startedAt, completedAt]; - - @override - String toString() { - return 'HttpRequest(url: $url, xpath: $xpath, insecure: $insecure, id: $id, ' - 'response: ${response?.substring(0, response!.length.clamp(0, 50)) ?? 'null'}..., ' - 'error: $error)'; - } } diff --git a/zswatch_app/lib/data/models/http_request.freezed.dart b/zswatch_app/lib/data/models/http_request.freezed.dart new file mode 100644 index 0000000..49426b2 --- /dev/null +++ b/zswatch_app/lib/data/models/http_request.freezed.dart @@ -0,0 +1,308 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'http_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$HttpRequest { + +/// The URL to fetch + String get url;/// Optional XPath expression to evaluate against XML response + String? get xpath;/// Whether to disable TLS certificate validation (default: false) + bool get insecure;/// Request ID from watch (echoed back in response for concurrent request handling) + String? get id;/// Response body or XPath result (populated after successful fetch) + String? get response;/// Error message (populated on failure) + String? get error;/// When the request was received from the watch + DateTime? get startedAt;/// When the request completed (success or failure) + DateTime? get completedAt; +/// Create a copy of HttpRequest +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HttpRequestCopyWith get copyWith => _$HttpRequestCopyWithImpl(this as HttpRequest, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HttpRequest&&(identical(other.url, url) || other.url == url)&&(identical(other.xpath, xpath) || other.xpath == xpath)&&(identical(other.insecure, insecure) || other.insecure == insecure)&&(identical(other.id, id) || other.id == id)&&(identical(other.response, response) || other.response == response)&&(identical(other.error, error) || other.error == error)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.completedAt, completedAt) || other.completedAt == completedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,url,xpath,insecure,id,response,error,startedAt,completedAt); + +@override +String toString() { + return 'HttpRequest(url: $url, xpath: $xpath, insecure: $insecure, id: $id, response: $response, error: $error, startedAt: $startedAt, completedAt: $completedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $HttpRequestCopyWith<$Res> { + factory $HttpRequestCopyWith(HttpRequest value, $Res Function(HttpRequest) _then) = _$HttpRequestCopyWithImpl; +@useResult +$Res call({ + String url, String? xpath, bool insecure, String? id, String? response, String? error, DateTime? startedAt, DateTime? completedAt +}); + + + + +} +/// @nodoc +class _$HttpRequestCopyWithImpl<$Res> + implements $HttpRequestCopyWith<$Res> { + _$HttpRequestCopyWithImpl(this._self, this._then); + + final HttpRequest _self; + final $Res Function(HttpRequest) _then; + +/// Create a copy of HttpRequest +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? url = null,Object? xpath = freezed,Object? insecure = null,Object? id = freezed,Object? response = freezed,Object? error = freezed,Object? startedAt = freezed,Object? completedAt = freezed,}) { + return _then(_self.copyWith( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,xpath: freezed == xpath ? _self.xpath : xpath // ignore: cast_nullable_to_non_nullable +as String?,insecure: null == insecure ? _self.insecure : insecure // ignore: cast_nullable_to_non_nullable +as bool,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,completedAt: freezed == completedAt ? _self.completedAt : completedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HttpRequest]. +extension HttpRequestPatterns on HttpRequest { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HttpRequest value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HttpRequest() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HttpRequest value) $default,){ +final _that = this; +switch (_that) { +case _HttpRequest(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HttpRequest value)? $default,){ +final _that = this; +switch (_that) { +case _HttpRequest() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String url, String? xpath, bool insecure, String? id, String? response, String? error, DateTime? startedAt, DateTime? completedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HttpRequest() when $default != null: +return $default(_that.url,_that.xpath,_that.insecure,_that.id,_that.response,_that.error,_that.startedAt,_that.completedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String url, String? xpath, bool insecure, String? id, String? response, String? error, DateTime? startedAt, DateTime? completedAt) $default,) {final _that = this; +switch (_that) { +case _HttpRequest(): +return $default(_that.url,_that.xpath,_that.insecure,_that.id,_that.response,_that.error,_that.startedAt,_that.completedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String url, String? xpath, bool insecure, String? id, String? response, String? error, DateTime? startedAt, DateTime? completedAt)? $default,) {final _that = this; +switch (_that) { +case _HttpRequest() when $default != null: +return $default(_that.url,_that.xpath,_that.insecure,_that.id,_that.response,_that.error,_that.startedAt,_that.completedAt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HttpRequest extends HttpRequest { + const _HttpRequest({required this.url, this.xpath, this.insecure = false, this.id, this.response, this.error, this.startedAt, this.completedAt}): super._(); + + +/// The URL to fetch +@override final String url; +/// Optional XPath expression to evaluate against XML response +@override final String? xpath; +/// Whether to disable TLS certificate validation (default: false) +@override@JsonKey() final bool insecure; +/// Request ID from watch (echoed back in response for concurrent request handling) +@override final String? id; +/// Response body or XPath result (populated after successful fetch) +@override final String? response; +/// Error message (populated on failure) +@override final String? error; +/// When the request was received from the watch +@override final DateTime? startedAt; +/// When the request completed (success or failure) +@override final DateTime? completedAt; + +/// Create a copy of HttpRequest +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HttpRequestCopyWith<_HttpRequest> get copyWith => __$HttpRequestCopyWithImpl<_HttpRequest>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HttpRequest&&(identical(other.url, url) || other.url == url)&&(identical(other.xpath, xpath) || other.xpath == xpath)&&(identical(other.insecure, insecure) || other.insecure == insecure)&&(identical(other.id, id) || other.id == id)&&(identical(other.response, response) || other.response == response)&&(identical(other.error, error) || other.error == error)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.completedAt, completedAt) || other.completedAt == completedAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,url,xpath,insecure,id,response,error,startedAt,completedAt); + +@override +String toString() { + return 'HttpRequest(url: $url, xpath: $xpath, insecure: $insecure, id: $id, response: $response, error: $error, startedAt: $startedAt, completedAt: $completedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$HttpRequestCopyWith<$Res> implements $HttpRequestCopyWith<$Res> { + factory _$HttpRequestCopyWith(_HttpRequest value, $Res Function(_HttpRequest) _then) = __$HttpRequestCopyWithImpl; +@override @useResult +$Res call({ + String url, String? xpath, bool insecure, String? id, String? response, String? error, DateTime? startedAt, DateTime? completedAt +}); + + + + +} +/// @nodoc +class __$HttpRequestCopyWithImpl<$Res> + implements _$HttpRequestCopyWith<$Res> { + __$HttpRequestCopyWithImpl(this._self, this._then); + + final _HttpRequest _self; + final $Res Function(_HttpRequest) _then; + +/// Create a copy of HttpRequest +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? url = null,Object? xpath = freezed,Object? insecure = null,Object? id = freezed,Object? response = freezed,Object? error = freezed,Object? startedAt = freezed,Object? completedAt = freezed,}) { + return _then(_HttpRequest( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,xpath: freezed == xpath ? _self.xpath : xpath // ignore: cast_nullable_to_non_nullable +as String?,insecure: null == insecure ? _self.insecure : insecure // ignore: cast_nullable_to_non_nullable +as bool,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,completedAt: freezed == completedAt ? _self.completedAt : completedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/log_entry.dart b/zswatch_app/lib/data/models/log_entry.dart index c5b6acf..991fd50 100644 --- a/zswatch_app/lib/data/models/log_entry.dart +++ b/zswatch_app/lib/data/models/log_entry.dart @@ -1,17 +1,16 @@ -import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'log_entry.freezed.dart'; /// Direction of the log entry (incoming from watch or outgoing to watch) -enum LogDirection { - incoming, - outgoing, -} +enum LogDirection { incoming, outgoing } -/// Log level parsed from log messages (, , , ) +/// Log level parsed from log messages (``, ``, ``, ``) enum LogLevel { - debug, // - info, // + debug, // + info, // warning, // - error, // + error, // unknown, // No level tag found } @@ -64,48 +63,32 @@ enum LogEntryType { } /// A single log entry representing BLE NUS data -@immutable -class LogEntry { - /// Unique identifier for this entry - final int id; - - /// Timestamp when the entry was received/sent - final DateTime timestamp; +@freezed +abstract class LogEntry with _$LogEntry { + const LogEntry._(); - /// The raw message content - final String message; + const factory LogEntry({ + /// Unique identifier for this entry + required int id, - /// The parsed message type (from 't' field in JSON) - final String? messageType; + /// Timestamp when the entry was received/sent + required DateTime timestamp, - /// Categorized type for filtering - final LogEntryType type; + /// The raw message content + required String message, - /// Direction (incoming from watch or outgoing to watch) - final LogDirection direction; + /// The parsed message type (from 't' field in JSON) + String? messageType, - /// Whether this is a JSON message (vs raw text/log) - final bool isJson; + /// Categorized type for filtering + required LogEntryType type, - const LogEntry({ - required this.id, - required this.timestamp, - required this.message, - this.messageType, - required this.type, - required this.direction, - this.isJson = false, - }); + /// Direction (incoming from watch or outgoing to watch) + required LogDirection direction, - /// Log level parsed from message (, , , ) - /// Computed on-demand from message content to avoid issues with hot reload - LogLevel get level { - if (message.contains('')) return LogLevel.debug; - if (message.contains('')) return LogLevel.info; - if (message.contains('')) return LogLevel.warning; - if (message.contains('')) return LogLevel.error; - return LogLevel.unknown; - } + /// Whether this is a JSON message (vs raw text/log) + @Default(false) bool isJson, + }) = _LogEntry; /// Create a log entry from incoming BLE data factory LogEntry.incoming({ @@ -200,6 +183,16 @@ class LogEntry { } } + /// Log level parsed from message (``, ``, ``, ``) + /// Computed on-demand from message content to avoid issues with hot reload + LogLevel get level { + if (message.contains('')) return LogLevel.debug; + if (message.contains('')) return LogLevel.info; + if (message.contains('')) return LogLevel.warning; + if (message.contains('')) return LogLevel.error; + return LogLevel.unknown; + } + /// Get a display-friendly type name String get typeDisplayName { switch (type) { @@ -263,20 +256,4 @@ class LogEntry { return ''; } } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is LogEntry && - runtimeType == other.runtimeType && - id == other.id && - timestamp == other.timestamp && - message == other.message; - - @override - int get hashCode => Object.hash(id, timestamp, message); - - @override - String toString() => - 'LogEntry(id: $id, type: $type, direction: $direction, message: $message)'; } diff --git a/zswatch_app/lib/data/models/log_entry.freezed.dart b/zswatch_app/lib/data/models/log_entry.freezed.dart new file mode 100644 index 0000000..8f6764b --- /dev/null +++ b/zswatch_app/lib/data/models/log_entry.freezed.dart @@ -0,0 +1,303 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'log_entry.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LogEntry { + +/// Unique identifier for this entry + int get id;/// Timestamp when the entry was received/sent + DateTime get timestamp;/// The raw message content + String get message;/// The parsed message type (from 't' field in JSON) + String? get messageType;/// Categorized type for filtering + LogEntryType get type;/// Direction (incoming from watch or outgoing to watch) + LogDirection get direction;/// Whether this is a JSON message (vs raw text/log) + bool get isJson; +/// Create a copy of LogEntry +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LogEntryCopyWith get copyWith => _$LogEntryCopyWithImpl(this as LogEntry, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.message, message) || other.message == message)&&(identical(other.messageType, messageType) || other.messageType == messageType)&&(identical(other.type, type) || other.type == type)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.isJson, isJson) || other.isJson == isJson)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,message,messageType,type,direction,isJson); + +@override +String toString() { + return 'LogEntry(id: $id, timestamp: $timestamp, message: $message, messageType: $messageType, type: $type, direction: $direction, isJson: $isJson)'; +} + + +} + +/// @nodoc +abstract mixin class $LogEntryCopyWith<$Res> { + factory $LogEntryCopyWith(LogEntry value, $Res Function(LogEntry) _then) = _$LogEntryCopyWithImpl; +@useResult +$Res call({ + int id, DateTime timestamp, String message, String? messageType, LogEntryType type, LogDirection direction, bool isJson +}); + + + + +} +/// @nodoc +class _$LogEntryCopyWithImpl<$Res> + implements $LogEntryCopyWith<$Res> { + _$LogEntryCopyWithImpl(this._self, this._then); + + final LogEntry _self; + final $Res Function(LogEntry) _then; + +/// Create a copy of LogEntry +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? message = null,Object? messageType = freezed,Object? type = null,Object? direction = null,Object? isJson = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,messageType: freezed == messageType ? _self.messageType : messageType // ignore: cast_nullable_to_non_nullable +as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as LogEntryType,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as LogDirection,isJson: null == isJson ? _self.isJson : isJson // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LogEntry]. +extension LogEntryPatterns on LogEntry { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LogEntry value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LogEntry() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LogEntry value) $default,){ +final _that = this; +switch (_that) { +case _LogEntry(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LogEntry value)? $default,){ +final _that = this; +switch (_that) { +case _LogEntry() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, DateTime timestamp, String message, String? messageType, LogEntryType type, LogDirection direction, bool isJson)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LogEntry() when $default != null: +return $default(_that.id,_that.timestamp,_that.message,_that.messageType,_that.type,_that.direction,_that.isJson);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, DateTime timestamp, String message, String? messageType, LogEntryType type, LogDirection direction, bool isJson) $default,) {final _that = this; +switch (_that) { +case _LogEntry(): +return $default(_that.id,_that.timestamp,_that.message,_that.messageType,_that.type,_that.direction,_that.isJson);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, DateTime timestamp, String message, String? messageType, LogEntryType type, LogDirection direction, bool isJson)? $default,) {final _that = this; +switch (_that) { +case _LogEntry() when $default != null: +return $default(_that.id,_that.timestamp,_that.message,_that.messageType,_that.type,_that.direction,_that.isJson);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LogEntry extends LogEntry { + const _LogEntry({required this.id, required this.timestamp, required this.message, this.messageType, required this.type, required this.direction, this.isJson = false}): super._(); + + +/// Unique identifier for this entry +@override final int id; +/// Timestamp when the entry was received/sent +@override final DateTime timestamp; +/// The raw message content +@override final String message; +/// The parsed message type (from 't' field in JSON) +@override final String? messageType; +/// Categorized type for filtering +@override final LogEntryType type; +/// Direction (incoming from watch or outgoing to watch) +@override final LogDirection direction; +/// Whether this is a JSON message (vs raw text/log) +@override@JsonKey() final bool isJson; + +/// Create a copy of LogEntry +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LogEntryCopyWith<_LogEntry> get copyWith => __$LogEntryCopyWithImpl<_LogEntry>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.message, message) || other.message == message)&&(identical(other.messageType, messageType) || other.messageType == messageType)&&(identical(other.type, type) || other.type == type)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.isJson, isJson) || other.isJson == isJson)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,message,messageType,type,direction,isJson); + +@override +String toString() { + return 'LogEntry(id: $id, timestamp: $timestamp, message: $message, messageType: $messageType, type: $type, direction: $direction, isJson: $isJson)'; +} + + +} + +/// @nodoc +abstract mixin class _$LogEntryCopyWith<$Res> implements $LogEntryCopyWith<$Res> { + factory _$LogEntryCopyWith(_LogEntry value, $Res Function(_LogEntry) _then) = __$LogEntryCopyWithImpl; +@override @useResult +$Res call({ + int id, DateTime timestamp, String message, String? messageType, LogEntryType type, LogDirection direction, bool isJson +}); + + + + +} +/// @nodoc +class __$LogEntryCopyWithImpl<$Res> + implements _$LogEntryCopyWith<$Res> { + __$LogEntryCopyWithImpl(this._self, this._then); + + final _LogEntry _self; + final $Res Function(_LogEntry) _then; + +/// Create a copy of LogEntry +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? message = null,Object? messageType = freezed,Object? type = null,Object? direction = null,Object? isJson = null,}) { + return _then(_LogEntry( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,messageType: freezed == messageType ? _self.messageType : messageType // ignore: cast_nullable_to_non_nullable +as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as LogEntryType,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as LogDirection,isJson: null == isJson ? _self.isJson : isJson // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/log_filter.dart b/zswatch_app/lib/data/models/log_filter.dart index 4ef5dc5..fd69e40 100644 --- a/zswatch_app/lib/data/models/log_filter.dart +++ b/zswatch_app/lib/data/models/log_filter.dart @@ -1,4 +1,6 @@ -import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'log_filter.freezed.dart'; /// Filter types for log viewer enum LogFilter { @@ -40,63 +42,25 @@ enum LogFilter { } /// Current state of log streaming from watch -@immutable -class LogStreamingState { - /// Whether app has requested log streaming from watch - final bool requestedByApp; - - /// Whether log streaming is currently enabled on watch - /// Note: May be true even if not requested by app (watch setting) - final bool enabledOnWatch; - - /// Whether we're waiting for confirmation from watch - final bool pending; - - /// Error message if log enable/disable failed - final String? error; - - const LogStreamingState({ - this.requestedByApp = false, - this.enabledOnWatch = false, - this.pending = false, - this.error, - }); - - const LogStreamingState.initial() - : requestedByApp = false, - enabledOnWatch = false, - pending = false, - error = null; - - LogStreamingState copyWith({ - bool? requestedByApp, - bool? enabledOnWatch, - bool? pending, - String? error, - }) { - return LogStreamingState( - requestedByApp: requestedByApp ?? this.requestedByApp, - enabledOnWatch: enabledOnWatch ?? this.enabledOnWatch, - pending: pending ?? this.pending, - error: error, - ); - } +@freezed +abstract class LogStreamingState with _$LogStreamingState { + const LogStreamingState._(); - @override - bool operator ==(Object other) => - identical(this, other) || - other is LogStreamingState && - runtimeType == other.runtimeType && - requestedByApp == other.requestedByApp && - enabledOnWatch == other.enabledOnWatch && - pending == other.pending && - error == other.error; + const factory LogStreamingState({ + /// Whether app has requested log streaming from watch + @Default(false) bool requestedByApp, - @override - int get hashCode => - Object.hash(requestedByApp, enabledOnWatch, pending, error); + /// Whether log streaming is currently enabled on watch + /// Note: May be true even if not requested by app (watch setting) + @Default(false) bool enabledOnWatch, - @override - String toString() => - 'LogStreamingState(requestedByApp: $requestedByApp, enabledOnWatch: $enabledOnWatch, pending: $pending)'; + /// Whether we're waiting for confirmation from watch + @Default(false) bool pending, + + /// Error message if log enable/disable failed + String? error, + }) = _LogStreamingState; + + /// Initial state + factory LogStreamingState.initial() => const LogStreamingState(); } diff --git a/zswatch_app/lib/data/models/log_filter.freezed.dart b/zswatch_app/lib/data/models/log_filter.freezed.dart new file mode 100644 index 0000000..6bd7cbb --- /dev/null +++ b/zswatch_app/lib/data/models/log_filter.freezed.dart @@ -0,0 +1,290 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'log_filter.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LogStreamingState { + +/// Whether app has requested log streaming from watch + bool get requestedByApp;/// Whether log streaming is currently enabled on watch +/// Note: May be true even if not requested by app (watch setting) + bool get enabledOnWatch;/// Whether we're waiting for confirmation from watch + bool get pending;/// Error message if log enable/disable failed + String? get error; +/// Create a copy of LogStreamingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LogStreamingStateCopyWith get copyWith => _$LogStreamingStateCopyWithImpl(this as LogStreamingState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LogStreamingState&&(identical(other.requestedByApp, requestedByApp) || other.requestedByApp == requestedByApp)&&(identical(other.enabledOnWatch, enabledOnWatch) || other.enabledOnWatch == enabledOnWatch)&&(identical(other.pending, pending) || other.pending == pending)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,requestedByApp,enabledOnWatch,pending,error); + +@override +String toString() { + return 'LogStreamingState(requestedByApp: $requestedByApp, enabledOnWatch: $enabledOnWatch, pending: $pending, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $LogStreamingStateCopyWith<$Res> { + factory $LogStreamingStateCopyWith(LogStreamingState value, $Res Function(LogStreamingState) _then) = _$LogStreamingStateCopyWithImpl; +@useResult +$Res call({ + bool requestedByApp, bool enabledOnWatch, bool pending, String? error +}); + + + + +} +/// @nodoc +class _$LogStreamingStateCopyWithImpl<$Res> + implements $LogStreamingStateCopyWith<$Res> { + _$LogStreamingStateCopyWithImpl(this._self, this._then); + + final LogStreamingState _self; + final $Res Function(LogStreamingState) _then; + +/// Create a copy of LogStreamingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? requestedByApp = null,Object? enabledOnWatch = null,Object? pending = null,Object? error = freezed,}) { + return _then(_self.copyWith( +requestedByApp: null == requestedByApp ? _self.requestedByApp : requestedByApp // ignore: cast_nullable_to_non_nullable +as bool,enabledOnWatch: null == enabledOnWatch ? _self.enabledOnWatch : enabledOnWatch // ignore: cast_nullable_to_non_nullable +as bool,pending: null == pending ? _self.pending : pending // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LogStreamingState]. +extension LogStreamingStatePatterns on LogStreamingState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LogStreamingState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LogStreamingState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LogStreamingState value) $default,){ +final _that = this; +switch (_that) { +case _LogStreamingState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LogStreamingState value)? $default,){ +final _that = this; +switch (_that) { +case _LogStreamingState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool requestedByApp, bool enabledOnWatch, bool pending, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LogStreamingState() when $default != null: +return $default(_that.requestedByApp,_that.enabledOnWatch,_that.pending,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool requestedByApp, bool enabledOnWatch, bool pending, String? error) $default,) {final _that = this; +switch (_that) { +case _LogStreamingState(): +return $default(_that.requestedByApp,_that.enabledOnWatch,_that.pending,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool requestedByApp, bool enabledOnWatch, bool pending, String? error)? $default,) {final _that = this; +switch (_that) { +case _LogStreamingState() when $default != null: +return $default(_that.requestedByApp,_that.enabledOnWatch,_that.pending,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LogStreamingState extends LogStreamingState { + const _LogStreamingState({this.requestedByApp = false, this.enabledOnWatch = false, this.pending = false, this.error}): super._(); + + +/// Whether app has requested log streaming from watch +@override@JsonKey() final bool requestedByApp; +/// Whether log streaming is currently enabled on watch +/// Note: May be true even if not requested by app (watch setting) +@override@JsonKey() final bool enabledOnWatch; +/// Whether we're waiting for confirmation from watch +@override@JsonKey() final bool pending; +/// Error message if log enable/disable failed +@override final String? error; + +/// Create a copy of LogStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LogStreamingStateCopyWith<_LogStreamingState> get copyWith => __$LogStreamingStateCopyWithImpl<_LogStreamingState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LogStreamingState&&(identical(other.requestedByApp, requestedByApp) || other.requestedByApp == requestedByApp)&&(identical(other.enabledOnWatch, enabledOnWatch) || other.enabledOnWatch == enabledOnWatch)&&(identical(other.pending, pending) || other.pending == pending)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,requestedByApp,enabledOnWatch,pending,error); + +@override +String toString() { + return 'LogStreamingState(requestedByApp: $requestedByApp, enabledOnWatch: $enabledOnWatch, pending: $pending, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$LogStreamingStateCopyWith<$Res> implements $LogStreamingStateCopyWith<$Res> { + factory _$LogStreamingStateCopyWith(_LogStreamingState value, $Res Function(_LogStreamingState) _then) = __$LogStreamingStateCopyWithImpl; +@override @useResult +$Res call({ + bool requestedByApp, bool enabledOnWatch, bool pending, String? error +}); + + + + +} +/// @nodoc +class __$LogStreamingStateCopyWithImpl<$Res> + implements _$LogStreamingStateCopyWith<$Res> { + __$LogStreamingStateCopyWithImpl(this._self, this._then); + + final _LogStreamingState _self; + final $Res Function(_LogStreamingState) _then; + +/// Create a copy of LogStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? requestedByApp = null,Object? enabledOnWatch = null,Object? pending = null,Object? error = freezed,}) { + return _then(_LogStreamingState( +requestedByApp: null == requestedByApp ? _self.requestedByApp : requestedByApp // ignore: cast_nullable_to_non_nullable +as bool,enabledOnWatch: null == enabledOnWatch ? _self.enabledOnWatch : enabledOnWatch // ignore: cast_nullable_to_non_nullable +as bool,pending: null == pending ? _self.pending : pending // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/notification.dart b/zswatch_app/lib/data/models/notification.dart index b27efe7..7ec7587 100644 --- a/zswatch_app/lib/data/models/notification.dart +++ b/zswatch_app/lib/data/models/notification.dart @@ -1,3 +1,7 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification.freezed.dart'; + /// Notification model for phone → watch notification forwarding /// /// Represents a phone notification that will be forwarded to the watch @@ -46,61 +50,50 @@ enum NotificationCategory { } /// Phone notification to be forwarded to watch -class PhoneNotification { - /// Stable positive ID (mapped from Android StatusBarNotification.id) - final int id; +@freezed +abstract class PhoneNotification with _$PhoneNotification { + const PhoneNotification._(); - /// Package name of the source app - final String packageName; + const factory PhoneNotification({ + /// Stable positive ID (mapped from Android StatusBarNotification.id) + required int id, - /// Human-readable app name - final String appName; + /// Package name of the source app + required String packageName, - /// Notification title (may be null for some apps) - final String? title; + /// Human-readable app name + required String appName, - /// Notification body text - final String? body; + /// Notification title (may be null for some apps) + String? title, - /// Sender name (for messaging apps) - final String? sender; + /// Notification body text + String? body, - /// Subject (for email apps) - final String? subject; + /// Sender name (for messaging apps) + String? sender, - /// Phone number (for calls/SMS) - final String? phoneNumber; + /// Subject (for email apps) + String? subject, - /// Notification category - final NotificationCategory category; + /// Phone number (for calls/SMS) + String? phoneNumber, - /// Whether this notification supports reply action - final bool canReply; + /// Notification category + @Default(NotificationCategory.other) NotificationCategory category, - /// Whether this notification is a group summary - final bool isGroupSummary; + /// Whether this notification supports reply action + @Default(false) bool canReply, - /// Timestamp when the notification was posted - final DateTime postedAt; + /// Whether this notification is a group summary + @Default(false) bool isGroupSummary, - /// Android notification key for dismissal - final String? key; + /// Timestamp when the notification was posted + required DateTime postedAt, - const PhoneNotification({ - required this.id, - required this.packageName, - required this.appName, - this.title, - this.body, - this.sender, - this.subject, - this.phoneNumber, - this.category = NotificationCategory.other, - this.canReply = false, - this.isGroupSummary = false, - required this.postedAt, - this.key, - }); + /// Android notification key for dismissal + String? key, + }) = _PhoneNotification; /// Create from Android notification data (via MethodChannel) factory PhoneNotification.fromMap(Map map) { @@ -143,44 +136,26 @@ class PhoneNotification { 'key': key, }; } - - @override - String toString() { - return 'PhoneNotification(id: $id, app: $appName, title: $title)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is PhoneNotification && - other.id == id && - other.packageName == packageName; - } - - @override - int get hashCode => id.hashCode ^ packageName.hashCode; } /// Notification filter settings for an app -class AppNotificationFilter { - /// Package name of the app - final String packageName; +@freezed +abstract class AppNotificationFilter with _$AppNotificationFilter { + const AppNotificationFilter._(); - /// Human-readable app name - final String appName; + const factory AppNotificationFilter({ + /// Package name of the app + required String packageName, - /// Whether notifications from this app should be forwarded - final bool enabled; + /// Human-readable app name + required String appName, - /// App icon (base64 encoded, if available) - final String? iconBase64; + /// Whether notifications from this app should be forwarded + @Default(true) bool enabled, - const AppNotificationFilter({ - required this.packageName, - required this.appName, - this.enabled = true, - this.iconBase64, - }); + /// App icon (base64 encoded, if available) + String? iconBase64, + }) = _AppNotificationFilter; factory AppNotificationFilter.fromMap(Map map) { return AppNotificationFilter( @@ -199,29 +174,4 @@ class AppNotificationFilter { 'iconBase64': iconBase64, }; } - - AppNotificationFilter copyWith({ - String? packageName, - String? appName, - bool? enabled, - String? iconBase64, - }) { - return AppNotificationFilter( - packageName: packageName ?? this.packageName, - appName: appName ?? this.appName, - enabled: enabled ?? this.enabled, - iconBase64: iconBase64 ?? this.iconBase64, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is AppNotificationFilter && - other.packageName == packageName; - } - - @override - int get hashCode => packageName.hashCode; } - diff --git a/zswatch_app/lib/data/models/notification.freezed.dart b/zswatch_app/lib/data/models/notification.freezed.dart new file mode 100644 index 0000000..080da95 --- /dev/null +++ b/zswatch_app/lib/data/models/notification.freezed.dart @@ -0,0 +1,607 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notification.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PhoneNotification { + +/// Stable positive ID (mapped from Android StatusBarNotification.id) + int get id;/// Package name of the source app + String get packageName;/// Human-readable app name + String get appName;/// Notification title (may be null for some apps) + String? get title;/// Notification body text + String? get body;/// Sender name (for messaging apps) + String? get sender;/// Subject (for email apps) + String? get subject;/// Phone number (for calls/SMS) + String? get phoneNumber;/// Notification category + NotificationCategory get category;/// Whether this notification supports reply action + bool get canReply;/// Whether this notification is a group summary + bool get isGroupSummary;/// Timestamp when the notification was posted + DateTime get postedAt;/// Android notification key for dismissal + String? get key; +/// Create a copy of PhoneNotification +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PhoneNotificationCopyWith get copyWith => _$PhoneNotificationCopyWithImpl(this as PhoneNotification, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PhoneNotification&&(identical(other.id, id) || other.id == id)&&(identical(other.packageName, packageName) || other.packageName == packageName)&&(identical(other.appName, appName) || other.appName == appName)&&(identical(other.title, title) || other.title == title)&&(identical(other.body, body) || other.body == body)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.category, category) || other.category == category)&&(identical(other.canReply, canReply) || other.canReply == canReply)&&(identical(other.isGroupSummary, isGroupSummary) || other.isGroupSummary == isGroupSummary)&&(identical(other.postedAt, postedAt) || other.postedAt == postedAt)&&(identical(other.key, key) || other.key == key)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,packageName,appName,title,body,sender,subject,phoneNumber,category,canReply,isGroupSummary,postedAt,key); + +@override +String toString() { + return 'PhoneNotification(id: $id, packageName: $packageName, appName: $appName, title: $title, body: $body, sender: $sender, subject: $subject, phoneNumber: $phoneNumber, category: $category, canReply: $canReply, isGroupSummary: $isGroupSummary, postedAt: $postedAt, key: $key)'; +} + + +} + +/// @nodoc +abstract mixin class $PhoneNotificationCopyWith<$Res> { + factory $PhoneNotificationCopyWith(PhoneNotification value, $Res Function(PhoneNotification) _then) = _$PhoneNotificationCopyWithImpl; +@useResult +$Res call({ + int id, String packageName, String appName, String? title, String? body, String? sender, String? subject, String? phoneNumber, NotificationCategory category, bool canReply, bool isGroupSummary, DateTime postedAt, String? key +}); + + + + +} +/// @nodoc +class _$PhoneNotificationCopyWithImpl<$Res> + implements $PhoneNotificationCopyWith<$Res> { + _$PhoneNotificationCopyWithImpl(this._self, this._then); + + final PhoneNotification _self; + final $Res Function(PhoneNotification) _then; + +/// Create a copy of PhoneNotification +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? packageName = null,Object? appName = null,Object? title = freezed,Object? body = freezed,Object? sender = freezed,Object? subject = freezed,Object? phoneNumber = freezed,Object? category = null,Object? canReply = null,Object? isGroupSummary = null,Object? postedAt = null,Object? key = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,packageName: null == packageName ? _self.packageName : packageName // ignore: cast_nullable_to_non_nullable +as String,appName: null == appName ? _self.appName : appName // ignore: cast_nullable_to_non_nullable +as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,sender: freezed == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable +as String?,subject: freezed == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable +as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable +as String?,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as NotificationCategory,canReply: null == canReply ? _self.canReply : canReply // ignore: cast_nullable_to_non_nullable +as bool,isGroupSummary: null == isGroupSummary ? _self.isGroupSummary : isGroupSummary // ignore: cast_nullable_to_non_nullable +as bool,postedAt: null == postedAt ? _self.postedAt : postedAt // ignore: cast_nullable_to_non_nullable +as DateTime,key: freezed == key ? _self.key : key // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PhoneNotification]. +extension PhoneNotificationPatterns on PhoneNotification { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PhoneNotification value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PhoneNotification() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PhoneNotification value) $default,){ +final _that = this; +switch (_that) { +case _PhoneNotification(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PhoneNotification value)? $default,){ +final _that = this; +switch (_that) { +case _PhoneNotification() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, String packageName, String appName, String? title, String? body, String? sender, String? subject, String? phoneNumber, NotificationCategory category, bool canReply, bool isGroupSummary, DateTime postedAt, String? key)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PhoneNotification() when $default != null: +return $default(_that.id,_that.packageName,_that.appName,_that.title,_that.body,_that.sender,_that.subject,_that.phoneNumber,_that.category,_that.canReply,_that.isGroupSummary,_that.postedAt,_that.key);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, String packageName, String appName, String? title, String? body, String? sender, String? subject, String? phoneNumber, NotificationCategory category, bool canReply, bool isGroupSummary, DateTime postedAt, String? key) $default,) {final _that = this; +switch (_that) { +case _PhoneNotification(): +return $default(_that.id,_that.packageName,_that.appName,_that.title,_that.body,_that.sender,_that.subject,_that.phoneNumber,_that.category,_that.canReply,_that.isGroupSummary,_that.postedAt,_that.key);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, String packageName, String appName, String? title, String? body, String? sender, String? subject, String? phoneNumber, NotificationCategory category, bool canReply, bool isGroupSummary, DateTime postedAt, String? key)? $default,) {final _that = this; +switch (_that) { +case _PhoneNotification() when $default != null: +return $default(_that.id,_that.packageName,_that.appName,_that.title,_that.body,_that.sender,_that.subject,_that.phoneNumber,_that.category,_that.canReply,_that.isGroupSummary,_that.postedAt,_that.key);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PhoneNotification extends PhoneNotification { + const _PhoneNotification({required this.id, required this.packageName, required this.appName, this.title, this.body, this.sender, this.subject, this.phoneNumber, this.category = NotificationCategory.other, this.canReply = false, this.isGroupSummary = false, required this.postedAt, this.key}): super._(); + + +/// Stable positive ID (mapped from Android StatusBarNotification.id) +@override final int id; +/// Package name of the source app +@override final String packageName; +/// Human-readable app name +@override final String appName; +/// Notification title (may be null for some apps) +@override final String? title; +/// Notification body text +@override final String? body; +/// Sender name (for messaging apps) +@override final String? sender; +/// Subject (for email apps) +@override final String? subject; +/// Phone number (for calls/SMS) +@override final String? phoneNumber; +/// Notification category +@override@JsonKey() final NotificationCategory category; +/// Whether this notification supports reply action +@override@JsonKey() final bool canReply; +/// Whether this notification is a group summary +@override@JsonKey() final bool isGroupSummary; +/// Timestamp when the notification was posted +@override final DateTime postedAt; +/// Android notification key for dismissal +@override final String? key; + +/// Create a copy of PhoneNotification +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PhoneNotificationCopyWith<_PhoneNotification> get copyWith => __$PhoneNotificationCopyWithImpl<_PhoneNotification>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PhoneNotification&&(identical(other.id, id) || other.id == id)&&(identical(other.packageName, packageName) || other.packageName == packageName)&&(identical(other.appName, appName) || other.appName == appName)&&(identical(other.title, title) || other.title == title)&&(identical(other.body, body) || other.body == body)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.category, category) || other.category == category)&&(identical(other.canReply, canReply) || other.canReply == canReply)&&(identical(other.isGroupSummary, isGroupSummary) || other.isGroupSummary == isGroupSummary)&&(identical(other.postedAt, postedAt) || other.postedAt == postedAt)&&(identical(other.key, key) || other.key == key)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,packageName,appName,title,body,sender,subject,phoneNumber,category,canReply,isGroupSummary,postedAt,key); + +@override +String toString() { + return 'PhoneNotification(id: $id, packageName: $packageName, appName: $appName, title: $title, body: $body, sender: $sender, subject: $subject, phoneNumber: $phoneNumber, category: $category, canReply: $canReply, isGroupSummary: $isGroupSummary, postedAt: $postedAt, key: $key)'; +} + + +} + +/// @nodoc +abstract mixin class _$PhoneNotificationCopyWith<$Res> implements $PhoneNotificationCopyWith<$Res> { + factory _$PhoneNotificationCopyWith(_PhoneNotification value, $Res Function(_PhoneNotification) _then) = __$PhoneNotificationCopyWithImpl; +@override @useResult +$Res call({ + int id, String packageName, String appName, String? title, String? body, String? sender, String? subject, String? phoneNumber, NotificationCategory category, bool canReply, bool isGroupSummary, DateTime postedAt, String? key +}); + + + + +} +/// @nodoc +class __$PhoneNotificationCopyWithImpl<$Res> + implements _$PhoneNotificationCopyWith<$Res> { + __$PhoneNotificationCopyWithImpl(this._self, this._then); + + final _PhoneNotification _self; + final $Res Function(_PhoneNotification) _then; + +/// Create a copy of PhoneNotification +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? packageName = null,Object? appName = null,Object? title = freezed,Object? body = freezed,Object? sender = freezed,Object? subject = freezed,Object? phoneNumber = freezed,Object? category = null,Object? canReply = null,Object? isGroupSummary = null,Object? postedAt = null,Object? key = freezed,}) { + return _then(_PhoneNotification( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,packageName: null == packageName ? _self.packageName : packageName // ignore: cast_nullable_to_non_nullable +as String,appName: null == appName ? _self.appName : appName // ignore: cast_nullable_to_non_nullable +as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,sender: freezed == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable +as String?,subject: freezed == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable +as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable +as String?,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as NotificationCategory,canReply: null == canReply ? _self.canReply : canReply // ignore: cast_nullable_to_non_nullable +as bool,isGroupSummary: null == isGroupSummary ? _self.isGroupSummary : isGroupSummary // ignore: cast_nullable_to_non_nullable +as bool,postedAt: null == postedAt ? _self.postedAt : postedAt // ignore: cast_nullable_to_non_nullable +as DateTime,key: freezed == key ? _self.key : key // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$AppNotificationFilter { + +/// Package name of the app + String get packageName;/// Human-readable app name + String get appName;/// Whether notifications from this app should be forwarded + bool get enabled;/// App icon (base64 encoded, if available) + String? get iconBase64; +/// Create a copy of AppNotificationFilter +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AppNotificationFilterCopyWith get copyWith => _$AppNotificationFilterCopyWithImpl(this as AppNotificationFilter, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotificationFilter&&(identical(other.packageName, packageName) || other.packageName == packageName)&&(identical(other.appName, appName) || other.appName == appName)&&(identical(other.enabled, enabled) || other.enabled == enabled)&&(identical(other.iconBase64, iconBase64) || other.iconBase64 == iconBase64)); +} + + +@override +int get hashCode => Object.hash(runtimeType,packageName,appName,enabled,iconBase64); + +@override +String toString() { + return 'AppNotificationFilter(packageName: $packageName, appName: $appName, enabled: $enabled, iconBase64: $iconBase64)'; +} + + +} + +/// @nodoc +abstract mixin class $AppNotificationFilterCopyWith<$Res> { + factory $AppNotificationFilterCopyWith(AppNotificationFilter value, $Res Function(AppNotificationFilter) _then) = _$AppNotificationFilterCopyWithImpl; +@useResult +$Res call({ + String packageName, String appName, bool enabled, String? iconBase64 +}); + + + + +} +/// @nodoc +class _$AppNotificationFilterCopyWithImpl<$Res> + implements $AppNotificationFilterCopyWith<$Res> { + _$AppNotificationFilterCopyWithImpl(this._self, this._then); + + final AppNotificationFilter _self; + final $Res Function(AppNotificationFilter) _then; + +/// Create a copy of AppNotificationFilter +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? packageName = null,Object? appName = null,Object? enabled = null,Object? iconBase64 = freezed,}) { + return _then(_self.copyWith( +packageName: null == packageName ? _self.packageName : packageName // ignore: cast_nullable_to_non_nullable +as String,appName: null == appName ? _self.appName : appName // ignore: cast_nullable_to_non_nullable +as String,enabled: null == enabled ? _self.enabled : enabled // ignore: cast_nullable_to_non_nullable +as bool,iconBase64: freezed == iconBase64 ? _self.iconBase64 : iconBase64 // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AppNotificationFilter]. +extension AppNotificationFilterPatterns on AppNotificationFilter { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AppNotificationFilter value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AppNotificationFilter() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AppNotificationFilter value) $default,){ +final _that = this; +switch (_that) { +case _AppNotificationFilter(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AppNotificationFilter value)? $default,){ +final _that = this; +switch (_that) { +case _AppNotificationFilter() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String packageName, String appName, bool enabled, String? iconBase64)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AppNotificationFilter() when $default != null: +return $default(_that.packageName,_that.appName,_that.enabled,_that.iconBase64);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String packageName, String appName, bool enabled, String? iconBase64) $default,) {final _that = this; +switch (_that) { +case _AppNotificationFilter(): +return $default(_that.packageName,_that.appName,_that.enabled,_that.iconBase64);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String packageName, String appName, bool enabled, String? iconBase64)? $default,) {final _that = this; +switch (_that) { +case _AppNotificationFilter() when $default != null: +return $default(_that.packageName,_that.appName,_that.enabled,_that.iconBase64);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AppNotificationFilter extends AppNotificationFilter { + const _AppNotificationFilter({required this.packageName, required this.appName, this.enabled = true, this.iconBase64}): super._(); + + +/// Package name of the app +@override final String packageName; +/// Human-readable app name +@override final String appName; +/// Whether notifications from this app should be forwarded +@override@JsonKey() final bool enabled; +/// App icon (base64 encoded, if available) +@override final String? iconBase64; + +/// Create a copy of AppNotificationFilter +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AppNotificationFilterCopyWith<_AppNotificationFilter> get copyWith => __$AppNotificationFilterCopyWithImpl<_AppNotificationFilter>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotificationFilter&&(identical(other.packageName, packageName) || other.packageName == packageName)&&(identical(other.appName, appName) || other.appName == appName)&&(identical(other.enabled, enabled) || other.enabled == enabled)&&(identical(other.iconBase64, iconBase64) || other.iconBase64 == iconBase64)); +} + + +@override +int get hashCode => Object.hash(runtimeType,packageName,appName,enabled,iconBase64); + +@override +String toString() { + return 'AppNotificationFilter(packageName: $packageName, appName: $appName, enabled: $enabled, iconBase64: $iconBase64)'; +} + + +} + +/// @nodoc +abstract mixin class _$AppNotificationFilterCopyWith<$Res> implements $AppNotificationFilterCopyWith<$Res> { + factory _$AppNotificationFilterCopyWith(_AppNotificationFilter value, $Res Function(_AppNotificationFilter) _then) = __$AppNotificationFilterCopyWithImpl; +@override @useResult +$Res call({ + String packageName, String appName, bool enabled, String? iconBase64 +}); + + + + +} +/// @nodoc +class __$AppNotificationFilterCopyWithImpl<$Res> + implements _$AppNotificationFilterCopyWith<$Res> { + __$AppNotificationFilterCopyWithImpl(this._self, this._then); + + final _AppNotificationFilter _self; + final $Res Function(_AppNotificationFilter) _then; + +/// Create a copy of AppNotificationFilter +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? packageName = null,Object? appName = null,Object? enabled = null,Object? iconBase64 = freezed,}) { + return _then(_AppNotificationFilter( +packageName: null == packageName ? _self.packageName : packageName // ignore: cast_nullable_to_non_nullable +as String,appName: null == appName ? _self.appName : appName // ignore: cast_nullable_to_non_nullable +as String,enabled: null == enabled ? _self.enabled : enabled // ignore: cast_nullable_to_non_nullable +as bool,iconBase64: freezed == iconBase64 ? _self.iconBase64 : iconBase64 // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/sensor_fusion_data.dart b/zswatch_app/lib/data/models/sensor_fusion_data.dart index cc788a5..2977d7e 100644 --- a/zswatch_app/lib/data/models/sensor_fusion_data.dart +++ b/zswatch_app/lib/data/models/sensor_fusion_data.dart @@ -1,44 +1,44 @@ import 'dart:math' as math; import 'dart:typed_data'; -import 'package:flutter/foundation.dart' show immutable; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sensor_fusion_data.freezed.dart'; /// Sensor fusion data from the watch's onboard IMU fusion algorithm /// /// The watch performs sensor fusion onboard using Madgwick/Mahony algorithm, /// outputting a quaternion (w, x, y, z) representing the device orientation. /// This data is streamed via GATT characteristic ADAFRUIT_CHAR_3D at ~5Hz. -@immutable -class SensorFusionData { - /// Quaternion w component (scalar part) - final double w; +@freezed +abstract class SensorFusionData with _$SensorFusionData { + const SensorFusionData._(); - /// Quaternion x component (vector i) - final double x; + const factory SensorFusionData({ + /// Quaternion w component (scalar part) + required double w, - /// Quaternion y component (vector j) - final double y; + /// Quaternion x component (vector i) + required double x, - /// Quaternion z component (vector k) - final double z; + /// Quaternion y component (vector j) + required double y, - /// Timestamp when the data was received - final DateTime timestamp; + /// Quaternion z component (vector k) + required double z, - const SensorFusionData({ - required this.w, - required this.x, - required this.y, - required this.z, - required this.timestamp, - }); + /// Timestamp when the data was received + required DateTime timestamp, + }) = _SensorFusionData; /// Create from BLE notification data (16 bytes = 4 floats, little-endian) /// /// Data format from firmware: [w, x, y, z] as float32 little-endian factory SensorFusionData.fromBleData(List data) { if (data.length < 16) { - throw ArgumentError('Sensor fusion data requires 16 bytes, got ${data.length}'); + throw ArgumentError( + 'Sensor fusion data requires 16 bytes, got ${data.length}', + ); } final bytes = ByteData.sublistView(Uint8List.fromList(data)); @@ -81,13 +81,7 @@ class SensorFusionData { /// Get conjugate (inverse for unit quaternions) /// q* = (w, -x, -y, -z) SensorFusionData get conjugate { - return SensorFusionData( - w: w, - x: -x, - y: -y, - z: -z, - timestamp: timestamp, - ); + return SensorFusionData(w: w, x: -x, y: -y, z: -z, timestamp: timestamp); } /// Get inverse quaternion @@ -121,7 +115,7 @@ class SensorFusionData { /// This gives the relative rotation from the offset orientation SensorFusionData applyOffset(SensorFusionData offset) { // To get rotation relative to offset: q_relative = q_current * q_offset^-1 - return this.multiply(offset.inverse); + return multiply(offset.inverse); } /// Convert quaternion to Euler angles (roll, pitch, yaw) in radians @@ -148,31 +142,9 @@ class SensorFusionData { final cosyCosp = 1 - 2 * (y * y + z * z); final yaw = math.atan2(sinyCosp, cosyCosp); - return EulerAngles( - roll: roll, - pitch: pitch, - yaw: yaw, - ); + return EulerAngles(roll: roll, pitch: pitch, yaw: yaw); } - @override - bool operator ==(Object other) => - identical(this, other) || - other is SensorFusionData && - runtimeType == other.runtimeType && - w == other.w && - x == other.x && - y == other.y && - z == other.z; - - @override - int get hashCode => Object.hash(w, x, y, z); - - @override - String toString() => - 'SensorFusionData(w: ${w.toStringAsFixed(4)}, x: ${x.toStringAsFixed(4)}, ' - 'y: ${y.toStringAsFixed(4)}, z: ${z.toStringAsFixed(4)})'; - /// Format for display with specified decimal places String toDisplayString([int decimals = 3]) { return 'w: ${w.toStringAsFixed(decimals)}, ' @@ -183,22 +155,20 @@ class SensorFusionData { } /// Euler angles representation (roll, pitch, yaw) -@immutable -class EulerAngles { - /// Roll (rotation around X-axis) in radians - final double roll; +@freezed +abstract class EulerAngles with _$EulerAngles { + const EulerAngles._(); - /// Pitch (rotation around Y-axis) in radians - final double pitch; + const factory EulerAngles({ + /// Roll (rotation around X-axis) in radians + required double roll, - /// Yaw (rotation around Z-axis) in radians - final double yaw; + /// Pitch (rotation around Y-axis) in radians + required double pitch, - const EulerAngles({ - required this.roll, - required this.pitch, - required this.yaw, - }); + /// Yaw (rotation around Z-axis) in radians + required double yaw, + }) = _EulerAngles; /// Roll in degrees double get rollDegrees => roll * 180 / math.pi; @@ -209,12 +179,6 @@ class EulerAngles { /// Yaw in degrees double get yawDegrees => yaw * 180 / math.pi; - @override - String toString() => - 'EulerAngles(roll: ${rollDegrees.toStringAsFixed(1)}°, ' - 'pitch: ${pitchDegrees.toStringAsFixed(1)}°, ' - 'yaw: ${yawDegrees.toStringAsFixed(1)}°)'; - /// Format for display String toDisplayString([int decimals = 1]) { return 'Roll: ${rollDegrees.toStringAsFixed(decimals)}°, ' diff --git a/zswatch_app/lib/data/models/sensor_fusion_data.freezed.dart b/zswatch_app/lib/data/models/sensor_fusion_data.freezed.dart new file mode 100644 index 0000000..e9e96dc --- /dev/null +++ b/zswatch_app/lib/data/models/sensor_fusion_data.freezed.dart @@ -0,0 +1,562 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sensor_fusion_data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$SensorFusionData { + +/// Quaternion w component (scalar part) + double get w;/// Quaternion x component (vector i) + double get x;/// Quaternion y component (vector j) + double get y;/// Quaternion z component (vector k) + double get z;/// Timestamp when the data was received + DateTime get timestamp; +/// Create a copy of SensorFusionData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SensorFusionDataCopyWith get copyWith => _$SensorFusionDataCopyWithImpl(this as SensorFusionData, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SensorFusionData&&(identical(other.w, w) || other.w == w)&&(identical(other.x, x) || other.x == x)&&(identical(other.y, y) || other.y == y)&&(identical(other.z, z) || other.z == z)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,w,x,y,z,timestamp); + +@override +String toString() { + return 'SensorFusionData(w: $w, x: $x, y: $y, z: $z, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class $SensorFusionDataCopyWith<$Res> { + factory $SensorFusionDataCopyWith(SensorFusionData value, $Res Function(SensorFusionData) _then) = _$SensorFusionDataCopyWithImpl; +@useResult +$Res call({ + double w, double x, double y, double z, DateTime timestamp +}); + + + + +} +/// @nodoc +class _$SensorFusionDataCopyWithImpl<$Res> + implements $SensorFusionDataCopyWith<$Res> { + _$SensorFusionDataCopyWithImpl(this._self, this._then); + + final SensorFusionData _self; + final $Res Function(SensorFusionData) _then; + +/// Create a copy of SensorFusionData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? w = null,Object? x = null,Object? y = null,Object? z = null,Object? timestamp = null,}) { + return _then(_self.copyWith( +w: null == w ? _self.w : w // ignore: cast_nullable_to_non_nullable +as double,x: null == x ? _self.x : x // ignore: cast_nullable_to_non_nullable +as double,y: null == y ? _self.y : y // ignore: cast_nullable_to_non_nullable +as double,z: null == z ? _self.z : z // ignore: cast_nullable_to_non_nullable +as double,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SensorFusionData]. +extension SensorFusionDataPatterns on SensorFusionData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SensorFusionData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SensorFusionData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SensorFusionData value) $default,){ +final _that = this; +switch (_that) { +case _SensorFusionData(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SensorFusionData value)? $default,){ +final _that = this; +switch (_that) { +case _SensorFusionData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double w, double x, double y, double z, DateTime timestamp)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SensorFusionData() when $default != null: +return $default(_that.w,_that.x,_that.y,_that.z,_that.timestamp);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double w, double x, double y, double z, DateTime timestamp) $default,) {final _that = this; +switch (_that) { +case _SensorFusionData(): +return $default(_that.w,_that.x,_that.y,_that.z,_that.timestamp);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double w, double x, double y, double z, DateTime timestamp)? $default,) {final _that = this; +switch (_that) { +case _SensorFusionData() when $default != null: +return $default(_that.w,_that.x,_that.y,_that.z,_that.timestamp);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SensorFusionData extends SensorFusionData { + const _SensorFusionData({required this.w, required this.x, required this.y, required this.z, required this.timestamp}): super._(); + + +/// Quaternion w component (scalar part) +@override final double w; +/// Quaternion x component (vector i) +@override final double x; +/// Quaternion y component (vector j) +@override final double y; +/// Quaternion z component (vector k) +@override final double z; +/// Timestamp when the data was received +@override final DateTime timestamp; + +/// Create a copy of SensorFusionData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SensorFusionDataCopyWith<_SensorFusionData> get copyWith => __$SensorFusionDataCopyWithImpl<_SensorFusionData>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SensorFusionData&&(identical(other.w, w) || other.w == w)&&(identical(other.x, x) || other.x == x)&&(identical(other.y, y) || other.y == y)&&(identical(other.z, z) || other.z == z)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,w,x,y,z,timestamp); + +@override +String toString() { + return 'SensorFusionData(w: $w, x: $x, y: $y, z: $z, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class _$SensorFusionDataCopyWith<$Res> implements $SensorFusionDataCopyWith<$Res> { + factory _$SensorFusionDataCopyWith(_SensorFusionData value, $Res Function(_SensorFusionData) _then) = __$SensorFusionDataCopyWithImpl; +@override @useResult +$Res call({ + double w, double x, double y, double z, DateTime timestamp +}); + + + + +} +/// @nodoc +class __$SensorFusionDataCopyWithImpl<$Res> + implements _$SensorFusionDataCopyWith<$Res> { + __$SensorFusionDataCopyWithImpl(this._self, this._then); + + final _SensorFusionData _self; + final $Res Function(_SensorFusionData) _then; + +/// Create a copy of SensorFusionData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? w = null,Object? x = null,Object? y = null,Object? z = null,Object? timestamp = null,}) { + return _then(_SensorFusionData( +w: null == w ? _self.w : w // ignore: cast_nullable_to_non_nullable +as double,x: null == x ? _self.x : x // ignore: cast_nullable_to_non_nullable +as double,y: null == y ? _self.y : y // ignore: cast_nullable_to_non_nullable +as double,z: null == z ? _self.z : z // ignore: cast_nullable_to_non_nullable +as double,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + +/// @nodoc +mixin _$EulerAngles { + +/// Roll (rotation around X-axis) in radians + double get roll;/// Pitch (rotation around Y-axis) in radians + double get pitch;/// Yaw (rotation around Z-axis) in radians + double get yaw; +/// Create a copy of EulerAngles +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EulerAnglesCopyWith get copyWith => _$EulerAnglesCopyWithImpl(this as EulerAngles, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EulerAngles&&(identical(other.roll, roll) || other.roll == roll)&&(identical(other.pitch, pitch) || other.pitch == pitch)&&(identical(other.yaw, yaw) || other.yaw == yaw)); +} + + +@override +int get hashCode => Object.hash(runtimeType,roll,pitch,yaw); + +@override +String toString() { + return 'EulerAngles(roll: $roll, pitch: $pitch, yaw: $yaw)'; +} + + +} + +/// @nodoc +abstract mixin class $EulerAnglesCopyWith<$Res> { + factory $EulerAnglesCopyWith(EulerAngles value, $Res Function(EulerAngles) _then) = _$EulerAnglesCopyWithImpl; +@useResult +$Res call({ + double roll, double pitch, double yaw +}); + + + + +} +/// @nodoc +class _$EulerAnglesCopyWithImpl<$Res> + implements $EulerAnglesCopyWith<$Res> { + _$EulerAnglesCopyWithImpl(this._self, this._then); + + final EulerAngles _self; + final $Res Function(EulerAngles) _then; + +/// Create a copy of EulerAngles +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? roll = null,Object? pitch = null,Object? yaw = null,}) { + return _then(_self.copyWith( +roll: null == roll ? _self.roll : roll // ignore: cast_nullable_to_non_nullable +as double,pitch: null == pitch ? _self.pitch : pitch // ignore: cast_nullable_to_non_nullable +as double,yaw: null == yaw ? _self.yaw : yaw // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EulerAngles]. +extension EulerAnglesPatterns on EulerAngles { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EulerAngles value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EulerAngles() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EulerAngles value) $default,){ +final _that = this; +switch (_that) { +case _EulerAngles(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EulerAngles value)? $default,){ +final _that = this; +switch (_that) { +case _EulerAngles() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double roll, double pitch, double yaw)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EulerAngles() when $default != null: +return $default(_that.roll,_that.pitch,_that.yaw);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double roll, double pitch, double yaw) $default,) {final _that = this; +switch (_that) { +case _EulerAngles(): +return $default(_that.roll,_that.pitch,_that.yaw);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double roll, double pitch, double yaw)? $default,) {final _that = this; +switch (_that) { +case _EulerAngles() when $default != null: +return $default(_that.roll,_that.pitch,_that.yaw);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EulerAngles extends EulerAngles { + const _EulerAngles({required this.roll, required this.pitch, required this.yaw}): super._(); + + +/// Roll (rotation around X-axis) in radians +@override final double roll; +/// Pitch (rotation around Y-axis) in radians +@override final double pitch; +/// Yaw (rotation around Z-axis) in radians +@override final double yaw; + +/// Create a copy of EulerAngles +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EulerAnglesCopyWith<_EulerAngles> get copyWith => __$EulerAnglesCopyWithImpl<_EulerAngles>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EulerAngles&&(identical(other.roll, roll) || other.roll == roll)&&(identical(other.pitch, pitch) || other.pitch == pitch)&&(identical(other.yaw, yaw) || other.yaw == yaw)); +} + + +@override +int get hashCode => Object.hash(runtimeType,roll,pitch,yaw); + +@override +String toString() { + return 'EulerAngles(roll: $roll, pitch: $pitch, yaw: $yaw)'; +} + + +} + +/// @nodoc +abstract mixin class _$EulerAnglesCopyWith<$Res> implements $EulerAnglesCopyWith<$Res> { + factory _$EulerAnglesCopyWith(_EulerAngles value, $Res Function(_EulerAngles) _then) = __$EulerAnglesCopyWithImpl; +@override @useResult +$Res call({ + double roll, double pitch, double yaw +}); + + + + +} +/// @nodoc +class __$EulerAnglesCopyWithImpl<$Res> + implements _$EulerAnglesCopyWith<$Res> { + __$EulerAnglesCopyWithImpl(this._self, this._then); + + final _EulerAngles _self; + final $Res Function(_EulerAngles) _then; + +/// Create a copy of EulerAngles +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? roll = null,Object? pitch = null,Object? yaw = null,}) { + return _then(_EulerAngles( +roll: null == roll ? _self.roll : roll // ignore: cast_nullable_to_non_nullable +as double,pitch: null == pitch ? _self.pitch : pitch // ignore: cast_nullable_to_non_nullable +as double,yaw: null == yaw ? _self.yaw : yaw // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/sensor_reading.dart b/zswatch_app/lib/data/models/sensor_reading.dart index f0547e6..aec4c19 100644 --- a/zswatch_app/lib/data/models/sensor_reading.dart +++ b/zswatch_app/lib/data/models/sensor_reading.dart @@ -1,4 +1,6 @@ -import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sensor_reading.freezed.dart'; /// Type of sensor data enum SensorType { @@ -25,34 +27,29 @@ enum SensorType { } /// A single sensor reading from the watch -@immutable -class SensorReading { - /// Timestamp when the reading was received - final DateTime timestamp; +@freezed +abstract class SensorReading with _$SensorReading { + const SensorReading._(); - /// Type of sensor - final SensorType type; + const factory SensorReading({ + /// Timestamp when the reading was received + required DateTime timestamp, - /// X-axis value (for multi-axis sensors) or primary value - final double x; + /// Type of sensor + required SensorType type, - /// Y-axis value (for multi-axis sensors) - final double? y; + /// X-axis value (for multi-axis sensors) or primary value + required double x, - /// Z-axis value (for multi-axis sensors) - final double? z; + /// Y-axis value (for multi-axis sensors) + double? y, - /// Raw integer value (for PPG) - final int? rawValue; + /// Z-axis value (for multi-axis sensors) + double? z, - const SensorReading({ - required this.timestamp, - required this.type, - required this.x, - this.y, - this.z, - this.rawValue, - }); + /// Raw integer value (for PPG) + int? rawValue, + }) = _SensorReading; /// Create an accelerometer reading factory SensorReading.accelerometer({ @@ -85,9 +82,7 @@ class SensorReading { } /// Create a temperature reading - factory SensorReading.temperature({ - required double celsius, - }) { + factory SensorReading.temperature({required double celsius}) { return SensorReading( timestamp: DateTime.now(), type: SensorType.temperature, @@ -111,9 +106,7 @@ class SensorReading { } /// Create a pressure reading - factory SensorReading.pressure({ - required double hPa, - }) { + factory SensorReading.pressure({required double hPa}) { return SensorReading( timestamp: DateTime.now(), type: SensorType.pressure, @@ -122,9 +115,7 @@ class SensorReading { } /// Create a light sensor reading - factory SensorReading.light({ - required double lux, - }) { + factory SensorReading.light({required double lux}) { return SensorReading( timestamp: DateTime.now(), type: SensorType.light, @@ -133,9 +124,7 @@ class SensorReading { } /// Create a humidity reading - factory SensorReading.humidity({ - required double percent, - }) { + factory SensorReading.humidity({required double percent}) { return SensorReading( timestamp: DateTime.now(), type: SensorType.humidity, @@ -201,23 +190,6 @@ class SensorReading { } return '${x.toStringAsFixed(2)} $unit'; } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SensorReading && - runtimeType == other.runtimeType && - timestamp == other.timestamp && - type == other.type && - x == other.x && - y == other.y && - z == other.z; - - @override - int get hashCode => Object.hash(timestamp, type, x, y, z); - - @override - String toString() => 'SensorReading($typeName: $formatted)'; } /// Buffer for storing recent sensor readings for real-time display @@ -231,10 +203,7 @@ class SensorBuffer { /// List of readings (oldest first, newest last) final List _readings = []; - SensorBuffer({ - this.maxSize = 200, - required this.type, - }); + SensorBuffer({this.maxSize = 200, required this.type}); /// Get all readings List get readings => List.unmodifiable(_readings); diff --git a/zswatch_app/lib/data/models/sensor_reading.freezed.dart b/zswatch_app/lib/data/models/sensor_reading.freezed.dart new file mode 100644 index 0000000..ba409a4 --- /dev/null +++ b/zswatch_app/lib/data/models/sensor_reading.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sensor_reading.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$SensorReading { + +/// Timestamp when the reading was received + DateTime get timestamp;/// Type of sensor + SensorType get type;/// X-axis value (for multi-axis sensors) or primary value + double get x;/// Y-axis value (for multi-axis sensors) + double? get y;/// Z-axis value (for multi-axis sensors) + double? get z;/// Raw integer value (for PPG) + int? get rawValue; +/// Create a copy of SensorReading +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SensorReadingCopyWith get copyWith => _$SensorReadingCopyWithImpl(this as SensorReading, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SensorReading&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.type, type) || other.type == type)&&(identical(other.x, x) || other.x == x)&&(identical(other.y, y) || other.y == y)&&(identical(other.z, z) || other.z == z)&&(identical(other.rawValue, rawValue) || other.rawValue == rawValue)); +} + + +@override +int get hashCode => Object.hash(runtimeType,timestamp,type,x,y,z,rawValue); + +@override +String toString() { + return 'SensorReading(timestamp: $timestamp, type: $type, x: $x, y: $y, z: $z, rawValue: $rawValue)'; +} + + +} + +/// @nodoc +abstract mixin class $SensorReadingCopyWith<$Res> { + factory $SensorReadingCopyWith(SensorReading value, $Res Function(SensorReading) _then) = _$SensorReadingCopyWithImpl; +@useResult +$Res call({ + DateTime timestamp, SensorType type, double x, double? y, double? z, int? rawValue +}); + + + + +} +/// @nodoc +class _$SensorReadingCopyWithImpl<$Res> + implements $SensorReadingCopyWith<$Res> { + _$SensorReadingCopyWithImpl(this._self, this._then); + + final SensorReading _self; + final $Res Function(SensorReading) _then; + +/// Create a copy of SensorReading +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? timestamp = null,Object? type = null,Object? x = null,Object? y = freezed,Object? z = freezed,Object? rawValue = freezed,}) { + return _then(_self.copyWith( +timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as SensorType,x: null == x ? _self.x : x // ignore: cast_nullable_to_non_nullable +as double,y: freezed == y ? _self.y : y // ignore: cast_nullable_to_non_nullable +as double?,z: freezed == z ? _self.z : z // ignore: cast_nullable_to_non_nullable +as double?,rawValue: freezed == rawValue ? _self.rawValue : rawValue // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SensorReading]. +extension SensorReadingPatterns on SensorReading { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SensorReading value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SensorReading() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SensorReading value) $default,){ +final _that = this; +switch (_that) { +case _SensorReading(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SensorReading value)? $default,){ +final _that = this; +switch (_that) { +case _SensorReading() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime timestamp, SensorType type, double x, double? y, double? z, int? rawValue)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SensorReading() when $default != null: +return $default(_that.timestamp,_that.type,_that.x,_that.y,_that.z,_that.rawValue);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( DateTime timestamp, SensorType type, double x, double? y, double? z, int? rawValue) $default,) {final _that = this; +switch (_that) { +case _SensorReading(): +return $default(_that.timestamp,_that.type,_that.x,_that.y,_that.z,_that.rawValue);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime timestamp, SensorType type, double x, double? y, double? z, int? rawValue)? $default,) {final _that = this; +switch (_that) { +case _SensorReading() when $default != null: +return $default(_that.timestamp,_that.type,_that.x,_that.y,_that.z,_that.rawValue);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SensorReading extends SensorReading { + const _SensorReading({required this.timestamp, required this.type, required this.x, this.y, this.z, this.rawValue}): super._(); + + +/// Timestamp when the reading was received +@override final DateTime timestamp; +/// Type of sensor +@override final SensorType type; +/// X-axis value (for multi-axis sensors) or primary value +@override final double x; +/// Y-axis value (for multi-axis sensors) +@override final double? y; +/// Z-axis value (for multi-axis sensors) +@override final double? z; +/// Raw integer value (for PPG) +@override final int? rawValue; + +/// Create a copy of SensorReading +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SensorReadingCopyWith<_SensorReading> get copyWith => __$SensorReadingCopyWithImpl<_SensorReading>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SensorReading&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.type, type) || other.type == type)&&(identical(other.x, x) || other.x == x)&&(identical(other.y, y) || other.y == y)&&(identical(other.z, z) || other.z == z)&&(identical(other.rawValue, rawValue) || other.rawValue == rawValue)); +} + + +@override +int get hashCode => Object.hash(runtimeType,timestamp,type,x,y,z,rawValue); + +@override +String toString() { + return 'SensorReading(timestamp: $timestamp, type: $type, x: $x, y: $y, z: $z, rawValue: $rawValue)'; +} + + +} + +/// @nodoc +abstract mixin class _$SensorReadingCopyWith<$Res> implements $SensorReadingCopyWith<$Res> { + factory _$SensorReadingCopyWith(_SensorReading value, $Res Function(_SensorReading) _then) = __$SensorReadingCopyWithImpl; +@override @useResult +$Res call({ + DateTime timestamp, SensorType type, double x, double? y, double? z, int? rawValue +}); + + + + +} +/// @nodoc +class __$SensorReadingCopyWithImpl<$Res> + implements _$SensorReadingCopyWith<$Res> { + __$SensorReadingCopyWithImpl(this._self, this._then); + + final _SensorReading _self; + final $Res Function(_SensorReading) _then; + +/// Create a copy of SensorReading +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? timestamp = null,Object? type = null,Object? x = null,Object? y = freezed,Object? z = freezed,Object? rawValue = freezed,}) { + return _then(_SensorReading( +timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as SensorType,x: null == x ? _self.x : x // ignore: cast_nullable_to_non_nullable +as double,y: freezed == y ? _self.y : y // ignore: cast_nullable_to_non_nullable +as double?,z: freezed == z ? _self.z : z // ignore: cast_nullable_to_non_nullable +as double?,rawValue: freezed == rawValue ? _self.rawValue : rawValue // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/thread_monitor_data.dart b/zswatch_app/lib/data/models/thread_monitor_data.dart new file mode 100644 index 0000000..0889051 --- /dev/null +++ b/zswatch_app/lib/data/models/thread_monitor_data.dart @@ -0,0 +1,251 @@ +import 'dart:math' as math; + +/// Parsed snapshot of a single Zephyr thread from `kernel thread list`. +class ThreadSnapshot { + final String address; + final String name; + final String state; + final int priority; + final int totalCycles; + final int stackSize; + final int stackUsed; + final int stackUnused; + final int usagePercent; + + const ThreadSnapshot({ + required this.address, + required this.name, + required this.state, + required this.priority, + required this.totalCycles, + required this.stackSize, + required this.stackUsed, + required this.stackUnused, + required this.usagePercent, + }); +} + +/// Per-thread tracked data across monitoring session. +class ThreadHistory { + final String name; + int maxStackUsed; + int stackSize; + int currentStackUsed; + int currentUsagePercent; + String state; + int priority; + + /// Execution cycles from previous poll (for delta computation). + int _prevCycles; + + /// Cycles/s history (one entry per poll). + final List cyclesPerSecHistory; + + /// The global poll index when this thread was first seen. + final int firstSeenPoll; + + /// Whether the thread has disappeared from the shell output. + bool removed = false; + + /// Timestamp when the thread was first marked as removed. + DateTime? removedAt; + + /// Max number of history entries to keep. + static const int maxHistory = 60; + + ThreadHistory({ + required this.name, + required this.maxStackUsed, + required this.stackSize, + required this.currentStackUsed, + required this.currentUsagePercent, + required this.state, + required this.priority, + required int initialCycles, + required this.firstSeenPoll, + }) : _prevCycles = initialCycles, + cyclesPerSecHistory = []; + + void update(ThreadSnapshot snapshot, double pollIntervalSec) { + // Thread reappeared — clear removed status. + if (removed) { + removed = false; + removedAt = null; + } + currentStackUsed = snapshot.stackUsed; + currentUsagePercent = snapshot.usagePercent; + stackSize = snapshot.stackSize; + state = snapshot.state; + priority = snapshot.priority; + maxStackUsed = math.max(maxStackUsed, snapshot.stackUsed); + + final deltaCycles = snapshot.totalCycles - _prevCycles; + _prevCycles = snapshot.totalCycles; + + final cps = (pollIntervalSec > 0 && deltaCycles >= 0) + ? deltaCycles / pollIntervalSec + : 0.0; + cyclesPerSecHistory.add(cps); + if (cyclesPerSecHistory.length > maxHistory) { + cyclesPerSecHistory.removeAt(0); + } + } +} + +/// Global scheduler cycles from the header line. +class SchedulerInfo { + final int cyclesSinceLastCall; + const SchedulerInfo({required this.cyclesSinceLastCall}); +} + +/// Result of parsing a full `kernel thread list` output. +class ThreadListParseResult { + final SchedulerInfo? scheduler; + final List threads; + const ThreadListParseResult({this.scheduler, required this.threads}); +} + +/// Parses the output of `kernel thread list`. +ThreadListParseResult parseThreadList(String output) { + // Strip \r and split — SMP transport may use \r\n line endings. + final lines = output.replaceAll('\r', '').split('\n'); + SchedulerInfo? scheduler; + final threads = []; + + // Parse scheduler line: "Scheduler: 10189 since last call" + final schedRegex = RegExp(r'Scheduler:\s+(\d+)\s+since last call'); + + int i = 0; + while (i < lines.length) { + final line = lines[i]; + + final schedMatch = schedRegex.firstMatch(line); + if (schedMatch != null) { + scheduler = SchedulerInfo( + cyclesSinceLastCall: int.parse(schedMatch.group(1)!), + ); + i++; + continue; + } + + // Thread header line starts with optional '*' then hex address + // e.g. " 0x20009488 mbox_wq #0" or "*0x2000b198 mcumgr smp" + // Allow address with or without '0x' prefix. + final headerRegex = RegExp(r'^\s*\*?((?:0x)?[0-9a-fA-F]{4,})\s+(.+)$'); + final headerMatch = headerRegex.firstMatch(line); + if (headerMatch != null) { + final address = headerMatch.group(1)!; + final name = headerMatch.group(2)!.trim(); + + // Parse following indented lines for this thread + String state = ''; + int priority = 0; + int totalCycles = 0; + int stackSize = 0; + int stackUsed = 0; + int stackUnused = 0; + int usagePercent = 0; + + i++; + while (i < lines.length) { + final sub = lines[i]; + if (sub.trim().isEmpty) { + i++; + continue; + } + // Stop if we hit the next thread header or non-indented line + if (!sub.startsWith('\t') && !sub.startsWith(' ')) break; + + // "options: 0x0, priority: 7 timeout: 0" + final prioMatch = RegExp(r'priority:\s*(-?\d+)').firstMatch(sub); + if (prioMatch != null) { + priority = int.parse(prioMatch.group(1)!); + } + + // "state: pending, entry: 0x786d1" + final stateMatch = RegExp(r'state:\s*(\w+)').firstMatch(sub); + if (stateMatch != null) { + state = stateMatch.group(1)!; + } + + // "Total execution cycles: 4810 (0 %)" + final cyclesMatch = RegExp( + r'Total execution cycles:\s*(\d+)', + ).firstMatch(sub); + if (cyclesMatch != null) { + totalCycles = int.parse(cyclesMatch.group(1)!); + } + + // "stack size 1024, unused 576, usage 448 / 1024 (43 %)" + final stackMatch = RegExp( + r'stack size\s+(\d+),\s*unused\s+(\d+),\s*usage\s+(\d+)\s*/\s*\d+\s*\((\d+)\s*%\)', + ).firstMatch(sub); + if (stackMatch != null) { + stackSize = int.parse(stackMatch.group(1)!); + stackUnused = int.parse(stackMatch.group(2)!); + stackUsed = int.parse(stackMatch.group(3)!); + usagePercent = int.parse(stackMatch.group(4)!); + } + + i++; + } + + threads.add( + ThreadSnapshot( + address: address, + name: name, + state: state, + priority: priority, + totalCycles: totalCycles, + stackSize: stackSize, + stackUsed: stackUsed, + stackUnused: stackUnused, + usagePercent: usagePercent, + ), + ); + continue; + } + + i++; + } + + return ThreadListParseResult(scheduler: scheduler, threads: threads); +} + +/// Parse CPU freq output like "CPU frequency profile: fast" +String? parseCpuFreq(String output) { + final match = RegExp(r'CPU frequency profile:\s*(\w+)').firstMatch(output); + return match?.group(1); +} + +/// Parsed power status. +class PowerInfo { + final String state; + final int timeToSleepSec; + final int uptimeSec; + + const PowerInfo({ + required this.state, + required this.timeToSleepSec, + required this.uptimeSec, + }); +} + +/// Parse power status output. +PowerInfo? parsePowerStatus(String output) { + final stateMatch = RegExp(r'State:\s*(.+)').firstMatch(output); + final sleepMatch = RegExp( + r'Time to sleep:\s*(\d+)\s*seconds', + ).firstMatch(output); + final uptimeMatch = RegExp( + r'Total uptime:\s*(\d+)\s*seconds', + ).firstMatch(output); + + if (stateMatch == null) return null; + + return PowerInfo( + state: stateMatch.group(1)!.trim(), + timeToSleepSec: sleepMatch != null ? int.parse(sleepMatch.group(1)!) : 0, + uptimeSec: uptimeMatch != null ? int.parse(uptimeMatch.group(1)!) : 0, + ); +} diff --git a/zswatch_app/lib/data/models/voice_memo.dart b/zswatch_app/lib/data/models/voice_memo.dart new file mode 100644 index 0000000..a7ee4ec --- /dev/null +++ b/zswatch_app/lib/data/models/voice_memo.dart @@ -0,0 +1,167 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'voice_memo.freezed.dart'; + +/// Sync state of a voice memo +enum VoiceMemoSyncStatus { + /// Only exists on the watch, not yet downloaded + onWatchOnly, + + /// Currently being downloaded from the watch + downloading, + + /// Downloaded to phone, verified, watch copy may still exist + synced, + + /// Download failed — will retry on next sync + downloadFailed, + + /// Transcription completed + transcribed, +} + +/// AI processing status for a voice memo +enum VoiceNoteProcessingStatus { + /// Not yet processed by AI + pending, + + /// AI is summarizing the transcript + summarizing, + + /// AI is categorizing the note + categorizing, + + /// AI is extracting actions + extractingActions, + + /// AI processing completed successfully + ready, + + /// AI processing failed + failed, +} + +/// Category assigned by AI to a voice note +enum VoiceNoteCategory { idea, task, reminder, meeting, note } + +/// Domain model for a voice memo recording +@freezed +abstract class VoiceMemo with _$VoiceMemo { + const VoiceMemo._(); + + const factory VoiceMemo({ + required int id, + required String filename, + required DateTime timestampUtc, + required int durationMs, + required int sizeBytes, + String? localFilePath, + String? transcription, + @Default(false) bool syncedFromWatch, + @Default(false) bool deletedOnWatch, + DateTime? downloadedAt, + DateTime? transcribedAt, + String? convertedFilePath, + + // AI-enhanced fields + String? summary, + String? category, + String? processingStatus, + String? aiModel, + DateTime? aiProcessedAt, + @Default(false) bool taskCreated, + @Default(false) bool calendarEventCreated, + String? actionReviewState, + }) = _VoiceMemo; + + /// Computed sync status based on field values + VoiceMemoSyncStatus get syncStatus { + if (transcription != null) return VoiceMemoSyncStatus.transcribed; + if (syncedFromWatch && localFilePath != null) { + return VoiceMemoSyncStatus.synced; + } + return VoiceMemoSyncStatus.onWatchOnly; + } + + /// Parsed AI processing status + VoiceNoteProcessingStatus get aiProcessingStatus { + if (processingStatus == null) return VoiceNoteProcessingStatus.pending; + switch (processingStatus) { + case 'summarizing': + return VoiceNoteProcessingStatus.summarizing; + case 'categorizing': + return VoiceNoteProcessingStatus.categorizing; + case 'extractingActions': + return VoiceNoteProcessingStatus.extractingActions; + case 'ready': + return VoiceNoteProcessingStatus.ready; + case 'failed': + return VoiceNoteProcessingStatus.failed; + default: + return VoiceNoteProcessingStatus.pending; + } + } + + /// Convenience alias used by UI code + VoiceNoteCategory? get aiCategory => categoryEnum; + + /// Parsed category enum + VoiceNoteCategory? get categoryEnum { + if (category == null) return null; + switch (category) { + case 'idea': + return VoiceNoteCategory.idea; + case 'task': + return VoiceNoteCategory.task; + case 'reminder': + return VoiceNoteCategory.reminder; + case 'meeting': + return VoiceNoteCategory.meeting; + case 'note': + return VoiceNoteCategory.note; + default: + return VoiceNoteCategory.note; + } + } + + /// Whether AI has processed this memo + bool get isAiProcessed => summary != null && processingStatus == 'ready'; + + /// Whether AI is currently processing this memo + bool get isAiProcessing { + final s = processingStatus; + return s == 'summarizing' || + s == 'categorizing' || + s == 'extractingActions'; + } + + /// Duration formatted as MM:SS + String get formattedDuration { + final totalSeconds = durationMs ~/ 1000; + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + /// File size formatted as human-readable string + String get formattedSize { + if (sizeBytes < 1024) return '$sizeBytes B'; + if (sizeBytes < 1024 * 1024) { + return '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Relative time display (e.g., "2 min ago", "Yesterday") + String get relativeTime { + final now = DateTime.now().toUtc(); + final diff = now.difference(timestampUtc); + + if (diff.inSeconds < 60) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes} min ago'; + if (diff.inHours < 24) return '${diff.inHours} hr ago'; + if (diff.inDays == 1) return 'Yesterday'; + if (diff.inDays < 7) return '${diff.inDays} days ago'; + return '${timestampUtc.month}/${timestampUtc.day}/${timestampUtc.year}'; + } +} diff --git a/zswatch_app/lib/data/models/voice_memo.freezed.dart b/zswatch_app/lib/data/models/voice_memo.freezed.dart new file mode 100644 index 0000000..8e74add --- /dev/null +++ b/zswatch_app/lib/data/models/voice_memo.freezed.dart @@ -0,0 +1,330 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'voice_memo.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$VoiceMemo { + + int get id; String get filename; DateTime get timestampUtc; int get durationMs; int get sizeBytes; String? get localFilePath; String? get transcription; bool get syncedFromWatch; bool get deletedOnWatch; DateTime? get downloadedAt; DateTime? get transcribedAt; String? get convertedFilePath;// AI-enhanced fields + String? get summary; String? get category; String? get processingStatus; String? get aiModel; DateTime? get aiProcessedAt; bool get taskCreated; bool get calendarEventCreated; String? get actionReviewState; +/// Create a copy of VoiceMemo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$VoiceMemoCopyWith get copyWith => _$VoiceMemoCopyWithImpl(this as VoiceMemo, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is VoiceMemo&&(identical(other.id, id) || other.id == id)&&(identical(other.filename, filename) || other.filename == filename)&&(identical(other.timestampUtc, timestampUtc) || other.timestampUtc == timestampUtc)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs)&&(identical(other.sizeBytes, sizeBytes) || other.sizeBytes == sizeBytes)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.transcription, transcription) || other.transcription == transcription)&&(identical(other.syncedFromWatch, syncedFromWatch) || other.syncedFromWatch == syncedFromWatch)&&(identical(other.deletedOnWatch, deletedOnWatch) || other.deletedOnWatch == deletedOnWatch)&&(identical(other.downloadedAt, downloadedAt) || other.downloadedAt == downloadedAt)&&(identical(other.transcribedAt, transcribedAt) || other.transcribedAt == transcribedAt)&&(identical(other.convertedFilePath, convertedFilePath) || other.convertedFilePath == convertedFilePath)&&(identical(other.summary, summary) || other.summary == summary)&&(identical(other.category, category) || other.category == category)&&(identical(other.processingStatus, processingStatus) || other.processingStatus == processingStatus)&&(identical(other.aiModel, aiModel) || other.aiModel == aiModel)&&(identical(other.aiProcessedAt, aiProcessedAt) || other.aiProcessedAt == aiProcessedAt)&&(identical(other.taskCreated, taskCreated) || other.taskCreated == taskCreated)&&(identical(other.calendarEventCreated, calendarEventCreated) || other.calendarEventCreated == calendarEventCreated)&&(identical(other.actionReviewState, actionReviewState) || other.actionReviewState == actionReviewState)); +} + + +@override +int get hashCode => Object.hashAll([runtimeType,id,filename,timestampUtc,durationMs,sizeBytes,localFilePath,transcription,syncedFromWatch,deletedOnWatch,downloadedAt,transcribedAt,convertedFilePath,summary,category,processingStatus,aiModel,aiProcessedAt,taskCreated,calendarEventCreated,actionReviewState]); + +@override +String toString() { + return 'VoiceMemo(id: $id, filename: $filename, timestampUtc: $timestampUtc, durationMs: $durationMs, sizeBytes: $sizeBytes, localFilePath: $localFilePath, transcription: $transcription, syncedFromWatch: $syncedFromWatch, deletedOnWatch: $deletedOnWatch, downloadedAt: $downloadedAt, transcribedAt: $transcribedAt, convertedFilePath: $convertedFilePath, summary: $summary, category: $category, processingStatus: $processingStatus, aiModel: $aiModel, aiProcessedAt: $aiProcessedAt, taskCreated: $taskCreated, calendarEventCreated: $calendarEventCreated, actionReviewState: $actionReviewState)'; +} + + +} + +/// @nodoc +abstract mixin class $VoiceMemoCopyWith<$Res> { + factory $VoiceMemoCopyWith(VoiceMemo value, $Res Function(VoiceMemo) _then) = _$VoiceMemoCopyWithImpl; +@useResult +$Res call({ + int id, String filename, DateTime timestampUtc, int durationMs, int sizeBytes, String? localFilePath, String? transcription, bool syncedFromWatch, bool deletedOnWatch, DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, String? summary, String? category, String? processingStatus, String? aiModel, DateTime? aiProcessedAt, bool taskCreated, bool calendarEventCreated, String? actionReviewState +}); + + + + +} +/// @nodoc +class _$VoiceMemoCopyWithImpl<$Res> + implements $VoiceMemoCopyWith<$Res> { + _$VoiceMemoCopyWithImpl(this._self, this._then); + + final VoiceMemo _self; + final $Res Function(VoiceMemo) _then; + +/// Create a copy of VoiceMemo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? filename = null,Object? timestampUtc = null,Object? durationMs = null,Object? sizeBytes = null,Object? localFilePath = freezed,Object? transcription = freezed,Object? syncedFromWatch = null,Object? deletedOnWatch = null,Object? downloadedAt = freezed,Object? transcribedAt = freezed,Object? convertedFilePath = freezed,Object? summary = freezed,Object? category = freezed,Object? processingStatus = freezed,Object? aiModel = freezed,Object? aiProcessedAt = freezed,Object? taskCreated = null,Object? calendarEventCreated = null,Object? actionReviewState = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,filename: null == filename ? _self.filename : filename // ignore: cast_nullable_to_non_nullable +as String,timestampUtc: null == timestampUtc ? _self.timestampUtc : timestampUtc // ignore: cast_nullable_to_non_nullable +as DateTime,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable +as int,sizeBytes: null == sizeBytes ? _self.sizeBytes : sizeBytes // ignore: cast_nullable_to_non_nullable +as int,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable +as String?,transcription: freezed == transcription ? _self.transcription : transcription // ignore: cast_nullable_to_non_nullable +as String?,syncedFromWatch: null == syncedFromWatch ? _self.syncedFromWatch : syncedFromWatch // ignore: cast_nullable_to_non_nullable +as bool,deletedOnWatch: null == deletedOnWatch ? _self.deletedOnWatch : deletedOnWatch // ignore: cast_nullable_to_non_nullable +as bool,downloadedAt: freezed == downloadedAt ? _self.downloadedAt : downloadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,transcribedAt: freezed == transcribedAt ? _self.transcribedAt : transcribedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,convertedFilePath: freezed == convertedFilePath ? _self.convertedFilePath : convertedFilePath // ignore: cast_nullable_to_non_nullable +as String?,summary: freezed == summary ? _self.summary : summary // ignore: cast_nullable_to_non_nullable +as String?,category: freezed == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as String?,processingStatus: freezed == processingStatus ? _self.processingStatus : processingStatus // ignore: cast_nullable_to_non_nullable +as String?,aiModel: freezed == aiModel ? _self.aiModel : aiModel // ignore: cast_nullable_to_non_nullable +as String?,aiProcessedAt: freezed == aiProcessedAt ? _self.aiProcessedAt : aiProcessedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,taskCreated: null == taskCreated ? _self.taskCreated : taskCreated // ignore: cast_nullable_to_non_nullable +as bool,calendarEventCreated: null == calendarEventCreated ? _self.calendarEventCreated : calendarEventCreated // ignore: cast_nullable_to_non_nullable +as bool,actionReviewState: freezed == actionReviewState ? _self.actionReviewState : actionReviewState // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [VoiceMemo]. +extension VoiceMemoPatterns on VoiceMemo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _VoiceMemo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _VoiceMemo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _VoiceMemo value) $default,){ +final _that = this; +switch (_that) { +case _VoiceMemo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _VoiceMemo value)? $default,){ +final _that = this; +switch (_that) { +case _VoiceMemo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, String filename, DateTime timestampUtc, int durationMs, int sizeBytes, String? localFilePath, String? transcription, bool syncedFromWatch, bool deletedOnWatch, DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, String? summary, String? category, String? processingStatus, String? aiModel, DateTime? aiProcessedAt, bool taskCreated, bool calendarEventCreated, String? actionReviewState)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _VoiceMemo() when $default != null: +return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_that.sizeBytes,_that.localFilePath,_that.transcription,_that.syncedFromWatch,_that.deletedOnWatch,_that.downloadedAt,_that.transcribedAt,_that.convertedFilePath,_that.summary,_that.category,_that.processingStatus,_that.aiModel,_that.aiProcessedAt,_that.taskCreated,_that.calendarEventCreated,_that.actionReviewState);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, String filename, DateTime timestampUtc, int durationMs, int sizeBytes, String? localFilePath, String? transcription, bool syncedFromWatch, bool deletedOnWatch, DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, String? summary, String? category, String? processingStatus, String? aiModel, DateTime? aiProcessedAt, bool taskCreated, bool calendarEventCreated, String? actionReviewState) $default,) {final _that = this; +switch (_that) { +case _VoiceMemo(): +return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_that.sizeBytes,_that.localFilePath,_that.transcription,_that.syncedFromWatch,_that.deletedOnWatch,_that.downloadedAt,_that.transcribedAt,_that.convertedFilePath,_that.summary,_that.category,_that.processingStatus,_that.aiModel,_that.aiProcessedAt,_that.taskCreated,_that.calendarEventCreated,_that.actionReviewState);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, String filename, DateTime timestampUtc, int durationMs, int sizeBytes, String? localFilePath, String? transcription, bool syncedFromWatch, bool deletedOnWatch, DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, String? summary, String? category, String? processingStatus, String? aiModel, DateTime? aiProcessedAt, bool taskCreated, bool calendarEventCreated, String? actionReviewState)? $default,) {final _that = this; +switch (_that) { +case _VoiceMemo() when $default != null: +return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_that.sizeBytes,_that.localFilePath,_that.transcription,_that.syncedFromWatch,_that.deletedOnWatch,_that.downloadedAt,_that.transcribedAt,_that.convertedFilePath,_that.summary,_that.category,_that.processingStatus,_that.aiModel,_that.aiProcessedAt,_that.taskCreated,_that.calendarEventCreated,_that.actionReviewState);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _VoiceMemo extends VoiceMemo { + const _VoiceMemo({required this.id, required this.filename, required this.timestampUtc, required this.durationMs, required this.sizeBytes, this.localFilePath, this.transcription, this.syncedFromWatch = false, this.deletedOnWatch = false, this.downloadedAt, this.transcribedAt, this.convertedFilePath, this.summary, this.category, this.processingStatus, this.aiModel, this.aiProcessedAt, this.taskCreated = false, this.calendarEventCreated = false, this.actionReviewState}): super._(); + + +@override final int id; +@override final String filename; +@override final DateTime timestampUtc; +@override final int durationMs; +@override final int sizeBytes; +@override final String? localFilePath; +@override final String? transcription; +@override@JsonKey() final bool syncedFromWatch; +@override@JsonKey() final bool deletedOnWatch; +@override final DateTime? downloadedAt; +@override final DateTime? transcribedAt; +@override final String? convertedFilePath; +// AI-enhanced fields +@override final String? summary; +@override final String? category; +@override final String? processingStatus; +@override final String? aiModel; +@override final DateTime? aiProcessedAt; +@override@JsonKey() final bool taskCreated; +@override@JsonKey() final bool calendarEventCreated; +@override final String? actionReviewState; + +/// Create a copy of VoiceMemo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$VoiceMemoCopyWith<_VoiceMemo> get copyWith => __$VoiceMemoCopyWithImpl<_VoiceMemo>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _VoiceMemo&&(identical(other.id, id) || other.id == id)&&(identical(other.filename, filename) || other.filename == filename)&&(identical(other.timestampUtc, timestampUtc) || other.timestampUtc == timestampUtc)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs)&&(identical(other.sizeBytes, sizeBytes) || other.sizeBytes == sizeBytes)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.transcription, transcription) || other.transcription == transcription)&&(identical(other.syncedFromWatch, syncedFromWatch) || other.syncedFromWatch == syncedFromWatch)&&(identical(other.deletedOnWatch, deletedOnWatch) || other.deletedOnWatch == deletedOnWatch)&&(identical(other.downloadedAt, downloadedAt) || other.downloadedAt == downloadedAt)&&(identical(other.transcribedAt, transcribedAt) || other.transcribedAt == transcribedAt)&&(identical(other.convertedFilePath, convertedFilePath) || other.convertedFilePath == convertedFilePath)&&(identical(other.summary, summary) || other.summary == summary)&&(identical(other.category, category) || other.category == category)&&(identical(other.processingStatus, processingStatus) || other.processingStatus == processingStatus)&&(identical(other.aiModel, aiModel) || other.aiModel == aiModel)&&(identical(other.aiProcessedAt, aiProcessedAt) || other.aiProcessedAt == aiProcessedAt)&&(identical(other.taskCreated, taskCreated) || other.taskCreated == taskCreated)&&(identical(other.calendarEventCreated, calendarEventCreated) || other.calendarEventCreated == calendarEventCreated)&&(identical(other.actionReviewState, actionReviewState) || other.actionReviewState == actionReviewState)); +} + + +@override +int get hashCode => Object.hashAll([runtimeType,id,filename,timestampUtc,durationMs,sizeBytes,localFilePath,transcription,syncedFromWatch,deletedOnWatch,downloadedAt,transcribedAt,convertedFilePath,summary,category,processingStatus,aiModel,aiProcessedAt,taskCreated,calendarEventCreated,actionReviewState]); + +@override +String toString() { + return 'VoiceMemo(id: $id, filename: $filename, timestampUtc: $timestampUtc, durationMs: $durationMs, sizeBytes: $sizeBytes, localFilePath: $localFilePath, transcription: $transcription, syncedFromWatch: $syncedFromWatch, deletedOnWatch: $deletedOnWatch, downloadedAt: $downloadedAt, transcribedAt: $transcribedAt, convertedFilePath: $convertedFilePath, summary: $summary, category: $category, processingStatus: $processingStatus, aiModel: $aiModel, aiProcessedAt: $aiProcessedAt, taskCreated: $taskCreated, calendarEventCreated: $calendarEventCreated, actionReviewState: $actionReviewState)'; +} + + +} + +/// @nodoc +abstract mixin class _$VoiceMemoCopyWith<$Res> implements $VoiceMemoCopyWith<$Res> { + factory _$VoiceMemoCopyWith(_VoiceMemo value, $Res Function(_VoiceMemo) _then) = __$VoiceMemoCopyWithImpl; +@override @useResult +$Res call({ + int id, String filename, DateTime timestampUtc, int durationMs, int sizeBytes, String? localFilePath, String? transcription, bool syncedFromWatch, bool deletedOnWatch, DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, String? summary, String? category, String? processingStatus, String? aiModel, DateTime? aiProcessedAt, bool taskCreated, bool calendarEventCreated, String? actionReviewState +}); + + + + +} +/// @nodoc +class __$VoiceMemoCopyWithImpl<$Res> + implements _$VoiceMemoCopyWith<$Res> { + __$VoiceMemoCopyWithImpl(this._self, this._then); + + final _VoiceMemo _self; + final $Res Function(_VoiceMemo) _then; + +/// Create a copy of VoiceMemo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? filename = null,Object? timestampUtc = null,Object? durationMs = null,Object? sizeBytes = null,Object? localFilePath = freezed,Object? transcription = freezed,Object? syncedFromWatch = null,Object? deletedOnWatch = null,Object? downloadedAt = freezed,Object? transcribedAt = freezed,Object? convertedFilePath = freezed,Object? summary = freezed,Object? category = freezed,Object? processingStatus = freezed,Object? aiModel = freezed,Object? aiProcessedAt = freezed,Object? taskCreated = null,Object? calendarEventCreated = null,Object? actionReviewState = freezed,}) { + return _then(_VoiceMemo( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,filename: null == filename ? _self.filename : filename // ignore: cast_nullable_to_non_nullable +as String,timestampUtc: null == timestampUtc ? _self.timestampUtc : timestampUtc // ignore: cast_nullable_to_non_nullable +as DateTime,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable +as int,sizeBytes: null == sizeBytes ? _self.sizeBytes : sizeBytes // ignore: cast_nullable_to_non_nullable +as int,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable +as String?,transcription: freezed == transcription ? _self.transcription : transcription // ignore: cast_nullable_to_non_nullable +as String?,syncedFromWatch: null == syncedFromWatch ? _self.syncedFromWatch : syncedFromWatch // ignore: cast_nullable_to_non_nullable +as bool,deletedOnWatch: null == deletedOnWatch ? _self.deletedOnWatch : deletedOnWatch // ignore: cast_nullable_to_non_nullable +as bool,downloadedAt: freezed == downloadedAt ? _self.downloadedAt : downloadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,transcribedAt: freezed == transcribedAt ? _self.transcribedAt : transcribedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,convertedFilePath: freezed == convertedFilePath ? _self.convertedFilePath : convertedFilePath // ignore: cast_nullable_to_non_nullable +as String?,summary: freezed == summary ? _self.summary : summary // ignore: cast_nullable_to_non_nullable +as String?,category: freezed == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as String?,processingStatus: freezed == processingStatus ? _self.processingStatus : processingStatus // ignore: cast_nullable_to_non_nullable +as String?,aiModel: freezed == aiModel ? _self.aiModel : aiModel // ignore: cast_nullable_to_non_nullable +as String?,aiProcessedAt: freezed == aiProcessedAt ? _self.aiProcessedAt : aiProcessedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,taskCreated: null == taskCreated ? _self.taskCreated : taskCreated // ignore: cast_nullable_to_non_nullable +as bool,calendarEventCreated: null == calendarEventCreated ? _self.calendarEventCreated : calendarEventCreated // ignore: cast_nullable_to_non_nullable +as bool,actionReviewState: freezed == actionReviewState ? _self.actionReviewState : actionReviewState // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/models/watch.dart b/zswatch_app/lib/data/models/watch.dart index a9f8eb3..67ca104 100644 --- a/zswatch_app/lib/data/models/watch.dart +++ b/zswatch_app/lib/data/models/watch.dart @@ -1,90 +1,50 @@ -import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'watch.freezed.dart'; /// Watch model representing a paired ZSWatch device /// /// This is a domain model used throughout the app. It's mapped from /// the database entity (WatchEntity) and contains business logic. -class Watch extends Equatable { - /// BLE device identifier (MAC address on Android, UUID on iOS) - final String id; +@freezed +abstract class Watch with _$Watch { + const Watch._(); - /// Advertised device name - final String name; + const factory Watch({ + /// BLE device identifier (MAC address on Android, UUID on iOS) + required String id, - /// User-defined custom name for the watch (FR-099 to FR-102) - final String? customName; + /// Advertised device name + required String name, - /// Last known firmware version - final String? firmwareVersion; + /// User-defined custom name for the watch (FR-099 to FR-102) + String? customName, - /// Hardware revision - final String? hardwareVersion; + /// Last known firmware version + String? firmwareVersion, - /// Last known battery level (0-100) - final int? batteryLevel; + /// Hardware revision + String? hardwareVersion, - /// Whether this is the currently selected watch - final bool isPrimary; + /// Last known battery level (0-100) + int? batteryLevel, - /// Whether firmware supports Extended ZSWatch API - final bool supportsExtendedApi; + /// Whether this is the currently selected watch + @Default(false) bool isPrimary, - /// Last successful connection timestamp - final DateTime? lastConnectedAt; + /// Whether firmware supports Extended ZSWatch API + @Default(false) bool supportsExtendedApi, - /// When the device was first paired - final DateTime createdAt; + /// Last successful connection timestamp + DateTime? lastConnectedAt, - const Watch({ - required this.id, - required this.name, - this.customName, - this.firmwareVersion, - this.hardwareVersion, - this.batteryLevel, - this.isPrimary = false, - this.supportsExtendedApi = false, - this.lastConnectedAt, - required this.createdAt, - }); + /// When the device was first paired + required DateTime createdAt, + }) = _Watch; /// Create a Watch from a scanned device (before pairing) - factory Watch.fromScan({ - required String id, - required String name, - }) { - return Watch( - id: id, - name: name, - createdAt: DateTime.now(), - ); - } - - /// Copy with modified fields - Watch copyWith({ - String? id, - String? name, - String? customName, - String? firmwareVersion, - String? hardwareVersion, - int? batteryLevel, - bool? isPrimary, - bool? supportsExtendedApi, - DateTime? lastConnectedAt, - DateTime? createdAt, - }) { - return Watch( - id: id ?? this.id, - name: name ?? this.name, - customName: customName ?? this.customName, - firmwareVersion: firmwareVersion ?? this.firmwareVersion, - hardwareVersion: hardwareVersion ?? this.hardwareVersion, - batteryLevel: batteryLevel ?? this.batteryLevel, - isPrimary: isPrimary ?? this.isPrimary, - supportsExtendedApi: supportsExtendedApi ?? this.supportsExtendedApi, - lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt, - createdAt: createdAt ?? this.createdAt, - ); + factory Watch.fromScan({required String id, required String name}) { + return Watch(id: id, name: name, createdAt: DateTime.now()); } /// Whether the watch has been connected at least once @@ -117,24 +77,4 @@ class Watch extends Equatable { .replaceFirst(RegExp(r'^ZSWatch\s*', caseSensitive: false), '') .trim(); } - - @override - List get props => [ - id, - name, - customName, - firmwareVersion, - hardwareVersion, - batteryLevel, - isPrimary, - supportsExtendedApi, - lastConnectedAt, - createdAt, - ]; - - @override - String toString() { - return 'Watch(id: $id, name: $name, battery: $batteryLevel%, fw: $firmwareVersion)'; - } } - diff --git a/zswatch_app/lib/data/models/watch.freezed.dart b/zswatch_app/lib/data/models/watch.freezed.dart new file mode 100644 index 0000000..aac6dc8 --- /dev/null +++ b/zswatch_app/lib/data/models/watch.freezed.dart @@ -0,0 +1,318 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'watch.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$Watch { + +/// BLE device identifier (MAC address on Android, UUID on iOS) + String get id;/// Advertised device name + String get name;/// User-defined custom name for the watch (FR-099 to FR-102) + String? get customName;/// Last known firmware version + String? get firmwareVersion;/// Hardware revision + String? get hardwareVersion;/// Last known battery level (0-100) + int? get batteryLevel;/// Whether this is the currently selected watch + bool get isPrimary;/// Whether firmware supports Extended ZSWatch API + bool get supportsExtendedApi;/// Last successful connection timestamp + DateTime? get lastConnectedAt;/// When the device was first paired + DateTime get createdAt; +/// Create a copy of Watch +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WatchCopyWith get copyWith => _$WatchCopyWithImpl(this as Watch, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Watch&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.customName, customName) || other.customName == customName)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.hardwareVersion, hardwareVersion) || other.hardwareVersion == hardwareVersion)&&(identical(other.batteryLevel, batteryLevel) || other.batteryLevel == batteryLevel)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.supportsExtendedApi, supportsExtendedApi) || other.supportsExtendedApi == supportsExtendedApi)&&(identical(other.lastConnectedAt, lastConnectedAt) || other.lastConnectedAt == lastConnectedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,customName,firmwareVersion,hardwareVersion,batteryLevel,isPrimary,supportsExtendedApi,lastConnectedAt,createdAt); + +@override +String toString() { + return 'Watch(id: $id, name: $name, customName: $customName, firmwareVersion: $firmwareVersion, hardwareVersion: $hardwareVersion, batteryLevel: $batteryLevel, isPrimary: $isPrimary, supportsExtendedApi: $supportsExtendedApi, lastConnectedAt: $lastConnectedAt, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class $WatchCopyWith<$Res> { + factory $WatchCopyWith(Watch value, $Res Function(Watch) _then) = _$WatchCopyWithImpl; +@useResult +$Res call({ + String id, String name, String? customName, String? firmwareVersion, String? hardwareVersion, int? batteryLevel, bool isPrimary, bool supportsExtendedApi, DateTime? lastConnectedAt, DateTime createdAt +}); + + + + +} +/// @nodoc +class _$WatchCopyWithImpl<$Res> + implements $WatchCopyWith<$Res> { + _$WatchCopyWithImpl(this._self, this._then); + + final Watch _self; + final $Res Function(Watch) _then; + +/// Create a copy of Watch +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? customName = freezed,Object? firmwareVersion = freezed,Object? hardwareVersion = freezed,Object? batteryLevel = freezed,Object? isPrimary = null,Object? supportsExtendedApi = null,Object? lastConnectedAt = freezed,Object? createdAt = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,customName: freezed == customName ? _self.customName : customName // ignore: cast_nullable_to_non_nullable +as String?,firmwareVersion: freezed == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable +as String?,hardwareVersion: freezed == hardwareVersion ? _self.hardwareVersion : hardwareVersion // ignore: cast_nullable_to_non_nullable +as String?,batteryLevel: freezed == batteryLevel ? _self.batteryLevel : batteryLevel // ignore: cast_nullable_to_non_nullable +as int?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable +as bool,supportsExtendedApi: null == supportsExtendedApi ? _self.supportsExtendedApi : supportsExtendedApi // ignore: cast_nullable_to_non_nullable +as bool,lastConnectedAt: freezed == lastConnectedAt ? _self.lastConnectedAt : lastConnectedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Watch]. +extension WatchPatterns on Watch { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Watch value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Watch() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Watch value) $default,){ +final _that = this; +switch (_that) { +case _Watch(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Watch value)? $default,){ +final _that = this; +switch (_that) { +case _Watch() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String? customName, String? firmwareVersion, String? hardwareVersion, int? batteryLevel, bool isPrimary, bool supportsExtendedApi, DateTime? lastConnectedAt, DateTime createdAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Watch() when $default != null: +return $default(_that.id,_that.name,_that.customName,_that.firmwareVersion,_that.hardwareVersion,_that.batteryLevel,_that.isPrimary,_that.supportsExtendedApi,_that.lastConnectedAt,_that.createdAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String? customName, String? firmwareVersion, String? hardwareVersion, int? batteryLevel, bool isPrimary, bool supportsExtendedApi, DateTime? lastConnectedAt, DateTime createdAt) $default,) {final _that = this; +switch (_that) { +case _Watch(): +return $default(_that.id,_that.name,_that.customName,_that.firmwareVersion,_that.hardwareVersion,_that.batteryLevel,_that.isPrimary,_that.supportsExtendedApi,_that.lastConnectedAt,_that.createdAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String? customName, String? firmwareVersion, String? hardwareVersion, int? batteryLevel, bool isPrimary, bool supportsExtendedApi, DateTime? lastConnectedAt, DateTime createdAt)? $default,) {final _that = this; +switch (_that) { +case _Watch() when $default != null: +return $default(_that.id,_that.name,_that.customName,_that.firmwareVersion,_that.hardwareVersion,_that.batteryLevel,_that.isPrimary,_that.supportsExtendedApi,_that.lastConnectedAt,_that.createdAt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _Watch extends Watch { + const _Watch({required this.id, required this.name, this.customName, this.firmwareVersion, this.hardwareVersion, this.batteryLevel, this.isPrimary = false, this.supportsExtendedApi = false, this.lastConnectedAt, required this.createdAt}): super._(); + + +/// BLE device identifier (MAC address on Android, UUID on iOS) +@override final String id; +/// Advertised device name +@override final String name; +/// User-defined custom name for the watch (FR-099 to FR-102) +@override final String? customName; +/// Last known firmware version +@override final String? firmwareVersion; +/// Hardware revision +@override final String? hardwareVersion; +/// Last known battery level (0-100) +@override final int? batteryLevel; +/// Whether this is the currently selected watch +@override@JsonKey() final bool isPrimary; +/// Whether firmware supports Extended ZSWatch API +@override@JsonKey() final bool supportsExtendedApi; +/// Last successful connection timestamp +@override final DateTime? lastConnectedAt; +/// When the device was first paired +@override final DateTime createdAt; + +/// Create a copy of Watch +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WatchCopyWith<_Watch> get copyWith => __$WatchCopyWithImpl<_Watch>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Watch&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.customName, customName) || other.customName == customName)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.hardwareVersion, hardwareVersion) || other.hardwareVersion == hardwareVersion)&&(identical(other.batteryLevel, batteryLevel) || other.batteryLevel == batteryLevel)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.supportsExtendedApi, supportsExtendedApi) || other.supportsExtendedApi == supportsExtendedApi)&&(identical(other.lastConnectedAt, lastConnectedAt) || other.lastConnectedAt == lastConnectedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,customName,firmwareVersion,hardwareVersion,batteryLevel,isPrimary,supportsExtendedApi,lastConnectedAt,createdAt); + +@override +String toString() { + return 'Watch(id: $id, name: $name, customName: $customName, firmwareVersion: $firmwareVersion, hardwareVersion: $hardwareVersion, batteryLevel: $batteryLevel, isPrimary: $isPrimary, supportsExtendedApi: $supportsExtendedApi, lastConnectedAt: $lastConnectedAt, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$WatchCopyWith<$Res> implements $WatchCopyWith<$Res> { + factory _$WatchCopyWith(_Watch value, $Res Function(_Watch) _then) = __$WatchCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String? customName, String? firmwareVersion, String? hardwareVersion, int? batteryLevel, bool isPrimary, bool supportsExtendedApi, DateTime? lastConnectedAt, DateTime createdAt +}); + + + + +} +/// @nodoc +class __$WatchCopyWithImpl<$Res> + implements _$WatchCopyWith<$Res> { + __$WatchCopyWithImpl(this._self, this._then); + + final _Watch _self; + final $Res Function(_Watch) _then; + +/// Create a copy of Watch +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? customName = freezed,Object? firmwareVersion = freezed,Object? hardwareVersion = freezed,Object? batteryLevel = freezed,Object? isPrimary = null,Object? supportsExtendedApi = null,Object? lastConnectedAt = freezed,Object? createdAt = null,}) { + return _then(_Watch( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,customName: freezed == customName ? _self.customName : customName // ignore: cast_nullable_to_non_nullable +as String?,firmwareVersion: freezed == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable +as String?,hardwareVersion: freezed == hardwareVersion ? _self.hardwareVersion : hardwareVersion // ignore: cast_nullable_to_non_nullable +as String?,batteryLevel: freezed == batteryLevel ? _self.batteryLevel : batteryLevel // ignore: cast_nullable_to_non_nullable +as int?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable +as bool,supportsExtendedApi: null == supportsExtendedApi ? _self.supportsExtendedApi : supportsExtendedApi // ignore: cast_nullable_to_non_nullable +as bool,lastConnectedAt: freezed == lastConnectedAt ? _self.lastConnectedAt : lastConnectedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/data/repositories/base_repository.dart b/zswatch_app/lib/data/repositories/base_repository.dart new file mode 100644 index 0000000..6ce42b9 --- /dev/null +++ b/zswatch_app/lib/data/repositories/base_repository.dart @@ -0,0 +1,8 @@ +/// Base class for all repositories that map between database entities and domain models. +/// +/// Provides a typed contract for the entity ↔ model conversion methods +/// that every repository must implement. +abstract class BaseRepository { + /// Convert a database entity to a domain model. + Model fromEntity(Entity entity); +} diff --git a/zswatch_app/lib/data/repositories/battery_repository.dart b/zswatch_app/lib/data/repositories/battery_repository.dart index ed6f36c..d528ac0 100644 --- a/zswatch_app/lib/data/repositories/battery_repository.dart +++ b/zswatch_app/lib/data/repositories/battery_repository.dart @@ -22,12 +22,14 @@ class BatteryRepository { bool isCharging = false, DateTime? timestamp, }) { - return _db.insertBatteryReading(BatteryReadingsCompanion( - watchId: Value(watchId), - level: Value(level), - isCharging: Value(isCharging), - timestamp: Value(timestamp ?? DateTime.now()), - )); + return _db.insertBatteryReading( + BatteryReadingsCompanion( + watchId: Value(watchId), + level: Value(level), + isCharging: Value(isCharging), + timestamp: Value(timestamp ?? DateTime.now()), + ), + ); } /// Get battery readings for a watch within date range @@ -36,11 +38,7 @@ class BatteryRepository { required DateTime from, required DateTime to, }) { - return _db.getBatteryReadings( - watchId: watchId, - from: from, - to: to, - ); + return _db.getBatteryReadings(watchId: watchId, from: from, to: to); } /// Get battery readings for the last 24 hours @@ -89,8 +87,9 @@ class BatteryRepository { if (readings.length < 2) return null; // Focus on discharging readings only - final dischargingReadings = - readings.where((r) => !r.isCharging).toList(growable: false); + final dischargingReadings = readings + .where((r) => !r.isCharging) + .toList(growable: false); if (dischargingReadings.length < 2) return null; // Find the start of the current discharge cycle (last upward jump → new slope) @@ -108,8 +107,9 @@ class BatteryRepository { // Use the full window up to "to" so long idle periods don't inflate the rate final effectiveEndTime = to.isAfter(end.timestamp) ? to : end.timestamp; - final elapsedMinutes = - effectiveEndTime.difference(start.timestamp).inMinutes; + final elapsedMinutes = effectiveEndTime + .difference(start.timestamp) + .inMinutes; // Require a reasonable span to avoid noisy spikes if (elapsedMinutes < 30) return null; diff --git a/zswatch_app/lib/data/repositories/comm_log_repository.dart b/zswatch_app/lib/data/repositories/comm_log_repository.dart index 82aece2..bacf4ec 100644 --- a/zswatch_app/lib/data/repositories/comm_log_repository.dart +++ b/zswatch_app/lib/data/repositories/comm_log_repository.dart @@ -37,7 +37,8 @@ class CommLogRepository { bool get isEmpty => _entries.isEmpty; /// Whether the repository is at max capacity - bool get isFull => _entries.length >= maxEntries || _totalBytes >= maxSizeBytes; + bool get isFull => + _entries.length >= maxEntries || _totalBytes >= maxSizeBytes; /// Total bytes stored int get totalBytes => _totalBytes; @@ -75,23 +76,28 @@ class CommLogRepository { } /// Add a TX (outgoing) entry - void addTx(String data, {String? messageType, bool wasChunked = false, int? chunkCount}) { - _addEntry(CommLogEntry.tx( - id: _nextId++, - data: data, - messageType: messageType, - wasChunked: wasChunked, - chunkCount: chunkCount, - )); + void addTx( + String data, { + String? messageType, + bool wasChunked = false, + int? chunkCount, + }) { + _addEntry( + CommLogEntry.tx( + id: _nextId++, + data: data, + messageType: messageType, + wasChunked: wasChunked, + chunkCount: chunkCount, + ), + ); } /// Add an RX (incoming) entry void addRx(String data, {String? messageType}) { - _addEntry(CommLogEntry.rx( - id: _nextId++, - data: data, - messageType: messageType, - )); + _addEntry( + CommLogEntry.rx(id: _nextId++, data: data, messageType: messageType), + ); } void _addEntry(CommLogEntry entry) { @@ -102,9 +108,6 @@ class CommLogRepository { _entries.add(entry); _totalBytes += entry.sizeBytes; - - debugPrint('[CommLog] Added entry ${entry.id} (${entry.directionDisplay}), ' - 'total: ${_entries.length} entries, ${_formatBytes(_totalBytes)}'); } bool _shouldRotate(int newEntrySize) { @@ -170,7 +173,9 @@ class CommLogRepository { buffer.writeln('---'); for (final entry in _entries) { - buffer.writeln('[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.data}'); + buffer.writeln( + '[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.data}', + ); } return buffer.toString(); diff --git a/zswatch_app/lib/data/repositories/connection_analytics_repository.dart b/zswatch_app/lib/data/repositories/connection_analytics_repository.dart index 3fcde2d..a2f8657 100644 --- a/zswatch_app/lib/data/repositories/connection_analytics_repository.dart +++ b/zswatch_app/lib/data/repositories/connection_analytics_repository.dart @@ -1,9 +1,11 @@ import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/watch_providers.dart'; import '../database/app_database.dart'; import '../models/connection_event.dart'; +import 'base_repository.dart'; /// Statistics about connection quality class ConnectionStats { @@ -51,6 +53,33 @@ class ConnectionStats { ); } +/// Type of a connection timeline segment +enum ConnectionSegmentType { + /// Watch was connected and the app was in the foreground/background tracking it + connected, + + /// Watch was disconnected but the app was running and recorded it + disconnected, + + /// No events recorded — app was likely not running (killed/suspended) + appNotRunning, +} + +/// A contiguous segment of the connection timeline +class ConnectionSegment { + final DateTime start; + final DateTime end; + final ConnectionSegmentType type; + + const ConnectionSegment({ + required this.start, + required this.end, + required this.type, + }); + + Duration get duration => end.difference(start); +} + /// Repository for connection analytics /// /// Handles: @@ -58,29 +87,31 @@ class ConnectionStats { /// - Calculating uptime percentage /// - Tracking disconnection frequency and reasons /// - Computing average session duration -class ConnectionAnalyticsRepository { +class ConnectionAnalyticsRepository + extends BaseRepository { final AppDatabase _db; ConnectionAnalyticsRepository(this._db); /// Record a connection event Future recordEvent(ConnectionEvent event) { - return _db.insertConnectionEvent(ConnectionEventsCompanion( - watchId: Value(event.watchId), - eventType: Value(event.eventType.toDbString()), - timestamp: Value(event.timestamp), - reason: Value(event.reason?.toDbString()), - details: Value(event.details), - sessionId: Value(event.sessionId), - )); + return _db.insertConnectionEvent( + ConnectionEventsCompanion( + watchId: Value(event.watchId), + eventType: Value(event.eventType.toDbString()), + timestamp: Value(event.timestamp), + reason: Value(event.reason?.toDbString()), + details: Value(event.details), + sessionId: Value(event.sessionId), + ), + ); } /// Record a connected event Future recordConnected(String watchId, {String? sessionId}) { - return recordEvent(ConnectionEvent.connected( - watchId: watchId, - sessionId: sessionId, - )); + return recordEvent( + ConnectionEvent.connected(watchId: watchId, sessionId: sessionId), + ); } /// Record a disconnected event @@ -90,20 +121,21 @@ class ConnectionAnalyticsRepository { String? details, String? sessionId, }) { - return recordEvent(ConnectionEvent.disconnected( - watchId: watchId, - reason: reason, - details: details, - sessionId: sessionId, - )); + return recordEvent( + ConnectionEvent.disconnected( + watchId: watchId, + reason: reason, + details: details, + sessionId: sessionId, + ), + ); } /// Record a reconnect attempt Future recordReconnectAttempt(String watchId, {String? sessionId}) { - return recordEvent(ConnectionEvent.reconnectAttempt( - watchId: watchId, - sessionId: sessionId, - )); + return recordEvent( + ConnectionEvent.reconnectAttempt(watchId: watchId, sessionId: sessionId), + ); } /// Record a failed reconnect @@ -112,11 +144,13 @@ class ConnectionAnalyticsRepository { String? details, String? sessionId, }) { - return recordEvent(ConnectionEvent.reconnectFailed( - watchId: watchId, - details: details, - sessionId: sessionId, - )); + return recordEvent( + ConnectionEvent.reconnectFailed( + watchId: watchId, + details: details, + sessionId: sessionId, + ), + ); } /// Get connection events for a watch within date range @@ -151,11 +185,7 @@ class ConnectionAnalyticsRepository { required DateTime from, required DateTime to, }) async { - final events = await getEvents( - watchId: watchId, - from: from, - to: to, - ); + final events = await getEvents(watchId: watchId, from: from, to: to); if (events.isEmpty) return 0; @@ -191,11 +221,7 @@ class ConnectionAnalyticsRepository { required DateTime from, required DateTime to, }) async { - final events = await getEvents( - watchId: watchId, - from: from, - to: to, - ); + final events = await getEvents(watchId: watchId, from: from, to: to); if (events.isEmpty) return Duration.zero; @@ -241,15 +267,11 @@ class ConnectionAnalyticsRepository { required DateTime from, required DateTime to, }) async { - final events = await getEvents( - watchId: watchId, - from: from, - to: to, - ); + final events = await getEvents(watchId: watchId, from: from, to: to); if (events.isEmpty) return Duration.zero; - List sessions = []; + final List sessions = []; DateTime? lastConnectTime; for (final event in events) { @@ -284,17 +306,13 @@ class ConnectionAnalyticsRepository { required DateTime from, required DateTime to, }) async { - final events = await getEvents( - watchId: watchId, - from: from, - to: to, - ); + final events = await getEvents(watchId: watchId, from: from, to: to); if (events.isEmpty) return ConnectionStats.empty; // Calculate all stats in one pass Duration totalConnected = Duration.zero; - List sessions = []; + final List sessions = []; DateTime? lastConnectTime; int disconnectionCount = 0; int reconnectAttempts = 0; @@ -349,8 +367,8 @@ class ConnectionAnalyticsRepository { // Successful reconnections = attempts - failures // (each success results in a connected event) - final successfulReconnections = - (reconnectAttempts - reconnectFailures).clamp(0, reconnectAttempts); + final successfulReconnections = (reconnectAttempts - reconnectFailures) + .clamp(0, reconnectAttempts); return ConnectionStats( uptimePercentage: uptimePercentage, @@ -382,21 +400,212 @@ class ConnectionAnalyticsRepository { ); } + /// Build a timeline of connection segments for a period. + /// + /// Returns a list of [ConnectionSegment] ordered by start time. + /// Gaps between events with no heartbeat activity are marked as + /// [ConnectionSegmentType.appNotRunning]. + /// + /// [appNotRunningThreshold] — minimum silent gap to classify as "app not + /// running" rather than just "disconnected". Defaults to 10 minutes. + Future> getConnectionTimeline({ + required String watchId, + required DateTime from, + required DateTime to, + Duration appNotRunningThreshold = const Duration(minutes: 10), + }) async { + final events = await getEvents(watchId: watchId, from: from, to: to); + // Peek at the event just before the window to seed the initial state. + // Without this, a session that started before windowStart would appear as + // a gap (appNotRunning) until the first event inside the window. + final seedEntity = await _db.getLastConnectionEventBefore( + watchId: watchId, + before: from, + ); + final seed = seedEntity != null ? _entityToModel(seedEntity) : null; + debugPrint('=== TIMELINE DEBUG ==='); + debugPrint('Window: $from → $to'); + debugPrint('Seed event: ${seed?.eventType} @ ${seed?.timestamp}'); + debugPrint('Events in window (${events.length}):'); + for (final e in events) { + debugPrint(' ${e.eventType} @ ${e.timestamp}'); + } + final result = _buildTimeline( + events, + from, + to, + appNotRunningThreshold, + seed, + ); + debugPrint('Segments (${result.length}):'); + for (final s in result) { + debugPrint(' ${s.type} ${s.start} → ${s.end}'); + } + debugPrint('=== END TIMELINE DEBUG ==='); + return result; + } + + List _buildTimeline( + List events, + DateTime from, + DateTime to, + Duration appNotRunningThreshold, + ConnectionEvent? seedEvent, + ) { + final List segments = []; + DateTime cursor = from; + + // Walk through sorted events and emit segments between them. + // Connected windows: between a 'connected' and the next 'disconnected'. + // Disconnected windows: between 'disconnected' and next 'connected'. + // App-not-running windows: a disconnected gap exceeding the threshold + // with no reconnect_attempt events inside it. + + // Seed from the last event before the window so we know whether the watch + // was already connected at windowStart (avoids a false appNotRunning gap). + DateTime? sessionStart = + (seedEvent?.eventType == ConnectionEventType.connected) ? from : null; + void emitGap( + DateTime start, + DateTime end, + List eventsInGap, + ) { + if (end.difference(start) < const Duration(seconds: 30)) return; + final hasActivity = eventsInGap.any( + (e) => + e.eventType == ConnectionEventType.reconnectAttempt || + e.eventType == ConnectionEventType.reconnectFailed, + ); + final type = + (!hasActivity && end.difference(start) >= appNotRunningThreshold) + ? ConnectionSegmentType.appNotRunning + : ConnectionSegmentType.disconnected; + segments.add(ConnectionSegment(start: start, end: end, type: type)); + } + + for (final event in events) { + switch (event.eventType) { + case ConnectionEventType.connected: + if (sessionStart != null) { + // Previous session was never closed (app was killed while connected). + // Close it as a connected segment up to the cursor position, then + // emit an appNotRunning gap from cursor to this new connect event. + if (sessionStart != cursor) { + segments.add( + ConnectionSegment( + start: sessionStart, + end: cursor, + type: ConnectionSegmentType.connected, + ), + ); + } + final gapEvents = events + .where( + (e) => + e.timestamp.isAfter(cursor) && + e.timestamp.isBefore(event.timestamp), + ) + .toList(); + emitGap(cursor, event.timestamp, gapEvents); + } else if (cursor.isBefore(event.timestamp)) { + // Gap with no prior session — disconnected or app-not-running. + final gapEvents = events + .where( + (e) => + e.timestamp.isAfter(cursor) && + e.timestamp.isBefore(event.timestamp), + ) + .toList(); + emitGap(cursor, event.timestamp, gapEvents); + } + sessionStart = event.timestamp; + cursor = event.timestamp; + break; + + case ConnectionEventType.disconnected: + if (sessionStart != null) { + // Close the connected segment + segments.add( + ConnectionSegment( + start: sessionStart, + end: event.timestamp, + type: ConnectionSegmentType.connected, + ), + ); + cursor = event.timestamp; + sessionStart = null; + } + break; + + case ConnectionEventType.reconnectAttempt: + case ConnectionEventType.reconnectFailed: + // These don't open/close segments, but prevent gap → appNotRunning + break; + } + } + + // Handle the tail: from cursor to `to` + if (sessionStart != null) { + // Still connected + segments.add( + ConnectionSegment( + start: sessionStart, + end: to.isBefore(DateTime.now()) ? to : DateTime.now(), + type: ConnectionSegmentType.connected, + ), + ); + } else if (cursor.isBefore(to)) { + final tailEvents = events + .where((e) => e.timestamp.isAfter(cursor)) + .toList(); + emitGap( + cursor, + to.isBefore(DateTime.now()) ? to : DateTime.now(), + tailEvents, + ); + } + + segments.sort((a, b) => a.start.compareTo(b.start)); + return segments; + } + /// Delete old connection events Future deleteOldEvents(DateTime cutoff) { return _db.deleteOldConnectionEvents(cutoff); } + /// Delete all connection events for a watch (for manual "clear sample" use) + Future clearAllEvents(String watchId) { + return _db.deleteAllConnectionEventsForWatch(watchId); + } + + /// Return the timestamp of the oldest stored event for a watch, or null if none. + Future getOldestEventTime(String watchId) async { + final events = await _db.getConnectionEvents( + watchId: watchId, + from: DateTime.fromMillisecondsSinceEpoch(0), + to: DateTime.now().add(const Duration(seconds: 1)), + ); + if (events.isEmpty) return null; + return events + .map((e) => e.timestamp) + .reduce((a, b) => a.isBefore(b) ? a : b); + } + /// Watch connection events stream Stream> watchEvents({ required String watchId, int limit = 100, }) { - return _db.watchConnectionEvents(watchId: watchId, limit: limit).map( - (entities) => entities.map(_entityToModel).toList(), - ); + return _db + .watchConnectionEvents(watchId: watchId, limit: limit) + .map((entities) => entities.map(_entityToModel).toList()); } + @override + ConnectionEvent fromEntity(ConnectionEventEntity entity) => + _entityToModel(entity); + ConnectionEvent _entityToModel(ConnectionEventEntity entity) { return ConnectionEvent( id: entity.id, @@ -415,6 +624,6 @@ class ConnectionAnalyticsRepository { /// Provider for connection analytics repository final connectionAnalyticsRepositoryProvider = Provider((ref) { - final db = ref.watch(databaseProvider); - return ConnectionAnalyticsRepository(db); -}); + final db = ref.watch(databaseProvider); + return ConnectionAnalyticsRepository(db); + }); diff --git a/zswatch_app/lib/data/repositories/crash_report_repository.dart b/zswatch_app/lib/data/repositories/crash_report_repository.dart new file mode 100644 index 0000000..67f8bc7 --- /dev/null +++ b/zswatch_app/lib/data/repositories/crash_report_repository.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; + +import '../database/app_database.dart'; +import '../models/coredump_analysis.dart'; +import '../models/crash_summary.dart'; + +/// Repository for persisting and querying crash reports. +class CrashReportRepository { + final AppDatabase _db; + + CrashReportRepository(this._db); + + /// Persist a crash summary received from the watch. + /// Returns the report ID, or null if it was a duplicate. + Future saveCrashSummary({ + required String watchId, + required CrashSummary summary, + }) async { + // Dedup: don't store the same crash twice + final existing = await _db.findExistingCrashReport( + watchId: watchId, + file: summary.file, + line: summary.line, + crashTime: summary.time, + ); + if (existing != null) { + debugPrint( + '[CrashReportRepo] Duplicate crash, skipping: ${summary.file}:${summary.line}', + ); + return existing.id; + } + + final id = await _db.insertCrashReport( + CrashReportsCompanion.insert( + watchId: watchId, + file: summary.file, + line: summary.line, + crashTime: summary.time, + fwVersion: summary.fwVersion, + fwCommitSha: summary.fwCommitSha, + board: summary.board, + buildType: summary.buildType, + receivedAt: DateTime.now(), + ), + ); + debugPrint( + '[CrashReportRepo] Saved crash report #$id: ${summary.file}:${summary.line}', + ); + return id; + } + + /// Update a crash report with analysis results. + Future saveAnalysisResult({ + required int reportId, + required CoredumpAnalysis analysis, + }) async { + await _db.updateCrashReportAnalysis( + reportId: reportId, + success: analysis.success, + backtrace: analysis.backtrace, + registers: analysis.registers, + rawOutput: analysis.rawOutput, + error: analysis.error, + elfAvailable: analysis.elfAvailable, + ); + } + + /// Get crash reports for a watch. + Future> getCrashReports({ + required String watchId, + int limit = 50, + }) { + return _db.getCrashReports(watchId: watchId, limit: limit); + } + + /// Watch crash reports for a watch (reactive stream). + Stream> watchCrashReports({ + required String watchId, + int limit = 50, + }) { + return _db.watchCrashReports(watchId: watchId, limit: limit); + } + + /// Watch all crash reports across all watches. + Stream> watchAllCrashReports({int limit = 50}) { + return _db.watchAllCrashReports(limit: limit); + } + + /// Get crash frequency stats per file. + Future> getCrashFileStats({String? watchId}) { + return _db.getCrashFileStats(watchId: watchId); + } + + /// Delete all crash reports. + Future deleteAll() { + return _db.deleteAllCrashReports(); + } + + /// Clean up old crash reports. + Future cleanup({int keep = 100}) { + return _db.deleteOldCrashReports(keep: keep); + } +} diff --git a/zswatch_app/lib/data/repositories/extracted_action_repository.dart b/zswatch_app/lib/data/repositories/extracted_action_repository.dart new file mode 100644 index 0000000..f38ab1c --- /dev/null +++ b/zswatch_app/lib/data/repositories/extracted_action_repository.dart @@ -0,0 +1,114 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import '../database/app_database.dart'; +import '../models/extracted_action.dart'; +import 'base_repository.dart'; + +/// Repository for AI-extracted action operations +class ExtractedActionRepository + extends BaseRepository { + final AppDatabase _db; + + ExtractedActionRepository(this._db); + + // ==================== Read Operations ==================== + + /// Get all extracted actions for a voice memo + Future> getActionsForMemo(int memoId) async { + final entities = await _db.getActionsForMemo(memoId); + return entities.map(_entityToModel).toList(); + } + + /// Watch extracted actions for a voice memo (reactive stream) + Stream> watchActionsForMemo(int memoId) { + return _db + .watchActionsForMemo(memoId) + .map((entities) => entities.map(_entityToModel).toList()); + } + + /// Get all pending (not created, not dismissed) actions + Future> getPendingActions() async { + final entities = await _db.getPendingActions(); + return entities.map(_entityToModel).toList(); + } + + // ==================== Write Operations ==================== + + /// Insert an extracted action + Future insertAction({ + required int memoId, + required ExtractedActionType actionType, + required String title, + String? notes, + DateTime? startTime, + DateTime? endTime, + DateTime? dueDate, + String? location, + int? reminderMinutes, + }) async { + final id = await _db.insertExtractedAction( + ExtractedActionsCompanion( + memoId: Value(memoId), + actionType: Value(ExtractedAction.typeToString(actionType)), + title: Value(title), + notes: Value(notes), + startTime: Value(startTime), + endTime: Value(endTime), + dueDate: Value(dueDate), + location: Value(location), + reminderMinutes: Value(reminderMinutes), + ), + ); + debugPrint( + '[ExtractedActionRepository] Inserted action $id for memo $memoId', + ); + return id; + } + + /// Mark an action as created in the OS + Future markCreated({ + required int actionId, + String? platformTargetId, + }) async { + await _db.markExtractedActionCreated( + actionId: actionId, + platformTargetId: platformTargetId, + ); + } + + /// Dismiss an action suggestion + Future dismiss(int actionId) async { + await _db.dismissExtractedAction(actionId); + } + + /// Delete all actions for a memo + Future deleteActionsForMemo(int memoId) async { + await _db.deleteActionsForMemo(memoId); + } + + // ==================== Private Helpers ==================== + + @override + ExtractedAction fromEntity(ExtractedActionEntity entity) => + _entityToModel(entity); + + ExtractedAction _entityToModel(ExtractedActionEntity entity) { + return ExtractedAction( + id: entity.id, + memoId: entity.memoId, + actionType: ExtractedAction.typeFromString(entity.actionType), + title: entity.title, + notes: entity.notes, + startTime: entity.startTime, + endTime: entity.endTime, + dueDate: entity.dueDate, + location: entity.location, + reminderMinutes: entity.reminderMinutes, + created: entity.created, + dismissed: entity.dismissed, + platformTargetId: entity.platformTargetId, + createdAt: entity.createdAt, + ); + } +} diff --git a/zswatch_app/lib/data/repositories/health_repository.dart b/zswatch_app/lib/data/repositories/health_repository.dart index 7aadae0..4186532 100644 --- a/zswatch_app/lib/data/repositories/health_repository.dart +++ b/zswatch_app/lib/data/repositories/health_repository.dart @@ -1,14 +1,17 @@ import 'package:drift/drift.dart'; +import '../../core/constants/app_constants.dart'; import '../database/app_database.dart'; import '../models/health_sample.dart'; +import 'base_repository.dart'; /// Repository for health data operations /// /// Provides a clean interface for CRUD operations on health samples, /// abstracting the database layer from the rest of the app. /// Includes 60-day data retention with automatic cleanup. -class HealthRepository { +class HealthRepository + extends BaseRepository { final AppDatabase _db; HealthRepository(this._db); @@ -58,7 +61,7 @@ class HealthRepository { }) async { final startOfDay = DateTime(date.year, date.month, date.day); final endOfDay = startOfDay.add(const Duration(days: 1)); - + return getSamples( watchId: watchId, type: HealthType.steps, @@ -74,7 +77,7 @@ class HealthRepository { }) async { final startOfDay = DateTime(date.year, date.month, date.day); final endOfDay = startOfDay.add(const Duration(days: 1)); - + return getSamples( watchId: watchId, type: HealthType.heartRate, @@ -128,7 +131,7 @@ class HealthRepository { } /// Get today's total steps - /// + /// /// Note: The watch sends cumulative daily steps, so we return the maximum /// (most recent) value rather than summing all samples. Future getTodaySteps(String watchId) async { @@ -140,7 +143,7 @@ class HealthRepository { from: startOfDay, to: now, ); - + if (samples.isEmpty) return 0; // Return the maximum value since the watch sends cumulative daily steps return samples.map((s) => s.intValue).reduce((a, b) => a > b ? a : b); @@ -156,7 +159,7 @@ class HealthRepository { from: oneHourAgo, to: now, ); - + if (samples.isEmpty) return null; return samples.last; } @@ -225,7 +228,7 @@ class HealthRepository { }) async { final startOfDay = DateTime(date.year, date.month, date.day); final endOfDay = startOfDay.add(const Duration(days: 1)); - + return getSamples( watchId: watchId, type: HealthType.activity, @@ -235,7 +238,7 @@ class HealthRepository { } /// Calculate activity breakdown (time spent in each state) for a day - /// + /// /// Returns a Map of activity state value to duration spent in that state. /// The calculation works by assuming each sample represents the state until /// the next sample arrives (or end of day). @@ -244,25 +247,25 @@ class HealthRepository { required DateTime date, }) async { final samples = await getDailyActivity(watchId: watchId, date: date); - + if (samples.isEmpty) { return {}; } - + final breakdown = {}; final startOfDay = DateTime(date.year, date.month, date.day); final endOfDay = startOfDay.add(const Duration(days: 1)); final now = DateTime.now(); final effectiveEnd = now.isBefore(endOfDay) ? now : endOfDay; - + // Sort by timestamp final sortedSamples = List.from(samples) ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - + for (int i = 0; i < sortedSamples.length; i++) { final sample = sortedSamples[i]; final stateValue = sample.intValue; - + // Calculate duration until next sample or end of day final DateTime nextTime; if (i + 1 < sortedSamples.length) { @@ -270,18 +273,19 @@ class HealthRepository { } else { nextTime = effectiveEnd; } - + final duration = nextTime.difference(sample.timestamp); if (duration.isNegative) continue; - - breakdown[stateValue] = (breakdown[stateValue] ?? Duration.zero) + duration; + + breakdown[stateValue] = + (breakdown[stateValue] ?? Duration.zero) + duration; } - + return breakdown; } /// Calculate activity breakdown for a date range (week/month) - /// + /// /// Returns a Map of activity state value to duration spent in that state, /// aggregated across all days in the range. Future> getActivityBreakdownForRange({ @@ -295,23 +299,23 @@ class HealthRepository { from: from, to: to, ); - + if (samples.isEmpty) { return {}; } - + final breakdown = {}; final now = DateTime.now(); final effectiveEnd = now.isBefore(to) ? now : to; - + // Sort by timestamp final sortedSamples = List.from(samples) ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - + for (int i = 0; i < sortedSamples.length; i++) { final sample = sortedSamples[i]; final stateValue = sample.intValue; - + // Calculate duration until next sample or end of range final DateTime nextTime; if (i + 1 < sortedSamples.length) { @@ -319,13 +323,14 @@ class HealthRepository { } else { nextTime = effectiveEnd; } - + final duration = nextTime.difference(sample.timestamp); if (duration.isNegative) continue; - - breakdown[stateValue] = (breakdown[stateValue] ?? Duration.zero) + duration; + + breakdown[stateValue] = + (breakdown[stateValue] ?? Duration.zero) + duration; } - + return breakdown; } @@ -333,7 +338,9 @@ class HealthRepository { /// Delete old data (60-day retention policy) Future cleanupOldData() async { - final cutoff = DateTime.now().subtract(const Duration(days: 60)); + final cutoff = DateTime.now().subtract( + const Duration(days: AppConstants.analyticsRetentionDays), + ); return _db.deleteOldHealthSamples(cutoff); } @@ -401,31 +408,39 @@ class HealthRepository { while (periodStart.isBefore(to)) { final periodEnd = periodStart.add(periodDuration); - - final periodSamples = samples.where((s) => - s.timestamp.isAfter(periodStart) && - s.timestamp.isBefore(periodEnd.add(const Duration(seconds: 1)))).toList(); + + final periodSamples = samples + .where( + (s) => + s.timestamp.isAfter(periodStart) && + s.timestamp.isBefore(periodEnd.add(const Duration(seconds: 1))), + ) + .toList(); if (periodSamples.isNotEmpty) { - aggregates.add(HealthAggregate.fromSamples( - samples: periodSamples, - periodStart: periodStart, - periodEnd: periodEnd, - granularity: granularity, - )); + aggregates.add( + HealthAggregate.fromSamples( + samples: periodSamples, + periodStart: periodStart, + periodEnd: periodEnd, + granularity: granularity, + ), + ); } else { // Add empty aggregate for periods with no data - aggregates.add(HealthAggregate( - type: type, - periodStart: periodStart, - periodEnd: periodEnd, - granularity: granularity, - total: 0, - average: 0, - min: 0, - max: 0, - sampleCount: 0, - )); + aggregates.add( + HealthAggregate( + type: type, + periodStart: periodStart, + periodEnd: periodEnd, + granularity: granularity, + total: 0, + average: 0, + min: 0, + max: 0, + sampleCount: 0, + ), + ); } periodStart = periodEnd; @@ -436,6 +451,9 @@ class HealthRepository { // ==================== Mapping ==================== + @override + HealthSample fromEntity(HealthSampleEntity entity) => _entityToModel(entity); + HealthSample _entityToModel(HealthSampleEntity entity) { return HealthSample( id: entity.id, diff --git a/zswatch_app/lib/data/repositories/voice_memo_repository.dart b/zswatch_app/lib/data/repositories/voice_memo_repository.dart new file mode 100644 index 0000000..36191f4 --- /dev/null +++ b/zswatch_app/lib/data/repositories/voice_memo_repository.dart @@ -0,0 +1,244 @@ +// ignore_for_file: avoid_slow_async_io +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import '../database/app_database.dart'; +import '../models/voice_memo.dart'; +import 'base_repository.dart'; + +/// Repository for voice memo data operations +/// +/// Provides a clean interface for CRUD operations on voice memos, +/// abstracting the database layer from the rest of the app. +class VoiceMemoRepository extends BaseRepository { + final AppDatabase _db; + + VoiceMemoRepository(this._db); + + // ==================== Read Operations ==================== + + /// Get all voice memos, newest first + Future> getAllMemos() async { + final entities = await _db.getAllVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Watch all voice memos (reactive stream), newest first + Stream> watchAllMemos() { + return _db.watchAllVoiceMemos().map( + (entities) => entities.map(_entityToModel).toList(), + ); + } + + /// Get a voice memo by filename + Future getMemoByFilename(String filename) async { + final entity = await _db.getVoiceMemoByFilename(filename); + return entity != null ? _entityToModel(entity) : null; + } + + /// Get memos that haven't been downloaded yet + Future> getUndownloadedMemos() async { + final entities = await _db.getUndownloadedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Get memos that are synced but not yet transcribed + Future> getUntranscribedMemos() async { + final entities = await _db.getUntranscribedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Get memos that have a local audio file and can be transcribed. + Future> getTranscribableMemos() async { + final allMemos = await getAllMemos(); + return allMemos + .where( + (memo) => + memo.convertedFilePath != null || memo.localFilePath != null, + ) + .toList(); + } + + // ==================== Write Operations ==================== + + /// Insert or update a voice memo from watch metadata + Future upsertFromWatch({ + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + }) async { + await _db.upsertVoiceMemo( + VoiceMemosCompanion( + filename: Value(filename), + timestampUtc: Value(timestampUtc), + durationMs: Value(durationMs), + sizeBytes: Value(sizeBytes), + ), + ); + } + + /// Mark a memo as downloaded + Future markDownloaded({ + required String filename, + required String localFilePath, + }) async { + await _db.updateVoiceMemoDownloaded( + filename: filename, + localFilePath: localFilePath, + ); + } + + /// Mark a memo as deleted on the watch + Future markDeletedOnWatch(String filename) async { + await _db.updateVoiceMemoDeletedOnWatch(filename); + } + + /// Update transcription result + Future updateTranscription({ + required String filename, + required String transcription, + }) async { + await _db.updateVoiceMemoTranscription( + filename: filename, + transcription: transcription, + ); + } + + /// Update converted file path + Future updateConvertedPath({ + required String filename, + required String convertedFilePath, + }) async { + await _db.updateVoiceMemoConvertedPath( + filename: filename, + convertedFilePath: convertedFilePath, + ); + } + + /// Update AI processing results + Future updateAiResults({ + required String filename, + required String summary, + required String category, + required String aiModel, + }) async { + await _db.updateVoiceMemoAiResults( + filename: filename, + summary: summary, + category: category, + aiModel: aiModel, + ); + } + + /// Clear AI results (undo) — resets summary, category, and processing status + /// while keeping raw audio and transcription intact. + Future clearAiResults(String filename) async { + final memo = await _db.getVoiceMemoByFilename(filename); + if (memo != null) { + await _db.deleteActionsForMemo(memo.id); + } + + await _db.updateVoiceMemoAiResults( + filename: filename, + summary: '', + category: '', + aiModel: '', + ); + await _db.updateVoiceMemoProcessingStatus( + filename: filename, + status: 'transcribed', + ); + } + + /// Update AI processing status + Future updateProcessingStatus({ + required String filename, + required String status, + }) async { + await _db.updateVoiceMemoProcessingStatus( + filename: filename, + status: status, + ); + } + + /// Get memos that are transcribed but not yet AI-processed + Future> getUnprocessedMemos() async { + final entities = await _db.getUnprocessedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Delete a voice memo by filename (deletes local files and DB entry) + Future deleteMemo(String filename) async { + // Get the memo first so we can clean up local files + final entity = await _db.getVoiceMemoByFilename(filename); + if (entity != null) { + // Delete local .zsw_opus file + if (entity.localFilePath != null) { + try { + final file = File(entity.localFilePath!); + if (await file.exists()) { + await file.delete(); + debugPrint( + '[VoiceMemoRepository] Deleted local file: ${entity.localFilePath}', + ); + } + } catch (e) { + debugPrint('[VoiceMemoRepository] Failed to delete local file: $e'); + } + } + // Delete converted .ogg file + if (entity.convertedFilePath != null) { + try { + final file = File(entity.convertedFilePath!); + if (await file.exists()) { + await file.delete(); + debugPrint( + '[VoiceMemoRepository] Deleted converted file: ${entity.convertedFilePath}', + ); + } + } catch (e) { + debugPrint( + '[VoiceMemoRepository] Failed to delete converted file: $e', + ); + } + } + } + await _db.deleteVoiceMemo(filename); + } + + // ==================== Private Helpers ==================== + + @override + VoiceMemo fromEntity(VoiceMemoEntity entity) => _entityToModel(entity); + + VoiceMemo _entityToModel(VoiceMemoEntity entity) { + return VoiceMemo( + id: entity.id, + filename: entity.filename, + timestampUtc: DateTime.fromMillisecondsSinceEpoch( + entity.timestampUtc * 1000, + isUtc: true, + ), + durationMs: entity.durationMs, + sizeBytes: entity.sizeBytes, + localFilePath: entity.localFilePath, + transcription: entity.transcription, + syncedFromWatch: entity.syncedFromWatch, + deletedOnWatch: entity.deletedOnWatch, + downloadedAt: entity.downloadedAt, + transcribedAt: entity.transcribedAt, + convertedFilePath: entity.convertedFilePath, + summary: entity.summary, + category: entity.category, + processingStatus: entity.processingStatus, + aiModel: entity.aiModel, + aiProcessedAt: entity.aiProcessedAt, + taskCreated: entity.taskCreated, + calendarEventCreated: entity.calendarEventCreated, + actionReviewState: entity.actionReviewState, + ); + } +} diff --git a/zswatch_app/lib/data/repositories/watch_repository.dart b/zswatch_app/lib/data/repositories/watch_repository.dart index 94fdee8..8db6fa0 100644 --- a/zswatch_app/lib/data/repositories/watch_repository.dart +++ b/zswatch_app/lib/data/repositories/watch_repository.dart @@ -3,12 +3,13 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../database/app_database.dart'; import '../models/watch.dart'; +import 'base_repository.dart'; /// Repository for Watch data operations /// /// Provides a clean interface for CRUD operations on watches, /// abstracting the database layer from the rest of the app. -class WatchRepository { +class WatchRepository extends BaseRepository { final AppDatabase _db; WatchRepository(this._db); @@ -24,8 +25,8 @@ class WatchRepository { /// Watch all saved watches (stream) Stream> watchAllWatches() { return _db.watchAllWatches().map( - (entities) => entities.map(_entityToModel).toList(), - ); + (entities) => entities.map(_entityToModel).toList(), + ); } /// Get watch by ID @@ -44,7 +45,7 @@ class WatchRepository { Future getLastConnectedWatch() async { final watches = await getAllWatches(); if (watches.isEmpty) return null; - + // Sort by lastConnectedAt descending, null values last watches.sort((a, b) { if (a.lastConnectedAt == null && b.lastConnectedAt == null) return 0; @@ -52,7 +53,7 @@ class WatchRepository { if (b.lastConnectedAt == null) return -1; return b.lastConnectedAt!.compareTo(a.lastConnectedAt!); }); - + return watches.firstOrNull; } @@ -92,10 +93,12 @@ class WatchRepository { }) async { final watch = await getWatchById(watchId); if (watch != null) { - await updateWatch(watch.copyWith( - firmwareVersion: firmwareVersion, - hardwareVersion: hardwareVersion ?? watch.hardwareVersion, - )); + await updateWatch( + watch.copyWith( + firmwareVersion: firmwareVersion, + hardwareVersion: hardwareVersion ?? watch.hardwareVersion, + ), + ); } } @@ -113,7 +116,7 @@ class WatchRepository { } /// Rename a watch by setting its custom name (T114) - /// + /// /// If [customName] is null or empty, clears the custom name and /// falls back to the default advertised name. Future renameWatch(String watchId, String? customName) async { @@ -138,11 +141,11 @@ class WatchRepository { } /// Forget a watch completely (T115) - /// + /// /// This removes the watch from the database AND removes the BLE bond. /// After calling this, the user will need to re-pair if they want to /// use the watch again. - /// + /// /// Note: removeBond() only works on Android. On iOS, users must /// manually forget the device in Bluetooth settings. Future forgetWatch(String watchId) async { @@ -154,7 +157,7 @@ class WatchRepository { // Bond removal may fail if device is not bonded or on iOS // Continue with database deletion anyway } - + // Then delete from database await deleteWatch(watchId); } @@ -188,6 +191,9 @@ class WatchRepository { // ==================== Mapping ==================== + @override + Watch fromEntity(WatchEntity entity) => _entityToModel(entity); + Watch _entityToModel(WatchEntity entity) { return Watch( id: entity.id, @@ -218,4 +224,3 @@ class WatchRepository { ); } } - diff --git a/zswatch_app/lib/main.dart b/zswatch_app/lib/main.dart index f4c8026..21af085 100644 --- a/zswatch_app/lib/main.dart +++ b/zswatch_app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:whisper_ggml_plus_ffmpeg/whisper_ggml_plus_ffmpeg.dart'; import 'app.dart'; @@ -8,6 +9,9 @@ void main() async { // Ensure Flutter bindings are initialized WidgetsFlutterBinding.ensureInitialized(); + // Register Whisper FFmpeg converter for Ogg/Opus → WAV transcription support + WhisperFFmpegConverter.register(); + // Set preferred orientations (portrait only for mobile) await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, @@ -31,9 +35,5 @@ void main() async { }; // Run the app with Riverpod provider scope - runApp( - const ProviderScope( - child: ZSWatchApp(), - ), - ); + runApp(const ProviderScope(child: ZSWatchApp())); } diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart new file mode 100644 index 0000000..1dc066e --- /dev/null +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -0,0 +1,231 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/extracted_action.dart'; +import '../data/repositories/extracted_action_repository.dart'; +import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/ai_debug_info.dart'; +import '../services/ai/extracted_action_creation_service.dart'; +import '../services/ai/llm_service.dart'; +import '../services/ai/voice_note_ai_pipeline.dart'; +import 'settings_providers.dart'; +import 'voice_memo_providers.dart'; +import 'watch_providers.dart'; + +// --------------------------------------------------------------------------- +// Core service providers +// --------------------------------------------------------------------------- + +/// Singleton LLM service backed by fllama. +final llmServiceProvider = Provider((ref) { + final service = LlmService(); + + // Update model gracefully without completely destroying the LlmService + ref.listen(selectedAiModelIdProvider, (previous, next) { + service.selectModel(next); + }); + + service.selectModel(ref.read(selectedAiModelIdProvider)); + + ref.onDispose(() => service.dispose()); + return service; +}); + +final llmAvailableModelsProvider = FutureProvider>(( + ref, +) async { + final service = ref.watch(llmServiceProvider); + return service.availableModels(); +}); + +final selectedLlmModelInfoProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.currentModelInfo(); +}); + +/// Observable service state (status + download progress). +final llmServiceStateProvider = StreamProvider((ref) { + final service = ref.watch(llmServiceProvider); + return service.stateStream; +}); + +/// Whether the GGUF model file exists on disk. +final llmModelDownloadedProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.isModelDownloaded(); +}); + +/// Size of the local model file in bytes (null if not downloaded). +final llmModelSizeProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.modelFileSize(); +}); + +/// Memory fit check for the currently selected model on this device. +final llmModelFitProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + final model = await ref.watch(selectedLlmModelInfoProvider.future); + return service.checkModelFit(model); +}); + +// --------------------------------------------------------------------------- +// Extracted-action repository +// --------------------------------------------------------------------------- + +final extractedActionRepositoryProvider = Provider(( + ref, +) { + final db = ref.watch(databaseProvider); + return ExtractedActionRepository(db); +}); + +final extractedActionCreationServiceProvider = + Provider((ref) { + return const ExtractedActionCreationService(); + }); + +final writableCalendarsProvider = FutureProvider>(( + ref, +) async { + final service = ref.watch(extractedActionCreationServiceProvider); + return service.listWritableCalendars(); +}); + +class ExtractedActionOperations { + final ExtractedActionRepository _actionRepository; + final ExtractedActionCreationService _creationService; + + const ExtractedActionOperations({ + required ExtractedActionRepository actionRepository, + required ExtractedActionCreationService creationService, + }) : _actionRepository = actionRepository, + _creationService = creationService; + + Future createAction({ + required ExtractedAction action, + required ActionCreationDraft draft, + }) async { + final created = await _creationService.createDraft(draft); + await _actionRepository.markCreated( + actionId: action.id, + platformTargetId: created.platformId, + ); + final warning = created.syncWarningMessage; + if (warning != null) { + return '${created.successMessage} (⚠ $warning)'; + } + return created.successMessage; + } + + Future dismissAction(int actionId) { + return _actionRepository.dismiss(actionId); + } +} + +final extractedActionOperationsProvider = Provider(( + ref, +) { + return ExtractedActionOperations( + actionRepository: ref.watch(extractedActionRepositoryProvider), + creationService: ref.watch(extractedActionCreationServiceProvider), + ); +}); + +// --------------------------------------------------------------------------- +// AI pipeline +// --------------------------------------------------------------------------- + +/// The voice-note AI pipeline wired with the LLM service + repositories. +final voiceNoteAiPipelineProvider = Provider((ref) { + final llm = ref.watch(llmServiceProvider); + final memoRepo = ref.watch(voiceMemoRepositoryProvider); + final actionRepo = ref.watch(extractedActionRepositoryProvider); + final correctionEnabled = ref.watch(aiCorrectionEnabledProvider); + return VoiceNoteAiPipeline( + llmService: llm, + memoRepository: memoRepo, + actionRepository: actionRepo, + correctTranscription: correctionEnabled, + ); +}); + +/// Stream of debug info from the most recent AI processing run. +final aiProcessingDebugInfoProvider = StreamProvider((ref) { + final pipeline = ref.watch(voiceNoteAiPipelineProvider); + return pipeline.debugInfoStream; +}); + +// --------------------------------------------------------------------------- +// AI actions notifier (used by settings + voice memos screens) +// --------------------------------------------------------------------------- + +class _AiActionsNotifier extends StateNotifier> { + final VoiceNoteAiPipeline _pipeline; + final VoiceMemoRepository _memoRepo; + + _AiActionsNotifier({ + required VoiceNoteAiPipeline pipeline, + required VoiceMemoRepository memoRepo, + }) : _pipeline = pipeline, + _memoRepo = memoRepo, + super(const AsyncData(null)); + + /// Process a single voice memo identified by [filename]. + Future processVoiceMemo(String filename) async { + state = const AsyncLoading(); + try { + final memo = await _memoRepo.getMemoByFilename(filename); + if (memo == null) throw Exception('Memo not found: $filename'); + + final transcript = memo.transcription?.trim(); + if (transcript == null || transcript.isEmpty) { + throw StateError('Transcribe the voice note before AI processing.'); + } + + final success = await _pipeline.processMemo( + memoId: memo.id, + filename: memo.filename, + transcript: transcript, + ); + + if (!success) { + throw StateError('AI processing did not complete for $filename.'); + } + + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[AiActions] processVoiceMemo error: $e'); + state = AsyncError(e, st); + } + } + + /// Process all transcribed-but-unprocessed memos. + Future processAllUnprocessed() async { + state = const AsyncLoading(); + try { + final count = await _pipeline.processAllUnprocessed(); + debugPrint('[AiActions] Processed $count memos'); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[AiActions] processAllUnprocessed error: $e'); + state = AsyncError(e, st); + } + } +} + +final aiActionsProvider = + StateNotifierProvider<_AiActionsNotifier, AsyncValue>((ref) { + final pipeline = ref.watch(voiceNoteAiPipelineProvider); + final memoRepo = ref.watch(voiceMemoRepositoryProvider); + return _AiActionsNotifier(pipeline: pipeline, memoRepo: memoRepo); + }); + +// --------------------------------------------------------------------------- +// Extracted actions per memo (for the detail screen) +// --------------------------------------------------------------------------- + +final extractedActionsForMemoProvider = + StreamProvider.family, int>((ref, memoId) { + final repo = ref.watch(extractedActionRepositoryProvider); + return repo.watchActionsForMemo(memoId); + }); diff --git a/zswatch_app/lib/providers/analytics_providers.dart b/zswatch_app/lib/providers/analytics_providers.dart index 53b7388..db46e78 100644 --- a/zswatch_app/lib/providers/analytics_providers.dart +++ b/zswatch_app/lib/providers/analytics_providers.dart @@ -9,10 +9,36 @@ import 'watch_providers.dart'; import 'watch_service_provider.dart'; // Re-export repository providers for convenient access -export '../data/repositories/battery_repository.dart' show batteryRepositoryProvider; -export '../data/repositories/connection_analytics_repository.dart' show connectionAnalyticsRepositoryProvider; -export '../services/analytics/battery_storage_service.dart' show batteryStorageServiceProvider; -export '../services/analytics/connection_analytics_service.dart' show connectionAnalyticsServiceProvider; +export '../data/repositories/battery_repository.dart' + show batteryRepositoryProvider; +export '../data/repositories/connection_analytics_repository.dart' + show + connectionAnalyticsRepositoryProvider, + ConnectionSegment, + ConnectionSegmentType, + ConnectionStats; +export '../services/analytics/battery_storage_service.dart' + show batteryStorageServiceProvider; +export '../services/analytics/connection_analytics_service.dart' + show connectionAnalyticsServiceProvider; + +// ============================================================================ +// Enums and Constants +// ============================================================================ + +/// Time range options for connection analytics timeline +enum TimeRangeOption { + oneHour(Duration(hours: 1), '1h'), + sixHours(Duration(hours: 6), '6h'), + twelveHours(Duration(hours: 12), '12h'), + twentyFourHours(Duration(hours: 24), '24h'), + sevenDays(Duration(days: 7), '7d'); + + final Duration duration; + final String label; + + const TimeRangeOption(this.duration, this.label); +} // ============================================================================ // Battery Analytics Providers @@ -21,42 +47,42 @@ export '../services/analytics/connection_analytics_service.dart' show connection /// Provider for battery readings from the last 24 hours final batteryReadings24HoursProvider = FutureProvider.autoDispose .family, String>((ref, watchId) async { - final repository = ref.watch(batteryRepositoryProvider); - return repository.getLast24Hours(watchId); -}); + final repository = ref.watch(batteryRepositoryProvider); + return repository.getLast24Hours(watchId); + }); /// Provider for battery readings from the last 7 days final batteryReadings7DaysProvider = FutureProvider.autoDispose .family, String>((ref, watchId) async { - final repository = ref.watch(batteryRepositoryProvider); - return repository.getLast7Days(watchId); -}); + final repository = ref.watch(batteryRepositoryProvider); + return repository.getLast7Days(watchId); + }); /// Provider for daily battery snapshots (for 7-day chart) final batteryDailySnapshotsProvider = FutureProvider.autoDispose .family, String>((ref, watchId) async { - final repository = ref.watch(batteryRepositoryProvider); - return repository.getDailySnapshots(watchId: watchId, days: 7); -}); + final repository = ref.watch(batteryRepositoryProvider); + return repository.getDailySnapshots(watchId: watchId, days: 7); + }); /// Provider for battery drain rate (% per hour) final batteryDrainRateProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(batteryRepositoryProvider); - final now = DateTime.now(); - return repository.calculateDrainRatePerHour( - watchId: watchId, - from: now.subtract(const Duration(hours: 24)), - to: now, - ); -}); + final repository = ref.watch(batteryRepositoryProvider); + final now = DateTime.now(); + return repository.calculateDrainRatePerHour( + watchId: watchId, + from: now.subtract(const Duration(hours: 24)), + to: now, + ); + }); /// Provider for estimated remaining battery time final estimatedBatteryTimeProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(batteryRepositoryProvider); - return repository.estimateRemainingTime(watchId); -}); + final repository = ref.watch(batteryRepositoryProvider); + return repository.estimateRemainingTime(watchId); + }); /// Provider for selected watch ID (from connection or primary watch) final selectedWatchIdProvider = Provider((ref) { @@ -69,19 +95,22 @@ final selectedWatchIdProvider = Provider((ref) { }); /// Provider for current watch battery readings (uses selected watch) -final currentWatchBatteryReadingsProvider = FutureProvider.autoDispose>((ref) async { - final selectedWatchId = ref.watch(selectedWatchIdProvider); - if (selectedWatchId == null) return []; - - final repository = ref.watch(batteryRepositoryProvider); - return repository.getLast24Hours(selectedWatchId); -}); +final currentWatchBatteryReadingsProvider = + FutureProvider.autoDispose>((ref) async { + final selectedWatchId = ref.watch(selectedWatchIdProvider); + if (selectedWatchId == null) return []; + + final repository = ref.watch(batteryRepositoryProvider); + return repository.getLast24Hours(selectedWatchId); + }); /// Provider for current watch battery drain rate -final currentWatchDrainRateProvider = FutureProvider.autoDispose((ref) async { +final currentWatchDrainRateProvider = FutureProvider.autoDispose(( + ref, +) async { final selectedWatchId = ref.watch(selectedWatchIdProvider); if (selectedWatchId == null) return null; - + final repository = ref.watch(batteryRepositoryProvider); final now = DateTime.now(); return repository.calculateDrainRatePerHour( @@ -92,13 +121,26 @@ final currentWatchDrainRateProvider = FutureProvider.autoDispose((ref) }); /// Provider for current watch estimated time remaining -final currentWatchEstimatedTimeProvider = FutureProvider.autoDispose((ref) async { - final selectedWatchId = ref.watch(selectedWatchIdProvider); - if (selectedWatchId == null) return null; - - final repository = ref.watch(batteryRepositoryProvider); - return repository.estimateRemainingTime(selectedWatchId); -}); +final currentWatchEstimatedTimeProvider = FutureProvider.autoDispose( + (ref) async { + final selectedWatchId = ref.watch(selectedWatchIdProvider); + if (selectedWatchId == null) return null; + + final repository = ref.watch(batteryRepositoryProvider); + return repository.estimateRemainingTime(selectedWatchId); + }, +); + +// ============================================================================ +// Connection Timeline Time Range Selection +// ============================================================================ + +/// Provider to track the selected time range for connection timeline per watch. +/// Defaults to full range (7 days). +final connectionTimelineRangeProvider = StateProvider.autoDispose + .family( + (ref, watchId) => TimeRangeOption.sevenDays, + ); // ============================================================================ // Connection Analytics Providers @@ -107,88 +149,123 @@ final currentWatchEstimatedTimeProvider = FutureProvider.autoDispose( /// Provider for connection stats for the last 24 hours final connectionStats24HoursProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - return repository.getLast24HoursStats(watchId); -}); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + return repository.getLast24HoursStats(watchId); + }); /// Provider for connection stats for the last 7 days final connectionStats7DaysProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - return repository.getLast7DaysStats(watchId); -}); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + return repository.getLast7DaysStats(watchId); + }); /// Provider for uptime percentage final uptimePercentageProvider = FutureProvider.autoDispose .family((ref, params) async { - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - final now = DateTime.now(); - return repository.calculateUptimePercentage( - watchId: params.watchId, - from: now.subtract(params.window), - to: now, - ); -}); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + final now = DateTime.now(); + return repository.calculateUptimePercentage( + watchId: params.watchId, + from: now.subtract(params.window), + to: now, + ); + }); /// Provider for disconnection count in last 24 hours final disconnectionCountProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - final now = DateTime.now(); - return repository.getDisconnectionCount( - watchId: watchId, - from: now.subtract(const Duration(hours: 24)), - to: now, - ); -}); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + final now = DateTime.now(); + return repository.getDisconnectionCount( + watchId: watchId, + from: now.subtract(const Duration(hours: 24)), + to: now, + ); + }); /// Provider for average session duration final averageSessionDurationProvider = FutureProvider.autoDispose .family((ref, watchId) async { - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - final now = DateTime.now(); - return repository.calculateAverageSessionDuration( - watchId: watchId, - from: now.subtract(const Duration(days: 7)), - to: now, - ); -}); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + final now = DateTime.now(); + return repository.calculateAverageSessionDuration( + watchId: watchId, + from: now.subtract(const Duration(days: 7)), + to: now, + ); + }); /// Provider for current watch connection stats (24h) -final currentWatchConnectionStatsProvider = FutureProvider.autoDispose((ref) async { - final selectedWatchId = ref.watch(selectedWatchIdProvider); - if (selectedWatchId == null) return null; - - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - return repository.getLast24HoursStats(selectedWatchId); -}); +final currentWatchConnectionStatsProvider = + FutureProvider.autoDispose((ref) async { + final selectedWatchId = ref.watch(selectedWatchIdProvider); + if (selectedWatchId == null) return null; + + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + return repository.getLast24HoursStats(selectedWatchId); + }); /// Provider for current watch 7-day connection stats -final currentWatchConnectionStats7DaysProvider = FutureProvider.autoDispose((ref) async { - final selectedWatchId = ref.watch(selectedWatchIdProvider); - if (selectedWatchId == null) return null; - - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - return repository.getLast7DaysStats(selectedWatchId); -}); +final currentWatchConnectionStats7DaysProvider = + FutureProvider.autoDispose((ref) async { + final selectedWatchId = ref.watch(selectedWatchIdProvider); + if (selectedWatchId == null) return null; + + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + return repository.getLast7DaysStats(selectedWatchId); + }); /// Provider for connection events stream (for real-time updates) final connectionEventsStreamProvider = StreamProvider.autoDispose .family, String>((ref, watchId) { - final db = ref.watch(databaseProvider); - return db.watchConnectionEvents(watchId: watchId, limit: 50); -}); + final db = ref.watch(databaseProvider); + return db.watchConnectionEvents(watchId: watchId, limit: 50); + }); + +/// Provider for the oldest connection event timestamp for a watch. +/// Used to auto-size the timeline window. +final oldestConnectionEventTimeProvider = FutureProvider.autoDispose + .family((ref, watchId) async { + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + return repository.getOldestEventTime(watchId); + }); + +/// Provider for the connection timeline segments. +/// The window is determined by the selected time range from connectionTimelineRangeProvider. +final connectionTimelineProvider = FutureProvider.autoDispose + .family< + ({ + List segments, + DateTime windowStart, + DateTime windowEnd, + }), + String + >((ref, watchId) async { + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + final selectedRange = ref.watch(connectionTimelineRangeProvider(watchId)); + final now = DateTime.now(); + final windowStart = now.subtract(selectedRange.duration); + + final segments = await repository.getConnectionTimeline( + watchId: watchId, + from: windowStart, + to: now, + ); + return (segments: segments, windowStart: windowStart, windowEnd: now); + }); /// Provider for current watch connection events stream -final currentWatchConnectionEventsProvider = StreamProvider.autoDispose>((ref) { - final selectedWatchId = ref.watch(selectedWatchIdProvider); - if (selectedWatchId == null) { - return Stream.value([]); - } - - final db = ref.watch(databaseProvider); - return db.watchConnectionEvents(watchId: selectedWatchId, limit: 50); -}); +final currentWatchConnectionEventsProvider = + StreamProvider.autoDispose>((ref) { + final selectedWatchId = ref.watch(selectedWatchIdProvider); + if (selectedWatchId == null) { + return Stream.value([]); + } + + final db = ref.watch(databaseProvider); + return db.watchConnectionEvents(watchId: selectedWatchId, limit: 50); + }); // ============================================================================ // Service Initialization Providers diff --git a/zswatch_app/lib/providers/auto_reconnect_provider.dart b/zswatch_app/lib/providers/auto_reconnect_provider.dart index 55a0310..52a00cb 100644 --- a/zswatch_app/lib/providers/auto_reconnect_provider.dart +++ b/zswatch_app/lib/providers/auto_reconnect_provider.dart @@ -4,124 +4,117 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../data/models/watch.dart'; import '../data/repositories/watch_repository.dart'; import '../services/ble/auto_reconnect_service.dart'; +import 'base_async_notifier.dart'; import 'watch_providers.dart'; import 'watch_service_provider.dart'; // Keys for SharedPreferences const _autoReconnectEnabledKey = 'auto_reconnect_enabled'; -/// Provider for WatchRepository (for auto-reconnect) +/// Provider for WatchRepository (for auto-reconnect). final _watchRepositoryProvider = Provider((ref) { final db = ref.watch(databaseProvider); return WatchRepository(db); }); -/// Provider for the AutoReconnectService -/// -/// Uses ref.read instead of ref.watch to prevent the service from being -/// recreated when watchService state changes. This preserves the -/// _isSuppressedForSession flag across connection state changes. +/// Provider for the AutoReconnectService. +/// +/// Uses ref.read instead of ref.watch to prevent the service from being +/// recreated when connection state changes. final autoReconnectServiceProvider = Provider((ref) { - final watchService = ref.read(watchServiceProvider); + final ble = ref.read(bleConnectionServiceProvider); final watchRepository = ref.read(_watchRepositoryProvider); - + final service = AutoReconnectService( - connectById: (watchId, {bool autoConnect = false}) => - watchService.connectById(watchId, autoConnect: autoConnect), + connectById: (watchId, {bool autoConnect = false}) => + ble.connectById(watchId, autoConnect: autoConnect), getLastConnectedWatch: () async { - // Get last connected watch from database (sorted by lastConnectedAt) return watchRepository.getLastConnectedWatch(); }, - cancelConnection: () => watchService.cancelPendingConnection(), + cancelConnection: () => ble.cancelPendingConnection(), ); - + ref.onDispose(() => service.dispose()); return service; }); -/// Provider for auto-reconnect state stream -final autoReconnectStateStreamProvider = StreamProvider((ref) { +/// Provider for auto-reconnect state stream. +final autoReconnectStateStreamProvider = StreamProvider(( + ref, +) { final service = ref.watch(autoReconnectServiceProvider); return service.stateStream; }); -/// Provider for current auto-reconnect state +/// Provider for current auto-reconnect state. final autoReconnectStateProvider = Provider((ref) { final asyncValue = ref.watch(autoReconnectStateStreamProvider); return asyncValue.valueOrNull ?? AutoReconnectState.idle; }); -/// Provider for whether auto-reconnect is currently in progress +/// Provider for whether auto-reconnect is currently in progress. final isAutoReconnectingProvider = Provider((ref) { final state = ref.watch(autoReconnectStateProvider); return state == AutoReconnectState.waiting; }); -/// Provider for auto-reconnect target watch +/// Provider for auto-reconnect target watch. final autoReconnectTargetWatchProvider = Provider((ref) { final service = ref.watch(autoReconnectServiceProvider); return service.targetWatch; }); -/// Provider for whether auto-reconnect is enabled (user preference) +/// Provider for whether auto-reconnect is enabled (user preference). final autoReconnectEnabledProvider = FutureProvider((ref) async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_autoReconnectEnabledKey) ?? true; // Default enabled + return prefs.getBool(_autoReconnectEnabledKey) ?? true; }); -/// Set auto-reconnect enabled preference +/// Set auto-reconnect enabled preference. Future setAutoReconnectEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_autoReconnectEnabledKey, enabled); } -/// Provider for last connected watch (from database) +/// Provider for last connected watch (from database). final lastConnectedWatchProvider = FutureProvider((ref) async { final watchRepository = ref.watch(_watchRepositoryProvider); return watchRepository.getLastConnectedWatch(); }); -/// Notifier for auto-reconnect actions -class AutoReconnectNotifier extends StateNotifier> { +/// Notifier for auto-reconnect actions. +class AutoReconnectNotifier extends BaseAsyncNotifier { final AutoReconnectService _service; - AutoReconnectNotifier(this._service) : super(const AsyncValue.data(null)); - - /// Start auto-reconnect to last connected watch - Future startAutoReconnect() async { - state = const AsyncValue.loading(); - try { - await _service.startAutoReconnect(); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } + AutoReconnectNotifier(this._service); - /// Cancel auto-reconnect (suppresses for this session) + /// Start auto-reconnect to last connected watch. + Future startAutoReconnect() => run(() => _service.startAutoReconnect()); + + /// Cancel auto-reconnect (suppresses for this session). void cancel() { _service.cancel(); } - /// Stop auto-reconnect + /// Stop auto-reconnect. void stop() { _service.stop(); } - /// Suppress auto-reconnect for this session (call when user manually disconnects) + /// Suppress auto-reconnect for this session. void suppressForSession() { _service.suppressForSession(); } - /// Set auto-reconnect enabled + /// Set auto-reconnect enabled. void setEnabled(bool enabled) { _service.setEnabled(enabled); } } -/// Provider for auto-reconnect notifier +/// Provider for auto-reconnect notifier. final autoReconnectNotifierProvider = StateNotifierProvider>((ref) { - final service = ref.watch(autoReconnectServiceProvider); - return AutoReconnectNotifier(service); -}); + final service = ref.watch(autoReconnectServiceProvider); + return AutoReconnectNotifier(service); + }); diff --git a/zswatch_app/lib/providers/base_async_notifier.dart b/zswatch_app/lib/providers/base_async_notifier.dart new file mode 100644 index 0000000..7cdb153 --- /dev/null +++ b/zswatch_app/lib/providers/base_async_notifier.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Base class for notifiers that manage an [AsyncValue] state. +/// +/// Provides shared helpers for the loading/error/done lifecycle that every +/// action-notifier needs: +/// - [run] wraps an async operation with loading → done/error transitions. +/// - [reset] clears any current error and returns to the idle/data state. +abstract class BaseAsyncNotifier extends StateNotifier> { + BaseAsyncNotifier() : super(const AsyncValue.data(null)); + + /// Run [action], setting state to loading while in-flight and to + /// [AsyncValue.error] on failure. Returns `true` on success. + Future run( + Future Function() action, { + bool rethrowError = false, + }) async { + state = const AsyncValue.loading(); + try { + await action(); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + if (rethrowError) rethrow; + return false; + } + } + + /// Clear any error and return to the idle state. + void reset() { + state = const AsyncValue.data(null); + } +} diff --git a/zswatch_app/lib/providers/ble_providers.dart b/zswatch_app/lib/providers/ble_providers.dart index b00a9b2..895827e 100644 --- a/zswatch_app/lib/providers/ble_providers.dart +++ b/zswatch_app/lib/providers/ble_providers.dart @@ -4,129 +4,109 @@ import 'package:permission_handler/permission_handler.dart'; import '../data/models/connection.dart'; import '../data/models/connection_state.dart'; -import '../services/ble/ble_connection_manager.dart'; import '../services/ble/ble_scanner.dart'; +import 'base_async_notifier.dart'; +import 'watch_service_provider.dart'; -/// Provider for the BLE scanner singleton +/// Provider for the BLE scanner singleton. final bleScannerProvider = Provider((ref) { final scanner = BleScanner(); ref.onDispose(() => scanner.dispose()); return scanner; }); -/// Provider for the BLE connection manager singleton -final bleConnectionManagerProvider = Provider((ref) { - final manager = BleConnectionManager(); - ref.onDispose(() => manager.dispose()); - return manager; -}); - -/// Provider for Bluetooth adapter state -final bluetoothAdapterStateProvider = - StreamProvider((ref) { +/// Provider for Bluetooth adapter state. +final bluetoothAdapterStateProvider = StreamProvider(( + ref, +) { return FlutterBluePlus.adapterState; }); -/// Provider for whether Bluetooth is available and on +/// Provider for whether Bluetooth is available and on. final isBluetoothAvailableProvider = Provider((ref) { final adapterState = ref.watch(bluetoothAdapterStateProvider); return adapterState.valueOrNull == BluetoothAdapterState.on; }); -/// Provider for scanned devices during scan +/// Provider for scanned devices during scan. final scannedDevicesProvider = StreamProvider>((ref) { final scanner = ref.watch(bleScannerProvider); return scanner.scanResults; }); -/// Provider for whether currently scanning -/// Provider for whether currently scanning (reactive stream) +/// Provider for whether currently scanning. final isScanningProvider = StreamProvider((ref) { return FlutterBluePlus.isScanning; }); -/// Provider for BLE connection state +/// Provider for BLE connection state — derives from BleConnectionService. final bleConnectionProvider = StreamProvider((ref) { - final manager = ref.watch(bleConnectionManagerProvider); - return manager.connectionStream; + final ble = ref.watch(bleConnectionServiceProvider); + return ble.connectionStream; }); -/// Provider for current connection info (non-stream) +/// Provider for current connection info (non-stream). final currentConnectionProvider = Provider((ref) { final asyncValue = ref.watch(bleConnectionProvider); return asyncValue.valueOrNull; }); -/// Provider for connection state enum +/// Provider for connection state enum. final connectionStateProvider = Provider((ref) { final connection = ref.watch(currentConnectionProvider); return connection?.state ?? WatchConnectionState.disconnected; }); -/// Provider for connection status (simplified boolean) +/// Provider for connection status (simplified boolean). final isConnectedProvider = Provider((ref) { final state = ref.watch(connectionStateProvider); return state == WatchConnectionState.connected; }); -/// Provider for whether connecting or reconnecting +/// Provider for whether connecting or reconnecting. final isConnectingProvider = Provider((ref) { final state = ref.watch(connectionStateProvider); return state.isConnectingOrReconnecting; }); -/// Provider for connection RSSI +/// Provider for connection RSSI. final connectionRssiProvider = Provider((ref) { final connection = ref.watch(currentConnectionProvider); return connection?.rssi; }); -/// Provider for connection MTU +/// Provider for connection MTU. final connectionMtuProvider = Provider((ref) { final connection = ref.watch(currentConnectionProvider); return connection?.mtu; }); -/// Provider for connected watch ID +/// Provider for connected watch ID. final connectedWatchIdProvider = Provider((ref) { final connection = ref.watch(currentConnectionProvider); return connection?.isConnected == true ? connection?.watchId : null; }); -/// Notifier for BLE operations -class BleNotifier extends StateNotifier> { +/// Notifier for BLE operations (scanning, permissions). +class BleNotifier extends BaseAsyncNotifier { final BleScanner _scanner; - final BleConnectionManager _connectionManager; - BleNotifier(this._scanner, this._connectionManager) - : super(const AsyncValue.data(null)); + BleNotifier(this._scanner); - /// Initialize BLE (check adapter state) - Future initialize() async { - state = const AsyncValue.loading(); - try { - // Wait for adapter state - final adapterState = await FlutterBluePlus.adapterState.first; - if (adapterState != BluetoothAdapterState.on) { - // BLE not available - not an error, just a state - } - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } + /// Initialize BLE (check adapter state). + Future initialize() => run(() async { + await FlutterBluePlus.adapterState.first; + }); - /// Request Bluetooth permissions using flutter_blue_plus + /// Request Bluetooth permissions. Future requestPermissions() async { - // Use flutter_blue_plus's built-in permission handling - // This properly handles Android 12+ vs older versions try { - // This triggers the system permission dialog - await FlutterBluePlus.startScan(timeout: const Duration(milliseconds: 100)); + await FlutterBluePlus.startScan( + timeout: const Duration(milliseconds: 100), + ); await FlutterBluePlus.stopScan(); return true; } catch (e) { - // If scan fails due to permissions, try requesting manually final results = await [ Permission.bluetoothScan, Permission.bluetoothConnect, @@ -134,98 +114,41 @@ class BleNotifier extends StateNotifier> { Permission.locationWhenInUse, ].request(); - // Check if we got what we need - final hasBluetooth = (results[Permission.bluetoothScan]?.isGranted ?? false) || - (results[Permission.bluetooth]?.isGranted ?? false); - final hasConnect = results[Permission.bluetoothConnect]?.isGranted ?? true; - + final hasBluetooth = + (results[Permission.bluetoothScan]?.isGranted ?? false) || + (results[Permission.bluetooth]?.isGranted ?? false); + final hasConnect = + results[Permission.bluetoothConnect]?.isGranted ?? true; + return hasBluetooth && hasConnect; } } - /// Check if permissions are granted + /// Check if permissions are granted. Future checkPermissions() async { - // Quick check - try to get adapter state try { - final state = await FlutterBluePlus.adapterState.first.timeout( + final adapterState = await FlutterBluePlus.adapterState.first.timeout( const Duration(seconds: 2), onTimeout: () => BluetoothAdapterState.unknown, ); - // If we can read adapter state, permissions are likely OK - return state != BluetoothAdapterState.unauthorized; + return adapterState != BluetoothAdapterState.unauthorized; } catch (e) { return false; } } - /// Start scanning for devices - Future startScan({Duration? timeout}) async { - state = const AsyncValue.loading(); - try { - await _scanner.startScan( - timeout: timeout ?? const Duration(seconds: 15), - ); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } + /// Start scanning for devices. + Future startScan({Duration? timeout}) => run(() async { + await _scanner.startScan(timeout: timeout ?? const Duration(seconds: 15)); + }); - /// Stop scanning + /// Stop scanning. Future stopScan() async { await _scanner.stopScan(); - state = const AsyncValue.data(null); - } - - /// Connect to a scanned device - Future connect(ScannedWatch device) async { - state = const AsyncValue.loading(); - try { - await _connectionManager.connect(device); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } - - /// Connect to a device by ID (saved device) - Future connectById(String deviceId) async { - state = const AsyncValue.loading(); - try { - await _connectionManager.connectById(deviceId); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } - - /// Cancel pending connection - void cancelPendingConnection() { - _connectionManager.cancelPendingConnection(); - state = const AsyncValue.data(null); - } - - /// Disconnect from current device - Future disconnect() async { - state = const AsyncValue.loading(); - try { - await _connectionManager.disconnect(); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } - - /// Read RSSI - Future readRssi() async { - try { - return await _connectionManager.readRssi(); - } catch (_) { - return null; - } + reset(); } - /// Turn on Bluetooth (Android only) + /// Turn on Bluetooth (Android only). Future turnOnBluetooth() async { try { await FlutterBluePlus.turnOn(); @@ -234,21 +157,20 @@ class BleNotifier extends StateNotifier> { } } - /// Open Bluetooth settings + /// Open Bluetooth settings. Future openBluetoothSettings() async { await openAppSettings(); } } -/// Provider for BLE operations notifier +/// Provider for BLE operations notifier. final bleNotifierProvider = StateNotifierProvider>((ref) { - final scanner = ref.watch(bleScannerProvider); - final connectionManager = ref.watch(bleConnectionManagerProvider); - return BleNotifier(scanner, connectionManager); -}); + final scanner = ref.watch(bleScannerProvider); + return BleNotifier(scanner); + }); -/// Provider for BLE permission status +/// Provider for BLE permission status. final blePermissionsProvider = FutureProvider((ref) async { final notifier = ref.read(bleNotifierProvider.notifier); return notifier.checkPermissions(); diff --git a/zswatch_app/lib/providers/coredump_providers.dart b/zswatch_app/lib/providers/coredump_providers.dart new file mode 100644 index 0000000..bcb880b --- /dev/null +++ b/zswatch_app/lib/providers/coredump_providers.dart @@ -0,0 +1,101 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/database/app_database.dart'; +import '../data/repositories/crash_report_repository.dart'; +import '../services/coredump/coredump_api_service.dart'; +import '../services/coredump/coredump_service.dart'; +import 'dfu_providers.dart'; +import 'settings_providers.dart'; +import 'watch_providers.dart'; +import 'watch_service_provider.dart'; + +/// Provider for the backend API service. +final coredumpApiServiceProvider = Provider((ref) { + final baseUrl = ref.watch(coredumpServerUrlProvider); + return CoredumpApiService(baseUrl: baseUrl); +}); + +/// Provider for the coredump analysis orchestration service. +final coredumpServiceProvider = Provider((ref) { + final watchService = ref.watch(watchServiceProvider); + final apiService = ref.watch(coredumpApiServiceProvider); + final firmwareManager = ref.watch(firmwareManagerProvider); + final service = CoredumpService(watchService, apiService, firmwareManager); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Stream of coredump analysis state updates. +final coredumpAnalysisStateProvider = StreamProvider(( + ref, +) { + final service = ref.watch(coredumpServiceProvider); + return service.stateStream; +}); + +/// Provider for the crash report repository. +final crashReportRepositoryProvider = Provider((ref) { + final db = ref.watch(databaseProvider); + return CrashReportRepository(db); +}); + +/// Stream of all crash reports (history), newest first. +final crashReportHistoryProvider = StreamProvider>(( + ref, +) { + final repo = ref.watch(crashReportRepositoryProvider); + return repo.watchAllCrashReports(limit: 50); +}); + +/// Crash file frequency stats. +final crashFileStatsProvider = FutureProvider>((ref) { + final repo = ref.watch(crashReportRepositoryProvider); + return repo.getCrashFileStats(); +}); + +/// Provider that auto-persists crash summaries and analysis results to DB. +/// Must be watched from a top-level widget to stay active. +final crashReportPersistenceProvider = Provider((ref) { + final repo = ref.watch(crashReportRepositoryProvider); + final watchService = ref.watch(watchServiceProvider); + final coredumpService = ref.watch(coredumpServiceProvider); + + // Persist crash summaries when received from watch + int? lastSavedReportId; + String? lastCrashKey; + ref.listen(crashSummaryStreamProvider, (previous, next) async { + final summary = next.valueOrNull; + if (summary == null) return; + + // Reset analysis state when a new (different) crash arrives + final crashKey = '${summary.file}:${summary.line}:${summary.time}'; + if (lastCrashKey != null && crashKey != lastCrashKey) { + coredumpService.reset(); + } + lastCrashKey = crashKey; + + final watchId = watchService.device?.remoteId.str; + if (watchId == null) return; + + lastSavedReportId = await repo.saveCrashSummary( + watchId: watchId, + summary: summary, + ); + }); + + // Persist analysis results when analysis completes + ref.listen(coredumpAnalysisStateProvider, (previous, next) async { + final state = next.valueOrNull; + if (state == null) return; + if (state.phase != CoredumpAnalysisPhase.completed) return; + if (state.result == null) return; + if (lastSavedReportId == null) return; + + await repo.saveAnalysisResult( + reportId: lastSavedReportId!, + analysis: state.result!, + ); + // Refresh stats after saving + ref.invalidate(crashFileStatsProvider); + }); +}); diff --git a/zswatch_app/lib/providers/developer_providers.dart b/zswatch_app/lib/providers/developer_providers.dart index 6474054..ba9639c 100644 --- a/zswatch_app/lib/providers/developer_providers.dart +++ b/zswatch_app/lib/providers/developer_providers.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:convert'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/comm_log_entry.dart'; @@ -12,6 +12,8 @@ import '../services/ble/sensor_gatt_service.dart'; import '../services/watch_service.dart'; import 'watch_service_provider.dart'; +part 'developer_providers.freezed.dart'; + // ============================================================================ // Log Viewer Providers (T105a) // ============================================================================ @@ -23,15 +25,17 @@ final logFilterProvider = StateProvider((ref) => LogFilter.all); final logStreamingEnabledProvider = StateProvider((ref) => false); /// Provider for log streaming state (with pending/error tracking) -final logStreamingStateProvider = StateNotifierProvider((ref) { - final watchService = ref.watch(watchServiceProvider); - return LogStreamingStateNotifier(watchService); -}); +final logStreamingStateProvider = + StateNotifierProvider((ref) { + final watchService = ref.watch(watchServiceProvider); + return LogStreamingStateNotifier(watchService); + }); class LogStreamingStateNotifier extends StateNotifier { final WatchService _watchService; - LogStreamingStateNotifier(this._watchService) : super(const LogStreamingState.initial()); + LogStreamingStateNotifier(this._watchService) + : super(const LogStreamingState()); Future enable() async { state = state.copyWith(pending: true, error: null); @@ -43,10 +47,7 @@ class LogStreamingStateNotifier extends StateNotifier { pending: false, ); } catch (e) { - state = state.copyWith( - pending: false, - error: e.toString(), - ); + state = state.copyWith(pending: false, error: e.toString()); } } @@ -60,10 +61,7 @@ class LogStreamingStateNotifier extends StateNotifier { pending: false, ); } catch (e) { - state = state.copyWith( - pending: false, - error: e.toString(), - ); + state = state.copyWith(pending: false, error: e.toString()); } } @@ -77,22 +75,23 @@ class LogStreamingStateNotifier extends StateNotifier { } /// Provider for all log entries (maintains a buffer) -final logEntriesProvider = StateNotifierProvider>((ref) { - final watchService = ref.watch(watchServiceProvider); - return LogEntriesNotifier(watchService); -}); +final logEntriesProvider = + StateNotifierProvider>((ref) { + final watchService = ref.watch(watchServiceProvider); + return LogEntriesNotifier(watchService); + }); class LogEntriesNotifier extends StateNotifier> { static const int maxEntries = 1000; - + /// BLE log message delimiters (from watch firmware) static const String _bleLogPrefix = ''; static const String _bleLogSuffix = ''; - + final WatchService _watchService; int _nextId = 1; StreamSubscription? _incomingSubscription; - + /// Buffer for incomplete log messages (logs can span multiple BLE packets) final StringBuffer _logBuffer = StringBuffer(); bool _isBufferingLog = false; @@ -113,7 +112,7 @@ class LogEntriesNotifier extends StateNotifier> { /// Process incoming data, extracting only BLELOG-wrapped log messages void _processIncomingData(String data) { String remaining = data; - + while (remaining.isNotEmpty) { if (_isBufferingLog) { // We're in the middle of receiving a log message @@ -135,15 +134,19 @@ class LogEntriesNotifier extends StateNotifier> { final prefixIndex = remaining.indexOf(_bleLogPrefix); if (prefixIndex >= 0) { // Found a log prefix - ignore any data before it (protocol traffic) - + // Start buffering the log message - final afterPrefix = remaining.substring(prefixIndex + _bleLogPrefix.length); + final afterPrefix = remaining.substring( + prefixIndex + _bleLogPrefix.length, + ); final suffixIndex = afterPrefix.indexOf(_bleLogSuffix); - + if (suffixIndex >= 0) { // Complete log message in this packet _emitLogEntry(afterPrefix.substring(0, suffixIndex)); - remaining = afterPrefix.substring(suffixIndex + _bleLogSuffix.length); + remaining = afterPrefix.substring( + suffixIndex + _bleLogSuffix.length, + ); } else { // Log continues in next packet(s) _isBufferingLog = true; @@ -157,11 +160,11 @@ class LogEntriesNotifier extends StateNotifier> { } } } - + /// Emit a log entry (from BLELOG wrapper) void _emitLogEntry(String logContent) { if (logContent.isEmpty) return; - + final entry = LogEntry.incoming( id: _nextId++, message: logContent, @@ -230,10 +233,11 @@ final filteredLogEntriesProvider = Provider>((ref) { /// Provider for the communication log repository /// Wired to watch service streams to automatically capture TX/RX traffic -final commLogRepositoryProvider = StateNotifierProvider>((ref) { - final watchService = ref.watch(watchServiceProvider); - return CommLogNotifier(watchService); -}); +final commLogRepositoryProvider = + StateNotifierProvider>((ref) { + final watchService = ref.watch(watchServiceProvider); + return CommLogNotifier(watchService); + }); /// State notifier that maintains the comm log repository and subscribes to watch streams class CommLogNotifier extends StateNotifier> { @@ -337,7 +341,7 @@ final commLogEntriesProvider = Provider>((ref) { final commLogStatsProvider = Provider((ref) { // Watch the state to trigger rebuild when entries change final entries = ref.watch(commLogRepositoryProvider); - + if (entries.isEmpty) { return const CommLogStats(); } @@ -394,61 +398,48 @@ final sensorStreamingStateProvider = StateProvider((ref) { }); /// State for which sensors are currently streaming -class SensorStreamingState { - final bool accelerometer; - final bool gyroscope; - final bool ppg; - final bool temperature; - - const SensorStreamingState({ - this.accelerometer = false, - this.gyroscope = false, - this.ppg = false, - this.temperature = false, - }); +@freezed +abstract class SensorStreamingState with _$SensorStreamingState { + const SensorStreamingState._(); - SensorStreamingState copyWith({ - bool? accelerometer, - bool? gyroscope, - bool? ppg, - bool? temperature, - }) { - return SensorStreamingState( - accelerometer: accelerometer ?? this.accelerometer, - gyroscope: gyroscope ?? this.gyroscope, - ppg: ppg ?? this.ppg, - temperature: temperature ?? this.temperature, - ); - } + const factory SensorStreamingState({ + @Default(false) bool accelerometer, + @Default(false) bool gyroscope, + @Default(false) bool ppg, + @Default(false) bool temperature, + }) = _SensorStreamingState; bool get any => accelerometer || gyroscope || ppg || temperature; } /// Provider for accelerometer readings buffer -final accelerometerBufferProvider = StateNotifierProvider>((ref) { - return SensorBufferNotifier(SensorType.accelerometer); -}); +final accelerometerBufferProvider = + StateNotifierProvider>((ref) { + return SensorBufferNotifier(SensorType.accelerometer); + }); /// Provider for gyroscope readings buffer -final gyroscopeBufferProvider = StateNotifierProvider>((ref) { - return SensorBufferNotifier(SensorType.gyroscope); -}); +final gyroscopeBufferProvider = + StateNotifierProvider>((ref) { + return SensorBufferNotifier(SensorType.gyroscope); + }); /// Provider for temperature readings buffer -final temperatureBufferProvider = StateNotifierProvider>((ref) { - return SensorBufferNotifier(SensorType.temperature); -}); +final temperatureBufferProvider = + StateNotifierProvider>((ref) { + return SensorBufferNotifier(SensorType.temperature); + }); class SensorBufferNotifier extends StateNotifier> { static const int maxReadings = 200; - + final SensorType type; SensorBufferNotifier(this.type) : super([]); void add(SensorReading reading) { if (reading.type != type) return; - + final newList = [...state, reading]; if (newList.length > maxReadings) { state = newList.sublist(newList.length - maxReadings); @@ -494,7 +485,7 @@ class BleDiagnostics { String get mtuDisplay => mtu != null ? '$mtu bytes' : 'N/A'; String get rssiDisplay => rssi != null ? '$rssi dBm' : 'N/A'; - + /// Connection duration since connected Duration? get connectionDuration { if (connectedAt == null || !isConnected) return null; @@ -505,11 +496,11 @@ class BleDiagnostics { String get connectionDurationDisplay { final duration = connectionDuration; if (duration == null) return 'N/A'; - + final hours = duration.inHours; final minutes = duration.inMinutes % 60; final seconds = duration.inSeconds % 60; - + if (hours > 0) { return '${hours}h ${minutes}m'; } else if (minutes > 0) { @@ -518,7 +509,7 @@ class BleDiagnostics { return '${seconds}s'; } } - + String get signalStrength { if (rssi == null) return 'Unknown'; if (rssi! > -50) return 'Excellent'; diff --git a/zswatch_app/lib/providers/developer_providers.freezed.dart b/zswatch_app/lib/providers/developer_providers.freezed.dart new file mode 100644 index 0000000..a531b2a --- /dev/null +++ b/zswatch_app/lib/providers/developer_providers.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'developer_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$SensorStreamingState { + + bool get accelerometer; bool get gyroscope; bool get ppg; bool get temperature; +/// Create a copy of SensorStreamingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SensorStreamingStateCopyWith get copyWith => _$SensorStreamingStateCopyWithImpl(this as SensorStreamingState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SensorStreamingState&&(identical(other.accelerometer, accelerometer) || other.accelerometer == accelerometer)&&(identical(other.gyroscope, gyroscope) || other.gyroscope == gyroscope)&&(identical(other.ppg, ppg) || other.ppg == ppg)&&(identical(other.temperature, temperature) || other.temperature == temperature)); +} + + +@override +int get hashCode => Object.hash(runtimeType,accelerometer,gyroscope,ppg,temperature); + +@override +String toString() { + return 'SensorStreamingState(accelerometer: $accelerometer, gyroscope: $gyroscope, ppg: $ppg, temperature: $temperature)'; +} + + +} + +/// @nodoc +abstract mixin class $SensorStreamingStateCopyWith<$Res> { + factory $SensorStreamingStateCopyWith(SensorStreamingState value, $Res Function(SensorStreamingState) _then) = _$SensorStreamingStateCopyWithImpl; +@useResult +$Res call({ + bool accelerometer, bool gyroscope, bool ppg, bool temperature +}); + + + + +} +/// @nodoc +class _$SensorStreamingStateCopyWithImpl<$Res> + implements $SensorStreamingStateCopyWith<$Res> { + _$SensorStreamingStateCopyWithImpl(this._self, this._then); + + final SensorStreamingState _self; + final $Res Function(SensorStreamingState) _then; + +/// Create a copy of SensorStreamingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? accelerometer = null,Object? gyroscope = null,Object? ppg = null,Object? temperature = null,}) { + return _then(_self.copyWith( +accelerometer: null == accelerometer ? _self.accelerometer : accelerometer // ignore: cast_nullable_to_non_nullable +as bool,gyroscope: null == gyroscope ? _self.gyroscope : gyroscope // ignore: cast_nullable_to_non_nullable +as bool,ppg: null == ppg ? _self.ppg : ppg // ignore: cast_nullable_to_non_nullable +as bool,temperature: null == temperature ? _self.temperature : temperature // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SensorStreamingState]. +extension SensorStreamingStatePatterns on SensorStreamingState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SensorStreamingState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SensorStreamingState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SensorStreamingState value) $default,){ +final _that = this; +switch (_that) { +case _SensorStreamingState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SensorStreamingState value)? $default,){ +final _that = this; +switch (_that) { +case _SensorStreamingState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool accelerometer, bool gyroscope, bool ppg, bool temperature)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SensorStreamingState() when $default != null: +return $default(_that.accelerometer,_that.gyroscope,_that.ppg,_that.temperature);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool accelerometer, bool gyroscope, bool ppg, bool temperature) $default,) {final _that = this; +switch (_that) { +case _SensorStreamingState(): +return $default(_that.accelerometer,_that.gyroscope,_that.ppg,_that.temperature);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool accelerometer, bool gyroscope, bool ppg, bool temperature)? $default,) {final _that = this; +switch (_that) { +case _SensorStreamingState() when $default != null: +return $default(_that.accelerometer,_that.gyroscope,_that.ppg,_that.temperature);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SensorStreamingState extends SensorStreamingState { + const _SensorStreamingState({this.accelerometer = false, this.gyroscope = false, this.ppg = false, this.temperature = false}): super._(); + + +@override@JsonKey() final bool accelerometer; +@override@JsonKey() final bool gyroscope; +@override@JsonKey() final bool ppg; +@override@JsonKey() final bool temperature; + +/// Create a copy of SensorStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SensorStreamingStateCopyWith<_SensorStreamingState> get copyWith => __$SensorStreamingStateCopyWithImpl<_SensorStreamingState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SensorStreamingState&&(identical(other.accelerometer, accelerometer) || other.accelerometer == accelerometer)&&(identical(other.gyroscope, gyroscope) || other.gyroscope == gyroscope)&&(identical(other.ppg, ppg) || other.ppg == ppg)&&(identical(other.temperature, temperature) || other.temperature == temperature)); +} + + +@override +int get hashCode => Object.hash(runtimeType,accelerometer,gyroscope,ppg,temperature); + +@override +String toString() { + return 'SensorStreamingState(accelerometer: $accelerometer, gyroscope: $gyroscope, ppg: $ppg, temperature: $temperature)'; +} + + +} + +/// @nodoc +abstract mixin class _$SensorStreamingStateCopyWith<$Res> implements $SensorStreamingStateCopyWith<$Res> { + factory _$SensorStreamingStateCopyWith(_SensorStreamingState value, $Res Function(_SensorStreamingState) _then) = __$SensorStreamingStateCopyWithImpl; +@override @useResult +$Res call({ + bool accelerometer, bool gyroscope, bool ppg, bool temperature +}); + + + + +} +/// @nodoc +class __$SensorStreamingStateCopyWithImpl<$Res> + implements _$SensorStreamingStateCopyWith<$Res> { + __$SensorStreamingStateCopyWithImpl(this._self, this._then); + + final _SensorStreamingState _self; + final $Res Function(_SensorStreamingState) _then; + +/// Create a copy of SensorStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? accelerometer = null,Object? gyroscope = null,Object? ppg = null,Object? temperature = null,}) { + return _then(_SensorStreamingState( +accelerometer: null == accelerometer ? _self.accelerometer : accelerometer // ignore: cast_nullable_to_non_nullable +as bool,gyroscope: null == gyroscope ? _self.gyroscope : gyroscope // ignore: cast_nullable_to_non_nullable +as bool,ppg: null == ppg ? _self.ppg : ppg // ignore: cast_nullable_to_non_nullable +as bool,temperature: null == temperature ? _self.temperature : temperature // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/dfu_providers.dart b/zswatch_app/lib/providers/dfu_providers.dart index c7ab449..79e6fed 100644 --- a/zswatch_app/lib/providers/dfu_providers.dart +++ b/zswatch_app/lib/providers/dfu_providers.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; @@ -14,6 +15,8 @@ import '../services/dfu/firmware_manager.dart'; import 'filesystem_providers.dart'; import 'watch_service_provider.dart'; +part 'dfu_providers.freezed.dart'; + /// Provider for the DFU service singleton final dfuServiceProvider = Provider((ref) { final service = DfuService(); @@ -64,7 +67,8 @@ final downloadProgressStreamProvider = StreamProvider((ref) { /// Provider for current download progress final downloadProgressProvider = Provider((ref) { final asyncValue = ref.watch(downloadProgressStreamProvider); - return asyncValue.valueOrNull ?? const DownloadProgress(0, 0, DownloadStatus.idle); + return asyncValue.valueOrNull ?? + const DownloadProgress(0, 0, DownloadStatus.idle); }); /// Provider for whether download is in progress @@ -86,14 +90,16 @@ class ReleasesNotifier extends StateNotifier>> { /// Provider for available GitHub releases (manual fetch) final releasesProvider = - StateNotifierProvider>>( - (ref) { - final manager = ref.watch(firmwareManagerProvider); - return ReleasesNotifier(manager); -}); + StateNotifierProvider>>(( + ref, + ) { + final manager = ref.watch(firmwareManagerProvider); + return ReleasesNotifier(manager); + }); /// State notifier for workflow runs (manual fetch to avoid rate limiting) -class WorkflowRunsNotifier extends StateNotifier>> { +class WorkflowRunsNotifier + extends StateNotifier>> { final FirmwareManager _manager; WorkflowRunsNotifier(this._manager) : super(const AsyncValue.data([])); @@ -106,11 +112,12 @@ class WorkflowRunsNotifier extends StateNotifier>> /// Provider for GitHub Actions workflow runs (manual fetch) final workflowRunsProvider = - StateNotifierProvider>>( - (ref) { - final manager = ref.watch(firmwareManagerProvider); - return WorkflowRunsNotifier(manager); -}); + StateNotifierProvider>>(( + ref, + ) { + final manager = ref.watch(firmwareManagerProvider); + return WorkflowRunsNotifier(manager); + }); /// State notifier for DFU operations class DfuNotifier extends StateNotifier { @@ -119,10 +126,13 @@ class DfuNotifier extends StateNotifier { final Ref _ref; DfuNotifier(this._dfuService, this._firmwareManager, this._ref) - : super(const DfuOperationState()); + : super(const DfuOperationState()); /// Download firmware from a GitHub release asset - Future downloadReleaseAsset(GitHubRelease release, ReleaseAsset asset) async { + Future downloadReleaseAsset( + GitHubRelease release, + ReleaseAsset asset, + ) async { state = state.copyWith( selectedRelease: release, isDownloading: true, @@ -130,26 +140,33 @@ class DfuNotifier extends StateNotifier { ); try { - final result = await _firmwareManager.downloadReleaseAsset(release, asset); + final result = await _firmwareManager.downloadReleaseAsset( + release, + asset, + ); state = state.copyWith( downloadedImage: result.firmwareImage, filesystemImage: result.filesystemImage, isDownloading: false, ); } catch (e) { - state = state.copyWith( - isDownloading: false, - error: e.toString(), - ); + state = state.copyWith(isDownloading: false, error: e.toString()); } } /// Download firmware from a GitHub Actions artifact - Future downloadArtifact(WorkflowRun run, WorkflowArtifact artifact) async { + Future downloadArtifact( + WorkflowRun run, + WorkflowArtifact artifact, + ) async { debugPrint('[DfuNotifier] downloadArtifact called'); - debugPrint('[DfuNotifier] Run: ${run.id}, Branch: ${run.branch}, SHA: ${run.shortSha}'); - debugPrint('[DfuNotifier] Artifact: ${artifact.name}, ID: ${artifact.id}, Size: ${artifact.sizeInBytes}'); - + debugPrint( + '[DfuNotifier] Run: ${run.id}, Branch: ${run.branch}, SHA: ${run.shortSha}', + ); + debugPrint( + '[DfuNotifier] Artifact: ${artifact.name}, ID: ${artifact.id}, Size: ${artifact.sizeInBytes}', + ); + state = state.copyWith( selectedWorkflowRun: run, selectedArtifact: artifact, @@ -166,10 +183,7 @@ class DfuNotifier extends StateNotifier { ); } catch (e) { debugPrint('[DfuNotifier] downloadArtifact error: $e'); - state = state.copyWith( - isDownloading: false, - error: e.toString(), - ); + state = state.copyWith(isDownloading: false, error: e.toString()); } } @@ -180,10 +194,7 @@ class DfuNotifier extends StateNotifier { /// Load a local firmware file Future loadLocalFile(String filePath) async { - state = state.copyWith( - isDownloading: true, - error: null, - ); + state = state.copyWith(isDownloading: true, error: null); try { final result = await _firmwareManager.loadLocalFile(filePath); @@ -193,11 +204,32 @@ class DfuNotifier extends StateNotifier { isDownloading: false, ); } catch (e) { - state = state.copyWith( - isDownloading: false, - error: e.toString(), + state = state.copyWith(isDownloading: false, error: e.toString()); + } + } + + /// Ensure SMP is enabled on the watch before DFU/FS operations. + /// + /// Sends the GadgetBridge `smp_enable` command, waits for the watch to + /// register the SMP BT transport, then re-discovers services so the + /// MCUmgr GATT service appears. + Future _ensureSmpEnabled() async { + final watchService = _ref.read(watchServiceProvider); + if (watchService.hasSmpService) return; + + debugPrint('[DfuNotifier] SMP not available, sending smp_enable…'); + await watchService.enableSmp(); + await Future.delayed(const Duration(seconds: 2)); + final ready = await watchService.rediscoverServices(); + if (!ready) { + throw Exception( + 'SMP service did not become available. ' + 'If your watch firmware is older, you may need to manually enable it: ' + 'on the watch go to Apps → Update → Enable FW Update.', ); } + _ref.invalidate(hasSmpServiceProvider); + debugPrint('[DfuNotifier] SMP enabled and discovered'); } /// Start the DFU process (firmware only) @@ -208,10 +240,7 @@ class DfuNotifier extends StateNotifier { return; } - state = state.copyWith( - isUpdating: true, - error: null, - ); + state = state.copyWith(isUpdating: true, error: null); try { // Prepare firmware (extract if needed) @@ -224,6 +253,9 @@ class DfuNotifier extends StateNotifier { throw Exception('Watch not connected'); } + // Auto-enable SMP on the watch if needed + await _ensureSmpEnabled(); + // The device ID is needed to create a BluetoothDevice final connection = watchService.currentConnection; final deviceId = connection.watchId; @@ -233,7 +265,7 @@ class DfuNotifier extends StateNotifier { // Create a BluetoothDevice from the ID final bluetoothDevice = BluetoothDevice.fromId(deviceId); - + // Start the DFU await _dfuService.startUpdate( device: bluetoothDevice, @@ -241,12 +273,8 @@ class DfuNotifier extends StateNotifier { ); state = state.copyWith(isUpdating: false); - } catch (e) { - state = state.copyWith( - isUpdating: false, - error: e.toString(), - ); + state = state.copyWith(isUpdating: false, error: e.toString()); } } @@ -258,10 +286,7 @@ class DfuNotifier extends StateNotifier { return; } - state = state.copyWith( - isFilesystemUploading: true, - error: null, - ); + state = state.copyWith(isFilesystemUploading: true, error: null); try { // Get the connected device @@ -270,6 +295,9 @@ class DfuNotifier extends StateNotifier { throw Exception('Watch not connected'); } + // Auto-enable SMP on the watch if needed + await _ensureSmpEnabled(); + final connection = watchService.currentConnection; final deviceId = connection.watchId; if (deviceId.isEmpty) { @@ -278,15 +306,10 @@ class DfuNotifier extends StateNotifier { // Start the filesystem upload final fsService = _ref.read(filesystemUploadServiceProvider); - await fsService.startUpload( - deviceId: deviceId, - image: fsImage, - ); + await fsService.startUpload(deviceId: deviceId, image: fsImage); // Wait for filesystem upload to complete before restarting - await fsService.stateStream.firstWhere( - (s) => s.status.isTerminal, - ); + await fsService.stateStream.firstWhere((s) => s.status.isTerminal); if (fsService.currentState.status == FilesystemUploadStatus.completed) { // Restart the watch so the new filesystem resources take effect @@ -296,18 +319,16 @@ class DfuNotifier extends StateNotifier { await OsManager.reset(deviceId); debugPrint('[DfuNotifier] Watch restart command sent'); } catch (e) { - debugPrint('[DfuNotifier] Failed to restart watch after FS upload: $e'); + debugPrint( + '[DfuNotifier] Failed to restart watch after FS upload: $e', + ); // Don't fail the overall operation — the upload itself succeeded } } state = state.copyWith(isFilesystemUploading: false); - } catch (e) { - state = state.copyWith( - isFilesystemUploading: false, - error: e.toString(), - ); + state = state.copyWith(isFilesystemUploading: false, error: e.toString()); } } @@ -335,6 +356,9 @@ class DfuNotifier extends StateNotifier { throw Exception('Watch not connected'); } + // Auto-enable SMP on the watch if needed + await _ensureSmpEnabled(); + final connection = watchService.currentConnection; final deviceId = connection.watchId; if (deviceId.isEmpty) { @@ -344,23 +368,18 @@ class DfuNotifier extends StateNotifier { // Step 1: Upload filesystem if available if (fsImage != null) { state = state.copyWith(currentStep: 1); - + final fsService = _ref.read(filesystemUploadServiceProvider); - await fsService.startUpload( - deviceId: deviceId, - image: fsImage, - ); + await fsService.startUpload(deviceId: deviceId, image: fsImage); // Wait for filesystem upload to complete - await fsService.stateStream.firstWhere( - (s) => s.status.isTerminal, - ); + await fsService.stateStream.firstWhere((s) => s.status.isTerminal); final fsState = fsService.currentState; if (fsState.status != FilesystemUploadStatus.completed) { throw Exception('Filesystem upload failed: ${fsState.errorMessage}'); } - + // Give BLE transport time to fully release before DFU await Future.delayed(const Duration(milliseconds: 500)); } @@ -368,14 +387,14 @@ class DfuNotifier extends StateNotifier { // Step 2: Start firmware update if available if (fwImage != null) { state = state.copyWith(currentStep: 2); - + // Prepare firmware (extract if needed) final images = await _firmwareManager.prepareFirmware(fwImage); state = state.copyWith(preparedImages: images); // Create a BluetoothDevice from the ID final bluetoothDevice = BluetoothDevice.fromId(deviceId); - + // Start the DFU await _dfuService.startUpdate( device: bluetoothDevice, @@ -384,12 +403,8 @@ class DfuNotifier extends StateNotifier { } state = state.copyWith(isBothUpdating: false); - } catch (e) { - state = state.copyWith( - isBothUpdating: false, - error: e.toString(), - ); + state = state.copyWith(isBothUpdating: false, error: e.toString()); } } @@ -398,12 +413,12 @@ class DfuNotifier extends StateNotifier { if (_dfuService.isInProgress) { await _dfuService.cancel(); } - + final fsService = _ref.read(filesystemUploadServiceProvider); if (fsService.isInProgress) { await fsService.cancel(); } - + _firmwareManager.cancelDownload(); state = state.copyWith( isDownloading: false, @@ -435,60 +450,50 @@ class DfuNotifier extends StateNotifier { /// Provider for DFU notifier final dfuNotifierProvider = StateNotifierProvider((ref) { - final dfuService = ref.watch(dfuServiceProvider); - final firmwareManager = ref.watch(firmwareManagerProvider); - return DfuNotifier(dfuService, firmwareManager, ref); -}); + final dfuService = ref.watch(dfuServiceProvider); + final firmwareManager = ref.watch(firmwareManagerProvider); + return DfuNotifier(dfuService, firmwareManager, ref); + }); /// State for DFU operations -class DfuOperationState { - final GitHubRelease? selectedRelease; - final WorkflowRun? selectedWorkflowRun; - final WorkflowArtifact? selectedArtifact; - final FirmwareImage? downloadedImage; - final FilesystemImage? filesystemImage; - final List preparedImages; - final bool isDownloading; - final bool isUpdating; - final bool isFilesystemUploading; - final bool isBothUpdating; - final int currentStep; - final int totalSteps; - final String? error; - - const DfuOperationState({ - this.selectedRelease, - this.selectedWorkflowRun, - this.selectedArtifact, - this.downloadedImage, - this.filesystemImage, - this.preparedImages = const [], - this.isDownloading = false, - this.isUpdating = false, - this.isFilesystemUploading = false, - this.isBothUpdating = false, - this.currentStep = 0, - this.totalSteps = 0, - this.error, - }); +@freezed +abstract class DfuOperationState with _$DfuOperationState { + const DfuOperationState._(); + + const factory DfuOperationState({ + GitHubRelease? selectedRelease, + WorkflowRun? selectedWorkflowRun, + WorkflowArtifact? selectedArtifact, + FirmwareImage? downloadedImage, + FilesystemImage? filesystemImage, + @Default([]) List preparedImages, + @Default(false) bool isDownloading, + @Default(false) bool isUpdating, + @Default(false) bool isFilesystemUploading, + @Default(false) bool isBothUpdating, + @Default(0) int currentStep, + @Default(0) int totalSteps, + String? error, + }) = _DfuOperationState; bool get hasError => error != null; bool get hasFirmware => downloadedImage != null; bool get hasFilesystem => filesystemImage != null; bool get hasBoth => hasFirmware && hasFilesystem; - + /// Whether any operation is in progress - bool get isAnyInProgress => isDownloading || isUpdating || isFilesystemUploading || isBothUpdating; - + bool get isAnyInProgress => + isDownloading || isUpdating || isFilesystemUploading || isBothUpdating; + /// Whether user can start firmware update bool get canStartFirmwareUpdate => hasFirmware && !isAnyInProgress; - + /// Whether user can start filesystem upload bool get canStartFilesystemUpload => hasFilesystem && !isAnyInProgress; - + /// Whether user can start both updates bool get canStartBoth => hasBoth && !isAnyInProgress; - + /// Legacy: alias for canStartFirmwareUpdate bool get canStartUpdate => canStartFirmwareUpdate; @@ -522,38 +527,6 @@ class DfuOperationState { if (currentStep == 2) return 'Step 2/$totalSteps: Updating firmware...'; return 'Step $currentStep/$totalSteps'; } - - DfuOperationState copyWith({ - GitHubRelease? selectedRelease, - WorkflowRun? selectedWorkflowRun, - WorkflowArtifact? selectedArtifact, - FirmwareImage? downloadedImage, - FilesystemImage? filesystemImage, - List? preparedImages, - bool? isDownloading, - bool? isUpdating, - bool? isFilesystemUploading, - bool? isBothUpdating, - int? currentStep, - int? totalSteps, - String? error, - }) { - return DfuOperationState( - selectedRelease: selectedRelease ?? this.selectedRelease, - selectedWorkflowRun: selectedWorkflowRun ?? this.selectedWorkflowRun, - selectedArtifact: selectedArtifact ?? this.selectedArtifact, - downloadedImage: downloadedImage ?? this.downloadedImage, - filesystemImage: filesystemImage ?? this.filesystemImage, - preparedImages: preparedImages ?? this.preparedImages, - isDownloading: isDownloading ?? this.isDownloading, - isUpdating: isUpdating ?? this.isUpdating, - isFilesystemUploading: isFilesystemUploading ?? this.isFilesystemUploading, - isBothUpdating: isBothUpdating ?? this.isBothUpdating, - currentStep: currentStep ?? this.currentStep, - totalSteps: totalSteps ?? this.totalSteps, - error: error, - ); - } } /// Provider for DFU logs (combined from all services) @@ -568,4 +541,3 @@ final dfuLogsProvider = StreamProvider((ref) { fsService.logStream, ]); }); - diff --git a/zswatch_app/lib/providers/dfu_providers.freezed.dart b/zswatch_app/lib/providers/dfu_providers.freezed.dart new file mode 100644 index 0000000..c519c57 --- /dev/null +++ b/zswatch_app/lib/providers/dfu_providers.freezed.dart @@ -0,0 +1,397 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'dfu_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DfuOperationState implements DiagnosticableTreeMixin { + + GitHubRelease? get selectedRelease; WorkflowRun? get selectedWorkflowRun; WorkflowArtifact? get selectedArtifact; FirmwareImage? get downloadedImage; FilesystemImage? get filesystemImage; List get preparedImages; bool get isDownloading; bool get isUpdating; bool get isFilesystemUploading; bool get isBothUpdating; int get currentStep; int get totalSteps; String? get error; +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DfuOperationStateCopyWith get copyWith => _$DfuOperationStateCopyWithImpl(this as DfuOperationState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'DfuOperationState')) + ..add(DiagnosticsProperty('selectedRelease', selectedRelease))..add(DiagnosticsProperty('selectedWorkflowRun', selectedWorkflowRun))..add(DiagnosticsProperty('selectedArtifact', selectedArtifact))..add(DiagnosticsProperty('downloadedImage', downloadedImage))..add(DiagnosticsProperty('filesystemImage', filesystemImage))..add(DiagnosticsProperty('preparedImages', preparedImages))..add(DiagnosticsProperty('isDownloading', isDownloading))..add(DiagnosticsProperty('isUpdating', isUpdating))..add(DiagnosticsProperty('isFilesystemUploading', isFilesystemUploading))..add(DiagnosticsProperty('isBothUpdating', isBothUpdating))..add(DiagnosticsProperty('currentStep', currentStep))..add(DiagnosticsProperty('totalSteps', totalSteps))..add(DiagnosticsProperty('error', error)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DfuOperationState&&(identical(other.selectedRelease, selectedRelease) || other.selectedRelease == selectedRelease)&&(identical(other.selectedWorkflowRun, selectedWorkflowRun) || other.selectedWorkflowRun == selectedWorkflowRun)&&(identical(other.selectedArtifact, selectedArtifact) || other.selectedArtifact == selectedArtifact)&&(identical(other.downloadedImage, downloadedImage) || other.downloadedImage == downloadedImage)&&(identical(other.filesystemImage, filesystemImage) || other.filesystemImage == filesystemImage)&&const DeepCollectionEquality().equals(other.preparedImages, preparedImages)&&(identical(other.isDownloading, isDownloading) || other.isDownloading == isDownloading)&&(identical(other.isUpdating, isUpdating) || other.isUpdating == isUpdating)&&(identical(other.isFilesystemUploading, isFilesystemUploading) || other.isFilesystemUploading == isFilesystemUploading)&&(identical(other.isBothUpdating, isBothUpdating) || other.isBothUpdating == isBothUpdating)&&(identical(other.currentStep, currentStep) || other.currentStep == currentStep)&&(identical(other.totalSteps, totalSteps) || other.totalSteps == totalSteps)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,selectedRelease,selectedWorkflowRun,selectedArtifact,downloadedImage,filesystemImage,const DeepCollectionEquality().hash(preparedImages),isDownloading,isUpdating,isFilesystemUploading,isBothUpdating,currentStep,totalSteps,error); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'DfuOperationState(selectedRelease: $selectedRelease, selectedWorkflowRun: $selectedWorkflowRun, selectedArtifact: $selectedArtifact, downloadedImage: $downloadedImage, filesystemImage: $filesystemImage, preparedImages: $preparedImages, isDownloading: $isDownloading, isUpdating: $isUpdating, isFilesystemUploading: $isFilesystemUploading, isBothUpdating: $isBothUpdating, currentStep: $currentStep, totalSteps: $totalSteps, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $DfuOperationStateCopyWith<$Res> { + factory $DfuOperationStateCopyWith(DfuOperationState value, $Res Function(DfuOperationState) _then) = _$DfuOperationStateCopyWithImpl; +@useResult +$Res call({ + GitHubRelease? selectedRelease, WorkflowRun? selectedWorkflowRun, WorkflowArtifact? selectedArtifact, FirmwareImage? downloadedImage, FilesystemImage? filesystemImage, List preparedImages, bool isDownloading, bool isUpdating, bool isFilesystemUploading, bool isBothUpdating, int currentStep, int totalSteps, String? error +}); + + +$GitHubReleaseCopyWith<$Res>? get selectedRelease;$FirmwareImageCopyWith<$Res>? get downloadedImage;$FilesystemImageCopyWith<$Res>? get filesystemImage; + +} +/// @nodoc +class _$DfuOperationStateCopyWithImpl<$Res> + implements $DfuOperationStateCopyWith<$Res> { + _$DfuOperationStateCopyWithImpl(this._self, this._then); + + final DfuOperationState _self; + final $Res Function(DfuOperationState) _then; + +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? selectedRelease = freezed,Object? selectedWorkflowRun = freezed,Object? selectedArtifact = freezed,Object? downloadedImage = freezed,Object? filesystemImage = freezed,Object? preparedImages = null,Object? isDownloading = null,Object? isUpdating = null,Object? isFilesystemUploading = null,Object? isBothUpdating = null,Object? currentStep = null,Object? totalSteps = null,Object? error = freezed,}) { + return _then(_self.copyWith( +selectedRelease: freezed == selectedRelease ? _self.selectedRelease : selectedRelease // ignore: cast_nullable_to_non_nullable +as GitHubRelease?,selectedWorkflowRun: freezed == selectedWorkflowRun ? _self.selectedWorkflowRun : selectedWorkflowRun // ignore: cast_nullable_to_non_nullable +as WorkflowRun?,selectedArtifact: freezed == selectedArtifact ? _self.selectedArtifact : selectedArtifact // ignore: cast_nullable_to_non_nullable +as WorkflowArtifact?,downloadedImage: freezed == downloadedImage ? _self.downloadedImage : downloadedImage // ignore: cast_nullable_to_non_nullable +as FirmwareImage?,filesystemImage: freezed == filesystemImage ? _self.filesystemImage : filesystemImage // ignore: cast_nullable_to_non_nullable +as FilesystemImage?,preparedImages: null == preparedImages ? _self.preparedImages : preparedImages // ignore: cast_nullable_to_non_nullable +as List,isDownloading: null == isDownloading ? _self.isDownloading : isDownloading // ignore: cast_nullable_to_non_nullable +as bool,isUpdating: null == isUpdating ? _self.isUpdating : isUpdating // ignore: cast_nullable_to_non_nullable +as bool,isFilesystemUploading: null == isFilesystemUploading ? _self.isFilesystemUploading : isFilesystemUploading // ignore: cast_nullable_to_non_nullable +as bool,isBothUpdating: null == isBothUpdating ? _self.isBothUpdating : isBothUpdating // ignore: cast_nullable_to_non_nullable +as bool,currentStep: null == currentStep ? _self.currentStep : currentStep // ignore: cast_nullable_to_non_nullable +as int,totalSteps: null == totalSteps ? _self.totalSteps : totalSteps // ignore: cast_nullable_to_non_nullable +as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GitHubReleaseCopyWith<$Res>? get selectedRelease { + if (_self.selectedRelease == null) { + return null; + } + + return $GitHubReleaseCopyWith<$Res>(_self.selectedRelease!, (value) { + return _then(_self.copyWith(selectedRelease: value)); + }); +}/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FirmwareImageCopyWith<$Res>? get downloadedImage { + if (_self.downloadedImage == null) { + return null; + } + + return $FirmwareImageCopyWith<$Res>(_self.downloadedImage!, (value) { + return _then(_self.copyWith(downloadedImage: value)); + }); +}/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FilesystemImageCopyWith<$Res>? get filesystemImage { + if (_self.filesystemImage == null) { + return null; + } + + return $FilesystemImageCopyWith<$Res>(_self.filesystemImage!, (value) { + return _then(_self.copyWith(filesystemImage: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [DfuOperationState]. +extension DfuOperationStatePatterns on DfuOperationState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DfuOperationState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DfuOperationState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DfuOperationState value) $default,){ +final _that = this; +switch (_that) { +case _DfuOperationState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DfuOperationState value)? $default,){ +final _that = this; +switch (_that) { +case _DfuOperationState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GitHubRelease? selectedRelease, WorkflowRun? selectedWorkflowRun, WorkflowArtifact? selectedArtifact, FirmwareImage? downloadedImage, FilesystemImage? filesystemImage, List preparedImages, bool isDownloading, bool isUpdating, bool isFilesystemUploading, bool isBothUpdating, int currentStep, int totalSteps, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DfuOperationState() when $default != null: +return $default(_that.selectedRelease,_that.selectedWorkflowRun,_that.selectedArtifact,_that.downloadedImage,_that.filesystemImage,_that.preparedImages,_that.isDownloading,_that.isUpdating,_that.isFilesystemUploading,_that.isBothUpdating,_that.currentStep,_that.totalSteps,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GitHubRelease? selectedRelease, WorkflowRun? selectedWorkflowRun, WorkflowArtifact? selectedArtifact, FirmwareImage? downloadedImage, FilesystemImage? filesystemImage, List preparedImages, bool isDownloading, bool isUpdating, bool isFilesystemUploading, bool isBothUpdating, int currentStep, int totalSteps, String? error) $default,) {final _that = this; +switch (_that) { +case _DfuOperationState(): +return $default(_that.selectedRelease,_that.selectedWorkflowRun,_that.selectedArtifact,_that.downloadedImage,_that.filesystemImage,_that.preparedImages,_that.isDownloading,_that.isUpdating,_that.isFilesystemUploading,_that.isBothUpdating,_that.currentStep,_that.totalSteps,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GitHubRelease? selectedRelease, WorkflowRun? selectedWorkflowRun, WorkflowArtifact? selectedArtifact, FirmwareImage? downloadedImage, FilesystemImage? filesystemImage, List preparedImages, bool isDownloading, bool isUpdating, bool isFilesystemUploading, bool isBothUpdating, int currentStep, int totalSteps, String? error)? $default,) {final _that = this; +switch (_that) { +case _DfuOperationState() when $default != null: +return $default(_that.selectedRelease,_that.selectedWorkflowRun,_that.selectedArtifact,_that.downloadedImage,_that.filesystemImage,_that.preparedImages,_that.isDownloading,_that.isUpdating,_that.isFilesystemUploading,_that.isBothUpdating,_that.currentStep,_that.totalSteps,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _DfuOperationState extends DfuOperationState with DiagnosticableTreeMixin { + const _DfuOperationState({this.selectedRelease, this.selectedWorkflowRun, this.selectedArtifact, this.downloadedImage, this.filesystemImage, final List preparedImages = const [], this.isDownloading = false, this.isUpdating = false, this.isFilesystemUploading = false, this.isBothUpdating = false, this.currentStep = 0, this.totalSteps = 0, this.error}): _preparedImages = preparedImages,super._(); + + +@override final GitHubRelease? selectedRelease; +@override final WorkflowRun? selectedWorkflowRun; +@override final WorkflowArtifact? selectedArtifact; +@override final FirmwareImage? downloadedImage; +@override final FilesystemImage? filesystemImage; + final List _preparedImages; +@override@JsonKey() List get preparedImages { + if (_preparedImages is EqualUnmodifiableListView) return _preparedImages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_preparedImages); +} + +@override@JsonKey() final bool isDownloading; +@override@JsonKey() final bool isUpdating; +@override@JsonKey() final bool isFilesystemUploading; +@override@JsonKey() final bool isBothUpdating; +@override@JsonKey() final int currentStep; +@override@JsonKey() final int totalSteps; +@override final String? error; + +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DfuOperationStateCopyWith<_DfuOperationState> get copyWith => __$DfuOperationStateCopyWithImpl<_DfuOperationState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'DfuOperationState')) + ..add(DiagnosticsProperty('selectedRelease', selectedRelease))..add(DiagnosticsProperty('selectedWorkflowRun', selectedWorkflowRun))..add(DiagnosticsProperty('selectedArtifact', selectedArtifact))..add(DiagnosticsProperty('downloadedImage', downloadedImage))..add(DiagnosticsProperty('filesystemImage', filesystemImage))..add(DiagnosticsProperty('preparedImages', preparedImages))..add(DiagnosticsProperty('isDownloading', isDownloading))..add(DiagnosticsProperty('isUpdating', isUpdating))..add(DiagnosticsProperty('isFilesystemUploading', isFilesystemUploading))..add(DiagnosticsProperty('isBothUpdating', isBothUpdating))..add(DiagnosticsProperty('currentStep', currentStep))..add(DiagnosticsProperty('totalSteps', totalSteps))..add(DiagnosticsProperty('error', error)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DfuOperationState&&(identical(other.selectedRelease, selectedRelease) || other.selectedRelease == selectedRelease)&&(identical(other.selectedWorkflowRun, selectedWorkflowRun) || other.selectedWorkflowRun == selectedWorkflowRun)&&(identical(other.selectedArtifact, selectedArtifact) || other.selectedArtifact == selectedArtifact)&&(identical(other.downloadedImage, downloadedImage) || other.downloadedImage == downloadedImage)&&(identical(other.filesystemImage, filesystemImage) || other.filesystemImage == filesystemImage)&&const DeepCollectionEquality().equals(other._preparedImages, _preparedImages)&&(identical(other.isDownloading, isDownloading) || other.isDownloading == isDownloading)&&(identical(other.isUpdating, isUpdating) || other.isUpdating == isUpdating)&&(identical(other.isFilesystemUploading, isFilesystemUploading) || other.isFilesystemUploading == isFilesystemUploading)&&(identical(other.isBothUpdating, isBothUpdating) || other.isBothUpdating == isBothUpdating)&&(identical(other.currentStep, currentStep) || other.currentStep == currentStep)&&(identical(other.totalSteps, totalSteps) || other.totalSteps == totalSteps)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,selectedRelease,selectedWorkflowRun,selectedArtifact,downloadedImage,filesystemImage,const DeepCollectionEquality().hash(_preparedImages),isDownloading,isUpdating,isFilesystemUploading,isBothUpdating,currentStep,totalSteps,error); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'DfuOperationState(selectedRelease: $selectedRelease, selectedWorkflowRun: $selectedWorkflowRun, selectedArtifact: $selectedArtifact, downloadedImage: $downloadedImage, filesystemImage: $filesystemImage, preparedImages: $preparedImages, isDownloading: $isDownloading, isUpdating: $isUpdating, isFilesystemUploading: $isFilesystemUploading, isBothUpdating: $isBothUpdating, currentStep: $currentStep, totalSteps: $totalSteps, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$DfuOperationStateCopyWith<$Res> implements $DfuOperationStateCopyWith<$Res> { + factory _$DfuOperationStateCopyWith(_DfuOperationState value, $Res Function(_DfuOperationState) _then) = __$DfuOperationStateCopyWithImpl; +@override @useResult +$Res call({ + GitHubRelease? selectedRelease, WorkflowRun? selectedWorkflowRun, WorkflowArtifact? selectedArtifact, FirmwareImage? downloadedImage, FilesystemImage? filesystemImage, List preparedImages, bool isDownloading, bool isUpdating, bool isFilesystemUploading, bool isBothUpdating, int currentStep, int totalSteps, String? error +}); + + +@override $GitHubReleaseCopyWith<$Res>? get selectedRelease;@override $FirmwareImageCopyWith<$Res>? get downloadedImage;@override $FilesystemImageCopyWith<$Res>? get filesystemImage; + +} +/// @nodoc +class __$DfuOperationStateCopyWithImpl<$Res> + implements _$DfuOperationStateCopyWith<$Res> { + __$DfuOperationStateCopyWithImpl(this._self, this._then); + + final _DfuOperationState _self; + final $Res Function(_DfuOperationState) _then; + +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? selectedRelease = freezed,Object? selectedWorkflowRun = freezed,Object? selectedArtifact = freezed,Object? downloadedImage = freezed,Object? filesystemImage = freezed,Object? preparedImages = null,Object? isDownloading = null,Object? isUpdating = null,Object? isFilesystemUploading = null,Object? isBothUpdating = null,Object? currentStep = null,Object? totalSteps = null,Object? error = freezed,}) { + return _then(_DfuOperationState( +selectedRelease: freezed == selectedRelease ? _self.selectedRelease : selectedRelease // ignore: cast_nullable_to_non_nullable +as GitHubRelease?,selectedWorkflowRun: freezed == selectedWorkflowRun ? _self.selectedWorkflowRun : selectedWorkflowRun // ignore: cast_nullable_to_non_nullable +as WorkflowRun?,selectedArtifact: freezed == selectedArtifact ? _self.selectedArtifact : selectedArtifact // ignore: cast_nullable_to_non_nullable +as WorkflowArtifact?,downloadedImage: freezed == downloadedImage ? _self.downloadedImage : downloadedImage // ignore: cast_nullable_to_non_nullable +as FirmwareImage?,filesystemImage: freezed == filesystemImage ? _self.filesystemImage : filesystemImage // ignore: cast_nullable_to_non_nullable +as FilesystemImage?,preparedImages: null == preparedImages ? _self._preparedImages : preparedImages // ignore: cast_nullable_to_non_nullable +as List,isDownloading: null == isDownloading ? _self.isDownloading : isDownloading // ignore: cast_nullable_to_non_nullable +as bool,isUpdating: null == isUpdating ? _self.isUpdating : isUpdating // ignore: cast_nullable_to_non_nullable +as bool,isFilesystemUploading: null == isFilesystemUploading ? _self.isFilesystemUploading : isFilesystemUploading // ignore: cast_nullable_to_non_nullable +as bool,isBothUpdating: null == isBothUpdating ? _self.isBothUpdating : isBothUpdating // ignore: cast_nullable_to_non_nullable +as bool,currentStep: null == currentStep ? _self.currentStep : currentStep // ignore: cast_nullable_to_non_nullable +as int,totalSteps: null == totalSteps ? _self.totalSteps : totalSteps // ignore: cast_nullable_to_non_nullable +as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GitHubReleaseCopyWith<$Res>? get selectedRelease { + if (_self.selectedRelease == null) { + return null; + } + + return $GitHubReleaseCopyWith<$Res>(_self.selectedRelease!, (value) { + return _then(_self.copyWith(selectedRelease: value)); + }); +}/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FirmwareImageCopyWith<$Res>? get downloadedImage { + if (_self.downloadedImage == null) { + return null; + } + + return $FirmwareImageCopyWith<$Res>(_self.downloadedImage!, (value) { + return _then(_self.copyWith(downloadedImage: value)); + }); +}/// Create a copy of DfuOperationState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FilesystemImageCopyWith<$Res>? get filesystemImage { + if (_self.filesystemImage == null) { + return null; + } + + return $FilesystemImageCopyWith<$Res>(_self.filesystemImage!, (value) { + return _then(_self.copyWith(filesystemImage: value)); + }); +} +} + +// dart format on diff --git a/zswatch_app/lib/providers/filesystem_providers.dart b/zswatch_app/lib/providers/filesystem_providers.dart index 97e60af..3fecb3f 100644 --- a/zswatch_app/lib/providers/filesystem_providers.dart +++ b/zswatch_app/lib/providers/filesystem_providers.dart @@ -4,22 +4,25 @@ import '../data/models/filesystem_image.dart'; import '../services/dfu/filesystem_upload_service.dart'; /// Provider for the filesystem upload service singleton -final filesystemUploadServiceProvider = Provider((ref) { +final filesystemUploadServiceProvider = Provider(( + ref, +) { final service = FilesystemUploadService(); ref.onDispose(() => service.dispose()); return service; }); /// Stream provider for filesystem upload state -final filesystemUploadStateStreamProvider = StreamProvider((ref) { - final service = ref.watch(filesystemUploadServiceProvider); - return service.stateStream; -}); +final filesystemUploadStateStreamProvider = + StreamProvider((ref) { + final service = ref.watch(filesystemUploadServiceProvider); + return service.stateStream; + }); /// Provider for current filesystem upload state final filesystemUploadStateProvider = Provider((ref) { final asyncValue = ref.watch(filesystemUploadStateStreamProvider); - return asyncValue.valueOrNull ?? FilesystemUploadState.idle; + return asyncValue.valueOrNull ?? const FilesystemUploadState(); }); /// Provider for filesystem upload status @@ -37,4 +40,3 @@ final filesystemUploadLogsProvider = StreamProvider((ref) { final service = ref.watch(filesystemUploadServiceProvider); return service.logStream; }); - diff --git a/zswatch_app/lib/providers/foreground_service_providers.dart b/zswatch_app/lib/providers/foreground_service_providers.dart index 9087acf..e24e351 100644 --- a/zswatch_app/lib/providers/foreground_service_providers.dart +++ b/zswatch_app/lib/providers/foreground_service_providers.dart @@ -49,10 +49,14 @@ class ForegroundServiceNotifier extends StateNotifier { void _initialize() { // Listen to connection state changes final watchService = _ref.read(watchServiceProvider); - _connectionSubscription = watchService.connectionStream.listen(_handleConnectionChange); + _connectionSubscription = watchService.connectionStream.listen( + _handleConnectionChange, + ); // Listen to disconnect requests from notification - _disconnectRequestedSubscription = _service.onDisconnectRequested.listen((_) { + _disconnectRequestedSubscription = _service.onDisconnectRequested.listen(( + _, + ) { _handleNotificationDisconnect(); }); @@ -67,9 +71,7 @@ class ForegroundServiceNotifier extends StateNotifier { void _handleConnectionChange(Connection connection) { final backgroundEnabled = _ref.read(backgroundConnectionEnabledProvider); - - debugPrint('[ForegroundServiceNotifier] Connection changed: ${connection.state}, backgroundEnabled=$backgroundEnabled'); - + // Only manage foreground service on Android if (!Platform.isAndroid) return; @@ -137,15 +139,17 @@ class ForegroundServiceNotifier extends StateNotifier { } void _handleNotificationDisconnect() { - debugPrint('[ForegroundServiceNotifier] Disconnect requested from notification'); - + debugPrint( + '[ForegroundServiceNotifier] Disconnect requested from notification', + ); + // Mark as user-initiated disconnect _wasUserDisconnect = true; - + // Disconnect from watch final watchService = _ref.read(watchServiceProvider); watchService.disconnect(); - + // Stop foreground service _stopService(); } @@ -153,8 +157,10 @@ class ForegroundServiceNotifier extends StateNotifier { Future _startService(ForegroundConnectionState connectionState) async { if (state) return; // Already running - debugPrint('[ForegroundServiceNotifier] Starting foreground service for $_currentWatchName'); - + debugPrint( + '[ForegroundServiceNotifier] Starting foreground service for $_currentWatchName', + ); + await _service.start( watchName: _currentWatchName ?? 'ZSWatch', state: connectionState, @@ -166,12 +172,14 @@ class ForegroundServiceNotifier extends StateNotifier { if (!state) return; // Not running debugPrint('[ForegroundServiceNotifier] Stopping foreground service'); - + await _service.stop(); state = false; } - Future _updateNotification(ForegroundConnectionState connectionState) async { + Future _updateNotification( + ForegroundConnectionState connectionState, + ) async { if (!state) return; // Not running await _service.updateNotification( @@ -183,14 +191,14 @@ class ForegroundServiceNotifier extends StateNotifier { /// Manually start the foreground service Future start() async { if (!Platform.isAndroid) return; - + final connection = _ref.read(watchConnectionProvider); _currentWatchName = connection.watchName ?? 'ZSWatch'; - + final connectionState = connection.isConnected ? ForegroundConnectionState.connected : ForegroundConnectionState.reconnecting; - + await _startService(connectionState); } @@ -216,15 +224,16 @@ class ForegroundServiceNotifier extends StateNotifier { /// Provider for the foreground service notifier final foregroundServiceNotifierProvider = StateNotifierProvider((ref) { - final service = ref.watch(foregroundServiceProvider); - return ForegroundServiceNotifier(ref, service); -}); + final service = ref.watch(foregroundServiceProvider); + return ForegroundServiceNotifier(ref, service); + }); /// Provider to check and request battery optimization exemption class BatteryOptimizationNotifier extends StateNotifier> { final ForegroundService _service; - BatteryOptimizationNotifier(this._service) : super(const AsyncValue.loading()) { + BatteryOptimizationNotifier(this._service) + : super(const AsyncValue.loading()) { _checkStatus(); } @@ -264,6 +273,6 @@ class BatteryOptimizationNotifier extends StateNotifier> { /// Provider for battery optimization notifier final batteryOptimizationNotifierProvider = StateNotifierProvider>((ref) { - final service = ref.watch(foregroundServiceProvider); - return BatteryOptimizationNotifier(service); -}); + final service = ref.watch(foregroundServiceProvider); + return BatteryOptimizationNotifier(service); + }); diff --git a/zswatch_app/lib/providers/gps_providers.dart b/zswatch_app/lib/providers/gps_providers.dart index 012e9f7..c2a4371 100644 --- a/zswatch_app/lib/providers/gps_providers.dart +++ b/zswatch_app/lib/providers/gps_providers.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; @@ -9,43 +10,23 @@ import '../services/protocol/protocol_service.dart'; import '../services/watch_service.dart'; import 'watch_service_provider.dart'; +part 'gps_providers.freezed.dart'; + /// GPS service provider final gpsServiceProvider = Provider((ref) { return GpsService(); }); /// State for GPS tracking -class GpsState { - final bool isActive; - final bool isRequesting; - final Position? lastPosition; - final GpsError? lastError; - final DateTime? lastUpdateTime; - - const GpsState({ - this.isActive = false, - this.isRequesting = false, - this.lastPosition, - this.lastError, - this.lastUpdateTime, - }); - - GpsState copyWith({ - bool? isActive, - bool? isRequesting, +@freezed +abstract class GpsState with _$GpsState { + const factory GpsState({ + @Default(false) bool isActive, + @Default(false) bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime, - bool clearError = false, - }) { - return GpsState( - isActive: isActive ?? this.isActive, - isRequesting: isRequesting ?? this.isRequesting, - lastPosition: lastPosition ?? this.lastPosition, - lastError: clearError ? null : (lastError ?? this.lastError), - lastUpdateTime: lastUpdateTime ?? this.lastUpdateTime, - ); - } + }) = _GpsState; } /// GPS notifier that handles GPS power requests from watch @@ -65,8 +46,9 @@ class GpsNotifier extends StateNotifier { void _init() { debugPrint('[GpsNotifier] Initializing - listening to watch messages'); - _messageSubscription = - _watchService.incomingMessages.listen(_handleWatchMessage); + _messageSubscription = _watchService.incomingMessages.listen( + _handleWatchMessage, + ); } void _handleWatchMessage(Map message) { @@ -90,12 +72,11 @@ class GpsNotifier extends StateNotifier { /// Start sending GPS updates to the watch Future _startGpsUpdates() async { if (state.isActive) { - debugPrint('[GpsNotifier] GPS already active'); return; } debugPrint('[GpsNotifier] Starting GPS updates'); - state = state.copyWith(isActive: true, clearError: true); + state = state.copyWith(isActive: true, lastError: null); // Send initial location immediately await _sendCurrentLocation(); @@ -117,8 +98,11 @@ class GpsNotifier extends StateNotifier { /// Get current location and send to watch Future _sendCurrentLocation() async { + if (!_watchService.isConnected) { + _stopGpsUpdates(); + return; + } if (state.isRequesting) { - debugPrint('[GpsNotifier] Already requesting location, skipping'); return; } @@ -133,17 +117,14 @@ class GpsNotifier extends StateNotifier { isRequesting: false, lastPosition: position, lastUpdateTime: DateTime.now(), - clearError: true, + lastError: null, ); // Send to watch await _sendGpsToWatch(position); } else { debugPrint('[GpsNotifier] GPS error: ${result.error}'); - state = state.copyWith( - isRequesting: false, - lastError: result.error, - ); + state = state.copyWith(isRequesting: false, lastError: result.error); } } catch (e) { debugPrint('[GpsNotifier] Exception getting location: $e'); @@ -168,7 +149,8 @@ class GpsNotifier extends StateNotifier { ); debugPrint( - '[GpsNotifier] Sending GPS: ${position.latitude}, ${position.longitude}'); + '[GpsNotifier] Sending GPS: ${position.latitude}, ${position.longitude}', + ); await _watchService.sendGpsData(gpsData); } @@ -178,14 +160,14 @@ class GpsNotifier extends StateNotifier { } /// Open app settings so user can enable location permission - /// + /// /// Call this when [GpsError.permissionDeniedForever] is encountered. Future openAppSettings() async { return _gpsService.openAppSettings(); } /// Open system location settings - /// + /// /// Call this when [GpsError.serviceDisabled] is encountered. Future openLocationSettings() async { return _gpsService.openLocationSettings(); @@ -201,8 +183,7 @@ class GpsNotifier extends StateNotifier { } /// Provider for GPS state and control -final gpsNotifierProvider = - StateNotifierProvider((ref) { +final gpsNotifierProvider = StateNotifierProvider((ref) { final gpsService = ref.watch(gpsServiceProvider); final watchService = ref.watch(watchServiceProvider); return GpsNotifier(gpsService, watchService); diff --git a/zswatch_app/lib/providers/gps_providers.freezed.dart b/zswatch_app/lib/providers/gps_providers.freezed.dart new file mode 100644 index 0000000..f9e4e8a --- /dev/null +++ b/zswatch_app/lib/providers/gps_providers.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'gps_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$GpsState implements DiagnosticableTreeMixin { + + bool get isActive; bool get isRequesting; Position? get lastPosition; GpsError? get lastError; DateTime? get lastUpdateTime; +/// Create a copy of GpsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GpsStateCopyWith get copyWith => _$GpsStateCopyWithImpl(this as GpsState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'GpsState')) + ..add(DiagnosticsProperty('isActive', isActive))..add(DiagnosticsProperty('isRequesting', isRequesting))..add(DiagnosticsProperty('lastPosition', lastPosition))..add(DiagnosticsProperty('lastError', lastError))..add(DiagnosticsProperty('lastUpdateTime', lastUpdateTime)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GpsState&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.isRequesting, isRequesting) || other.isRequesting == isRequesting)&&(identical(other.lastPosition, lastPosition) || other.lastPosition == lastPosition)&&(identical(other.lastError, lastError) || other.lastError == lastError)&&(identical(other.lastUpdateTime, lastUpdateTime) || other.lastUpdateTime == lastUpdateTime)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isActive,isRequesting,lastPosition,lastError,lastUpdateTime); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'GpsState(isActive: $isActive, isRequesting: $isRequesting, lastPosition: $lastPosition, lastError: $lastError, lastUpdateTime: $lastUpdateTime)'; +} + + +} + +/// @nodoc +abstract mixin class $GpsStateCopyWith<$Res> { + factory $GpsStateCopyWith(GpsState value, $Res Function(GpsState) _then) = _$GpsStateCopyWithImpl; +@useResult +$Res call({ + bool isActive, bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime +}); + + + + +} +/// @nodoc +class _$GpsStateCopyWithImpl<$Res> + implements $GpsStateCopyWith<$Res> { + _$GpsStateCopyWithImpl(this._self, this._then); + + final GpsState _self; + final $Res Function(GpsState) _then; + +/// Create a copy of GpsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isActive = null,Object? isRequesting = null,Object? lastPosition = freezed,Object? lastError = freezed,Object? lastUpdateTime = freezed,}) { + return _then(_self.copyWith( +isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,isRequesting: null == isRequesting ? _self.isRequesting : isRequesting // ignore: cast_nullable_to_non_nullable +as bool,lastPosition: freezed == lastPosition ? _self.lastPosition : lastPosition // ignore: cast_nullable_to_non_nullable +as Position?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as GpsError?,lastUpdateTime: freezed == lastUpdateTime ? _self.lastUpdateTime : lastUpdateTime // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GpsState]. +extension GpsStatePatterns on GpsState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GpsState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GpsState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GpsState value) $default,){ +final _that = this; +switch (_that) { +case _GpsState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GpsState value)? $default,){ +final _that = this; +switch (_that) { +case _GpsState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isActive, bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GpsState() when $default != null: +return $default(_that.isActive,_that.isRequesting,_that.lastPosition,_that.lastError,_that.lastUpdateTime);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isActive, bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime) $default,) {final _that = this; +switch (_that) { +case _GpsState(): +return $default(_that.isActive,_that.isRequesting,_that.lastPosition,_that.lastError,_that.lastUpdateTime);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isActive, bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime)? $default,) {final _that = this; +switch (_that) { +case _GpsState() when $default != null: +return $default(_that.isActive,_that.isRequesting,_that.lastPosition,_that.lastError,_that.lastUpdateTime);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GpsState with DiagnosticableTreeMixin implements GpsState { + const _GpsState({this.isActive = false, this.isRequesting = false, this.lastPosition, this.lastError, this.lastUpdateTime}); + + +@override@JsonKey() final bool isActive; +@override@JsonKey() final bool isRequesting; +@override final Position? lastPosition; +@override final GpsError? lastError; +@override final DateTime? lastUpdateTime; + +/// Create a copy of GpsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GpsStateCopyWith<_GpsState> get copyWith => __$GpsStateCopyWithImpl<_GpsState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'GpsState')) + ..add(DiagnosticsProperty('isActive', isActive))..add(DiagnosticsProperty('isRequesting', isRequesting))..add(DiagnosticsProperty('lastPosition', lastPosition))..add(DiagnosticsProperty('lastError', lastError))..add(DiagnosticsProperty('lastUpdateTime', lastUpdateTime)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GpsState&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.isRequesting, isRequesting) || other.isRequesting == isRequesting)&&(identical(other.lastPosition, lastPosition) || other.lastPosition == lastPosition)&&(identical(other.lastError, lastError) || other.lastError == lastError)&&(identical(other.lastUpdateTime, lastUpdateTime) || other.lastUpdateTime == lastUpdateTime)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isActive,isRequesting,lastPosition,lastError,lastUpdateTime); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'GpsState(isActive: $isActive, isRequesting: $isRequesting, lastPosition: $lastPosition, lastError: $lastError, lastUpdateTime: $lastUpdateTime)'; +} + + +} + +/// @nodoc +abstract mixin class _$GpsStateCopyWith<$Res> implements $GpsStateCopyWith<$Res> { + factory _$GpsStateCopyWith(_GpsState value, $Res Function(_GpsState) _then) = __$GpsStateCopyWithImpl; +@override @useResult +$Res call({ + bool isActive, bool isRequesting, Position? lastPosition, GpsError? lastError, DateTime? lastUpdateTime +}); + + + + +} +/// @nodoc +class __$GpsStateCopyWithImpl<$Res> + implements _$GpsStateCopyWith<$Res> { + __$GpsStateCopyWithImpl(this._self, this._then); + + final _GpsState _self; + final $Res Function(_GpsState) _then; + +/// Create a copy of GpsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isActive = null,Object? isRequesting = null,Object? lastPosition = freezed,Object? lastError = freezed,Object? lastUpdateTime = freezed,}) { + return _then(_GpsState( +isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,isRequesting: null == isRequesting ? _self.isRequesting : isRequesting // ignore: cast_nullable_to_non_nullable +as bool,lastPosition: freezed == lastPosition ? _self.lastPosition : lastPosition // ignore: cast_nullable_to_non_nullable +as Position?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as GpsError?,lastUpdateTime: freezed == lastUpdateTime ? _self.lastUpdateTime : lastUpdateTime // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/health_providers.dart b/zswatch_app/lib/providers/health_providers.dart index 6782660..3ef3540 100644 --- a/zswatch_app/lib/providers/health_providers.dart +++ b/zswatch_app/lib/providers/health_providers.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/health_sample.dart'; @@ -8,7 +9,10 @@ import '../services/health/health_sync_service.dart'; import 'watch_providers.dart'; import 'watch_service_provider.dart'; -export '../services/health/health_sync_service.dart' show ActivityState, ActivityBreakdown; +export '../services/health/health_sync_service.dart' + show ActivityState, ActivityBreakdown; + +part 'health_providers.freezed.dart'; // ==================== Repository Provider ==================== @@ -24,12 +28,12 @@ final healthRepositoryProvider = Provider((ref) { final healthSyncServiceProvider = Provider((ref) { final watchService = ref.watch(watchServiceProvider); final healthRepository = ref.watch(healthRepositoryProvider); - + final service = HealthSyncService( watchService: watchService, healthRepository: healthRepository, ); - + ref.onDispose(() => service.dispose()); return service; }); @@ -37,32 +41,16 @@ final healthSyncServiceProvider = Provider((ref) { // ==================== Heart Rate Streaming State ==================== /// State class for heart rate streaming -class HeartRateStreamingState { - final bool isStreaming; - final int? currentBpm; - final List recentReadings; - final DateTime? lastUpdate; - - const HeartRateStreamingState({ - this.isStreaming = false, - this.currentBpm, - this.recentReadings = const [], - this.lastUpdate, - }); +@freezed +abstract class HeartRateStreamingState with _$HeartRateStreamingState { + const HeartRateStreamingState._(); - HeartRateStreamingState copyWith({ - bool? isStreaming, + const factory HeartRateStreamingState({ + @Default(false) bool isStreaming, int? currentBpm, - List? recentReadings, + @Default([]) List recentReadings, DateTime? lastUpdate, - }) { - return HeartRateStreamingState( - isStreaming: isStreaming ?? this.isStreaming, - currentBpm: currentBpm ?? this.currentBpm, - recentReadings: recentReadings ?? this.recentReadings, - lastUpdate: lastUpdate ?? this.lastUpdate, - ); - } + }) = _HeartRateStreamingState; /// Average BPM from recent readings int? get averageBpm { @@ -89,32 +77,32 @@ class HeartRateReading { final int bpm; final DateTime timestamp; - const HeartRateReading({ - required this.bpm, - required this.timestamp, - }); + const HeartRateReading({required this.bpm, required this.timestamp}); } /// Notifier for heart rate streaming state -class HeartRateStreamingNotifier extends StateNotifier { +class HeartRateStreamingNotifier + extends StateNotifier { final HealthSyncService _healthSyncService; final Ref _ref; - + StreamSubscription? _hrSubscription; StreamSubscription? _streamingSubscription; bool _disposed = false; - + /// Maximum number of readings to keep for chart static const int maxReadings = 120; // 2 minutes at 1 reading/second - HeartRateStreamingNotifier(this._healthSyncService, this._ref) - : super(const HeartRateStreamingState()) { + HeartRateStreamingNotifier(this._healthSyncService, this._ref) + : super(const HeartRateStreamingState()) { _initialize(); } void _initialize() { // Listen to streaming state - _streamingSubscription = _healthSyncService.isStreamingStream.listen((isStreaming) { + _streamingSubscription = _healthSyncService.isStreamingStream.listen(( + isStreaming, + ) { if (_disposed) return; state = state.copyWith(isStreaming: isStreaming); }); @@ -130,16 +118,16 @@ class HeartRateStreamingNotifier extends StateNotifier void _addReading(int bpm) { if (_disposed) return; - + final now = DateTime.now(); final newReading = HeartRateReading(bpm: bpm, timestamp: now); - + // Keep only recent readings final readings = [...state.recentReadings, newReading]; if (readings.length > maxReadings) { readings.removeRange(0, readings.length - maxReadings); } - + state = state.copyWith( currentBpm: bpm, recentReadings: readings, @@ -151,17 +139,14 @@ class HeartRateStreamingNotifier extends StateNotifier Future startStreaming() async { final connection = _ref.read(watchConnectionProvider); if (!connection.isConnected) return; - + await _healthSyncService.startHeartRateStreaming(connection.watchId); } /// Stop heart rate streaming Future stopStreaming() async { await _healthSyncService.stopHeartRateStreaming(); - state = state.copyWith( - isStreaming: false, - currentBpm: null, - ); + state = state.copyWith(isStreaming: false, currentBpm: null); } /// Clear reading history @@ -180,41 +165,25 @@ class HeartRateStreamingNotifier extends StateNotifier } /// Provider for heart rate streaming notifier -final heartRateStreamingProvider = - StateNotifierProvider((ref) { - final healthSyncService = ref.watch(healthSyncServiceProvider); - return HeartRateStreamingNotifier(healthSyncService, ref); -}); +final heartRateStreamingProvider = + StateNotifierProvider(( + ref, + ) { + final healthSyncService = ref.watch(healthSyncServiceProvider); + return HeartRateStreamingNotifier(healthSyncService, ref); + }); // ==================== Activity Breakdown Provider ==================== /// State class for activity breakdown with range support -class ActivityBreakdownState { - final StepsHistoryRange range; - final ActivityBreakdown breakdown; - final bool isLoading; - final String? error; - - const ActivityBreakdownState({ - this.range = StepsHistoryRange.day, - this.breakdown = const ActivityBreakdown(), - this.isLoading = false, - this.error, - }); - - ActivityBreakdownState copyWith({ - StepsHistoryRange? range, - ActivityBreakdown? breakdown, - bool? isLoading, +@freezed +abstract class ActivityBreakdownState with _$ActivityBreakdownState { + const factory ActivityBreakdownState({ + @Default(StepsHistoryRange.day) StepsHistoryRange range, + @Default(ActivityBreakdown()) ActivityBreakdown breakdown, + @Default(false) bool isLoading, String? error, - }) { - return ActivityBreakdownState( - range: range ?? this.range, - breakdown: breakdown ?? this.breakdown, - isLoading: isLoading ?? this.isLoading, - error: error, - ); - } + }) = _ActivityBreakdownState; } /// Notifier for activity breakdown state with range support @@ -222,20 +191,25 @@ class ActivityBreakdownNotifier extends StateNotifier { final HealthRepository _healthRepository; final HealthSyncService _healthSyncService; final Ref _ref; - + StreamSubscription? _activitySubscription; bool _disposed = false; - ActivityBreakdownNotifier(this._healthRepository, this._healthSyncService, this._ref) - : super(const ActivityBreakdownState()) { + ActivityBreakdownNotifier( + this._healthRepository, + this._healthSyncService, + this._ref, + ) : super(const ActivityBreakdownState()) { _initialize(); } - + void _initialize() { loadData(StepsHistoryRange.day); - + // Listen to real-time activity updates - only update when viewing today - _activitySubscription = _healthSyncService.activityBreakdownStream.listen((breakdown) { + _activitySubscription = _healthSyncService.activityBreakdownStream.listen(( + breakdown, + ) { if (_disposed) return; // Only update with real-time data for day view if (state.range == StepsHistoryRange.day) { @@ -311,10 +285,7 @@ class ActivityBreakdownNotifier extends StateNotifier { error: null, ); } catch (e) { - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -322,7 +293,7 @@ class ActivityBreakdownNotifier extends StateNotifier { Future refresh() async { await loadData(state.range); } - + @override void dispose() { _disposed = true; @@ -332,46 +303,31 @@ class ActivityBreakdownNotifier extends StateNotifier { } /// Provider for activity breakdown notifier -final activityBreakdownProvider = - StateNotifierProvider((ref) { - final healthRepository = ref.watch(healthRepositoryProvider); - final healthSyncService = ref.watch(healthSyncServiceProvider); - return ActivityBreakdownNotifier(healthRepository, healthSyncService, ref); -}); +final activityBreakdownProvider = + StateNotifierProvider(( + ref, + ) { + final healthRepository = ref.watch(healthRepositoryProvider); + final healthSyncService = ref.watch(healthSyncServiceProvider); + return ActivityBreakdownNotifier( + healthRepository, + healthSyncService, + ref, + ); + }); // ==================== Health Summary State ==================== /// State class for health summary -class HealthSummaryState { - final int todaySteps; - final int? latestHeartRate; - final List weeklySteps; - final bool isLoading; - final String? error; - - const HealthSummaryState({ - this.todaySteps = 0, - this.latestHeartRate, - this.weeklySteps = const [], - this.isLoading = false, - this.error, - }); - - HealthSummaryState copyWith({ - int? todaySteps, +@freezed +abstract class HealthSummaryState with _$HealthSummaryState { + const factory HealthSummaryState({ + @Default(0) int todaySteps, int? latestHeartRate, - List? weeklySteps, - bool? isLoading, + @Default([]) List weeklySteps, + @Default(false) bool isLoading, String? error, - }) { - return HealthSummaryState( - todaySteps: todaySteps ?? this.todaySteps, - latestHeartRate: latestHeartRate ?? this.latestHeartRate, - weeklySteps: weeklySteps ?? this.weeklySteps, - isLoading: isLoading ?? this.isLoading, - error: error, - ); - } + }) = _HealthSummaryState; } /// Notifier for health summary state @@ -379,25 +335,28 @@ class HealthSummaryNotifier extends StateNotifier { final HealthRepository _healthRepository; final HealthSyncService _healthSyncService; final Ref _ref; - + StreamSubscription? _stepsSubscription; StreamSubscription? _hrSubscription; bool _disposed = false; - HealthSummaryNotifier(this._healthRepository, this._healthSyncService, this._ref) - : super(const HealthSummaryState()) { + HealthSummaryNotifier( + this._healthRepository, + this._healthSyncService, + this._ref, + ) : super(const HealthSummaryState()) { _initialize(); } - + void _initialize() { _loadSummary(); - + // Listen to real-time step updates _stepsSubscription = _healthSyncService.stepsStream.listen((steps) { if (_disposed) return; state = state.copyWith(todaySteps: steps); }); - + // Listen to real-time HR updates _hrSubscription = _healthSyncService.heartRateStream.listen((hr) { if (_disposed) return; @@ -438,10 +397,7 @@ class HealthSummaryNotifier extends StateNotifier { error: null, ); } catch (e) { - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -449,7 +405,7 @@ class HealthSummaryNotifier extends StateNotifier { Future refresh() async { await _loadSummary(); } - + @override void dispose() { _disposed = true; @@ -460,53 +416,28 @@ class HealthSummaryNotifier extends StateNotifier { } /// Provider for health summary notifier -final healthSummaryProvider = +final healthSummaryProvider = StateNotifierProvider((ref) { - final healthRepository = ref.watch(healthRepositoryProvider); - final healthSyncService = ref.watch(healthSyncServiceProvider); - return HealthSummaryNotifier(healthRepository, healthSyncService, ref); -}); + final healthRepository = ref.watch(healthRepositoryProvider); + final healthSyncService = ref.watch(healthSyncServiceProvider); + return HealthSummaryNotifier(healthRepository, healthSyncService, ref); + }); // ==================== Steps History State ==================== /// Time range for steps history -enum StepsHistoryRange { - day, - week, - month, -} +enum StepsHistoryRange { day, week, month } /// State class for steps history -class StepsHistoryState { - final StepsHistoryRange range; - final List data; - final int totalSteps; - final bool isLoading; - final String? error; - - const StepsHistoryState({ - this.range = StepsHistoryRange.day, - this.data = const [], - this.totalSteps = 0, - this.isLoading = false, - this.error, - }); - - StepsHistoryState copyWith({ - StepsHistoryRange? range, - List? data, - int? totalSteps, - bool? isLoading, +@freezed +abstract class StepsHistoryState with _$StepsHistoryState { + const factory StepsHistoryState({ + @Default(StepsHistoryRange.day) StepsHistoryRange range, + @Default([]) List data, + @Default(0) int totalSteps, + @Default(false) bool isLoading, String? error, - }) { - return StepsHistoryState( - range: range ?? this.range, - data: data ?? this.data, - totalSteps: totalSteps ?? this.totalSteps, - isLoading: isLoading ?? this.isLoading, - error: error, - ); - } + }) = _StepsHistoryState; } /// Notifier for steps history state @@ -514,18 +445,21 @@ class StepsHistoryNotifier extends StateNotifier { final HealthRepository _healthRepository; final HealthSyncService _healthSyncService; final Ref _ref; - + StreamSubscription? _stepsSubscription; bool _disposed = false; - StepsHistoryNotifier(this._healthRepository, this._healthSyncService, this._ref) - : super(const StepsHistoryState()) { + StepsHistoryNotifier( + this._healthRepository, + this._healthSyncService, + this._ref, + ) : super(const StepsHistoryState()) { _initialize(); } - + void _initialize() { loadData(StepsHistoryRange.day); - + // Listen to real-time step updates - only update total when viewing today _stepsSubscription = _healthSyncService.stepsStream.listen((steps) { if (_disposed) return; @@ -578,7 +512,9 @@ class StepsHistoryNotifier extends StateNotifier { total = 0; } else if (range == StepsHistoryRange.day) { // For today, use the max value since it's cumulative - total = data.map((a) => a.total.round()).reduce((a, b) => a > b ? a : b); + total = data + .map((a) => a.total.round()) + .reduce((a, b) => a > b ? a : b); } else { // For week/month, sum up each period's max total = data.map((a) => a.total.round()).reduce((a, b) => a + b); @@ -591,10 +527,7 @@ class StepsHistoryNotifier extends StateNotifier { error: null, ); } catch (e) { - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -602,7 +535,7 @@ class StepsHistoryNotifier extends StateNotifier { Future refresh() async { await loadData(state.range); } - + @override void dispose() { _disposed = true; @@ -612,12 +545,12 @@ class StepsHistoryNotifier extends StateNotifier { } /// Provider for steps history notifier -final stepsHistoryProvider = +final stepsHistoryProvider = StateNotifierProvider((ref) { - final healthRepository = ref.watch(healthRepositoryProvider); - final healthSyncService = ref.watch(healthSyncServiceProvider); - return StepsHistoryNotifier(healthRepository, healthSyncService, ref); -}); + final healthRepository = ref.watch(healthRepositoryProvider); + final healthSyncService = ref.watch(healthSyncServiceProvider); + return StepsHistoryNotifier(healthRepository, healthSyncService, ref); + }); // ==================== Heart Rate History State ==================== @@ -655,18 +588,21 @@ class HeartRateHistoryNotifier extends StateNotifier { final HealthRepository _healthRepository; final HealthSyncService _healthSyncService; final Ref _ref; - + StreamSubscription? _hrSubscription; bool _disposed = false; - HeartRateHistoryNotifier(this._healthRepository, this._healthSyncService, this._ref) - : super(const HeartRateHistoryState()) { + HeartRateHistoryNotifier( + this._healthRepository, + this._healthSyncService, + this._ref, + ) : super(const HeartRateHistoryState()) { _initialize(); } - + void _initialize() { loadData(); - + // Listen to real-time HR updates (from activity messages or live streaming) // and refresh data when new HR values arrive _hrSubscription = _healthSyncService.heartRateStream.listen((hr) { @@ -707,10 +643,7 @@ class HeartRateHistoryNotifier extends StateNotifier { error: null, ); } catch (e) { - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -718,7 +651,7 @@ class HeartRateHistoryNotifier extends StateNotifier { Future refresh() async { await loadData(); } - + @override void dispose() { _disposed = true; @@ -728,12 +661,14 @@ class HeartRateHistoryNotifier extends StateNotifier { } /// Provider for heart rate history notifier -final heartRateHistoryProvider = - StateNotifierProvider((ref) { - final healthRepository = ref.watch(healthRepositoryProvider); - final healthSyncService = ref.watch(healthSyncServiceProvider); - return HeartRateHistoryNotifier(healthRepository, healthSyncService, ref); -}); +final heartRateHistoryProvider = + StateNotifierProvider(( + ref, + ) { + final healthRepository = ref.watch(healthRepositoryProvider); + final healthSyncService = ref.watch(healthSyncServiceProvider); + return HeartRateHistoryNotifier(healthRepository, healthSyncService, ref); + }); // ==================== Data Cleanup Provider ==================== diff --git a/zswatch_app/lib/providers/health_providers.freezed.dart b/zswatch_app/lib/providers/health_providers.freezed.dart new file mode 100644 index 0000000..6af44f6 --- /dev/null +++ b/zswatch_app/lib/providers/health_providers.freezed.dart @@ -0,0 +1,1102 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'health_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$HeartRateStreamingState { + + bool get isStreaming; int? get currentBpm; List get recentReadings; DateTime? get lastUpdate; +/// Create a copy of HeartRateStreamingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HeartRateStreamingStateCopyWith get copyWith => _$HeartRateStreamingStateCopyWithImpl(this as HeartRateStreamingState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HeartRateStreamingState&&(identical(other.isStreaming, isStreaming) || other.isStreaming == isStreaming)&&(identical(other.currentBpm, currentBpm) || other.currentBpm == currentBpm)&&const DeepCollectionEquality().equals(other.recentReadings, recentReadings)&&(identical(other.lastUpdate, lastUpdate) || other.lastUpdate == lastUpdate)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isStreaming,currentBpm,const DeepCollectionEquality().hash(recentReadings),lastUpdate); + +@override +String toString() { + return 'HeartRateStreamingState(isStreaming: $isStreaming, currentBpm: $currentBpm, recentReadings: $recentReadings, lastUpdate: $lastUpdate)'; +} + + +} + +/// @nodoc +abstract mixin class $HeartRateStreamingStateCopyWith<$Res> { + factory $HeartRateStreamingStateCopyWith(HeartRateStreamingState value, $Res Function(HeartRateStreamingState) _then) = _$HeartRateStreamingStateCopyWithImpl; +@useResult +$Res call({ + bool isStreaming, int? currentBpm, List recentReadings, DateTime? lastUpdate +}); + + + + +} +/// @nodoc +class _$HeartRateStreamingStateCopyWithImpl<$Res> + implements $HeartRateStreamingStateCopyWith<$Res> { + _$HeartRateStreamingStateCopyWithImpl(this._self, this._then); + + final HeartRateStreamingState _self; + final $Res Function(HeartRateStreamingState) _then; + +/// Create a copy of HeartRateStreamingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isStreaming = null,Object? currentBpm = freezed,Object? recentReadings = null,Object? lastUpdate = freezed,}) { + return _then(_self.copyWith( +isStreaming: null == isStreaming ? _self.isStreaming : isStreaming // ignore: cast_nullable_to_non_nullable +as bool,currentBpm: freezed == currentBpm ? _self.currentBpm : currentBpm // ignore: cast_nullable_to_non_nullable +as int?,recentReadings: null == recentReadings ? _self.recentReadings : recentReadings // ignore: cast_nullable_to_non_nullable +as List,lastUpdate: freezed == lastUpdate ? _self.lastUpdate : lastUpdate // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HeartRateStreamingState]. +extension HeartRateStreamingStatePatterns on HeartRateStreamingState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HeartRateStreamingState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HeartRateStreamingState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HeartRateStreamingState value) $default,){ +final _that = this; +switch (_that) { +case _HeartRateStreamingState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HeartRateStreamingState value)? $default,){ +final _that = this; +switch (_that) { +case _HeartRateStreamingState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isStreaming, int? currentBpm, List recentReadings, DateTime? lastUpdate)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HeartRateStreamingState() when $default != null: +return $default(_that.isStreaming,_that.currentBpm,_that.recentReadings,_that.lastUpdate);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isStreaming, int? currentBpm, List recentReadings, DateTime? lastUpdate) $default,) {final _that = this; +switch (_that) { +case _HeartRateStreamingState(): +return $default(_that.isStreaming,_that.currentBpm,_that.recentReadings,_that.lastUpdate);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isStreaming, int? currentBpm, List recentReadings, DateTime? lastUpdate)? $default,) {final _that = this; +switch (_that) { +case _HeartRateStreamingState() when $default != null: +return $default(_that.isStreaming,_that.currentBpm,_that.recentReadings,_that.lastUpdate);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HeartRateStreamingState extends HeartRateStreamingState { + const _HeartRateStreamingState({this.isStreaming = false, this.currentBpm, final List recentReadings = const [], this.lastUpdate}): _recentReadings = recentReadings,super._(); + + +@override@JsonKey() final bool isStreaming; +@override final int? currentBpm; + final List _recentReadings; +@override@JsonKey() List get recentReadings { + if (_recentReadings is EqualUnmodifiableListView) return _recentReadings; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_recentReadings); +} + +@override final DateTime? lastUpdate; + +/// Create a copy of HeartRateStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HeartRateStreamingStateCopyWith<_HeartRateStreamingState> get copyWith => __$HeartRateStreamingStateCopyWithImpl<_HeartRateStreamingState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HeartRateStreamingState&&(identical(other.isStreaming, isStreaming) || other.isStreaming == isStreaming)&&(identical(other.currentBpm, currentBpm) || other.currentBpm == currentBpm)&&const DeepCollectionEquality().equals(other._recentReadings, _recentReadings)&&(identical(other.lastUpdate, lastUpdate) || other.lastUpdate == lastUpdate)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isStreaming,currentBpm,const DeepCollectionEquality().hash(_recentReadings),lastUpdate); + +@override +String toString() { + return 'HeartRateStreamingState(isStreaming: $isStreaming, currentBpm: $currentBpm, recentReadings: $recentReadings, lastUpdate: $lastUpdate)'; +} + + +} + +/// @nodoc +abstract mixin class _$HeartRateStreamingStateCopyWith<$Res> implements $HeartRateStreamingStateCopyWith<$Res> { + factory _$HeartRateStreamingStateCopyWith(_HeartRateStreamingState value, $Res Function(_HeartRateStreamingState) _then) = __$HeartRateStreamingStateCopyWithImpl; +@override @useResult +$Res call({ + bool isStreaming, int? currentBpm, List recentReadings, DateTime? lastUpdate +}); + + + + +} +/// @nodoc +class __$HeartRateStreamingStateCopyWithImpl<$Res> + implements _$HeartRateStreamingStateCopyWith<$Res> { + __$HeartRateStreamingStateCopyWithImpl(this._self, this._then); + + final _HeartRateStreamingState _self; + final $Res Function(_HeartRateStreamingState) _then; + +/// Create a copy of HeartRateStreamingState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isStreaming = null,Object? currentBpm = freezed,Object? recentReadings = null,Object? lastUpdate = freezed,}) { + return _then(_HeartRateStreamingState( +isStreaming: null == isStreaming ? _self.isStreaming : isStreaming // ignore: cast_nullable_to_non_nullable +as bool,currentBpm: freezed == currentBpm ? _self.currentBpm : currentBpm // ignore: cast_nullable_to_non_nullable +as int?,recentReadings: null == recentReadings ? _self._recentReadings : recentReadings // ignore: cast_nullable_to_non_nullable +as List,lastUpdate: freezed == lastUpdate ? _self.lastUpdate : lastUpdate // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +/// @nodoc +mixin _$ActivityBreakdownState { + + StepsHistoryRange get range; ActivityBreakdown get breakdown; bool get isLoading; String? get error; +/// Create a copy of ActivityBreakdownState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ActivityBreakdownStateCopyWith get copyWith => _$ActivityBreakdownStateCopyWithImpl(this as ActivityBreakdownState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ActivityBreakdownState&&(identical(other.range, range) || other.range == range)&&(identical(other.breakdown, breakdown) || other.breakdown == breakdown)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,range,breakdown,isLoading,error); + +@override +String toString() { + return 'ActivityBreakdownState(range: $range, breakdown: $breakdown, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $ActivityBreakdownStateCopyWith<$Res> { + factory $ActivityBreakdownStateCopyWith(ActivityBreakdownState value, $Res Function(ActivityBreakdownState) _then) = _$ActivityBreakdownStateCopyWithImpl; +@useResult +$Res call({ + StepsHistoryRange range, ActivityBreakdown breakdown, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class _$ActivityBreakdownStateCopyWithImpl<$Res> + implements $ActivityBreakdownStateCopyWith<$Res> { + _$ActivityBreakdownStateCopyWithImpl(this._self, this._then); + + final ActivityBreakdownState _self; + final $Res Function(ActivityBreakdownState) _then; + +/// Create a copy of ActivityBreakdownState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? range = null,Object? breakdown = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_self.copyWith( +range: null == range ? _self.range : range // ignore: cast_nullable_to_non_nullable +as StepsHistoryRange,breakdown: null == breakdown ? _self.breakdown : breakdown // ignore: cast_nullable_to_non_nullable +as ActivityBreakdown,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ActivityBreakdownState]. +extension ActivityBreakdownStatePatterns on ActivityBreakdownState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ActivityBreakdownState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ActivityBreakdownState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ActivityBreakdownState value) $default,){ +final _that = this; +switch (_that) { +case _ActivityBreakdownState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ActivityBreakdownState value)? $default,){ +final _that = this; +switch (_that) { +case _ActivityBreakdownState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( StepsHistoryRange range, ActivityBreakdown breakdown, bool isLoading, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ActivityBreakdownState() when $default != null: +return $default(_that.range,_that.breakdown,_that.isLoading,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( StepsHistoryRange range, ActivityBreakdown breakdown, bool isLoading, String? error) $default,) {final _that = this; +switch (_that) { +case _ActivityBreakdownState(): +return $default(_that.range,_that.breakdown,_that.isLoading,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( StepsHistoryRange range, ActivityBreakdown breakdown, bool isLoading, String? error)? $default,) {final _that = this; +switch (_that) { +case _ActivityBreakdownState() when $default != null: +return $default(_that.range,_that.breakdown,_that.isLoading,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ActivityBreakdownState implements ActivityBreakdownState { + const _ActivityBreakdownState({this.range = StepsHistoryRange.day, this.breakdown = const ActivityBreakdown(), this.isLoading = false, this.error}); + + +@override@JsonKey() final StepsHistoryRange range; +@override@JsonKey() final ActivityBreakdown breakdown; +@override@JsonKey() final bool isLoading; +@override final String? error; + +/// Create a copy of ActivityBreakdownState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ActivityBreakdownStateCopyWith<_ActivityBreakdownState> get copyWith => __$ActivityBreakdownStateCopyWithImpl<_ActivityBreakdownState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ActivityBreakdownState&&(identical(other.range, range) || other.range == range)&&(identical(other.breakdown, breakdown) || other.breakdown == breakdown)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,range,breakdown,isLoading,error); + +@override +String toString() { + return 'ActivityBreakdownState(range: $range, breakdown: $breakdown, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$ActivityBreakdownStateCopyWith<$Res> implements $ActivityBreakdownStateCopyWith<$Res> { + factory _$ActivityBreakdownStateCopyWith(_ActivityBreakdownState value, $Res Function(_ActivityBreakdownState) _then) = __$ActivityBreakdownStateCopyWithImpl; +@override @useResult +$Res call({ + StepsHistoryRange range, ActivityBreakdown breakdown, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class __$ActivityBreakdownStateCopyWithImpl<$Res> + implements _$ActivityBreakdownStateCopyWith<$Res> { + __$ActivityBreakdownStateCopyWithImpl(this._self, this._then); + + final _ActivityBreakdownState _self; + final $Res Function(_ActivityBreakdownState) _then; + +/// Create a copy of ActivityBreakdownState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? range = null,Object? breakdown = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_ActivityBreakdownState( +range: null == range ? _self.range : range // ignore: cast_nullable_to_non_nullable +as StepsHistoryRange,breakdown: null == breakdown ? _self.breakdown : breakdown // ignore: cast_nullable_to_non_nullable +as ActivityBreakdown,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$HealthSummaryState { + + int get todaySteps; int? get latestHeartRate; List get weeklySteps; bool get isLoading; String? get error; +/// Create a copy of HealthSummaryState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HealthSummaryStateCopyWith get copyWith => _$HealthSummaryStateCopyWithImpl(this as HealthSummaryState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HealthSummaryState&&(identical(other.todaySteps, todaySteps) || other.todaySteps == todaySteps)&&(identical(other.latestHeartRate, latestHeartRate) || other.latestHeartRate == latestHeartRate)&&const DeepCollectionEquality().equals(other.weeklySteps, weeklySteps)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,todaySteps,latestHeartRate,const DeepCollectionEquality().hash(weeklySteps),isLoading,error); + +@override +String toString() { + return 'HealthSummaryState(todaySteps: $todaySteps, latestHeartRate: $latestHeartRate, weeklySteps: $weeklySteps, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $HealthSummaryStateCopyWith<$Res> { + factory $HealthSummaryStateCopyWith(HealthSummaryState value, $Res Function(HealthSummaryState) _then) = _$HealthSummaryStateCopyWithImpl; +@useResult +$Res call({ + int todaySteps, int? latestHeartRate, List weeklySteps, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class _$HealthSummaryStateCopyWithImpl<$Res> + implements $HealthSummaryStateCopyWith<$Res> { + _$HealthSummaryStateCopyWithImpl(this._self, this._then); + + final HealthSummaryState _self; + final $Res Function(HealthSummaryState) _then; + +/// Create a copy of HealthSummaryState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? todaySteps = null,Object? latestHeartRate = freezed,Object? weeklySteps = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_self.copyWith( +todaySteps: null == todaySteps ? _self.todaySteps : todaySteps // ignore: cast_nullable_to_non_nullable +as int,latestHeartRate: freezed == latestHeartRate ? _self.latestHeartRate : latestHeartRate // ignore: cast_nullable_to_non_nullable +as int?,weeklySteps: null == weeklySteps ? _self.weeklySteps : weeklySteps // ignore: cast_nullable_to_non_nullable +as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HealthSummaryState]. +extension HealthSummaryStatePatterns on HealthSummaryState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HealthSummaryState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HealthSummaryState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HealthSummaryState value) $default,){ +final _that = this; +switch (_that) { +case _HealthSummaryState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HealthSummaryState value)? $default,){ +final _that = this; +switch (_that) { +case _HealthSummaryState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int todaySteps, int? latestHeartRate, List weeklySteps, bool isLoading, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HealthSummaryState() when $default != null: +return $default(_that.todaySteps,_that.latestHeartRate,_that.weeklySteps,_that.isLoading,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int todaySteps, int? latestHeartRate, List weeklySteps, bool isLoading, String? error) $default,) {final _that = this; +switch (_that) { +case _HealthSummaryState(): +return $default(_that.todaySteps,_that.latestHeartRate,_that.weeklySteps,_that.isLoading,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int todaySteps, int? latestHeartRate, List weeklySteps, bool isLoading, String? error)? $default,) {final _that = this; +switch (_that) { +case _HealthSummaryState() when $default != null: +return $default(_that.todaySteps,_that.latestHeartRate,_that.weeklySteps,_that.isLoading,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HealthSummaryState implements HealthSummaryState { + const _HealthSummaryState({this.todaySteps = 0, this.latestHeartRate, final List weeklySteps = const [], this.isLoading = false, this.error}): _weeklySteps = weeklySteps; + + +@override@JsonKey() final int todaySteps; +@override final int? latestHeartRate; + final List _weeklySteps; +@override@JsonKey() List get weeklySteps { + if (_weeklySteps is EqualUnmodifiableListView) return _weeklySteps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_weeklySteps); +} + +@override@JsonKey() final bool isLoading; +@override final String? error; + +/// Create a copy of HealthSummaryState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HealthSummaryStateCopyWith<_HealthSummaryState> get copyWith => __$HealthSummaryStateCopyWithImpl<_HealthSummaryState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HealthSummaryState&&(identical(other.todaySteps, todaySteps) || other.todaySteps == todaySteps)&&(identical(other.latestHeartRate, latestHeartRate) || other.latestHeartRate == latestHeartRate)&&const DeepCollectionEquality().equals(other._weeklySteps, _weeklySteps)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,todaySteps,latestHeartRate,const DeepCollectionEquality().hash(_weeklySteps),isLoading,error); + +@override +String toString() { + return 'HealthSummaryState(todaySteps: $todaySteps, latestHeartRate: $latestHeartRate, weeklySteps: $weeklySteps, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$HealthSummaryStateCopyWith<$Res> implements $HealthSummaryStateCopyWith<$Res> { + factory _$HealthSummaryStateCopyWith(_HealthSummaryState value, $Res Function(_HealthSummaryState) _then) = __$HealthSummaryStateCopyWithImpl; +@override @useResult +$Res call({ + int todaySteps, int? latestHeartRate, List weeklySteps, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class __$HealthSummaryStateCopyWithImpl<$Res> + implements _$HealthSummaryStateCopyWith<$Res> { + __$HealthSummaryStateCopyWithImpl(this._self, this._then); + + final _HealthSummaryState _self; + final $Res Function(_HealthSummaryState) _then; + +/// Create a copy of HealthSummaryState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? todaySteps = null,Object? latestHeartRate = freezed,Object? weeklySteps = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_HealthSummaryState( +todaySteps: null == todaySteps ? _self.todaySteps : todaySteps // ignore: cast_nullable_to_non_nullable +as int,latestHeartRate: freezed == latestHeartRate ? _self.latestHeartRate : latestHeartRate // ignore: cast_nullable_to_non_nullable +as int?,weeklySteps: null == weeklySteps ? _self._weeklySteps : weeklySteps // ignore: cast_nullable_to_non_nullable +as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$StepsHistoryState { + + StepsHistoryRange get range; List get data; int get totalSteps; bool get isLoading; String? get error; +/// Create a copy of StepsHistoryState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StepsHistoryStateCopyWith get copyWith => _$StepsHistoryStateCopyWithImpl(this as StepsHistoryState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StepsHistoryState&&(identical(other.range, range) || other.range == range)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.totalSteps, totalSteps) || other.totalSteps == totalSteps)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,range,const DeepCollectionEquality().hash(data),totalSteps,isLoading,error); + +@override +String toString() { + return 'StepsHistoryState(range: $range, data: $data, totalSteps: $totalSteps, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $StepsHistoryStateCopyWith<$Res> { + factory $StepsHistoryStateCopyWith(StepsHistoryState value, $Res Function(StepsHistoryState) _then) = _$StepsHistoryStateCopyWithImpl; +@useResult +$Res call({ + StepsHistoryRange range, List data, int totalSteps, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class _$StepsHistoryStateCopyWithImpl<$Res> + implements $StepsHistoryStateCopyWith<$Res> { + _$StepsHistoryStateCopyWithImpl(this._self, this._then); + + final StepsHistoryState _self; + final $Res Function(StepsHistoryState) _then; + +/// Create a copy of StepsHistoryState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? range = null,Object? data = null,Object? totalSteps = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_self.copyWith( +range: null == range ? _self.range : range // ignore: cast_nullable_to_non_nullable +as StepsHistoryRange,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as List,totalSteps: null == totalSteps ? _self.totalSteps : totalSteps // ignore: cast_nullable_to_non_nullable +as int,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [StepsHistoryState]. +extension StepsHistoryStatePatterns on StepsHistoryState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _StepsHistoryState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _StepsHistoryState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _StepsHistoryState value) $default,){ +final _that = this; +switch (_that) { +case _StepsHistoryState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _StepsHistoryState value)? $default,){ +final _that = this; +switch (_that) { +case _StepsHistoryState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( StepsHistoryRange range, List data, int totalSteps, bool isLoading, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _StepsHistoryState() when $default != null: +return $default(_that.range,_that.data,_that.totalSteps,_that.isLoading,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( StepsHistoryRange range, List data, int totalSteps, bool isLoading, String? error) $default,) {final _that = this; +switch (_that) { +case _StepsHistoryState(): +return $default(_that.range,_that.data,_that.totalSteps,_that.isLoading,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( StepsHistoryRange range, List data, int totalSteps, bool isLoading, String? error)? $default,) {final _that = this; +switch (_that) { +case _StepsHistoryState() when $default != null: +return $default(_that.range,_that.data,_that.totalSteps,_that.isLoading,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _StepsHistoryState implements StepsHistoryState { + const _StepsHistoryState({this.range = StepsHistoryRange.day, final List data = const [], this.totalSteps = 0, this.isLoading = false, this.error}): _data = data; + + +@override@JsonKey() final StepsHistoryRange range; + final List _data; +@override@JsonKey() List get data { + if (_data is EqualUnmodifiableListView) return _data; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_data); +} + +@override@JsonKey() final int totalSteps; +@override@JsonKey() final bool isLoading; +@override final String? error; + +/// Create a copy of StepsHistoryState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StepsHistoryStateCopyWith<_StepsHistoryState> get copyWith => __$StepsHistoryStateCopyWithImpl<_StepsHistoryState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StepsHistoryState&&(identical(other.range, range) || other.range == range)&&const DeepCollectionEquality().equals(other._data, _data)&&(identical(other.totalSteps, totalSteps) || other.totalSteps == totalSteps)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,range,const DeepCollectionEquality().hash(_data),totalSteps,isLoading,error); + +@override +String toString() { + return 'StepsHistoryState(range: $range, data: $data, totalSteps: $totalSteps, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$StepsHistoryStateCopyWith<$Res> implements $StepsHistoryStateCopyWith<$Res> { + factory _$StepsHistoryStateCopyWith(_StepsHistoryState value, $Res Function(_StepsHistoryState) _then) = __$StepsHistoryStateCopyWithImpl; +@override @useResult +$Res call({ + StepsHistoryRange range, List data, int totalSteps, bool isLoading, String? error +}); + + + + +} +/// @nodoc +class __$StepsHistoryStateCopyWithImpl<$Res> + implements _$StepsHistoryStateCopyWith<$Res> { + __$StepsHistoryStateCopyWithImpl(this._self, this._then); + + final _StepsHistoryState _self; + final $Res Function(_StepsHistoryState) _then; + +/// Create a copy of StepsHistoryState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? range = null,Object? data = null,Object? totalSteps = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_StepsHistoryState( +range: null == range ? _self.range : range // ignore: cast_nullable_to_non_nullable +as StepsHistoryRange,data: null == data ? _self._data : data // ignore: cast_nullable_to_non_nullable +as List,totalSteps: null == totalSteps ? _self.totalSteps : totalSteps // ignore: cast_nullable_to_non_nullable +as int,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/http_providers.dart b/zswatch_app/lib/providers/http_providers.dart index 57292bd..ee6cfcc 100644 --- a/zswatch_app/lib/providers/http_providers.dart +++ b/zswatch_app/lib/providers/http_providers.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/http_request.dart'; @@ -8,6 +9,8 @@ import '../services/http/http_relay_service.dart'; import '../services/watch_service.dart'; import 'watch_service_provider.dart'; +part 'http_providers.freezed.dart'; + /// HTTP relay service provider final httpRelayServiceProvider = Provider((ref) { final service = HttpRelayService(); @@ -16,39 +19,26 @@ final httpRelayServiceProvider = Provider((ref) { }); /// State for HTTP relay tracking -class HttpRelayState { - /// Currently pending requests (keyed by request ID) - final Map pendingRequests; +@freezed +abstract class HttpRelayState with _$HttpRelayState { + const HttpRelayState._(); - /// Recently completed requests (for debugging/logging) - final List recentRequests; + const factory HttpRelayState({ + /// Currently pending requests (keyed by request ID) + @Default({}) Map pendingRequests, - /// Whether HTTP relay is enabled - final bool isEnabled; + /// Recently completed requests (for debugging/logging) + @Default([]) List recentRequests, - const HttpRelayState({ - this.pendingRequests = const {}, - this.recentRequests = const [], - this.isEnabled = true, - }); + /// Whether HTTP relay is enabled + @Default(true) bool isEnabled, + }) = _HttpRelayState; /// Number of pending requests int get pendingCount => pendingRequests.length; /// Whether any requests are pending bool get hasPending => pendingRequests.isNotEmpty; - - HttpRelayState copyWith({ - Map? pendingRequests, - List? recentRequests, - bool? isEnabled, - }) { - return HttpRelayState( - pendingRequests: pendingRequests ?? this.pendingRequests, - recentRequests: recentRequests ?? this.recentRequests, - isEnabled: isEnabled ?? this.isEnabled, - ); - } } /// HTTP relay notifier that handles HTTP requests from the watch. @@ -71,14 +61,17 @@ class HttpRelayNotifier extends StateNotifier { static const _maxRecentRequests = 50; HttpRelayNotifier(this._httpService, this._watchService) - : super(const HttpRelayState()) { + : super(const HttpRelayState()) { _init(); } void _init() { - debugPrint('[HttpRelayNotifier] Initializing - listening to watch messages'); - _messageSubscription = - _watchService.incomingMessages.listen(_handleWatchMessage); + debugPrint( + '[HttpRelayNotifier] Initializing - listening to watch messages', + ); + _messageSubscription = _watchService.incomingMessages.listen( + _handleWatchMessage, + ); } void _handleWatchMessage(Map message) { @@ -110,7 +103,8 @@ class HttpRelayNotifier extends StateNotifier { } // Track pending request - final requestId = request.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + final requestId = + request.id ?? DateTime.now().millisecondsSinceEpoch.toString(); state = state.copyWith( pendingRequests: {...state.pendingRequests, requestId: request}, ); @@ -128,7 +122,10 @@ class HttpRelayNotifier extends StateNotifier { // Send response or error to watch if (result.isSuccess) { - await _watchService.sendHttpResponse(request.id ?? '', result.response!); + await _watchService.sendHttpResponse( + request.id ?? '', + result.response!, + ); } else { await _watchService.sendHttpError(request.id ?? '', result.error!); } @@ -161,10 +158,7 @@ class HttpRelayNotifier extends StateNotifier { recent.removeLast(); } - state = state.copyWith( - pendingRequests: pending, - recentRequests: recent, - ); + state = state.copyWith(pendingRequests: pending, recentRequests: recent); } /// Enable or disable HTTP relay @@ -188,10 +182,10 @@ class HttpRelayNotifier extends StateNotifier { /// Provider for HTTP relay state and control final httpRelayNotifierProvider = StateNotifierProvider((ref) { - final httpService = ref.watch(httpRelayServiceProvider); - final watchService = ref.watch(watchServiceProvider); - return HttpRelayNotifier(httpService, watchService); -}); + final httpService = ref.watch(httpRelayServiceProvider); + final watchService = ref.watch(watchServiceProvider); + return HttpRelayNotifier(httpService, watchService); + }); /// Whether HTTP relay is enabled final httpRelayEnabledProvider = Provider((ref) { diff --git a/zswatch_app/lib/providers/http_providers.freezed.dart b/zswatch_app/lib/providers/http_providers.freezed.dart new file mode 100644 index 0000000..788fda7 --- /dev/null +++ b/zswatch_app/lib/providers/http_providers.freezed.dart @@ -0,0 +1,309 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'http_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$HttpRelayState implements DiagnosticableTreeMixin { + +/// Currently pending requests (keyed by request ID) + Map get pendingRequests;/// Recently completed requests (for debugging/logging) + List get recentRequests;/// Whether HTTP relay is enabled + bool get isEnabled; +/// Create a copy of HttpRelayState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HttpRelayStateCopyWith get copyWith => _$HttpRelayStateCopyWithImpl(this as HttpRelayState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'HttpRelayState')) + ..add(DiagnosticsProperty('pendingRequests', pendingRequests))..add(DiagnosticsProperty('recentRequests', recentRequests))..add(DiagnosticsProperty('isEnabled', isEnabled)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HttpRelayState&&const DeepCollectionEquality().equals(other.pendingRequests, pendingRequests)&&const DeepCollectionEquality().equals(other.recentRequests, recentRequests)&&(identical(other.isEnabled, isEnabled) || other.isEnabled == isEnabled)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(pendingRequests),const DeepCollectionEquality().hash(recentRequests),isEnabled); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'HttpRelayState(pendingRequests: $pendingRequests, recentRequests: $recentRequests, isEnabled: $isEnabled)'; +} + + +} + +/// @nodoc +abstract mixin class $HttpRelayStateCopyWith<$Res> { + factory $HttpRelayStateCopyWith(HttpRelayState value, $Res Function(HttpRelayState) _then) = _$HttpRelayStateCopyWithImpl; +@useResult +$Res call({ + Map pendingRequests, List recentRequests, bool isEnabled +}); + + + + +} +/// @nodoc +class _$HttpRelayStateCopyWithImpl<$Res> + implements $HttpRelayStateCopyWith<$Res> { + _$HttpRelayStateCopyWithImpl(this._self, this._then); + + final HttpRelayState _self; + final $Res Function(HttpRelayState) _then; + +/// Create a copy of HttpRelayState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? pendingRequests = null,Object? recentRequests = null,Object? isEnabled = null,}) { + return _then(_self.copyWith( +pendingRequests: null == pendingRequests ? _self.pendingRequests : pendingRequests // ignore: cast_nullable_to_non_nullable +as Map,recentRequests: null == recentRequests ? _self.recentRequests : recentRequests // ignore: cast_nullable_to_non_nullable +as List,isEnabled: null == isEnabled ? _self.isEnabled : isEnabled // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HttpRelayState]. +extension HttpRelayStatePatterns on HttpRelayState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HttpRelayState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HttpRelayState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HttpRelayState value) $default,){ +final _that = this; +switch (_that) { +case _HttpRelayState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HttpRelayState value)? $default,){ +final _that = this; +switch (_that) { +case _HttpRelayState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map pendingRequests, List recentRequests, bool isEnabled)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HttpRelayState() when $default != null: +return $default(_that.pendingRequests,_that.recentRequests,_that.isEnabled);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map pendingRequests, List recentRequests, bool isEnabled) $default,) {final _that = this; +switch (_that) { +case _HttpRelayState(): +return $default(_that.pendingRequests,_that.recentRequests,_that.isEnabled);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map pendingRequests, List recentRequests, bool isEnabled)? $default,) {final _that = this; +switch (_that) { +case _HttpRelayState() when $default != null: +return $default(_that.pendingRequests,_that.recentRequests,_that.isEnabled);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HttpRelayState extends HttpRelayState with DiagnosticableTreeMixin { + const _HttpRelayState({final Map pendingRequests = const {}, final List recentRequests = const [], this.isEnabled = true}): _pendingRequests = pendingRequests,_recentRequests = recentRequests,super._(); + + +/// Currently pending requests (keyed by request ID) + final Map _pendingRequests; +/// Currently pending requests (keyed by request ID) +@override@JsonKey() Map get pendingRequests { + if (_pendingRequests is EqualUnmodifiableMapView) return _pendingRequests; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_pendingRequests); +} + +/// Recently completed requests (for debugging/logging) + final List _recentRequests; +/// Recently completed requests (for debugging/logging) +@override@JsonKey() List get recentRequests { + if (_recentRequests is EqualUnmodifiableListView) return _recentRequests; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_recentRequests); +} + +/// Whether HTTP relay is enabled +@override@JsonKey() final bool isEnabled; + +/// Create a copy of HttpRelayState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HttpRelayStateCopyWith<_HttpRelayState> get copyWith => __$HttpRelayStateCopyWithImpl<_HttpRelayState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'HttpRelayState')) + ..add(DiagnosticsProperty('pendingRequests', pendingRequests))..add(DiagnosticsProperty('recentRequests', recentRequests))..add(DiagnosticsProperty('isEnabled', isEnabled)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HttpRelayState&&const DeepCollectionEquality().equals(other._pendingRequests, _pendingRequests)&&const DeepCollectionEquality().equals(other._recentRequests, _recentRequests)&&(identical(other.isEnabled, isEnabled) || other.isEnabled == isEnabled)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_pendingRequests),const DeepCollectionEquality().hash(_recentRequests),isEnabled); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'HttpRelayState(pendingRequests: $pendingRequests, recentRequests: $recentRequests, isEnabled: $isEnabled)'; +} + + +} + +/// @nodoc +abstract mixin class _$HttpRelayStateCopyWith<$Res> implements $HttpRelayStateCopyWith<$Res> { + factory _$HttpRelayStateCopyWith(_HttpRelayState value, $Res Function(_HttpRelayState) _then) = __$HttpRelayStateCopyWithImpl; +@override @useResult +$Res call({ + Map pendingRequests, List recentRequests, bool isEnabled +}); + + + + +} +/// @nodoc +class __$HttpRelayStateCopyWithImpl<$Res> + implements _$HttpRelayStateCopyWith<$Res> { + __$HttpRelayStateCopyWithImpl(this._self, this._then); + + final _HttpRelayState _self; + final $Res Function(_HttpRelayState) _then; + +/// Create a copy of HttpRelayState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? pendingRequests = null,Object? recentRequests = null,Object? isEnabled = null,}) { + return _then(_HttpRelayState( +pendingRequests: null == pendingRequests ? _self._pendingRequests : pendingRequests // ignore: cast_nullable_to_non_nullable +as Map,recentRequests: null == recentRequests ? _self._recentRequests : recentRequests // ignore: cast_nullable_to_non_nullable +as List,isEnabled: null == isEnabled ? _self.isEnabled : isEnabled // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/notification_providers.dart b/zswatch_app/lib/providers/notification_providers.dart index 0b8355d..642ce12 100644 --- a/zswatch_app/lib/providers/notification_providers.dart +++ b/zswatch_app/lib/providers/notification_providers.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -13,6 +14,8 @@ import '../services/protocol/protocol_service.dart'; import '../services/watch_service.dart'; import 'watch_service_provider.dart'; +part 'notification_providers.freezed.dart'; + // Keys for SharedPreferences const _notificationForwardingEnabledKey = 'notification_forwarding_enabled'; const _blockedAppsKey = 'notification_blocked_apps'; @@ -32,47 +35,24 @@ final mediaServiceProvider = Provider((ref) { }); /// State class for notification forwarding -class NotificationForwardingState { - final bool isEnabled; - final bool hasPermission; - final bool isServiceRunning; - final Set blockedApps; - final int forwardedCount; - final int dismissedCount; - - const NotificationForwardingState({ - this.isEnabled = false, - this.hasPermission = false, - this.isServiceRunning = false, - this.blockedApps = const {}, - this.forwardedCount = 0, - this.dismissedCount = 0, - }); - - NotificationForwardingState copyWith({ - bool? isEnabled, - bool? hasPermission, - bool? isServiceRunning, - Set? blockedApps, - int? forwardedCount, - int? dismissedCount, - }) { - return NotificationForwardingState( - isEnabled: isEnabled ?? this.isEnabled, - hasPermission: hasPermission ?? this.hasPermission, - isServiceRunning: isServiceRunning ?? this.isServiceRunning, - blockedApps: blockedApps ?? this.blockedApps, - forwardedCount: forwardedCount ?? this.forwardedCount, - dismissedCount: dismissedCount ?? this.dismissedCount, - ); - } +@freezed +abstract class NotificationForwardingState with _$NotificationForwardingState { + const factory NotificationForwardingState({ + @Default(false) bool isEnabled, + @Default(false) bool hasPermission, + @Default(false) bool isServiceRunning, + @Default({}) Set blockedApps, + @Default(0) int forwardedCount, + @Default(0) int dismissedCount, + }) = _NotificationForwardingState; } /// Notifier for notification forwarding state and actions -class NotificationForwardingNotifier extends StateNotifier { +class NotificationForwardingNotifier + extends StateNotifier { final NotificationService _notificationService; final WatchService _watchService; - + StreamSubscription? _notificationSubscription; StreamSubscription? _notificationRemovedSubscription; StreamSubscription>? _watchMessageSubscription; @@ -85,10 +65,8 @@ class NotificationForwardingNotifier extends StateNotifier _notificationIdToKey = {}; - NotificationForwardingNotifier( - this._notificationService, - this._watchService, - ) : super(const NotificationForwardingState()) { + NotificationForwardingNotifier(this._notificationService, this._watchService) + : super(const NotificationForwardingState()) { _initialize(); } @@ -101,12 +79,14 @@ class NotificationForwardingNotifier extends StateNotifier - now.difference(time).inSeconds > 10 + _recentlySentNotifications.removeWhere( + (key, time) => now.difference(time).inSeconds > 10, ); - + // Record this send _recentlySentNotifications[dedupeKey] = now; @@ -248,7 +236,9 @@ class NotificationForwardingNotifier extends StateNotifier 200) { - final keysToRemove = _notificationIdToKey.keys.take(_notificationIdToKey.length - 150).toList(); + final keysToRemove = _notificationIdToKey.keys + .take(_notificationIdToKey.length - 150) + .toList(); for (final k in keysToRemove) { _notificationIdToKey.remove(k); } @@ -283,7 +273,9 @@ class NotificationForwardingNotifier extends StateNotifier refreshPermission() async { - final hasPermission = await _notificationService.isNotificationAccessEnabled(); + final hasPermission = await _notificationService + .isNotificationAccessEnabled(); final isServiceRunning = await _notificationService.isServiceRunning(); - + state = state.copyWith( hasPermission: hasPermission, isServiceRunning: isServiceRunning, @@ -351,9 +344,13 @@ class NotificationForwardingNotifier extends StateNotifier> getNotificationApps() async { final apps = await _notificationService.getNotificationApps(); - return apps.map((app) => app.copyWith( - enabled: !state.blockedApps.contains(app.packageName), - )).toList(); + return apps + .map( + (app) => app.copyWith( + enabled: !state.blockedApps.contains(app.packageName), + ), + ) + .toList(); } @override @@ -366,79 +363,59 @@ class NotificationForwardingNotifier extends StateNotifier((ref) { - final notificationService = ref.watch(notificationServiceProvider); - final watchService = ref.watch(watchServiceProvider); - return NotificationForwardingNotifier(notificationService, watchService); -}); +final notificationForwardingProvider = + StateNotifierProvider< + NotificationForwardingNotifier, + NotificationForwardingState + >((ref) { + final notificationService = ref.watch(notificationServiceProvider); + final watchService = ref.watch(watchServiceProvider); + return NotificationForwardingNotifier(notificationService, watchService); + }); /// State class for media control -class MediaControlState { - final bool isInitialized; - final String? playbackState; // play, pause, stop - final int positionSeconds; - final String? artist; - final String? album; - final String? track; - final int? durationSeconds; - - const MediaControlState({ - this.isInitialized = false, - this.playbackState, - this.positionSeconds = 0, - this.artist, - this.album, - this.track, - this.durationSeconds, - }); - - bool get isPlaying => playbackState == 'play'; - bool get hasMedia => track != null; - - MediaControlState copyWith({ - bool? isInitialized, - String? playbackState, - int? positionSeconds, +@freezed +abstract class MediaControlState with _$MediaControlState { + const MediaControlState._(); + + const factory MediaControlState({ + @Default(false) bool isInitialized, + String? playbackState, // play, pause, stop + @Default(0) int positionSeconds, String? artist, String? album, String? track, int? durationSeconds, - }) { - return MediaControlState( - isInitialized: isInitialized ?? this.isInitialized, - playbackState: playbackState ?? this.playbackState, - positionSeconds: positionSeconds ?? this.positionSeconds, - artist: artist ?? this.artist, - album: album ?? this.album, - track: track ?? this.track, - durationSeconds: durationSeconds ?? this.durationSeconds, - ); - } + }) = _MediaControlState; + + bool get isPlaying => playbackState == 'play'; + bool get hasMedia => track != null; } /// Notifier for media control state and actions class MediaControlNotifier extends StateNotifier { final MediaService _mediaService; final WatchService _watchService; - + StreamSubscription? _playbackSubscription; StreamSubscription? _metadataSubscription; StreamSubscription>? _watchMessageSubscription; StreamSubscription? _connectionSubscription; - + /// Track previous connection state to detect state changes (not just RSSI updates) bool _wasConnected = false; MediaControlNotifier(this._mediaService, this._watchService) - : super(const MediaControlState()) { + : super(const MediaControlState()) { _initialize(); } Future _initialize() async { // Listen for connection changes to sync on connect (FR-085) // Note: connectionStream emits on RSSI updates too, so we track state changes - _connectionSubscription = _watchService.connectionStream.listen((connection) { + _connectionSubscription = _watchService.connectionStream.listen(( + connection, + ) { final isNowConnected = connection.isConnected; // Only sync when transitioning from disconnected to connected if (isNowConnected && !_wasConnected) { @@ -461,17 +438,20 @@ class MediaControlNotifier extends StateNotifier { if (success) { // Listen for playback state changes - _playbackSubscription = _mediaService.playbackStateStream.listen((playbackState) { + _playbackSubscription = _mediaService.playbackStateStream.listen(( + playbackState, + ) { final oldState = state.playbackState; final oldPosition = state.positionSeconds; - + state = state.copyWith( playbackState: playbackState.state, positionSeconds: playbackState.positionSeconds, ); - + // Only send if state actually changed - if (oldState != playbackState.state || oldPosition != playbackState.positionSeconds) { + if (oldState != playbackState.state || + oldPosition != playbackState.positionSeconds) { _sendStateToWatch(); } }); @@ -481,22 +461,26 @@ class MediaControlNotifier extends StateNotifier { final oldTrack = state.track; final oldArtist = state.artist; final oldAlbum = state.album; - + state = state.copyWith( artist: metadata.artist, album: metadata.album, track: metadata.track, durationSeconds: metadata.durationSeconds, ); - + // Only send if metadata actually changed - if (oldTrack != metadata.track || oldArtist != metadata.artist || oldAlbum != metadata.album) { + if (oldTrack != metadata.track || + oldArtist != metadata.artist || + oldAlbum != metadata.album) { _sendInfoToWatch(); } }); // Listen for music control from watch - _watchMessageSubscription = _watchService.incomingMessages.listen(_handleWatchMessage); + _watchMessageSubscription = _watchService.incomingMessages.listen( + _handleWatchMessage, + ); } } catch (e) { debugPrint('MediaControlNotifier init error: $e'); @@ -537,7 +521,7 @@ class MediaControlNotifier extends StateNotifier { Future _sendStateToWatch() async { if (!_watchService.isConnected) return; if (state.playbackState == null) return; - + try { await _watchService.sendMusicState( state: state.playbackState!, @@ -552,7 +536,7 @@ class MediaControlNotifier extends StateNotifier { Future _sendInfoToWatch() async { if (!_watchService.isConnected) return; if (state.track == null) return; - + try { await _watchService.sendMusicInfo( artist: state.artist, @@ -612,12 +596,12 @@ class MediaControlNotifier extends StateNotifier { } /// Provider for media control notifier -final mediaControlProvider = +final mediaControlProvider = StateNotifierProvider((ref) { - final mediaService = ref.watch(mediaServiceProvider); - final watchService = ref.watch(watchServiceProvider); - return MediaControlNotifier(mediaService, watchService); -}); + final mediaService = ref.watch(mediaServiceProvider); + final watchService = ref.watch(watchServiceProvider); + return MediaControlNotifier(mediaService, watchService); + }); /// Provider for whether notification forwarding is supported final isNotificationForwardingSupported = Provider((ref) { @@ -628,4 +612,3 @@ final isNotificationForwardingSupported = Provider((ref) { final isMediaControlSupported = Provider((ref) { return Platform.isAndroid; }); - diff --git a/zswatch_app/lib/providers/notification_providers.freezed.dart b/zswatch_app/lib/providers/notification_providers.freezed.dart new file mode 100644 index 0000000..3840fb6 --- /dev/null +++ b/zswatch_app/lib/providers/notification_providers.freezed.dart @@ -0,0 +1,593 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notification_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$NotificationForwardingState implements DiagnosticableTreeMixin { + + bool get isEnabled; bool get hasPermission; bool get isServiceRunning; Set get blockedApps; int get forwardedCount; int get dismissedCount; +/// Create a copy of NotificationForwardingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NotificationForwardingStateCopyWith get copyWith => _$NotificationForwardingStateCopyWithImpl(this as NotificationForwardingState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'NotificationForwardingState')) + ..add(DiagnosticsProperty('isEnabled', isEnabled))..add(DiagnosticsProperty('hasPermission', hasPermission))..add(DiagnosticsProperty('isServiceRunning', isServiceRunning))..add(DiagnosticsProperty('blockedApps', blockedApps))..add(DiagnosticsProperty('forwardedCount', forwardedCount))..add(DiagnosticsProperty('dismissedCount', dismissedCount)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NotificationForwardingState&&(identical(other.isEnabled, isEnabled) || other.isEnabled == isEnabled)&&(identical(other.hasPermission, hasPermission) || other.hasPermission == hasPermission)&&(identical(other.isServiceRunning, isServiceRunning) || other.isServiceRunning == isServiceRunning)&&const DeepCollectionEquality().equals(other.blockedApps, blockedApps)&&(identical(other.forwardedCount, forwardedCount) || other.forwardedCount == forwardedCount)&&(identical(other.dismissedCount, dismissedCount) || other.dismissedCount == dismissedCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isEnabled,hasPermission,isServiceRunning,const DeepCollectionEquality().hash(blockedApps),forwardedCount,dismissedCount); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'NotificationForwardingState(isEnabled: $isEnabled, hasPermission: $hasPermission, isServiceRunning: $isServiceRunning, blockedApps: $blockedApps, forwardedCount: $forwardedCount, dismissedCount: $dismissedCount)'; +} + + +} + +/// @nodoc +abstract mixin class $NotificationForwardingStateCopyWith<$Res> { + factory $NotificationForwardingStateCopyWith(NotificationForwardingState value, $Res Function(NotificationForwardingState) _then) = _$NotificationForwardingStateCopyWithImpl; +@useResult +$Res call({ + bool isEnabled, bool hasPermission, bool isServiceRunning, Set blockedApps, int forwardedCount, int dismissedCount +}); + + + + +} +/// @nodoc +class _$NotificationForwardingStateCopyWithImpl<$Res> + implements $NotificationForwardingStateCopyWith<$Res> { + _$NotificationForwardingStateCopyWithImpl(this._self, this._then); + + final NotificationForwardingState _self; + final $Res Function(NotificationForwardingState) _then; + +/// Create a copy of NotificationForwardingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isEnabled = null,Object? hasPermission = null,Object? isServiceRunning = null,Object? blockedApps = null,Object? forwardedCount = null,Object? dismissedCount = null,}) { + return _then(_self.copyWith( +isEnabled: null == isEnabled ? _self.isEnabled : isEnabled // ignore: cast_nullable_to_non_nullable +as bool,hasPermission: null == hasPermission ? _self.hasPermission : hasPermission // ignore: cast_nullable_to_non_nullable +as bool,isServiceRunning: null == isServiceRunning ? _self.isServiceRunning : isServiceRunning // ignore: cast_nullable_to_non_nullable +as bool,blockedApps: null == blockedApps ? _self.blockedApps : blockedApps // ignore: cast_nullable_to_non_nullable +as Set,forwardedCount: null == forwardedCount ? _self.forwardedCount : forwardedCount // ignore: cast_nullable_to_non_nullable +as int,dismissedCount: null == dismissedCount ? _self.dismissedCount : dismissedCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [NotificationForwardingState]. +extension NotificationForwardingStatePatterns on NotificationForwardingState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _NotificationForwardingState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NotificationForwardingState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _NotificationForwardingState value) $default,){ +final _that = this; +switch (_that) { +case _NotificationForwardingState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _NotificationForwardingState value)? $default,){ +final _that = this; +switch (_that) { +case _NotificationForwardingState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isEnabled, bool hasPermission, bool isServiceRunning, Set blockedApps, int forwardedCount, int dismissedCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NotificationForwardingState() when $default != null: +return $default(_that.isEnabled,_that.hasPermission,_that.isServiceRunning,_that.blockedApps,_that.forwardedCount,_that.dismissedCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isEnabled, bool hasPermission, bool isServiceRunning, Set blockedApps, int forwardedCount, int dismissedCount) $default,) {final _that = this; +switch (_that) { +case _NotificationForwardingState(): +return $default(_that.isEnabled,_that.hasPermission,_that.isServiceRunning,_that.blockedApps,_that.forwardedCount,_that.dismissedCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isEnabled, bool hasPermission, bool isServiceRunning, Set blockedApps, int forwardedCount, int dismissedCount)? $default,) {final _that = this; +switch (_that) { +case _NotificationForwardingState() when $default != null: +return $default(_that.isEnabled,_that.hasPermission,_that.isServiceRunning,_that.blockedApps,_that.forwardedCount,_that.dismissedCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _NotificationForwardingState with DiagnosticableTreeMixin implements NotificationForwardingState { + const _NotificationForwardingState({this.isEnabled = false, this.hasPermission = false, this.isServiceRunning = false, final Set blockedApps = const {}, this.forwardedCount = 0, this.dismissedCount = 0}): _blockedApps = blockedApps; + + +@override@JsonKey() final bool isEnabled; +@override@JsonKey() final bool hasPermission; +@override@JsonKey() final bool isServiceRunning; + final Set _blockedApps; +@override@JsonKey() Set get blockedApps { + if (_blockedApps is EqualUnmodifiableSetView) return _blockedApps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_blockedApps); +} + +@override@JsonKey() final int forwardedCount; +@override@JsonKey() final int dismissedCount; + +/// Create a copy of NotificationForwardingState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NotificationForwardingStateCopyWith<_NotificationForwardingState> get copyWith => __$NotificationForwardingStateCopyWithImpl<_NotificationForwardingState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'NotificationForwardingState')) + ..add(DiagnosticsProperty('isEnabled', isEnabled))..add(DiagnosticsProperty('hasPermission', hasPermission))..add(DiagnosticsProperty('isServiceRunning', isServiceRunning))..add(DiagnosticsProperty('blockedApps', blockedApps))..add(DiagnosticsProperty('forwardedCount', forwardedCount))..add(DiagnosticsProperty('dismissedCount', dismissedCount)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NotificationForwardingState&&(identical(other.isEnabled, isEnabled) || other.isEnabled == isEnabled)&&(identical(other.hasPermission, hasPermission) || other.hasPermission == hasPermission)&&(identical(other.isServiceRunning, isServiceRunning) || other.isServiceRunning == isServiceRunning)&&const DeepCollectionEquality().equals(other._blockedApps, _blockedApps)&&(identical(other.forwardedCount, forwardedCount) || other.forwardedCount == forwardedCount)&&(identical(other.dismissedCount, dismissedCount) || other.dismissedCount == dismissedCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isEnabled,hasPermission,isServiceRunning,const DeepCollectionEquality().hash(_blockedApps),forwardedCount,dismissedCount); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'NotificationForwardingState(isEnabled: $isEnabled, hasPermission: $hasPermission, isServiceRunning: $isServiceRunning, blockedApps: $blockedApps, forwardedCount: $forwardedCount, dismissedCount: $dismissedCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$NotificationForwardingStateCopyWith<$Res> implements $NotificationForwardingStateCopyWith<$Res> { + factory _$NotificationForwardingStateCopyWith(_NotificationForwardingState value, $Res Function(_NotificationForwardingState) _then) = __$NotificationForwardingStateCopyWithImpl; +@override @useResult +$Res call({ + bool isEnabled, bool hasPermission, bool isServiceRunning, Set blockedApps, int forwardedCount, int dismissedCount +}); + + + + +} +/// @nodoc +class __$NotificationForwardingStateCopyWithImpl<$Res> + implements _$NotificationForwardingStateCopyWith<$Res> { + __$NotificationForwardingStateCopyWithImpl(this._self, this._then); + + final _NotificationForwardingState _self; + final $Res Function(_NotificationForwardingState) _then; + +/// Create a copy of NotificationForwardingState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isEnabled = null,Object? hasPermission = null,Object? isServiceRunning = null,Object? blockedApps = null,Object? forwardedCount = null,Object? dismissedCount = null,}) { + return _then(_NotificationForwardingState( +isEnabled: null == isEnabled ? _self.isEnabled : isEnabled // ignore: cast_nullable_to_non_nullable +as bool,hasPermission: null == hasPermission ? _self.hasPermission : hasPermission // ignore: cast_nullable_to_non_nullable +as bool,isServiceRunning: null == isServiceRunning ? _self.isServiceRunning : isServiceRunning // ignore: cast_nullable_to_non_nullable +as bool,blockedApps: null == blockedApps ? _self._blockedApps : blockedApps // ignore: cast_nullable_to_non_nullable +as Set,forwardedCount: null == forwardedCount ? _self.forwardedCount : forwardedCount // ignore: cast_nullable_to_non_nullable +as int,dismissedCount: null == dismissedCount ? _self.dismissedCount : dismissedCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$MediaControlState implements DiagnosticableTreeMixin { + + bool get isInitialized; String? get playbackState;// play, pause, stop + int get positionSeconds; String? get artist; String? get album; String? get track; int? get durationSeconds; +/// Create a copy of MediaControlState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaControlStateCopyWith get copyWith => _$MediaControlStateCopyWithImpl(this as MediaControlState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MediaControlState')) + ..add(DiagnosticsProperty('isInitialized', isInitialized))..add(DiagnosticsProperty('playbackState', playbackState))..add(DiagnosticsProperty('positionSeconds', positionSeconds))..add(DiagnosticsProperty('artist', artist))..add(DiagnosticsProperty('album', album))..add(DiagnosticsProperty('track', track))..add(DiagnosticsProperty('durationSeconds', durationSeconds)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaControlState&&(identical(other.isInitialized, isInitialized) || other.isInitialized == isInitialized)&&(identical(other.playbackState, playbackState) || other.playbackState == playbackState)&&(identical(other.positionSeconds, positionSeconds) || other.positionSeconds == positionSeconds)&&(identical(other.artist, artist) || other.artist == artist)&&(identical(other.album, album) || other.album == album)&&(identical(other.track, track) || other.track == track)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isInitialized,playbackState,positionSeconds,artist,album,track,durationSeconds); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MediaControlState(isInitialized: $isInitialized, playbackState: $playbackState, positionSeconds: $positionSeconds, artist: $artist, album: $album, track: $track, durationSeconds: $durationSeconds)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaControlStateCopyWith<$Res> { + factory $MediaControlStateCopyWith(MediaControlState value, $Res Function(MediaControlState) _then) = _$MediaControlStateCopyWithImpl; +@useResult +$Res call({ + bool isInitialized, String? playbackState, int positionSeconds, String? artist, String? album, String? track, int? durationSeconds +}); + + + + +} +/// @nodoc +class _$MediaControlStateCopyWithImpl<$Res> + implements $MediaControlStateCopyWith<$Res> { + _$MediaControlStateCopyWithImpl(this._self, this._then); + + final MediaControlState _self; + final $Res Function(MediaControlState) _then; + +/// Create a copy of MediaControlState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isInitialized = null,Object? playbackState = freezed,Object? positionSeconds = null,Object? artist = freezed,Object? album = freezed,Object? track = freezed,Object? durationSeconds = freezed,}) { + return _then(_self.copyWith( +isInitialized: null == isInitialized ? _self.isInitialized : isInitialized // ignore: cast_nullable_to_non_nullable +as bool,playbackState: freezed == playbackState ? _self.playbackState : playbackState // ignore: cast_nullable_to_non_nullable +as String?,positionSeconds: null == positionSeconds ? _self.positionSeconds : positionSeconds // ignore: cast_nullable_to_non_nullable +as int,artist: freezed == artist ? _self.artist : artist // ignore: cast_nullable_to_non_nullable +as String?,album: freezed == album ? _self.album : album // ignore: cast_nullable_to_non_nullable +as String?,track: freezed == track ? _self.track : track // ignore: cast_nullable_to_non_nullable +as String?,durationSeconds: freezed == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MediaControlState]. +extension MediaControlStatePatterns on MediaControlState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaControlState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaControlState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaControlState value) $default,){ +final _that = this; +switch (_that) { +case _MediaControlState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaControlState value)? $default,){ +final _that = this; +switch (_that) { +case _MediaControlState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isInitialized, String? playbackState, int positionSeconds, String? artist, String? album, String? track, int? durationSeconds)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaControlState() when $default != null: +return $default(_that.isInitialized,_that.playbackState,_that.positionSeconds,_that.artist,_that.album,_that.track,_that.durationSeconds);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isInitialized, String? playbackState, int positionSeconds, String? artist, String? album, String? track, int? durationSeconds) $default,) {final _that = this; +switch (_that) { +case _MediaControlState(): +return $default(_that.isInitialized,_that.playbackState,_that.positionSeconds,_that.artist,_that.album,_that.track,_that.durationSeconds);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isInitialized, String? playbackState, int positionSeconds, String? artist, String? album, String? track, int? durationSeconds)? $default,) {final _that = this; +switch (_that) { +case _MediaControlState() when $default != null: +return $default(_that.isInitialized,_that.playbackState,_that.positionSeconds,_that.artist,_that.album,_that.track,_that.durationSeconds);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaControlState extends MediaControlState with DiagnosticableTreeMixin { + const _MediaControlState({this.isInitialized = false, this.playbackState, this.positionSeconds = 0, this.artist, this.album, this.track, this.durationSeconds}): super._(); + + +@override@JsonKey() final bool isInitialized; +@override final String? playbackState; +// play, pause, stop +@override@JsonKey() final int positionSeconds; +@override final String? artist; +@override final String? album; +@override final String? track; +@override final int? durationSeconds; + +/// Create a copy of MediaControlState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaControlStateCopyWith<_MediaControlState> get copyWith => __$MediaControlStateCopyWithImpl<_MediaControlState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MediaControlState')) + ..add(DiagnosticsProperty('isInitialized', isInitialized))..add(DiagnosticsProperty('playbackState', playbackState))..add(DiagnosticsProperty('positionSeconds', positionSeconds))..add(DiagnosticsProperty('artist', artist))..add(DiagnosticsProperty('album', album))..add(DiagnosticsProperty('track', track))..add(DiagnosticsProperty('durationSeconds', durationSeconds)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaControlState&&(identical(other.isInitialized, isInitialized) || other.isInitialized == isInitialized)&&(identical(other.playbackState, playbackState) || other.playbackState == playbackState)&&(identical(other.positionSeconds, positionSeconds) || other.positionSeconds == positionSeconds)&&(identical(other.artist, artist) || other.artist == artist)&&(identical(other.album, album) || other.album == album)&&(identical(other.track, track) || other.track == track)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isInitialized,playbackState,positionSeconds,artist,album,track,durationSeconds); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MediaControlState(isInitialized: $isInitialized, playbackState: $playbackState, positionSeconds: $positionSeconds, artist: $artist, album: $album, track: $track, durationSeconds: $durationSeconds)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaControlStateCopyWith<$Res> implements $MediaControlStateCopyWith<$Res> { + factory _$MediaControlStateCopyWith(_MediaControlState value, $Res Function(_MediaControlState) _then) = __$MediaControlStateCopyWithImpl; +@override @useResult +$Res call({ + bool isInitialized, String? playbackState, int positionSeconds, String? artist, String? album, String? track, int? durationSeconds +}); + + + + +} +/// @nodoc +class __$MediaControlStateCopyWithImpl<$Res> + implements _$MediaControlStateCopyWith<$Res> { + __$MediaControlStateCopyWithImpl(this._self, this._then); + + final _MediaControlState _self; + final $Res Function(_MediaControlState) _then; + +/// Create a copy of MediaControlState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isInitialized = null,Object? playbackState = freezed,Object? positionSeconds = null,Object? artist = freezed,Object? album = freezed,Object? track = freezed,Object? durationSeconds = freezed,}) { + return _then(_MediaControlState( +isInitialized: null == isInitialized ? _self.isInitialized : isInitialized // ignore: cast_nullable_to_non_nullable +as bool,playbackState: freezed == playbackState ? _self.playbackState : playbackState // ignore: cast_nullable_to_non_nullable +as String?,positionSeconds: null == positionSeconds ? _self.positionSeconds : positionSeconds // ignore: cast_nullable_to_non_nullable +as int,artist: freezed == artist ? _self.artist : artist // ignore: cast_nullable_to_non_nullable +as String?,album: freezed == album ? _self.album : album // ignore: cast_nullable_to_non_nullable +as String?,track: freezed == track ? _self.track : track // ignore: cast_nullable_to_non_nullable +as String?,durationSeconds: freezed == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/permission_providers.dart b/zswatch_app/lib/providers/permission_providers.dart index f067e61..6bf0cd4 100644 --- a/zswatch_app/lib/providers/permission_providers.dart +++ b/zswatch_app/lib/providers/permission_providers.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -10,6 +11,8 @@ import '../services/background/foreground_service.dart'; import '../services/permission/permission_service.dart'; import 'notification_providers.dart'; +part 'permission_providers.freezed.dart'; + /// Key for storing whether onboarding has been completed const _onboardingCompletedKey = 'permission_onboarding_completed'; @@ -41,34 +44,18 @@ final permissionOnboardingCompletedProvider = FutureProvider((ref) async { }); /// State for the permission notifier -class PermissionState { - final AppPermissionsStatus status; - final bool isChecking; - final bool onboardingCompleted; - - const PermissionState({ - this.status = const AppPermissionsStatus(), - this.isChecking = false, - this.onboardingCompleted = false, - }); - - PermissionState copyWith({ - AppPermissionsStatus? status, - bool? isChecking, - bool? onboardingCompleted, - }) { - return PermissionState( - status: status ?? this.status, - isChecking: isChecking ?? this.isChecking, - onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, - ); - } +@freezed +abstract class PermissionState with _$PermissionState { + const PermissionState._(); + + const factory PermissionState({ + @Default(AppPermissionsStatus()) AppPermissionsStatus status, + @Default(false) bool isChecking, + @Default(false) bool onboardingCompleted, + }) = _PermissionState; /// Whether the app should show the permission onboarding screen - bool get shouldShowOnboarding { - // Show onboarding if not completed yet - return !onboardingCompleted; - } + bool get shouldShowOnboarding => !onboardingCompleted; } /// Notifier that manages permission state and lifecycle diff --git a/zswatch_app/lib/providers/permission_providers.freezed.dart b/zswatch_app/lib/providers/permission_providers.freezed.dart new file mode 100644 index 0000000..27cbb0c --- /dev/null +++ b/zswatch_app/lib/providers/permission_providers.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'permission_providers.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PermissionState { + + AppPermissionsStatus get status; bool get isChecking; bool get onboardingCompleted; +/// Create a copy of PermissionState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PermissionStateCopyWith get copyWith => _$PermissionStateCopyWithImpl(this as PermissionState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PermissionState&&(identical(other.status, status) || other.status == status)&&(identical(other.isChecking, isChecking) || other.isChecking == isChecking)&&(identical(other.onboardingCompleted, onboardingCompleted) || other.onboardingCompleted == onboardingCompleted)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,isChecking,onboardingCompleted); + +@override +String toString() { + return 'PermissionState(status: $status, isChecking: $isChecking, onboardingCompleted: $onboardingCompleted)'; +} + + +} + +/// @nodoc +abstract mixin class $PermissionStateCopyWith<$Res> { + factory $PermissionStateCopyWith(PermissionState value, $Res Function(PermissionState) _then) = _$PermissionStateCopyWithImpl; +@useResult +$Res call({ + AppPermissionsStatus status, bool isChecking, bool onboardingCompleted +}); + + + + +} +/// @nodoc +class _$PermissionStateCopyWithImpl<$Res> + implements $PermissionStateCopyWith<$Res> { + _$PermissionStateCopyWithImpl(this._self, this._then); + + final PermissionState _self; + final $Res Function(PermissionState) _then; + +/// Create a copy of PermissionState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? isChecking = null,Object? onboardingCompleted = null,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AppPermissionsStatus,isChecking: null == isChecking ? _self.isChecking : isChecking // ignore: cast_nullable_to_non_nullable +as bool,onboardingCompleted: null == onboardingCompleted ? _self.onboardingCompleted : onboardingCompleted // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PermissionState]. +extension PermissionStatePatterns on PermissionState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PermissionState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PermissionState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PermissionState value) $default,){ +final _that = this; +switch (_that) { +case _PermissionState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PermissionState value)? $default,){ +final _that = this; +switch (_that) { +case _PermissionState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( AppPermissionsStatus status, bool isChecking, bool onboardingCompleted)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PermissionState() when $default != null: +return $default(_that.status,_that.isChecking,_that.onboardingCompleted);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( AppPermissionsStatus status, bool isChecking, bool onboardingCompleted) $default,) {final _that = this; +switch (_that) { +case _PermissionState(): +return $default(_that.status,_that.isChecking,_that.onboardingCompleted);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( AppPermissionsStatus status, bool isChecking, bool onboardingCompleted)? $default,) {final _that = this; +switch (_that) { +case _PermissionState() when $default != null: +return $default(_that.status,_that.isChecking,_that.onboardingCompleted);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PermissionState extends PermissionState { + const _PermissionState({this.status = const AppPermissionsStatus(), this.isChecking = false, this.onboardingCompleted = false}): super._(); + + +@override@JsonKey() final AppPermissionsStatus status; +@override@JsonKey() final bool isChecking; +@override@JsonKey() final bool onboardingCompleted; + +/// Create a copy of PermissionState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PermissionStateCopyWith<_PermissionState> get copyWith => __$PermissionStateCopyWithImpl<_PermissionState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PermissionState&&(identical(other.status, status) || other.status == status)&&(identical(other.isChecking, isChecking) || other.isChecking == isChecking)&&(identical(other.onboardingCompleted, onboardingCompleted) || other.onboardingCompleted == onboardingCompleted)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,isChecking,onboardingCompleted); + +@override +String toString() { + return 'PermissionState(status: $status, isChecking: $isChecking, onboardingCompleted: $onboardingCompleted)'; +} + + +} + +/// @nodoc +abstract mixin class _$PermissionStateCopyWith<$Res> implements $PermissionStateCopyWith<$Res> { + factory _$PermissionStateCopyWith(_PermissionState value, $Res Function(_PermissionState) _then) = __$PermissionStateCopyWithImpl; +@override @useResult +$Res call({ + AppPermissionsStatus status, bool isChecking, bool onboardingCompleted +}); + + + + +} +/// @nodoc +class __$PermissionStateCopyWithImpl<$Res> + implements _$PermissionStateCopyWith<$Res> { + __$PermissionStateCopyWithImpl(this._self, this._then); + + final _PermissionState _self; + final $Res Function(_PermissionState) _then; + +/// Create a copy of PermissionState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? isChecking = null,Object? onboardingCompleted = null,}) { + return _then(_PermissionState( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AppPermissionsStatus,isChecking: null == isChecking ? _self.isChecking : isChecking // ignore: cast_nullable_to_non_nullable +as bool,onboardingCompleted: null == onboardingCompleted ? _self.onboardingCompleted : onboardingCompleted // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index a3235e5..2e99f9a 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../services/voice_memo/transcription_engine.dart'; + /// Keys for SharedPreferences abstract final class SettingsKeys { static const String developerModeEnabled = 'developer_mode_enabled'; @@ -9,29 +11,43 @@ abstract final class SettingsKeys { static const String autoTimeSync = 'auto_time_sync'; static const String preferredMtu = 'preferred_mtu'; static const String lastConnectedWatchId = 'last_connected_watch_id'; - static const String notificationFilterPackages = 'notification_filter_packages'; + static const String notificationFilterPackages = + 'notification_filter_packages'; static const String onboardingCompleted = 'onboarding_completed'; static const String keepScreenOnDuringDfu = 'keep_screen_on_during_dfu'; - static const String backgroundConnectionEnabled = 'background_connection_enabled'; + static const String backgroundConnectionEnabled = + 'background_connection_enabled'; + static const String transcriptionEngineType = 'transcription_engine_type'; + static const String localAiEnabled = 'local_ai_enabled'; + static const String autoProcessVoiceNotes = 'auto_process_voice_notes'; + static const String selectedAiModelId = 'selected_ai_model_id'; + static const String selectedProductivityCalendarId = + 'selected_productivity_calendar_id'; + static const String autoCreateActions = 'auto_create_actions'; + static const String aiCorrectionEnabled = 'ai_correction_enabled'; + static const String coredumpServerUrl = 'coredump_server_url'; + static const String coredumpUseLatestElf = 'coredump_use_latest_elf'; } /// Provider for SharedPreferences instance -final sharedPreferencesProvider = FutureProvider((ref) async { +final sharedPreferencesProvider = FutureProvider(( + ref, +) async { return SharedPreferences.getInstance(); }); /// Provider for developer mode setting final developerModeProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return DeveloperModeNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return DeveloperModeNotifier(prefs.valueOrNull); + }); class DeveloperModeNotifier extends StateNotifier { final SharedPreferences? _prefs; DeveloperModeNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.developerModeEnabled) ?? false); + : super(_prefs?.getBool(SettingsKeys.developerModeEnabled) ?? false); void toggle() { state = !state; @@ -47,15 +63,15 @@ class DeveloperModeNotifier extends StateNotifier { /// Provider for notifications enabled setting final notificationsEnabledProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return NotificationsEnabledNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return NotificationsEnabledNotifier(prefs.valueOrNull); + }); class NotificationsEnabledNotifier extends StateNotifier { final SharedPreferences? _prefs; NotificationsEnabledNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.notificationsEnabled) ?? true); + : super(_prefs?.getBool(SettingsKeys.notificationsEnabled) ?? true); void toggle() { state = !state; @@ -71,15 +87,15 @@ class NotificationsEnabledNotifier extends StateNotifier { /// Provider for auto-reconnect setting final autoReconnectProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return AutoReconnectNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return AutoReconnectNotifier(prefs.valueOrNull); + }); class AutoReconnectNotifier extends StateNotifier { final SharedPreferences? _prefs; AutoReconnectNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.autoReconnect) ?? true); + : super(_prefs?.getBool(SettingsKeys.autoReconnect) ?? true); void toggle() { state = !state; @@ -93,8 +109,9 @@ class AutoReconnectNotifier extends StateNotifier { } /// Provider for auto time sync setting -final autoTimeSyncProvider = - StateNotifierProvider((ref) { +final autoTimeSyncProvider = StateNotifierProvider(( + ref, +) { final prefs = ref.watch(sharedPreferencesProvider); return AutoTimeSyncNotifier(prefs.valueOrNull); }); @@ -103,7 +120,7 @@ class AutoTimeSyncNotifier extends StateNotifier { final SharedPreferences? _prefs; AutoTimeSyncNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.autoTimeSync) ?? true); + : super(_prefs?.getBool(SettingsKeys.autoTimeSync) ?? true); void toggle() { state = !state; @@ -117,8 +134,9 @@ class AutoTimeSyncNotifier extends StateNotifier { } /// Provider for preferred MTU setting -final preferredMtuProvider = - StateNotifierProvider((ref) { +final preferredMtuProvider = StateNotifierProvider(( + ref, +) { final prefs = ref.watch(sharedPreferencesProvider); return PreferredMtuNotifier(prefs.valueOrNull); }); @@ -127,7 +145,7 @@ class PreferredMtuNotifier extends StateNotifier { final SharedPreferences? _prefs; PreferredMtuNotifier(this._prefs) - : super(_prefs?.getInt(SettingsKeys.preferredMtu) ?? 512); + : super(_prefs?.getInt(SettingsKeys.preferredMtu) ?? 512); void setMtu(int mtu) { state = mtu; @@ -144,17 +162,17 @@ final lastConnectedWatchIdProvider = Provider((ref) { /// Provider for notification filter packages final notificationFilterPackagesProvider = StateNotifierProvider>((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return NotificationFilterNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return NotificationFilterNotifier(prefs.valueOrNull); + }); class NotificationFilterNotifier extends StateNotifier> { final SharedPreferences? _prefs; NotificationFilterNotifier(this._prefs) - : super( - _prefs?.getStringList(SettingsKeys.notificationFilterPackages) ?? [], - ); + : super( + _prefs?.getStringList(SettingsKeys.notificationFilterPackages) ?? [], + ); void addPackage(String packageName) { if (!state.contains(packageName)) { @@ -179,15 +197,15 @@ class NotificationFilterNotifier extends StateNotifier> { /// Provider for onboarding completed flag final onboardingCompletedProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return OnboardingCompletedNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return OnboardingCompletedNotifier(prefs.valueOrNull); + }); class OnboardingCompletedNotifier extends StateNotifier { final SharedPreferences? _prefs; OnboardingCompletedNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.onboardingCompleted) ?? false); + : super(_prefs?.getBool(SettingsKeys.onboardingCompleted) ?? false); void setCompleted() { state = true; @@ -203,15 +221,15 @@ class OnboardingCompletedNotifier extends StateNotifier { /// Provider for keep screen on during DFU setting final keepScreenOnDuringDfuProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return KeepScreenOnDuringDfuNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return KeepScreenOnDuringDfuNotifier(prefs.valueOrNull); + }); class KeepScreenOnDuringDfuNotifier extends StateNotifier { final SharedPreferences? _prefs; KeepScreenOnDuringDfuNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.keepScreenOnDuringDfu) ?? true); + : super(_prefs?.getBool(SettingsKeys.keepScreenOnDuringDfu) ?? true); void toggle() { state = !state; @@ -228,15 +246,15 @@ class KeepScreenOnDuringDfuNotifier extends StateNotifier { /// When enabled, the app maintains a persistent BLE connection when backgrounded final backgroundConnectionEnabledProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return BackgroundConnectionEnabledNotifier(prefs.valueOrNull); -}); + final prefs = ref.watch(sharedPreferencesProvider); + return BackgroundConnectionEnabledNotifier(prefs.valueOrNull); + }); class BackgroundConnectionEnabledNotifier extends StateNotifier { final SharedPreferences? _prefs; BackgroundConnectionEnabledNotifier(this._prefs) - : super(_prefs?.getBool(SettingsKeys.backgroundConnectionEnabled) ?? true); + : super(_prefs?.getBool(SettingsKeys.backgroundConnectionEnabled) ?? true); void toggle() { state = !state; @@ -279,3 +297,224 @@ final settingsManagerProvider = Provider((ref) { return SettingsManager(prefsValue); }); +// --------------------------------------------------------------------------- +// Transcription engine type +// --------------------------------------------------------------------------- + +/// Which offline Whisper engine variant to use for voice memo transcription. +/// Persisted in SharedPreferences. +final transcriptionEngineTypeProvider = + StateNotifierProvider< + TranscriptionEngineTypeNotifier, + TranscriptionEngineType + >((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return TranscriptionEngineTypeNotifier(prefs.valueOrNull); + }); + +class TranscriptionEngineTypeNotifier + extends StateNotifier { + final SharedPreferences? _prefs; + + TranscriptionEngineTypeNotifier(this._prefs) + : super( + _parseType(_prefs?.getString(SettingsKeys.transcriptionEngineType)), + ); + + static TranscriptionEngineType _parseType(String? value) { + for (final type in TranscriptionEngineType.values) { + if (value == type.name) return type; + } + return TranscriptionEngineType.whisperSmallEn; + } + + void setType(TranscriptionEngineType type) { + state = type; + _prefs?.setString(SettingsKeys.transcriptionEngineType, type.name); + } +} + +// --------------------------------------------------------------------------- +// Local AI settings +// --------------------------------------------------------------------------- + +/// Whether local AI processing of voice notes is enabled +final localAiEnabledProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return LocalAiEnabledNotifier(prefs.valueOrNull); + }); + +class LocalAiEnabledNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + LocalAiEnabledNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.localAiEnabled) ?? false); + + void toggle() { + state = !state; + _prefs?.setBool(SettingsKeys.localAiEnabled, state); + } + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.localAiEnabled, enabled); + } +} + +/// Whether voice notes should be automatically AI-processed after transcription +final autoProcessVoiceNotesProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AutoProcessVoiceNotesNotifier(prefs.valueOrNull); + }); + +class AutoProcessVoiceNotesNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AutoProcessVoiceNotesNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.autoProcessVoiceNotes) ?? true); + + void toggle() { + state = !state; + _prefs?.setBool(SettingsKeys.autoProcessVoiceNotes, state); + } + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.autoProcessVoiceNotes, enabled); + } +} + +/// Whether extracted actions (calendar events, reminders) should be automatically +/// created after AI processing, with a watch-side undo window. +final autoCreateActionsProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AutoCreateActionsNotifier(prefs.valueOrNull); + }); + +class AutoCreateActionsNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AutoCreateActionsNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.autoCreateActions) ?? false); + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.autoCreateActions, enabled); + } +} + +/// Whether AI transcript correction is enabled before classification. +final aiCorrectionEnabledProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AiCorrectionEnabledNotifier(prefs.valueOrNull); + }); + +class AiCorrectionEnabledNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AiCorrectionEnabledNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.aiCorrectionEnabled) ?? false); + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.aiCorrectionEnabled, enabled); + } +} + +/// Currently selected local AI model id. +final selectedAiModelIdProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return SelectedAiModelIdNotifier(prefs.valueOrNull); + }); + +class SelectedAiModelIdNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + SelectedAiModelIdNotifier(this._prefs) + : super( + _prefs?.getString(SettingsKeys.selectedAiModelId) ?? + 'qwen25_1_5b_q4_k_m', + ); + + void setModelId(String modelId) { + state = modelId; + _prefs?.setString(SettingsKeys.selectedAiModelId, modelId); + } +} + +/// Currently selected Android calendar id for created reminders/events. +final selectedProductivityCalendarIdProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return SelectedProductivityCalendarIdNotifier(prefs.valueOrNull); + }); + +class SelectedProductivityCalendarIdNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + SelectedProductivityCalendarIdNotifier(this._prefs) + : super(_prefs?.getInt(SettingsKeys.selectedProductivityCalendarId)); + + void setCalendarId(int? calendarId) { + state = calendarId; + if (calendarId == null) { + _prefs?.remove(SettingsKeys.selectedProductivityCalendarId); + return; + } + _prefs?.setInt(SettingsKeys.selectedProductivityCalendarId, calendarId); + } +} + +/// Dev mode: use the latest ELF on the server regardless of hash/commit matching. +final coredumpUseLatestElfProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return CoredumpUseLatestElfNotifier(prefs.valueOrNull); + }); + +class CoredumpUseLatestElfNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + CoredumpUseLatestElfNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.coredumpUseLatestElf) ?? false); + + void toggle() { + state = !state; + _prefs?.setBool(SettingsKeys.coredumpUseLatestElf, state); + } + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.coredumpUseLatestElf, enabled); + } +} + +/// Coredump analysis server URL. +final coredumpServerUrlProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return CoredumpServerUrlNotifier(prefs.valueOrNull); + }); + +class CoredumpServerUrlNotifier extends StateNotifier { + static const String defaultUrl = 'https://zswatch-production.up.railway.app'; + final SharedPreferences? _prefs; + + CoredumpServerUrlNotifier(this._prefs) + : super(_prefs?.getString(SettingsKeys.coredumpServerUrl) ?? defaultUrl); + + void setUrl(String url) { + state = url; + _prefs?.setString(SettingsKeys.coredumpServerUrl, url); + } + + void reset() { + state = defaultUrl; + _prefs?.remove(SettingsKeys.coredumpServerUrl); + } +} diff --git a/zswatch_app/lib/providers/shell_providers.dart b/zswatch_app/lib/providers/shell_providers.dart new file mode 100644 index 0000000..3556795 --- /dev/null +++ b/zswatch_app/lib/providers/shell_providers.dart @@ -0,0 +1,384 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/thread_monitor_data.dart'; +import '../services/shell/shell_service.dart'; +import 'watch_service_provider.dart'; + +// The interactive terminal surfaces non-zero return codes in the UI; the live +// monitor handles them separately so it can stop polling when a capability is +// missing. + +/// Provider for the shell service singleton. +final shellServiceProvider = Provider((ref) { + final service = ShellService(); + ref.onDispose(() => service.dispose()); + + // Auto-set device when connection changes. + ref.listen(watchConnectionProvider, (prev, next) { + if (next.isConnected && next.watchId.isNotEmpty) { + service.setDevice(next.watchId); + } + }, fireImmediately: true); + + return service; +}); + +/// A single terminal line (command or output). +class TerminalLine { + final String text; + final bool isCommand; + final bool isError; + final DateTime timestamp; + + const TerminalLine({ + required this.text, + this.isCommand = false, + this.isError = false, + required this.timestamp, + }); +} + +/// State for the shell terminal. +class ShellTerminalState { + final List lines; + final bool isExecuting; + final List commandHistory; + final int historyIndex; + + const ShellTerminalState({ + this.lines = const [], + this.isExecuting = false, + this.commandHistory = const [], + this.historyIndex = -1, + }); + + ShellTerminalState copyWith({ + List? lines, + bool? isExecuting, + List? commandHistory, + int? historyIndex, + }) { + return ShellTerminalState( + lines: lines ?? this.lines, + isExecuting: isExecuting ?? this.isExecuting, + commandHistory: commandHistory ?? this.commandHistory, + historyIndex: historyIndex ?? this.historyIndex, + ); + } +} + +/// Notifier for the interactive shell terminal. +class ShellTerminalNotifier extends StateNotifier { + final ShellService _shellService; + + ShellTerminalNotifier(this._shellService) : super(const ShellTerminalState()); + + Future execute(String command) async { + if (command.trim().isEmpty) return; + + // Add command to history + final history = [...state.commandHistory]; + // Remove duplicate if already the last entry + if (history.isEmpty || history.last != command) { + history.add(command); + } + + state = state.copyWith( + lines: [ + ...state.lines, + TerminalLine( + text: '\$ $command', + isCommand: true, + timestamp: DateTime.now(), + ), + ], + isExecuting: true, + commandHistory: history, + historyIndex: -1, + ); + + try { + final result = await _shellService.execute(command); + state = state.copyWith( + lines: [ + ...state.lines, + if (result.output.isNotEmpty) + TerminalLine(text: result.output, timestamp: DateTime.now()), + if (result.returnCode != 0) + TerminalLine( + text: '[exit code: ${result.returnCode}]', + isError: true, + timestamp: DateTime.now(), + ), + ], + isExecuting: false, + ); + } catch (e) { + state = state.copyWith( + lines: [ + ...state.lines, + TerminalLine( + text: 'Error: $e', + isError: true, + timestamp: DateTime.now(), + ), + ], + isExecuting: false, + ); + } + } + + void clear() { + state = state.copyWith(lines: []); + } + + String? historyUp() { + if (state.commandHistory.isEmpty) return null; + final newIndex = state.historyIndex == -1 + ? state.commandHistory.length - 1 + : (state.historyIndex - 1).clamp(0, state.commandHistory.length - 1); + state = state.copyWith(historyIndex: newIndex); + return state.commandHistory[newIndex]; + } + + String? historyDown() { + if (state.commandHistory.isEmpty || state.historyIndex == -1) return null; + final newIndex = state.historyIndex + 1; + if (newIndex >= state.commandHistory.length) { + state = state.copyWith(historyIndex: -1); + return ''; + } + state = state.copyWith(historyIndex: newIndex); + return state.commandHistory[newIndex]; + } +} + +final shellTerminalProvider = + StateNotifierProvider((ref) { + final shellService = ref.watch(shellServiceProvider); + return ShellTerminalNotifier(shellService); + }); + +/// State for the live monitor. +class LiveMonitorState { + final bool isEnabled; + final String? cpuFreq; + final PowerInfo? powerInfo; + final Map threadHistories; + final int? schedulerCycles; + final DateTime? lastUpdate; + final String? error; + final int pollCount; + + const LiveMonitorState({ + this.isEnabled = false, + this.cpuFreq, + this.powerInfo, + this.threadHistories = const {}, + this.schedulerCycles, + this.lastUpdate, + this.error, + this.pollCount = 0, + }); + + LiveMonitorState copyWith({ + bool? isEnabled, + String? cpuFreq, + PowerInfo? powerInfo, + Map? threadHistories, + int? schedulerCycles, + DateTime? lastUpdate, + String? error, + int? pollCount, + }) { + return LiveMonitorState( + isEnabled: isEnabled ?? this.isEnabled, + cpuFreq: cpuFreq ?? this.cpuFreq, + powerInfo: powerInfo ?? this.powerInfo, + threadHistories: threadHistories ?? this.threadHistories, + schedulerCycles: schedulerCycles ?? this.schedulerCycles, + lastUpdate: lastUpdate ?? this.lastUpdate, + error: error, + pollCount: pollCount ?? this.pollCount, + ); + } +} + +/// Notifier for the live monitor that polls thread/stack info. +class LiveMonitorNotifier extends StateNotifier { + final ShellService _shellService; + Timer? _pollTimer; + bool _polling = false; + int _consecutiveErrors = 0; + static const _pollInterval = Duration(seconds: 1); + static const _maxConsecutiveErrors = 5; + + LiveMonitorNotifier(this._shellService) : super(const LiveMonitorState()); + + void toggle() { + if (state.isEnabled) { + stop(); + } else { + start(); + } + } + + void start() { + if (state.isEnabled) return; + _consecutiveErrors = 0; + state = state.copyWith(isEnabled: true, error: null); + _poll(); + _pollTimer = Timer.periodic(_pollInterval, (_) => _poll()); + } + + /// Cancel the polling timer without modifying provider state. + /// Safe to call during widget lifecycle (build/deactivate/dispose). + void cancelPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + void stop() { + cancelPolling(); + state = LiveMonitorState( + isEnabled: false, + cpuFreq: state.cpuFreq, + powerInfo: state.powerInfo, + threadHistories: state.threadHistories, + schedulerCycles: state.schedulerCycles, + lastUpdate: state.lastUpdate, + pollCount: state.pollCount, + ); + } + + void resetHistory() { + state = state.copyWith(threadHistories: {}, pollCount: 0); + } + + Future _poll() async { + if (!state.isEnabled || _polling) return; + _polling = true; + + try { + // Power status + PowerInfo? powerInfo; + try { + final powerResult = await _shellService.execute('power status'); + if (state.isEnabled) { + powerInfo = parsePowerStatus(powerResult.output); + } + } catch (_) {} + if (!state.isEnabled) return; + + // CPU freq + String? cpuFreq; + try { + final cpuResult = await _shellService.execute('cpu freq'); + if (state.isEnabled) { + cpuFreq = parseCpuFreq(cpuResult.output); + } + } catch (_) {} + + // Thread list + ThreadListParseResult? parsed; + try { + final threadResult = await _shellService.execute('kernel thread list'); + if (state.isEnabled) { + parsed = parseThreadList(threadResult.output); + debugPrint( + '[LiveMonitor] Parsed ${parsed.threads.length} threads from ${threadResult.output.length} chars', + ); + if (parsed.threads.isEmpty) { + debugPrint( + '[LiveMonitor] Raw output (first 300): ${threadResult.output.substring(0, threadResult.output.length.clamp(0, 300))}', + ); + } + } + } catch (e) { + debugPrint('[LiveMonitor] Thread parse error: $e'); + } + if (!state.isEnabled) return; + + if (!state.isEnabled) return; + + // Update thread histories + final histories = Map.from(state.threadHistories); + final pollSec = _pollInterval.inMilliseconds / 1000.0; + + if (parsed != null) { + final seenKeys = {}; + for (final snapshot in parsed.threads) { + final key = snapshot.name; + seenKeys.add(key); + final existing = histories[key]; + if (existing != null) { + existing.update(snapshot, pollSec); + } else { + histories[key] = ThreadHistory( + name: snapshot.name, + maxStackUsed: snapshot.stackUsed, + stackSize: snapshot.stackSize, + currentStackUsed: snapshot.stackUsed, + currentUsagePercent: snapshot.usagePercent, + state: snapshot.state, + priority: snapshot.priority, + initialCycles: snapshot.totalCycles, + firstSeenPoll: state.pollCount, + ); + } + } + // Mark threads no longer reported as removed (keep their last state). + for (final entry in histories.entries) { + if (!seenKeys.contains(entry.key) && !entry.value.removed) { + entry.value.removed = true; + entry.value.removedAt = DateTime.now(); + entry.value.state = 'removed'; + } + } + } + + _consecutiveErrors = 0; + state = state.copyWith( + cpuFreq: cpuFreq, + powerInfo: powerInfo, + threadHistories: histories, + schedulerCycles: parsed?.scheduler?.cyclesSinceLastCall, + lastUpdate: DateTime.now(), + error: null, + pollCount: state.pollCount + 1, + ); + } catch (e) { + _consecutiveErrors++; + if (state.isEnabled) { + if (_consecutiveErrors >= _maxConsecutiveErrors) { + debugPrint( + '[LiveMonitor] Too many consecutive errors ($_consecutiveErrors), stopping', + ); + stop(); + state = state.copyWith( + error: 'Stopped: SMP connection failed repeatedly', + ); + } else { + state = state.copyWith(error: e.toString()); + } + } + } finally { + _polling = false; + } + } + + @override + void dispose() { + stop(); + super.dispose(); + } +} + +final liveMonitorProvider = + StateNotifierProvider((ref) { + final shellService = ref.watch(shellServiceProvider); + return LiveMonitorNotifier(shellService); + }); diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart new file mode 100644 index 0000000..b1e178d --- /dev/null +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -0,0 +1,529 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/extracted_action.dart'; +import '../data/models/voice_memo.dart'; +import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/extracted_action_creation_service.dart'; +import '../services/ai/voice_note_ai_pipeline.dart'; +import '../services/voice_memo/transcription_engine.dart'; +import '../services/voice_memo/voice_memo_sync_service.dart'; +import 'ai_providers.dart'; +import 'settings_providers.dart'; +import 'watch_providers.dart'; +import 'watch_service_provider.dart'; + +// ==================== Repository Provider ==================== + +/// Provider for the voice memo repository singleton +final voiceMemoRepositoryProvider = Provider((ref) { + final db = ref.watch(databaseProvider); + return VoiceMemoRepository(db); +}); + +// ==================== Transcription Engine Provider ==================== + +/// Provider for the transcription engine singleton. +/// Recreated automatically when the user changes the engine type in settings. +final transcriptionEngineProvider = Provider((ref) { + final engineType = ref.watch(transcriptionEngineTypeProvider); + final engine = createTranscriptionEngine(engineType); + ref.onDispose(() => engine.dispose()); + return engine; +}); + +/// Stream of transcription engine state +final transcriptionEngineStateProvider = + StreamProvider((ref) { + final engine = ref.watch(transcriptionEngineProvider); + return engine.stateStream; + }); + +class TranscriptionModelLocalStatus { + final bool downloaded; + final int? localSizeBytes; + final String localPath; + + const TranscriptionModelLocalStatus({ + required this.downloaded, + required this.localSizeBytes, + required this.localPath, + }); +} + +final transcriptionModelStatusProvider = + FutureProvider.family< + TranscriptionModelLocalStatus, + TranscriptionEngineType + >((ref, type) async { + final engine = createTranscriptionEngine(type); + try { + final localPath = await engine.modelFilePath(); + final file = File(localPath); + final downloaded = file.existsSync(); + + return TranscriptionModelLocalStatus( + downloaded: downloaded, + localSizeBytes: downloaded ? file.lengthSync() : null, + localPath: localPath, + ); + } finally { + engine.dispose(); + } + }); + +final transcriptionConfiguredProvider = FutureProvider((ref) async { + final selected = ref.watch(transcriptionEngineTypeProvider); + final status = await ref.watch( + transcriptionModelStatusProvider(selected).future, + ); + return status.downloaded; +}); + +// ==================== Sync Service Provider ==================== + +/// Provider for the voice memo sync service singleton +final voiceMemoSyncServiceProvider = Provider((ref) { + final watchService = ref.watch(watchServiceProvider); + final repository = ref.watch(voiceMemoRepositoryProvider); + + final service = VoiceMemoSyncService( + watchService: watchService, + repository: repository, + ); + + // Wire up auto-transcription (and optionally AI processing) after sync. + // Settings and pipeline are read lazily at call time so changes after + // provider creation are always picked up. + service.onSyncCompleted = (downloadedCount) async { + debugPrint( + '[VoiceMemoProviders] Sync completed ($downloadedCount new). ' + 'Starting auto-transcription.', + ); + final engine = ref.read(transcriptionEngineProvider); + final aiEnabled = ref.read(localAiEnabledProvider); + final autoProcess = ref.read(autoProcessVoiceNotesProvider); + VoiceNoteAiPipeline? pipeline; + if (aiEnabled && autoProcess) { + pipeline = ref.read(voiceNoteAiPipelineProvider); + // Wire round-trip confirmation: send result back to watch after AI processing + final autoCreate = ref.read(autoCreateActionsProvider); + pipeline!.onProcessingComplete = (filename, title, actionType, datetime) { + service.sendResultToWatch( + filename, + title, + actionType: actionType, + datetime: datetime, + onConfirmed: autoCreate + ? (confirmedFilename) => _autoCreateActionsForMemo( + ref: ref, + filename: confirmedFilename, + ) + : null, + ); + }; + } + await _autoTranscribeAndProcess( + repository: repository, + engine: engine, + pipeline: pipeline, + // Report per-memo progress so the UI shows "Transcribing..." state + onTranscribingMemo: (filename) { + ref + .read(autoTranscribeStateProvider.notifier) + .state = VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + activeFilename: filename, + ); + }, + onDone: () { + ref.read(autoTranscribeStateProvider.notifier).state = + const VoiceMemoActionState.idle(); + }, + ); + }; + + ref.onDispose(() => service.dispose()); + return service; +}); + +/// State for background auto-transcription triggered after sync. +/// The UI watches this alongside [voiceMemoActionsProvider] so buttons +/// reflect in-progress state even when transcription was auto-started. +final autoTranscribeStateProvider = StateProvider( + (ref) => const VoiceMemoActionState.idle(), +); + +/// Auto-transcribe all untranscribed memos after sync, then optionally +/// run the AI pipeline on newly transcribed memos. +Future _autoTranscribeAndProcess({ + required VoiceMemoRepository repository, + required TranscriptionEngine engine, + required VoiceNoteAiPipeline? pipeline, + void Function(String filename)? onTranscribingMemo, + void Function()? onDone, +}) async { + try { + final untranscribed = await repository.getUntranscribedMemos(); + if (untranscribed.isEmpty) { + // Even if nothing new to transcribe, there may be unprocessed memos + if (pipeline != null) { + await pipeline.processAllUnprocessed(); + } + onDone?.call(); + return; + } + + debugPrint( + '[VoiceMemoProviders] Auto-transcribing ${untranscribed.length} memos', + ); + + for (final memo in untranscribed) { + try { + final audioPath = memo.convertedFilePath ?? memo.localFilePath; + if (audioPath == null) continue; + + onTranscribingMemo?.call(memo.filename); + final text = await engine.transcribe(audioPath); + await repository.updateTranscription( + filename: memo.filename, + transcription: text.isEmpty ? '[No speech detected]' : text, + ); + debugPrint('[VoiceMemoProviders] Auto-transcribed: ${memo.filename}'); + } catch (e) { + debugPrint( + '[VoiceMemoProviders] Failed to transcribe ${memo.filename}: $e', + ); + } + } + + // After transcription, wait briefly to allow the whisper memory to be reclaimed + // and system to stabilize before starting LLM inference. This reduces memory + // pressure when the two models might both be in RAM simultaneously. + if (pipeline != null) { + debugPrint('[VoiceMemoProviders] Waiting 500ms before AI processing'); + await Future.delayed(const Duration(milliseconds: 500)); + debugPrint('[VoiceMemoProviders] Starting auto AI processing'); + await pipeline.processAllUnprocessed(); + } + } catch (e) { + debugPrint('[VoiceMemoProviders] Auto-transcription/processing error: $e'); + } finally { + onDone?.call(); + } +} + +/// Auto-create all pending extracted actions for a memo after the watch +/// confirmation timeout expires without an undo. +Future _autoCreateActionsForMemo({ + required Ref ref, + required String filename, +}) async { + try { + final repository = ref.read(voiceMemoRepositoryProvider); + final memo = await repository.getMemoByFilename(filename); + if (memo == null) { + debugPrint( + '[VoiceMemoProviders] Auto-create: memo not found for $filename', + ); + return; + } + + final actionRepo = ref.read(extractedActionRepositoryProvider); + final actions = await actionRepo.getActionsForMemo(memo.id); + final pending = actions.where((a) => !a.created && !a.dismissed).toList(); + + if (pending.isEmpty) { + debugPrint( + '[VoiceMemoProviders] Auto-create: no pending actions for $filename', + ); + return; + } + + final ops = ref.read(extractedActionOperationsProvider); + final selectedCalendarId = ref.read(selectedProductivityCalendarIdProvider); + + for (final action in pending) { + // Tasks and reminders require a scheduled time on Android — skip + // auto-creation if there's no date, the user can create manually. + final requiresDate = + action.actionType == ExtractedActionType.task || + action.actionType == ExtractedActionType.reminder; + if (requiresDate && action.startTime == null && action.dueDate == null) { + debugPrint( + '[VoiceMemoProviders] Skipping auto-create for action ${action.id} ' + '(${action.actionType}) — no scheduled time', + ); + continue; + } + try { + final draft = ActionCreationDraft.fromAction(action).copyWith( + platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, + ); + final message = await ops.createAction(action: action, draft: draft); + debugPrint('[VoiceMemoProviders] Auto-created action: $message'); + } catch (e) { + debugPrint( + '[VoiceMemoProviders] Failed to auto-create action ${action.id}: $e', + ); + } + } + } catch (e) { + debugPrint('[VoiceMemoProviders] Auto-create actions error: $e'); + } +} + +// ==================== Voice Memo List Provider ==================== + +/// Stream of all voice memos (reactive, newest first) +final voiceMemoListProvider = StreamProvider>((ref) { + final repository = ref.watch(voiceMemoRepositoryProvider); + return repository.watchAllMemos(); +}); + +/// Stream a single voice memo by database id. +final voiceMemoByIdProvider = StreamProvider.family((ref, id) { + final repository = ref.watch(voiceMemoRepositoryProvider); + return repository.watchAllMemos().map((memos) { + for (final memo in memos) { + if (memo.id == id) { + return memo; + } + } + return null; + }); +}); + +// ==================== Sync State Provider ==================== + +/// Stream of sync state updates +final voiceMemoSyncStateProvider = StreamProvider((ref) { + final service = ref.watch(voiceMemoSyncServiceProvider); + return service.syncState; +}); + +// ==================== Voice Memo Actions ==================== + +enum VoiceMemoActionType { sync, delete, transcribe } + +class VoiceMemoActionState { + final bool isLoading; + final VoiceMemoActionType? actionType; + final String? activeFilename; + final Object? error; + + const VoiceMemoActionState({ + required this.isLoading, + this.actionType, + this.activeFilename, + this.error, + }); + + const VoiceMemoActionState.idle() + : isLoading = false, + actionType = null, + activeFilename = null, + error = null; + + const VoiceMemoActionState.loading({ + required this.actionType, + this.activeFilename, + }) : isLoading = true, + error = null; + + const VoiceMemoActionState.error({ + required this.actionType, + this.activeFilename, + required this.error, + }) : isLoading = false; + + bool isTranscribingMemo(String filename) { + return isLoading && + actionType == VoiceMemoActionType.transcribe && + activeFilename == filename; + } +} + +/// Notifier for voice memo actions (sync, delete, transcribe) +class VoiceMemoActionsNotifier extends StateNotifier { + final VoiceMemoSyncService _syncService; + final VoiceMemoRepository _repository; + final TranscriptionEngine _transcriptionEngine; + + VoiceMemoActionsNotifier({ + required VoiceMemoSyncService syncService, + required VoiceMemoRepository repository, + required TranscriptionEngine transcriptionEngine, + }) : _syncService = syncService, + _repository = repository, + _transcriptionEngine = transcriptionEngine, + super(const VoiceMemoActionState.idle()); + + /// Trigger a sync of voice memos from the watch + Future sync() async { + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.sync, + ); + try { + await _syncService.syncRecordings(); + state = const VoiceMemoActionState.idle(); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Sync error: $e'); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.sync, + error: e, + ); + } + } + + /// Delete a voice memo locally + Future delete(String filename) async { + state = VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.delete, + activeFilename: filename, + ); + try { + await _repository.deleteMemo(filename); + state = const VoiceMemoActionState.idle(); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Delete error: $e'); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.delete, + activeFilename: filename, + error: e, + ); + } + } + + /// Transcribe a single voice memo + /// + /// Uses the Ogg file (converted from .zsw_opus) as input. + /// The FFmpeg converter registered at startup handles Ogg → WAV conversion + /// for Whisper automatically. + Future transcribe(VoiceMemo memo) async { + state = VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + activeFilename: memo.filename, + ); + try { + await _transcribeMemo(memo); + + debugPrint('[VoiceMemoActions] Transcription saved for ${memo.filename}'); + state = const VoiceMemoActionState.idle(); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Transcription error: $e'); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + activeFilename: memo.filename, + error: e, + ); + } + } + + /// Re-transcribe a memo using the currently selected transcription model. + /// + /// Existing transcription text is overwritten. + Future retranscribe(VoiceMemo memo) => transcribe(memo); + + /// Transcribe all synced but untranscribed memos + Future transcribeAll() async { + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + ); + try { + final untranscribed = await _repository.getUntranscribedMemos(); + debugPrint( + '[VoiceMemoActions] Transcribing ${untranscribed.length} memos', + ); + + for (final memo in untranscribed) { + try { + await transcribe(memo); + } catch (e) { + debugPrint( + '[VoiceMemoActions] Failed to transcribe ${memo.filename}: $e', + ); + // Continue with next memo + } + } + state = const VoiceMemoActionState.idle(); + } catch (e, st) { + debugPrint('[VoiceMemoActions] TranscribeAll error: $e'); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + error: e, + ); + } + } + + /// Re-transcribe all downloaded memos using the currently selected model. + /// + /// Returns the number of memos attempted. + Future retranscribeAll() async { + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + ); + try { + final memos = await _repository.getTranscribableMemos(); + debugPrint('[VoiceMemoActions] Re-transcribing ${memos.length} memos'); + + for (final memo in memos) { + try { + await _transcribeMemo(memo); + } catch (e) { + debugPrint( + '[VoiceMemoActions] Failed to re-transcribe ${memo.filename}: $e', + ); + } + } + + state = const VoiceMemoActionState.idle(); + return memos.length; + } catch (e, st) { + debugPrint('[VoiceMemoActions] RetranscribeAll error: $e'); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + error: e, + ); + rethrow; + } + } + + Future _transcribeMemo(VoiceMemo memo) async { + final audioPath = memo.convertedFilePath ?? memo.localFilePath; + if (audioPath == null) { + throw Exception('No audio file available for transcription'); + } + + debugPrint('[VoiceMemoActions] Transcribing: ${memo.filename}'); + final text = await _transcriptionEngine.transcribe(audioPath); + + await _repository.updateTranscription( + filename: memo.filename, + transcription: text.isEmpty ? '[No speech detected]' : text, + ); + } +} + +/// Provider for voice memo actions +final voiceMemoActionsProvider = + StateNotifierProvider(( + ref, + ) { + final syncService = ref.watch(voiceMemoSyncServiceProvider); + final repository = ref.watch(voiceMemoRepositoryProvider); + final transcriptionEngine = ref.watch(transcriptionEngineProvider); + return VoiceMemoActionsNotifier( + syncService: syncService, + repository: repository, + transcriptionEngine: transcriptionEngine, + ); + }); diff --git a/zswatch_app/lib/providers/watch_providers.dart b/zswatch_app/lib/providers/watch_providers.dart index 83fdedf..bb2b4ab 100644 --- a/zswatch_app/lib/providers/watch_providers.dart +++ b/zswatch_app/lib/providers/watch_providers.dart @@ -31,8 +31,10 @@ final primaryWatchProvider = FutureProvider((ref) async { }); /// Provider for a specific watch by ID -final watchByIdProvider = - FutureProvider.family((ref, watchId) async { +final watchByIdProvider = FutureProvider.family(( + ref, + watchId, +) async { final db = ref.watch(databaseProvider); return db.getWatchById(watchId); }); @@ -66,16 +68,18 @@ class WatchNotifier extends StateNotifier> { }) async { state = const AsyncValue.loading(); try { - await _db.upsertWatch(WatchesCompanion( - id: Value(id), - name: Value(name), - firmwareVersion: Value(firmwareVersion), - hardwareVersion: Value(hardwareVersion), - batteryLevel: Value(batteryLevel), - isPrimary: Value(isPrimary), - supportsExtendedApi: Value(supportsExtendedApi), - createdAt: Value(DateTime.now()), - )); + await _db.upsertWatch( + WatchesCompanion( + id: Value(id), + name: Value(name), + firmwareVersion: Value(firmwareVersion), + hardwareVersion: Value(hardwareVersion), + batteryLevel: Value(batteryLevel), + isPrimary: Value(isPrimary), + supportsExtendedApi: Value(supportsExtendedApi), + createdAt: Value(DateTime.now()), + ), + ); state = const AsyncValue.data(null); } catch (e, st) { state = AsyncValue.error(e, st); @@ -123,7 +127,7 @@ class WatchNotifier extends StateNotifier> { } /// Rename a watch by setting its custom name (T114, T119) - /// + /// /// If [customName] is null or empty, clears the custom name. Future renameWatch(String watchId, String? customName) async { state = const AsyncValue.loading(); @@ -136,7 +140,7 @@ class WatchNotifier extends StateNotifier> { } /// Forget a watch completely (T115, T118) - /// + /// /// Removes the watch from the database AND unbonds the BLE device. /// After calling this, the user will need to re-pair to use the watch again. Future forgetWatch(String watchId) async { @@ -162,9 +166,9 @@ class WatchNotifier extends StateNotifier> { /// Provider for watch operations notifier final watchNotifierProvider = StateNotifierProvider>((ref) { - final db = ref.watch(databaseProvider); - return WatchNotifier(db); -}); + final db = ref.watch(databaseProvider); + return WatchNotifier(db); + }); /// Provider for watch count final watchCountProvider = Provider((ref) { @@ -176,4 +180,3 @@ final watchCountProvider = Provider((ref) { final hasWatchesProvider = Provider((ref) { return ref.watch(watchCountProvider) > 0; }); - diff --git a/zswatch_app/lib/providers/watch_service_provider.dart b/zswatch_app/lib/providers/watch_service_provider.dart index 1b0d58c..bc4669d 100644 --- a/zswatch_app/lib/providers/watch_service_provider.dart +++ b/zswatch_app/lib/providers/watch_service_provider.dart @@ -4,32 +4,61 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/connection.dart'; +import '../data/models/connection_phase.dart'; import '../data/models/connection_state.dart'; +import '../data/models/crash_summary.dart'; import '../data/models/watch.dart'; import '../data/repositories/watch_repository.dart'; -import '../services/watch_service.dart'; +import '../services/ble/ble_connection_service.dart'; import '../services/ble/ble_scanner.dart'; +import '../services/watch_service.dart'; +import 'base_async_notifier.dart'; import 'demo_mode_provider.dart'; import 'watch_providers.dart'; -/// Provider for the unified WatchService +/// Provider for the BleConnectionService singleton — single source of truth +/// for connection lifecycle. +final bleConnectionServiceProvider = Provider((ref) { + final service = BleConnectionService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Provider for the WatchService (protocol, messaging, battery). final watchServiceProvider = Provider((ref) { - final service = WatchService(); - debugPrint('[watchServiceProvider] Created WatchService instance: ${service.hashCode}'); + final ble = ref.watch(bleConnectionServiceProvider); + final service = WatchService(ble); + debugPrint( + '[watchServiceProvider] Created WatchService instance: ${service.hashCode}', + ); ref.onDispose(() { - debugPrint('[watchServiceProvider] Disposing WatchService instance: ${service.hashCode}'); + debugPrint( + '[watchServiceProvider] Disposing WatchService instance: ${service.hashCode}', + ); service.dispose(); }); return service; }); -/// Provider for connection state stream +/// Provider for connection phase stream (primary state). +final connectionPhaseStreamProvider = StreamProvider((ref) { + final ble = ref.watch(bleConnectionServiceProvider); + return ble.phaseStream; +}); + +/// Provider for current connection phase. +final connectionPhaseProvider = Provider((ref) { + final asyncValue = ref.watch(connectionPhaseStreamProvider); + return asyncValue.valueOrNull ?? const ConnectionPhase.disconnected(); +}); + +/// Provider for connection state stream (backwards compat Connection model). final watchConnectionStreamProvider = StreamProvider((ref) { - final service = ref.watch(watchServiceProvider); - return service.connectionStream; + final ble = ref.watch(bleConnectionServiceProvider); + return ble.connectionStream; }); -/// Provider for current connection (non-stream) +/// Provider for current connection (non-stream). /// /// In demo mode, returns a fake "connected" connection so all screens /// are accessible without real hardware. @@ -38,204 +67,181 @@ final watchConnectionProvider = Provider((ref) { if (demo != null) return demo; final asyncValue = ref.watch(watchConnectionStreamProvider); - return asyncValue.valueOrNull ?? const Connection( - watchId: '', - state: WatchConnectionState.disconnected, - ); + return asyncValue.valueOrNull ?? + const Connection(watchId: '', state: WatchConnectionState.disconnected); }); -/// Provider for whether watch is connected +/// Provider for whether watch is connected. final isWatchConnectedProvider = Provider((ref) { final connection = ref.watch(watchConnectionProvider); return connection.state == WatchConnectionState.connected; }); -/// Provider for connection state enum +/// Provider for connection state enum. final watchConnectionStateProvider = Provider((ref) { final connection = ref.watch(watchConnectionProvider); return connection.state; }); -/// Provider for watch info stream +/// Provider for watch info stream. final watchInfoStreamProvider = StreamProvider((ref) { final service = ref.watch(watchServiceProvider); return service.watchInfoStream; }); -/// Provider for current watch info - merges live service data with database data -/// -/// The service provides live updates (battery, firmware from protocol, etc.) -/// The database provides persisted data (customName, etc.) -/// This provider merges them to provide a complete Watch with all fields. +/// Provider for current watch info - merges live service data with database data. /// /// In demo mode, returns a static placeholder watch. final currentWatchProvider = Provider((ref) { final demo = demoWatchOrNull(ref); if (demo != null) return demo; - // Watch the stream to get reactive updates from the service final asyncValue = ref.watch(watchInfoStreamProvider); - // Also get the synchronous value directly from the service as fallback final service = ref.watch(watchServiceProvider); - - // Get the service's watch info (has live updates) + final serviceWatch = asyncValue.valueOrNull ?? service.currentWatch; if (serviceWatch == null) return null; - - // Get the database watch info (has customName and other persisted data) + final dbWatchAsync = ref.watch(watchByIdProvider(serviceWatch.id)); final dbWatch = dbWatchAsync.valueOrNull; - - // Merge: use service watch as base, overlay customName from database + if (dbWatch != null && dbWatch.customName != null) { return serviceWatch.copyWith(customName: dbWatch.customName); } - + return serviceWatch; }); -/// Provider for current watch (non-null when connected) +/// Provider for current watch (non-null when connected). final connectedWatchProvider = Provider((ref) { final isConnected = ref.watch(isWatchConnectedProvider); if (!isConnected) return null; return ref.watch(currentWatchProvider); }); -/// Provider for battery level stream +/// Provider for battery level stream. final batteryLevelStreamProvider = StreamProvider((ref) { final service = ref.watch(watchServiceProvider); return service.batteryStream; }); -/// Provider for current battery level +/// Provider for current battery level. final batteryLevelProvider = Provider((ref) { final asyncValue = ref.watch(batteryLevelStreamProvider); return asyncValue.valueOrNull; }); -/// Provider for whether the connected watch has the SMP service available -/// (required for DFU firmware updates and filesystem uploads). -/// Re-evaluated whenever connection state changes. +/// Provider for whether the connected watch has the SMP service available. final hasSmpServiceProvider = Provider((ref) { - // Watch connection state to re-evaluate when connection changes final isConnected = ref.watch(isWatchConnectedProvider); if (!isConnected) return false; + final ble = ref.watch(bleConnectionServiceProvider); + return ble.hasSmpService; +}); + +/// Provider for crash summary stream (null if no crash available). +final crashSummaryStreamProvider = StreamProvider((ref) { final service = ref.watch(watchServiceProvider); - return service.hasSmpService; + return service.crashSummaryStream; +}); + +/// Provider for current crash summary. +final crashSummaryProvider = Provider((ref) { + final asyncValue = ref.watch(crashSummaryStreamProvider); + return asyncValue.valueOrNull; +}); + +/// Whether to show the crash indicator on the dashboard. +final showCrashIndicatorProvider = StateProvider((ref) { + final summary = ref.watch(crashSummaryProvider); + return summary != null; }); /// Provider that syncs watch info changes to the database. -/// -/// This provider listens to watch info stream and persists changes -/// (firmware version, hardware version, battery level, lastConnectedAt) -/// to the database when they change. final watchInfoPersistenceProvider = Provider((ref) { final db = ref.watch(databaseProvider); final repository = WatchRepository(db); - - // Track last persisted values to avoid duplicate writes + String? lastPersistedFw; String? lastPersistedHw; - - // Listen to watch info changes and persist to database + ref.listen(watchInfoStreamProvider, (previous, next) { final watch = next.valueOrNull; if (watch == null || watch.id.isEmpty) return; - - // Check if firmware or hardware version changed - final fwChanged = watch.firmwareVersion != null && + + final fwChanged = + watch.firmwareVersion != null && watch.firmwareVersion != lastPersistedFw; - final hwChanged = watch.hardwareVersion != null && + final hwChanged = + watch.hardwareVersion != null && watch.hardwareVersion != lastPersistedHw; - + if (fwChanged || hwChanged) { - debugPrint('[WatchInfoPersistence] Persisting firmware/hw version: ' - 'fw=${watch.firmwareVersion}, hw=${watch.hardwareVersion}'); - + debugPrint( + '[WatchInfoPersistence] Persisting firmware/hw version: ' + 'fw=${watch.firmwareVersion}, hw=${watch.hardwareVersion}', + ); + if (watch.firmwareVersion != null) { lastPersistedFw = watch.firmwareVersion; } if (watch.hardwareVersion != null) { lastPersistedHw = watch.hardwareVersion; } - - unawaited(repository.updateFirmwareVersion( - watch.id, - watch.firmwareVersion ?? '', - hardwareVersion: watch.hardwareVersion, - )); + + unawaited( + repository.updateFirmwareVersion( + watch.id, + watch.firmwareVersion ?? '', + hardwareVersion: watch.hardwareVersion, + ), + ); } }); - - // Listen to connection state to update lastConnectedAt when connected + bool hasUpdatedLastConnected = false; String lastConnectedWatchId = ''; - + ref.listen(watchConnectionStreamProvider, (previous, next) { final connection = next.valueOrNull; if (connection == null) return; - - // Reset tracking when disconnected or connecting to different device + if (connection.state == WatchConnectionState.disconnected || connection.watchId != lastConnectedWatchId) { hasUpdatedLastConnected = false; lastConnectedWatchId = connection.watchId; } - - // Update lastConnectedAt when connection becomes connected (once per connection) - if (connection.state == WatchConnectionState.connected && + + if (connection.state == WatchConnectionState.connected && connection.watchId.isNotEmpty && !hasUpdatedLastConnected) { hasUpdatedLastConnected = true; - debugPrint('[WatchInfoPersistence] Updating lastConnectedAt for ${connection.watchId}'); + debugPrint( + '[WatchInfoPersistence] Updating lastConnectedAt for ${connection.watchId}', + ); unawaited(repository.updateLastConnected(connection.watchId)); } }); }); -/// Notifier for watch operations -class WatchNotifier extends StateNotifier> { +/// Notifier for watch operations. +class WatchNotifier extends BaseAsyncNotifier { final WatchService _watchService; - WatchNotifier(this._watchService) - : super(const AsyncValue.data(null)); + WatchNotifier(this._watchService); - /// Connect to a scanned device - Future connect(ScannedWatch device) async { - state = const AsyncValue.loading(); - try { - await _watchService.connect(device); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - rethrow; - } - } + /// Connect to a scanned device. + Future connect(ScannedWatch device) => + run(() => _watchService.connect(device), rethrowError: true); - /// Connect to a saved device by ID - Future connectById(String deviceId) async { - state = const AsyncValue.loading(); - try { - await _watchService.connectById(deviceId); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - rethrow; - } - } + /// Connect to a saved device by ID. + Future connectById(String deviceId) => + run(() => _watchService.connectById(deviceId), rethrowError: true); - /// Disconnect from current device - Future disconnect() async { - state = const AsyncValue.loading(); - try { - await _watchService.disconnect(); - state = const AsyncValue.data(null); - } catch (e, st) { - state = AsyncValue.error(e, st); - } - } + /// Disconnect from current device. + Future disconnect() => run(() => _watchService.disconnect()); - /// Request device info + /// Request device info. Future requestDeviceInfo() async { try { await _watchService.requestDeviceInfo(); @@ -244,7 +250,7 @@ class WatchNotifier extends StateNotifier> { } } - /// Sync time + /// Sync time. Future syncTime() async { try { await _watchService.syncTime(); @@ -254,10 +260,9 @@ class WatchNotifier extends StateNotifier> { } } -/// Provider for watch notifier +/// Provider for watch notifier. final watchNotifierProvider = StateNotifierProvider>((ref) { - final watchService = ref.watch(watchServiceProvider); - return WatchNotifier(watchService); -}); - + final watchService = ref.watch(watchServiceProvider); + return WatchNotifier(watchService); + }); diff --git a/zswatch_app/lib/providers/watch_state_provider.dart b/zswatch_app/lib/providers/watch_state_provider.dart index cea4307..3e65821 100644 --- a/zswatch_app/lib/providers/watch_state_provider.dart +++ b/zswatch_app/lib/providers/watch_state_provider.dart @@ -1,50 +1,37 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/watch.dart'; import '../data/models/connection.dart'; import '../data/models/connection_state.dart'; import '../data/repositories/watch_repository.dart'; -import '../services/ble/ble_connection_manager.dart'; -import 'ble_providers.dart'; +import '../services/ble/ble_connection_service.dart'; import 'watch_providers.dart'; +import 'watch_service_provider.dart'; -/// The current state of the connected watch -class WatchState { - final Watch? watch; - final Connection connection; - final bool isLoading; - final String? error; - - const WatchState({ - this.watch, - required this.connection, - this.isLoading = false, - this.error, - }); +part 'watch_state_provider.freezed.dart'; - factory WatchState.initial() => const WatchState( - connection: Connection( - watchId: '', - state: WatchConnectionState.disconnected, - ), - ); +/// The current state of the connected watch +@freezed +abstract class WatchState with _$WatchState { + const WatchState._(); - WatchState copyWith({ + const factory WatchState({ Watch? watch, - Connection? connection, - bool? isLoading, + required Connection connection, + @Default(false) bool isLoading, String? error, - }) { - return WatchState( - watch: watch ?? this.watch, - connection: connection ?? this.connection, - isLoading: isLoading ?? this.isLoading, - error: error, - ); - } + }) = _WatchState; + + factory WatchState.initial() => const WatchState( + connection: Connection( + watchId: '', + state: WatchConnectionState.disconnected, + ), + ); bool get isConnected => connection.state == WatchConnectionState.connected; bool get isConnecting => connection.state.isConnectingOrReconnecting; @@ -53,18 +40,18 @@ class WatchState { /// Notifier that manages the connected watch state class WatchStateNotifier extends StateNotifier { - final BleConnectionManager _connectionManager; + final BleConnectionService _connectionService; final WatchRepository? _watchRepository; StreamSubscription? _connectionSubscription; - WatchStateNotifier(this._connectionManager, this._watchRepository) - : super(WatchState.initial()) { + WatchStateNotifier(this._connectionService, this._watchRepository) + : super(WatchState.initial()) { _init(); } void _init() { // Listen to connection state changes - _connectionSubscription = _connectionManager.connectionStream.listen( + _connectionSubscription = _connectionService.connectionStream.listen( _handleConnectionChange, ); } @@ -77,10 +64,7 @@ class WatchStateNotifier extends StateNotifier { _fetchWatchInfo(connection.watchId); } else if (connection.state == WatchConnectionState.disconnected) { // Clear watch when disconnected (but keep in DB) - state = state.copyWith( - watch: null, - connection: connection, - ); + state = state.copyWith(watch: null, connection: connection); } } @@ -92,27 +76,16 @@ class WatchStateNotifier extends StateNotifier { Watch? watch = await _watchRepository?.getWatchById(watchId); // New watch - create a basic entry if not in DB - watch ??= Watch( - id: watchId, - name: 'ZSWatch', - createdAt: DateTime.now(), - ); + watch ??= Watch(id: watchId, name: 'ZSWatch', createdAt: DateTime.now()); // Update state with watch info - state = state.copyWith( - watch: watch, - isLoading: false, - ); + state = state.copyWith(watch: watch, isLoading: false); // TODO: Request device info via protocol to get firmware version, battery, etc. // This will be done when we wire up the protocol service - } catch (e) { debugPrint('Error fetching watch info: $e'); - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -125,13 +98,15 @@ class WatchStateNotifier extends StateNotifier { }) { if (state.watch == null) return; - final updatedWatch = state.watch!.copyWith( - name: name, + var updatedWatch = state.watch!.copyWith( firmwareVersion: firmwareVersion, hardwareVersion: hardwareVersion, batteryLevel: batteryLevel, lastConnectedAt: DateTime.now(), ); + if (name != null) { + updatedWatch = updatedWatch.copyWith(name: name); + } state = state.copyWith(watch: updatedWatch); @@ -143,9 +118,7 @@ class WatchStateNotifier extends StateNotifier { void updateBatteryLevel(int level) { if (state.watch == null) return; - state = state.copyWith( - watch: state.watch!.copyWith(batteryLevel: level), - ); + state = state.copyWith(watch: state.watch!.copyWith(batteryLevel: level)); // Persist to database _watchRepository?.updateBatteryLevel(state.watch!.id, level); @@ -153,7 +126,7 @@ class WatchStateNotifier extends StateNotifier { /// Disconnect from the current watch Future disconnect() async { - await _connectionManager.disconnect(); + await _connectionService.disconnect(); } /// Save the current watch to the database @@ -178,10 +151,10 @@ final watchRepositoryProvider = Provider((ref) { /// Provider for the watch state notifier final watchStateProvider = StateNotifierProvider((ref) { - final connectionManager = ref.watch(bleConnectionManagerProvider); - final watchRepository = ref.watch(watchRepositoryProvider); - return WatchStateNotifier(connectionManager, watchRepository); -}); + final connectionService = ref.watch(bleConnectionServiceProvider); + final watchRepository = ref.watch(watchRepositoryProvider); + return WatchStateNotifier(connectionService, watchRepository); + }); /// Convenience providers for specific watch state properties final connectedWatchProvider = Provider((ref) { @@ -199,4 +172,3 @@ final watchFirmwareProvider = Provider((ref) { final isWatchConnectedProvider = Provider((ref) { return ref.watch(watchStateProvider).isConnected; }); - diff --git a/zswatch_app/lib/providers/watch_state_provider.freezed.dart b/zswatch_app/lib/providers/watch_state_provider.freezed.dart new file mode 100644 index 0000000..9e0f9a3 --- /dev/null +++ b/zswatch_app/lib/providers/watch_state_provider.freezed.dart @@ -0,0 +1,334 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'watch_state_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$WatchState implements DiagnosticableTreeMixin { + + Watch? get watch; Connection get connection; bool get isLoading; String? get error; +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WatchStateCopyWith get copyWith => _$WatchStateCopyWithImpl(this as WatchState, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WatchState')) + ..add(DiagnosticsProperty('watch', watch))..add(DiagnosticsProperty('connection', connection))..add(DiagnosticsProperty('isLoading', isLoading))..add(DiagnosticsProperty('error', error)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WatchState&&(identical(other.watch, watch) || other.watch == watch)&&(identical(other.connection, connection) || other.connection == connection)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,watch,connection,isLoading,error); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'WatchState(watch: $watch, connection: $connection, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $WatchStateCopyWith<$Res> { + factory $WatchStateCopyWith(WatchState value, $Res Function(WatchState) _then) = _$WatchStateCopyWithImpl; +@useResult +$Res call({ + Watch? watch, Connection connection, bool isLoading, String? error +}); + + +$WatchCopyWith<$Res>? get watch;$ConnectionCopyWith<$Res> get connection; + +} +/// @nodoc +class _$WatchStateCopyWithImpl<$Res> + implements $WatchStateCopyWith<$Res> { + _$WatchStateCopyWithImpl(this._self, this._then); + + final WatchState _self; + final $Res Function(WatchState) _then; + +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? watch = freezed,Object? connection = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_self.copyWith( +watch: freezed == watch ? _self.watch : watch // ignore: cast_nullable_to_non_nullable +as Watch?,connection: null == connection ? _self.connection : connection // ignore: cast_nullable_to_non_nullable +as Connection,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$WatchCopyWith<$Res>? get watch { + if (_self.watch == null) { + return null; + } + + return $WatchCopyWith<$Res>(_self.watch!, (value) { + return _then(_self.copyWith(watch: value)); + }); +}/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ConnectionCopyWith<$Res> get connection { + + return $ConnectionCopyWith<$Res>(_self.connection, (value) { + return _then(_self.copyWith(connection: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [WatchState]. +extension WatchStatePatterns on WatchState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _WatchState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _WatchState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _WatchState value) $default,){ +final _that = this; +switch (_that) { +case _WatchState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WatchState value)? $default,){ +final _that = this; +switch (_that) { +case _WatchState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Watch? watch, Connection connection, bool isLoading, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _WatchState() when $default != null: +return $default(_that.watch,_that.connection,_that.isLoading,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Watch? watch, Connection connection, bool isLoading, String? error) $default,) {final _that = this; +switch (_that) { +case _WatchState(): +return $default(_that.watch,_that.connection,_that.isLoading,_that.error);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Watch? watch, Connection connection, bool isLoading, String? error)? $default,) {final _that = this; +switch (_that) { +case _WatchState() when $default != null: +return $default(_that.watch,_that.connection,_that.isLoading,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _WatchState extends WatchState with DiagnosticableTreeMixin { + const _WatchState({this.watch, required this.connection, this.isLoading = false, this.error}): super._(); + + +@override final Watch? watch; +@override final Connection connection; +@override@JsonKey() final bool isLoading; +@override final String? error; + +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WatchStateCopyWith<_WatchState> get copyWith => __$WatchStateCopyWithImpl<_WatchState>(this, _$identity); + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WatchState')) + ..add(DiagnosticsProperty('watch', watch))..add(DiagnosticsProperty('connection', connection))..add(DiagnosticsProperty('isLoading', isLoading))..add(DiagnosticsProperty('error', error)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WatchState&&(identical(other.watch, watch) || other.watch == watch)&&(identical(other.connection, connection) || other.connection == connection)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,watch,connection,isLoading,error); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'WatchState(watch: $watch, connection: $connection, isLoading: $isLoading, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$WatchStateCopyWith<$Res> implements $WatchStateCopyWith<$Res> { + factory _$WatchStateCopyWith(_WatchState value, $Res Function(_WatchState) _then) = __$WatchStateCopyWithImpl; +@override @useResult +$Res call({ + Watch? watch, Connection connection, bool isLoading, String? error +}); + + +@override $WatchCopyWith<$Res>? get watch;@override $ConnectionCopyWith<$Res> get connection; + +} +/// @nodoc +class __$WatchStateCopyWithImpl<$Res> + implements _$WatchStateCopyWith<$Res> { + __$WatchStateCopyWithImpl(this._self, this._then); + + final _WatchState _self; + final $Res Function(_WatchState) _then; + +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? watch = freezed,Object? connection = null,Object? isLoading = null,Object? error = freezed,}) { + return _then(_WatchState( +watch: freezed == watch ? _self.watch : watch // ignore: cast_nullable_to_non_nullable +as Watch?,connection: null == connection ? _self.connection : connection // ignore: cast_nullable_to_non_nullable +as Connection,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$WatchCopyWith<$Res>? get watch { + if (_self.watch == null) { + return null; + } + + return $WatchCopyWith<$Res>(_self.watch!, (value) { + return _then(_self.copyWith(watch: value)); + }); +}/// Create a copy of WatchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ConnectionCopyWith<$Res> get connection { + + return $ConnectionCopyWith<$Res>(_self.connection, (value) { + return _then(_self.copyWith(connection: value)); + }); +} +} + +// dart format on diff --git a/zswatch_app/lib/services/ai/ai_debug_info.dart b/zswatch_app/lib/services/ai/ai_debug_info.dart new file mode 100644 index 0000000..010362e --- /dev/null +++ b/zswatch_app/lib/services/ai/ai_debug_info.dart @@ -0,0 +1,131 @@ +/// Unified debug info class shared by both the voice-memo AI pipeline and the +/// benchmark harness. Change field definitions here — both flows use the same +/// data model so the debug sheets stay in sync automatically. +class AiDebugInfo { + /// 'transcription', 'ai', or 'full-flow'. + final String testType; + final String modelName; + + // ---- Prompt / extraction details ---- + final String? promptStrategy; + final String? rawPrompt; + final String? parsedJson; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + final int attempts; + final bool retryEnabled; + + // ---- Phase / live progress ---- + + /// Current phase: 'loading', 'running', 'transcribing', 'correcting', + /// 'classifying', 'done', 'error'. + final String phase; + + /// Partial or summary output. During live streaming this accumulates + /// token-by-token; on completion it holds a human-readable summary. + final String partialOutput; + final int tokens; + final Duration elapsed; + final double? tokensPerSecond; + final String? error; + + /// Full raw LLM output preserved across completion. + final String? rawOutput; + + // ---- Correction pass ---- + final String? correctedTranscription; + final int correctionTokens; + final Duration correctionElapsed; + final double? correctionTokensPerSecond; + + // ---- Transcription stage ---- + final String? transcriptionResult; + final Duration? transcriptionElapsed; + + // ---- Voice-memo context ---- + final String? filename; + final String? summary; + final String? category; + final int actionCount; + final DateTime? timestamp; + + // ---- Memory & inference parameter debug info ---- + final int? deviceMemoryMB; + final int? availableMemoryMB; + final int? modelSizeMB; + final int? memoryHeadroomMB; + final int? requestedContextSize; + final int? inferenceContextSize; + final int? inferenceMaxTokensCap; + + // ---- Per-action chrono extraction details (multi-action support) ---- + final List extractedActions; + + const AiDebugInfo({ + this.testType = 'ai', + required this.modelName, + this.promptStrategy, + this.rawPrompt, + this.parsedJson, + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + this.attempts = 1, + this.retryEnabled = false, + this.phase = 'loading', + this.partialOutput = '', + this.tokens = 0, + this.elapsed = Duration.zero, + this.tokensPerSecond, + this.error, + this.rawOutput, + this.correctedTranscription, + this.correctionTokens = 0, + this.correctionElapsed = Duration.zero, + this.correctionTokensPerSecond, + this.transcriptionResult, + this.transcriptionElapsed, + this.filename, + this.summary, + this.category, + this.actionCount = 0, + this.timestamp, + this.deviceMemoryMB, + this.availableMemoryMB, + this.modelSizeMB, + this.memoryHeadroomMB, + this.requestedContextSize, + this.inferenceContextSize, + this.inferenceMaxTokensCap, + this.extractedActions = const [], + }); + + bool get isComplete => phase == 'done' || phase == 'error'; + bool get isError => phase == 'error'; +} + +/// Per-action debug data for the chrono extraction / resolution display. +class ActionChronoDebug { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + + const ActionChronoDebug({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + }); +} diff --git a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart new file mode 100644 index 0000000..d1fd1a3 --- /dev/null +++ b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart @@ -0,0 +1,470 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../data/models/extracted_action.dart'; + +class PlatformCalendar { + final int id; + final String? displayName; + final String? accountName; + final String? accountType; + final String? ownerAccount; + final bool isPrimary; + + const PlatformCalendar({ + required this.id, + this.displayName, + this.accountName, + this.accountType, + this.ownerAccount, + required this.isPrimary, + }); + + factory PlatformCalendar.fromMap(Map map) { + return PlatformCalendar( + id: (map['id'] as num).toInt(), + displayName: map['displayName'] as String?, + accountName: map['accountName'] as String?, + accountType: map['accountType'] as String?, + ownerAccount: map['ownerAccount'] as String?, + isPrimary: map['isPrimary'] as bool? ?? false, + ); + } + + String get label { + final name = displayName?.trim(); + final account = accountName?.trim(); + if (name != null && + name.isNotEmpty && + account != null && + account.isNotEmpty) { + return '$name — $account'; + } + return name?.isNotEmpty == true + ? name! + : (account?.isNotEmpty == true ? account! : 'Calendar $id'); + } + + bool get looksLocal { + final combined = [ + displayName, + accountName, + accountType, + ownerAccount, + ].whereType().join(' ').toLowerCase(); + return combined.contains('local'); + } +} + +class ActionCreationDraft { + final ExtractedActionType actionType; + final String title; + final String? notes; + final DateTime? scheduledAt; + final DateTime? endAt; + final String? location; + final int? reminderMinutes; + final int? platformCalendarId; + + const ActionCreationDraft({ + required this.actionType, + required this.title, + this.notes, + this.scheduledAt, + this.endAt, + this.location, + this.reminderMinutes, + this.platformCalendarId, + }); + + factory ActionCreationDraft.fromAction(ExtractedAction action) { + final scheduledAt = action.startTime ?? action.dueDate; + final defaultEndAt = + action.actionType == ExtractedActionType.calendarEvent && + scheduledAt != null + ? (action.endTime ?? scheduledAt.add(const Duration(minutes: 30))) + : action.endTime; + + return ActionCreationDraft( + actionType: action.actionType, + title: action.title, + notes: action.notes, + scheduledAt: scheduledAt, + endAt: defaultEndAt, + location: action.location, + reminderMinutes: + action.reminderMinutes ?? + (action.actionType == ExtractedActionType.reminder ? 0 : null), + platformCalendarId: null, + ); + } + + ActionCreationDraft copyWith({ + ExtractedActionType? actionType, + String? title, + String? notes, + DateTime? scheduledAt, + DateTime? endAt, + String? location, + int? reminderMinutes, + int? platformCalendarId, + }) { + return ActionCreationDraft( + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes ?? this.notes, + scheduledAt: scheduledAt ?? this.scheduledAt, + endAt: endAt ?? this.endAt, + location: location ?? this.location, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + platformCalendarId: platformCalendarId ?? this.platformCalendarId, + ); + } + + Map toPlatformMap() { + return { + 'actionType': ExtractedAction.typeToString(actionType), + 'title': title, + 'notes': notes, + 'scheduledAtMillis': scheduledAt?.millisecondsSinceEpoch, + 'endAtMillis': endAt?.millisecondsSinceEpoch, + 'location': location, + 'reminderMinutes': reminderMinutes, + 'calendarId': platformCalendarId, + }; + } +} + +class CreatedPlatformAction { + final String? platformId; + final String targetType; + final String? calendarDisplayName; + final String? calendarAccountName; + + /// True when the calendar sync adapter is disabled (isSyncable=0). + /// The event was inserted locally but won't appear in Google Calendar + /// until the user enables Calendar sync in Android Settings. + final bool syncDisabled; + + const CreatedPlatformAction({ + required this.platformId, + required this.targetType, + this.calendarDisplayName, + this.calendarAccountName, + this.syncDisabled = false, + }); + + String get successMessage { + final calendarSuffix = + calendarDisplayName != null && calendarDisplayName!.isNotEmpty + ? ' in ${calendarDisplayName!}' + : ''; + + switch (targetType) { + case 'calendar_event': + return 'Calendar event created$calendarSuffix'; + case 'reminder': + return 'Reminder created$calendarSuffix'; + case 'calendar_reminder': + return 'Calendar reminder created$calendarSuffix'; + default: + return 'Action created$calendarSuffix'; + } + } + + String? get syncWarningMessage { + if (!syncDisabled) return null; + return 'Calendar sync is disabled for this account. ' + 'Events are saved locally but won\u2019t appear in Google Calendar ' + 'until you enable Calendar sync in Android Settings.'; + } +} + +/// Sync health diagnostics returned by [ExtractedActionCreationService.checkCalendarSyncHealth]. +class CalendarSyncHealth { + final bool hasCalendar; + final bool syncWorking; + final int isSyncable; + final bool autoSync; + final bool masterSync; + final int? calendarId; + final String? calendarDisplayName; + final String? accountName; + final String? accountType; + final bool isLocal; + + const CalendarSyncHealth({ + required this.hasCalendar, + required this.syncWorking, + this.isSyncable = -1, + this.autoSync = false, + this.masterSync = false, + this.calendarId, + this.calendarDisplayName, + this.accountName, + this.accountType, + this.isLocal = false, + }); + + factory CalendarSyncHealth.fromMap(Map map) { + return CalendarSyncHealth( + hasCalendar: map['hasCalendar'] as bool? ?? false, + syncWorking: map['syncWorking'] as bool? ?? false, + isSyncable: (map['isSyncable'] as num?)?.toInt() ?? -1, + autoSync: map['autoSync'] as bool? ?? false, + masterSync: map['masterSync'] as bool? ?? false, + calendarId: (map['calendarId'] as num?)?.toInt(), + calendarDisplayName: map['calendarDisplayName'] as String?, + accountName: map['accountName'] as String?, + accountType: map['accountType'] as String?, + isLocal: map['isLocal'] as bool? ?? false, + ); + } +} + +class ExtractedActionCreationService { + static const MethodChannel _channel = MethodChannel( + 'dev.zswatch.app/productivity', + ); + + const ExtractedActionCreationService(); + + Future> listWritableCalendars() async { + if (!Platform.isAndroid) { + return const []; + } + + // Only check permission status — don't request it here. + // The user grants calendar permission via the explicit "Grant" button + // in the AI settings screen. + final status = await Permission.calendarFullAccess.status; + if (!status.isGranted) { + return const []; + } + + final result = await _channel.invokeListMethod( + 'listWritableCalendars', + ); + if (result == null) { + return const []; + } + + return result + .whereType>() + .map(PlatformCalendar.fromMap) + .toList(growable: false); + } + + Future createDraft(ActionCreationDraft draft) async { + await _ensurePermissions(draft.actionType); + + debugPrint( + '[ExtractedActionCreation] Creating ${ExtractedAction.typeToString(draft.actionType)} ' + 'title="${draft.title}" scheduledAt=${draft.scheduledAt?.toIso8601String()}', + ); + + final result = await _invokeCreateAction(draft); + + if (result == null) { + throw StateError('Native action creation returned no result.'); + } + + debugPrint('[ExtractedActionCreation] Native result: $result'); + + // Don't auto-open Google Calendar after creation — locally-inserted events + // may not appear until Google Calendar syncs. The user can tap "Open" later. + + final syncDisabled = result['syncDisabled'] as bool? ?? false; + if (syncDisabled) { + debugPrint( + '[ExtractedActionCreation] WARNING: Calendar sync is disabled! ' + 'Event saved locally but won\u2019t sync to Google.', + ); + } + + return CreatedPlatformAction( + platformId: result['platformId'] as String?, + targetType: (result['targetType'] as String?) ?? 'action', + calendarDisplayName: result['calendarDisplayName'] as String?, + calendarAccountName: result['calendarAccountName'] as String?, + syncDisabled: syncDisabled, + ); + } + + Future openCreatedAction(ExtractedAction action) async { + if (!Platform.isAndroid || action.platformTargetId == null) { + return; + } + + final targetType = switch (action.actionType) { + ExtractedActionType.calendarEvent => 'calendar_event', + ExtractedActionType.task || + ExtractedActionType.reminder => 'calendar_reminder', + }; + + await _openCreatedCalendarEntryIfSupported( + platformId: action.platformTargetId, + targetType: targetType, + scheduledAtMillis: + (action.startTime ?? action.dueDate)?.millisecondsSinceEpoch, + ); + } + + Future?> _invokeCreateAction( + ActionCreationDraft draft, + ) async { + try { + return await _channel.invokeMapMethod( + 'createAction', + draft.toPlatformMap(), + ); + } on PlatformException catch (error) { + debugPrint( + '[ExtractedActionCreation] Native createAction failed ' + 'code=${error.code} message=${error.message} details=${error.details}', + ); + rethrow; + } + } + + Future _openCreatedCalendarEntryIfSupported({ + required String? platformId, + required String targetType, + required int? scheduledAtMillis, + }) async { + if (platformId == null) { + return; + } + + if (targetType != 'calendar_event' && targetType != 'calendar_reminder') { + return; + } + + try { + await _channel.invokeMethod('openCalendarEntry', { + 'eventId': platformId, + 'scheduledAtMillis': scheduledAtMillis, + }); + } on PlatformException catch (error) { + debugPrint( + '[ExtractedActionCreation] Failed to open created calendar entry ' + 'code=${error.code} message=${error.message}', + ); + } + } + + Future _ensurePermissions(ExtractedActionType actionType) async { + if (!(Platform.isAndroid || Platform.isIOS)) { + throw UnsupportedError( + 'Action creation is only supported on Android and iOS.', + ); + } + + final permission = _permissionForActionType(actionType); + final failureMessage = _failureMessageForActionType(actionType); + + await _requestPermission(permission, failureMessage); + } + + Permission _permissionForActionType(ExtractedActionType actionType) { + if (Platform.isAndroid) { + return Permission.calendarFullAccess; + } + + switch (actionType) { + case ExtractedActionType.calendarEvent: + return Permission.calendarFullAccess; + case ExtractedActionType.task: + case ExtractedActionType.reminder: + return Permission.reminders; + } + } + + String _failureMessageForActionType(ExtractedActionType actionType) { + if (Platform.isAndroid) { + return actionType == ExtractedActionType.calendarEvent + ? 'Calendar permission is required to create events.' + : 'Calendar permission is required to create reminders on Android.'; + } + + return switch (actionType) { + ExtractedActionType.calendarEvent => + 'Calendar access is required to create events.', + ExtractedActionType.task || ExtractedActionType.reminder => + 'Reminders access is required to create reminders.', + }; + } + + Future _requestPermission( + Permission permission, + String failureMessage, + ) async { + var status = await permission.status; + debugPrint( + '[ExtractedActionCreation] Permission $permission status before request: $status', + ); + + if (!status.isGranted) { + status = await permission.request(); + debugPrint( + '[ExtractedActionCreation] Permission $permission status after request: $status', + ); + } + + if (!status.isGranted) { + throw StateError(failureMessage); + } + } + + /// Check whether the CalendarProvider sync adapter is working for a specific + /// calendar (or the best available one). + /// + /// Returns [CalendarSyncHealth] with diagnostics. Use [syncWorking] to + /// decide whether to show a warning banner. + Future checkCalendarSyncHealth({int? calendarId}) async { + if (!Platform.isAndroid) { + return const CalendarSyncHealth(hasCalendar: true, syncWorking: true); + } + try { + final result = await _channel.invokeMapMethod( + 'checkCalendarSyncHealth', + {'calendarId': calendarId}, + ); + if (result == null) { + return const CalendarSyncHealth(hasCalendar: false, syncWorking: false); + } + return CalendarSyncHealth.fromMap(result); + } on PlatformException catch (e) { + debugPrint( + '[ExtractedActionCreation] checkCalendarSyncHealth failed: ${e.message}', + ); + return const CalendarSyncHealth(hasCalendar: false, syncWorking: false); + } + } + + /// Open Android Settings → Sync Settings so the user can enable Calendar sync + /// for their Google account. This is a one-time action that fixes the + /// "isSyncable=0" issue permanently. + Future openCalendarSyncSettings({ + String? accountName, + String? accountType, + }) async { + if (!Platform.isAndroid) return false; + try { + final result = await _channel.invokeMethod( + 'openCalendarSyncSettings', + {'accountName': accountName, 'accountType': accountType}, + ); + // Kotlin returns a String describing which settings page was opened + return result != null; + } on PlatformException catch (e) { + debugPrint( + '[ExtractedActionCreation] openCalendarSyncSettings failed: ${e.message}', + ); + return false; + } + } +} diff --git a/zswatch_app/lib/services/ai/llm_compute_service.dart b/zswatch_app/lib/services/ai/llm_compute_service.dart new file mode 100644 index 0000000..6f66867 --- /dev/null +++ b/zswatch_app/lib/services/ai/llm_compute_service.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Manages an Android foreground service + PARTIAL_WAKE_LOCK that keeps the +/// CPU at full speed during LLM inference. +/// +/// Without this, Android throttles CPU scheduling as soon as the Activity +/// leaves the foreground — even for a brief notification-shade pull — which +/// can halve prompt-eval throughput. +/// +/// On iOS this is a no-op (CoreBluetooth background modes + no OS-level CPU +/// throttling for active work). +class LlmComputeService { + static const _channel = MethodChannel('dev.zswatch.app/llm_compute'); + + static LlmComputeService? _instance; + static LlmComputeService get instance => _instance ??= LlmComputeService._(); + + LlmComputeService._(); + + bool _running = false; + bool get isRunning => _running; + + /// Start the foreground service before inference. + Future start() async { + if (!Platform.isAndroid || _running) return; + try { + await _channel.invokeMethod('start'); + _running = true; + debugPrint('[LlmComputeService] Started'); + } on PlatformException catch (e) { + debugPrint('[LlmComputeService] Failed to start: ${e.message}'); + } + } + + /// Stop the foreground service after inference completes. + Future stop() async { + if (!Platform.isAndroid || !_running) return; + try { + await _channel.invokeMethod('stop'); + _running = false; + debugPrint('[LlmComputeService] Stopped'); + } on PlatformException catch (e) { + debugPrint('[LlmComputeService] Failed to stop: ${e.message}'); + } + } +} diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart new file mode 100644 index 0000000..060f4c1 --- /dev/null +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -0,0 +1,1606 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:fllama/fllama.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'ai_debug_info.dart'; +import 'llm_compute_service.dart'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +class LlmModelInfo { + final String id; + final String displayName; + final String family; + final String filename; + final String? downloadUrl; + final int? expectedSizeBytes; + final bool userProvided; + + /// Per-model context size override. When non-null the inference engine will + /// use this instead of the global [LlmService.nCtx]. Useful for memory- + /// hungry architectures (e.g. Qwen3.5 with gated attention). + final int? contextSize; + + /// Benchmark score from ai_testbench (passed cases out of [benchmarkTotal]). + /// Shown in the model picker so users can compare accuracy. + final int? benchmarkScore; + final int? benchmarkTotal; + + const LlmModelInfo({ + required this.id, + required this.displayName, + required this.family, + required this.filename, + this.downloadUrl, + this.expectedSizeBytes, + this.userProvided = false, + this.contextSize, + this.benchmarkScore, + this.benchmarkTotal, + }); + + bool get isDownloadable => downloadUrl != null; + + String get shortSourceLabel => userProvided ? 'Imported' : 'Catalog'; +} + +/// Status of the LLM service. +enum LlmServiceStatus { idle, downloading, processing, ready, error } + +/// How well a model fits into available device memory. +enum ModelMemoryFit { + /// Plenty of headroom — full context. + comfortable, + + /// Moderate — full context but tighter memory. + reduced, + + /// Very tight — reduced context fallback. + cpuFallback, +} + +/// Result of [LlmService.checkModelFit] for a specific model on this device. +class ModelFitResult { + final ModelMemoryFit fit; + final int contextSize; + final int deviceMemoryMB; + final int modelSizeMB; + final int headroomMB; + + const ModelFitResult({ + required this.fit, + required this.contextSize, + required this.deviceMemoryMB, + required this.modelSizeMB, + required this.headroomMB, + }); + + /// Human-readable summary for the UI. + String get summary { + switch (fit) { + case ModelMemoryFit.comfortable: + return 'Fits well — full prompt should be available'; + case ModelMemoryFit.reduced: + return 'Tight fit — may switch to a shorter prompt'; + case ModelMemoryFit.cpuFallback: + return 'Low memory — may fall back to the smallest prompt'; + } + } +} + +/// Observable state of the service (for the settings UI). +class LlmServiceState { + final LlmServiceStatus status; + final double downloadProgress; + final String? error; + + const LlmServiceState({ + this.status = LlmServiceStatus.idle, + this.downloadProgress = 0.0, + this.error, + }); + + LlmServiceState copyWith({ + LlmServiceStatus? status, + double? downloadProgress, + String? error, + }) => LlmServiceState( + status: status ?? this.status, + downloadProgress: downloadProgress ?? this.downloadProgress, + error: error ?? this.error, + ); +} + +/// One extracted action from the LLM output. +class ExtractedActionResult { + final String type; // "task", "calendar_event", "reminder" + final String title; + final String? notes; + final String? dueDate; + final String? startTime; + final String? location; + + const ExtractedActionResult({ + required this.type, + required this.title, + this.notes, + this.dueDate, + this.startTime, + this.location, + }); +} + +/// Performance metrics from a single LLM inference run. +class LlmInferenceMetrics { + final String modelName; + final String rawPrompt; + final String rawResponse; + final String? parsedJson; + final Duration wallTime; + final int promptTokens; + final int completionTokens; + final double tokensPerSecond; + final int attempts; + final String? promptStrategy; + final bool retryEnabled; + + const LlmInferenceMetrics({ + required this.modelName, + required this.rawPrompt, + required this.rawResponse, + this.parsedJson, + required this.wallTime, + this.promptTokens = 0, + this.completionTokens = 0, + this.tokensPerSecond = 0.0, + this.attempts = 1, + this.promptStrategy, + this.retryEnabled = false, + }); + + LlmInferenceMetrics copyWithParsedJson(String? json) => LlmInferenceMetrics( + modelName: modelName, + rawPrompt: rawPrompt, + rawResponse: rawResponse, + parsedJson: json ?? parsedJson, + wallTime: wallTime, + promptTokens: promptTokens, + completionTokens: completionTokens, + tokensPerSecond: tokensPerSecond, + attempts: attempts, + promptStrategy: promptStrategy, + retryEnabled: retryEnabled, + ); +} + +/// Result of processTranscript(). +class TranscriptResult { + final String summary; + final String category; + final List actions; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + final String? originalTranscription; + final String? correctedTranscription; + final LlmInferenceMetrics? correctionMetrics; + final LlmInferenceMetrics? classifyMetrics; + + /// Per-action chrono extraction details for debug display. + final List actionChronoDetails; + + const TranscriptResult({ + required this.summary, + required this.category, + this.actions = const [], + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + this.originalTranscription, + this.correctedTranscription, + this.correctionMetrics, + this.classifyMetrics, + this.actionChronoDetails = const [], + }); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Local LLM inference service backed by fllama (llama.cpp). +/// +/// Usage flow: +/// 1. [downloadModel] to fetch the GGUF file (one-time). +/// 2. [processTranscript] to run classification/summarisation. +/// +/// The model loads lazily on first inference and stays cached in-process. +class LlmService { + static const int defaultTargetContextSize = 3072; + static const int reducedContextSize = 2048; + static const int minimumContextSize = 1024; + + static const int _maxStructuredOutputAttempts = 2; + static const String promptPlaceholderCurrentLocalDateTime = + ChronoPromptTemplate.promptPlaceholderCurrentLocalDateTime; + static const String promptPlaceholderCurrentLocalDateTimeCompact = + ChronoPromptTemplate.promptPlaceholderCurrentLocalDateTimeCompact; + static const String promptPlaceholderWeekday = + ChronoPromptTemplate.promptPlaceholderWeekday; + static const String promptPlaceholderTimezoneOffset = + ChronoPromptTemplate.promptPlaceholderTimezoneOffset; + static const String promptPlaceholderTranscript = + ChronoPromptTemplate.promptPlaceholderTranscript; + + static String get defaultBenchmarkPromptTemplate => + ChronoPromptTemplate.defaultTemplate; + + static String get defaultClassifyPromptTemplate => + defaultBenchmarkPromptTemplate; + + final TimeExpressionResolver _timeExpressionResolver = + TimeExpressionResolver(); + final ChronoLlmParser _chronoLlmParser = const ChronoLlmParser(); + + static const String defaultModelId = 'qwen25_1_5b_q4_k_m'; + // Models ordered by benchmark score (best first). + // Worst 3 removed: SmolLM3 (1/40), Llama-3.2-3B (25/40), Qwen3-1.7B (26/40). + static const List catalogModels = [ + LlmModelInfo( + id: 'qwen35_2b_q4_k_m', + displayName: 'Qwen3.5 2B Instruct · Q4_K_M', + family: 'Qwen3.5-2B', + filename: 'Qwen3.5-2B-Q4_K_M.gguf', + downloadUrl: + 'https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf', + expectedSizeBytes: 1222 * 1024 * 1024, + benchmarkScore: 35, + benchmarkTotal: 40, + ), + LlmModelInfo( + id: 'qwen25_1_5b_q8_0', + displayName: 'Qwen2.5 1.5B Instruct · Q8_0', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q8_0.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q8_0.gguf', + expectedSizeBytes: 1890 * 1024 * 1024, + benchmarkScore: 35, + benchmarkTotal: 40, + ), + LlmModelInfo( + id: 'qwen25_1_5b_q5_k_m', + displayName: 'Qwen2.5 1.5B Instruct · Q5_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q5_k_m.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q5_k_m.gguf', + expectedSizeBytes: 1290 * 1024 * 1024, + benchmarkScore: 34, + benchmarkTotal: 40, + ), + LlmModelInfo( + id: defaultModelId, + displayName: 'Qwen2.5 1.5B Instruct · Q4_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q4_k_m.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf', + expectedSizeBytes: 1120 * 1024 * 1024, + benchmarkScore: 31, + benchmarkTotal: 40, + ), + ]; + + String _selectedModelId = defaultModelId; + String _selectedModelName = 'Qwen2.5 1.5B Instruct · Q4_K_M'; + + /// Human-readable name shown in the UI / persisted alongside AI results. + String get modelName => _selectedModelName; + String get selectedModelId => _selectedModelId; + + static bool usesFullPrompt(int contextSize) => + contextSize >= defaultTargetContextSize; + + static bool usesEmergencyCompactPrompt(int contextSize) => + contextSize <= minimumContextSize; + + // ---- Tunables ---- + int nCtx = defaultTargetContextSize; + + /// Compute a portable thread count for on-device inference. + /// Uses half the reported cores (targets performance/big cores on + /// big.LITTLE), clamped to [2, 6]. iOS with Metal doesn't benefit + /// from extra CPU threads; Android CPU-only benefits from ~4. + static int get _platformThreadCount { + final total = Platform.numberOfProcessors; + return (total ~/ 2).clamp(2, 6); + } + + int nThreads = _platformThreadCount; + int maxTokens = 512; + // Qwen3.5 recommended sampling for non-thinking text tasks. + double temperature = 0.3; + double topP = 1.0; + double presencePenalty = 2.0; + + // ---- Internal state ---- + String? _modelPath; + int _runningRequestId = -1; + int? _deviceMemoryMB; + + final _stateSubject = BehaviorSubject.seeded( + const LlmServiceState(), + ); + + /// Observable service state (for UI bindings). + Stream get stateStream => _stateSubject.stream; + LlmServiceState get currentState => _stateSubject.value; + + /// Check how well [modelInfo] fits on this device. Call from the UI when the + /// user selects a model to show a warning banner if limits will be applied. + Future checkModelFit(LlmModelInfo modelInfo) async { + final params = await _computeInferenceParams(modelInfo); + final deviceMB = _deviceMemoryMB ?? 4096; + final modelMB = (modelInfo.expectedSizeBytes ?? 0) ~/ (1024 * 1024); + final usableMB = (deviceMB * 0.55).round(); + final headroomMB = usableMB - modelMB; + + // Base fit on actual memory headroom. + ModelMemoryFit fit; + if (headroomMB < 100) { + fit = ModelMemoryFit.cpuFallback; + } else if (params.contextSize < nCtx) { + fit = ModelMemoryFit.reduced; + } else { + fit = ModelMemoryFit.comfortable; + } + + return ModelFitResult( + fit: fit, + contextSize: params.contextSize, + deviceMemoryMB: deviceMB, + modelSizeMB: modelMB, + headroomMB: headroomMB, + ); + } + + // ---- Helpers ---- + + Future _modelDir() async { + final appDir = await getApplicationSupportDirectory(); + final dir = Directory('${appDir.path}/llm_models'); + if (!dir.existsSync()) dir.createSync(recursive: true); + return dir.path; + } + + Future _importedModelDir() async { + final dir = Directory('${await _modelDir()}/imported'); + if (!dir.existsSync()) dir.createSync(recursive: true); + return dir.path; + } + + static String customModelIdForFilename(String filename) => + 'custom::$filename'; + + static bool _isCustomModelId(String id) => id.startsWith('custom::'); + + Future> availableModels() async { + final importedDir = Directory(await _importedModelDir()); + final imported = []; + + if (importedDir.existsSync()) { + for (final entity in importedDir.listSync()) { + if (entity is! File || !entity.path.toLowerCase().endsWith('.gguf')) { + continue; + } + + final filename = p.basename(entity.path); + imported.add( + LlmModelInfo( + id: customModelIdForFilename(filename), + displayName: 'Imported · $filename', + family: 'Imported', + filename: filename, + expectedSizeBytes: entity.lengthSync(), + userProvided: true, + ), + ); + } + } + + imported.sort((a, b) => a.displayName.compareTo(b.displayName)); + return [...catalogModels, ...imported]; + } + + void selectModel(String modelId) { + _selectedModelId = modelId; + final builtIn = catalogModels.where((m) => m.id == modelId).firstOrNull; + _selectedModelName = + builtIn?.displayName ?? + (_isCustomModelId(modelId) + ? modelId.replaceFirst('custom::', '') + : catalogModels.first.displayName); + _modelPath = null; + } + + Future currentModelInfo() async { + final resolved = await _resolveModelById(_selectedModelId); + return resolved ?? catalogModels.first; + } + + Future _resolveModelById(String modelId) async { + for (final model in catalogModels) { + if (model.id == modelId) { + return model; + } + } + + final allModels = await availableModels(); + for (final model in allModels) { + if (model.id == modelId) { + return model; + } + } + + return null; + } + + Future _modelFilePathFor(LlmModelInfo model) async { + if (model.userProvided) { + return '${await _importedModelDir()}/${model.filename}'; + } + return '${await _modelDir()}/${model.filename}'; + } + + /// Whether the model file is present on disk. + Future isModelDownloaded({String? modelId}) async { + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + return File(await _modelFilePathFor(model)).existsSync(); + } + + /// Size of the local model file in bytes, or null if not downloaded. + Future modelFileSize({String? modelId}) async { + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + final f = File(await _modelFilePathFor(model)); + return f.existsSync() ? f.lengthSync() : null; + } + + // ---- Model management ---- + + /// Download the GGUF model from HuggingFace. + Future downloadModel({String? modelId}) async { + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + + if (!model.isDownloadable) { + throw StateError('Selected model is imported and cannot be downloaded.'); + } + + if (await isModelDownloaded(modelId: model.id)) { + debugPrint('[LlmService] Model already downloaded'); + return; + } + + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.downloading, + downloadProgress: 0.0, + ), + ); + + try { + final destPath = await _modelFilePathFor(model); + final tmpPath = '$destPath.tmp'; + final client = http.Client(); + final request = http.Request('GET', Uri.parse(model.downloadUrl!)); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('Download failed: HTTP ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + final sink = File(tmpPath).openWrite(); + + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (contentLength > 0) { + _stateSubject.add( + _stateSubject.value.copyWith( + downloadProgress: received / contentLength, + ), + ); + } + } + + await sink.close(); + client.close(); + + // Atomic rename + File(tmpPath).renameSync(destPath); + + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.ready, + downloadProgress: 1.0, + ), + ); + + _selectedModelName = model.displayName; + debugPrint('[LlmService] Model downloaded to $destPath'); + } catch (e) { + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); + rethrow; + } + } + + /// Delete the local model file. + Future deleteModel({String? modelId}) async { + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + final f = File(await _modelFilePathFor(model)); + if (f.existsSync()) { + f.deleteSync(); + } + if ((modelId ?? _selectedModelId) == _selectedModelId) { + _modelPath = null; + } + _stateSubject.add(const LlmServiceState()); + debugPrint('[LlmService] Model deleted'); + } + + Future importModel(String sourcePath) async { + final source = File(sourcePath); + if (!source.existsSync()) { + throw ArgumentError('Model file not found: $sourcePath'); + } + if (!source.path.toLowerCase().endsWith('.gguf')) { + throw ArgumentError('Only .gguf models can be imported.'); + } + + final importedDir = await _importedModelDir(); + final baseName = p.basename(source.path); + var candidateName = baseName; + var counter = 1; + while (File('$importedDir/$candidateName').existsSync()) { + final stem = p.basenameWithoutExtension(baseName); + final ext = p.extension(baseName); + candidateName = '${stem}_$counter$ext'; + counter++; + } + + final destination = File('$importedDir/$candidateName'); + await source.copy(destination.path); + + final importedModel = LlmModelInfo( + id: customModelIdForFilename(candidateName), + displayName: 'Imported · $candidateName', + family: 'Imported', + filename: candidateName, + expectedSizeBytes: destination.lengthSync(), + userProvided: true, + ); + + selectModel(importedModel.id); + return importedModel; + } + + // ---- Inference ---- + + /// Ensure _modelPath is set (lazy init). + Future _ensureModel() async { + if (_modelPath != null) return; + final model = await currentModelInfo(); + final path = await _modelFilePathFor(model); + if (!File(path).existsSync()) { + throw StateError( + 'Selected model is not available locally. Download or import it first.', + ); + } + _selectedModelName = model.displayName; + _modelPath = path; + } + + // ---- Memory-aware tunables ---- + + static const MethodChannel _deviceChannel = MethodChannel( + 'dev.zswatch.app/productivity', + ); + + /// Snapshot of memory state from the most recent `_computeInferenceParams` + /// call. Exposed so callers (e.g. the AI pipeline) can surface this in the + /// debug UI. + ({ + int deviceMB, + int availableMB, + int modelMB, + int headroomMB, + int requestedContextSize, + int contextSize, + int? maxTokensCap, + })? + get lastInferenceMemoryInfo => _lastInferenceMemoryInfo; + ({ + int deviceMB, + int availableMB, + int modelMB, + int headroomMB, + int requestedContextSize, + int contextSize, + int? maxTokensCap, + })? + _lastInferenceMemoryInfo; + + /// Query device physical RAM (MB), cached after first call. + Future _queryDeviceMemoryMB() async { + if (_deviceMemoryMB != null) return _deviceMemoryMB!; + try { + final mb = await _deviceChannel.invokeMethod('getDeviceMemoryMB'); + _deviceMemoryMB = mb ?? 4096; // conservative fallback + } on MissingPluginException { + _deviceMemoryMB = 4096; + } catch (e) { + debugPrint('[LlmService] Failed to query device memory: $e'); + _deviceMemoryMB = 4096; + } + debugPrint('[LlmService] Device physical RAM: ${_deviceMemoryMB}MB'); + return _deviceMemoryMB!; + } + + /// Query currently available free RAM (MB) at inference time. + /// This is more accurate than using cached total, since it accounts for + /// memory used by other processes and the OS. + Future _queryAvailableMemoryMB() async { + try { + final mb = await _deviceChannel.invokeMethod('getAvailableMemoryMB'); + return mb ?? 512; // conservative fallback if platform returns null + } on MissingPluginException { + // Fallback: estimate 50% of total is available (conservative) + final total = await _queryDeviceMemoryMB(); + return (total * 0.5).toInt(); + } catch (e) { + debugPrint('[LlmService] Failed to query available memory: $e'); + return 512; // very conservative fallback + } + } + + /// Compute context size and max output tokens dynamically based on + /// real-time available RAM and model size. GPU is always off (CPU-only). + /// + /// Uses [LlmModelInfo.contextSize] if explicitly set, otherwise scales + /// down when the model weight file would leave too little headroom for the + /// KV cache + compute buffers. + /// + /// IMPORTANT: This method runs AFTER `_ensureModel()` has loaded the model, + /// so `os_proc_available_memory()` already reflects the model's RAM usage. + /// We do NOT subtract model size again — that would double-count. + /// The available memory IS the headroom for KV cache + compute buffers. + /// + /// Larger context windows need a larger KV cache and more scratch space. + /// The thresholds below prefer a smaller configuration over a crash when + /// free RAM is tight: + /// + /// available ≥ 800 MB → target nCtx, maxTokens unchanged + /// available ≥ 400 MB → nCtx 2048, maxTokens unchanged (shorter prompt) + /// available ≥ 100 MB → nCtx 1024, maxTokens unchanged (compact prompt) + /// available < 100 MB → nCtx 1024, maxTokens capped at 256 (compact prompt) + Future<({int contextSize, int? maxTokensCap})> _computeInferenceParams( + LlmModelInfo modelInfo, + ) async { + // Explicit per-model context override wins. + final explicitCtx = modelInfo.contextSize; + if (explicitCtx != null) { + return (contextSize: explicitCtx, maxTokensCap: null); + } + + final deviceMB = await _queryDeviceMemoryMB(); + final availableMB = await _queryAvailableMemoryMB(); + final modelMB = (modelInfo.expectedSizeBytes ?? 0) ~/ (1024 * 1024); + + // The model is already loaded by `_ensureModel()` before this method runs, + // so `availableMB` already reflects the model's memory footprint. + // availableMB IS the headroom for KV cache + compute buffers — do NOT + // subtract modelMB again (that would double-count and go negative). + final headroomMB = availableMB; + + debugPrint( + '[LlmService] Memory check: available=${availableMB}MB ' + 'model=${modelMB}MB (already loaded) headroom=${headroomMB}MB ' + 'deviceTotal=${deviceMB}MB', + ); + + int ctx; + int? tokensCap; // null = use default maxTokens + + if (headroomMB >= 800) { + ctx = nCtx; + } else if (headroomMB >= 400) { + // Low — shorter prompt and reduced context. + ctx = reducedContextSize; + debugPrint( + '[LlmService] Low memory (${headroomMB}MB). ' + 'Using shorter prompt context ($ctx).', + ); + } else if (headroomMB >= 100) { + ctx = minimumContextSize; + debugPrint( + '[LlmService] WARNING: Very low memory (${headroomMB}MB). ' + 'Using emergency compact prompt ($ctx). ' + 'Model ${modelInfo.id} ($modelMB MB), ' + 'available=${availableMB}MB.', + ); + } else { + // Critically low — minimum settings with compact prompt. + ctx = minimumContextSize; + tokensCap = 256; + debugPrint( + '[LlmService] CRITICAL: Extremely low memory (${headroomMB}MB). ' + 'Using minimum settings: compact prompt ($ctx), maxTokens=256. ' + 'Model ${modelInfo.id} ($modelMB MB), ' + 'available=${availableMB}MB, device=${deviceMB}MB.', + ); + } + + // Store memory snapshot for debug UI. + _lastInferenceMemoryInfo = ( + deviceMB: deviceMB, + availableMB: availableMB, + modelMB: modelMB, + headroomMB: headroomMB, + requestedContextSize: nCtx, + contextSize: ctx, + maxTokensCap: tokensCap, + ); + + return (contextSize: ctx, maxTokensCap: tokensCap); + } + + static void _logFilter(String log) { + if (log.contains('[fllama]') || + log.contains('loaded') || + log.contains('error') || + log.contains('Error') || + log.contains('token') || + log.contains('speed') || + log.contains('FAILED') || + log.contains('Model loaded') || + log.contains('BATCH') || + log.contains('Initialized')) { + debugPrint('[llama.cpp] $log'); + } + } + + /// Low-level chat completion. Returns the raw text output and metrics. + /// + /// If [onPartialResponse] is provided, it is called after every token with + /// the accumulated response so far and the current token count. + Future<({String text, LlmInferenceMetrics metrics})> _generate( + String prompt, { + int? overrideMaxTokens, + void Function(String partial, int tokens)? onPartialResponse, + }) async { + // Cancel any still-running inference (e.g. leftover from hot-restart or + // a previous phase) before starting a new one. This reduces the window + // for the "Callback invoked after it has been deleted" crash. + cancelInference(); + await _ensureModel(); + + // Keep CPU at full speed during inference (Android foreground service). + await LlmComputeService.instance.start(); + + final completer = Completer(); + final stopwatch = Stopwatch()..start(); + int tokenCount = 0; + + final modelInfo = await currentModelInfo(); + final params = await _computeInferenceParams(modelInfo); + + // Apply maxTokens cap from memory check. The cap is the hard ceiling; + // the caller's overrideMaxTokens or default maxTokens is also respected + // by taking the minimum. + int effectiveMaxTokens = overrideMaxTokens ?? maxTokens; + if (params.maxTokensCap != null && + effectiveMaxTokens > params.maxTokensCap!) { + debugPrint( + '[LlmService] Capping maxTokens from $effectiveMaxTokens ' + 'to ${params.maxTokensCap} due to low memory.', + ); + effectiveMaxTokens = params.maxTokensCap!; + } + + debugPrint( + '[LlmService] Inference: model=${modelInfo.id} nCtx=${params.contextSize} ' + 'maxTokens=$effectiveMaxTokens ' + 'threads=$nThreads deviceRAM=${_deviceMemoryMB ?? "?"}MB', + ); + + final request = OpenAiRequest( + messages: [Message(Role.user, prompt)], + modelPath: _modelPath!, + maxTokens: effectiveMaxTokens, + numGpuLayers: 0, + numThreads: nThreads, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: params.contextSize, + logger: _logFilter, + enableThinking: false, + ); + + _runningRequestId = await fllamaChat(request, ( + String response, + String responseJson, + bool done, + ) { + tokenCount++; + onPartialResponse?.call(response, tokenCount); + if (done && !completer.isCompleted) { + completer.complete(response); + } + }); + + final text = (await completer.future).trim(); + stopwatch.stop(); + + // Release the foreground service + wake lock. + await LlmComputeService.instance.stop(); + + final wallTime = stopwatch.elapsed; + final tokPerSec = wallTime.inMilliseconds > 0 + ? (tokenCount / (wallTime.inMilliseconds / 1000.0)) + : 0.0; + + final metrics = LlmInferenceMetrics( + modelName: _selectedModelName, + rawPrompt: prompt, + rawResponse: text, + wallTime: wallTime, + completionTokens: tokenCount, + tokensPerSecond: tokPerSec, + ); + + return (text: text, metrics: metrics); + } + + /// Process a voice memo transcript: optionally correct transcription errors, + /// then classify + summarise in a single LLM pass, and parse the structured + /// JSON output. + /// + /// This is the main entry-point used by [VoiceNoteAiPipeline]. + Future processTranscript( + String transcript, { + bool correctTranscription = true, + String? classifyPromptOverride, + String? promptStrategyOverride, + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.processing), + ); + + try { + final totalStopwatch = Stopwatch()..start(); + debugPrint( + '[LlmService] Processing transcript (${transcript.length} chars)', + ); + + // Pre-compute effective context size so we can select the right prompt. + await _ensureModel(); + final modelInfo = await currentModelInfo(); + final preParams = await _computeInferenceParams(modelInfo); + final effectiveCtx = preParams.contextSize; + + String effectiveTranscript = transcript; + LlmInferenceMetrics? correctionMetrics; + String? correctedTranscription; + + // --- Step 1: Correct transcription errors if enabled --- + if (correctTranscription) { + final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionMaxTokens = CorrectionPromptTemplate.estimateMaxTokens( + transcript, + ); + final correctionResult = await _generate( + correctionPrompt, + overrideMaxTokens: correctionMaxTokens, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('correcting', partial, tokens), + ); + final corrected = correctionResult.text.trim(); + correctionMetrics = correctionResult.metrics; + + // Only use the correction if it looks like actual text (not JSON/noise) + if (corrected.isNotEmpty && + !corrected.startsWith('{') && + corrected.length > 5) { + correctedTranscription = corrected; + effectiveTranscript = corrected; + debugPrint('[LlmService] Corrected transcription: $corrected'); + } else { + debugPrint( + '[LlmService] Correction output not usable, using original', + ); + } + } + + // Brief pause between inference calls to let the native (C++) side + // finish any post-done logging from the previous request. Without this, + // the next fllamaChat call triggers cleanup of the previous logger + // NativeCallable while C++ may still be invoking it, causing a fatal + // "Callback invoked after it has been deleted" crash. + await Future.delayed(const Duration(milliseconds: 500)); + + // --- Step 2: Build the extraction prompt --- + final promptTemplate = classifyPromptOverride?.trim(); + final prompt = (promptTemplate != null && promptTemplate.isNotEmpty) + ? _renderClassifyPromptTemplate( + promptTemplate, + transcript: effectiveTranscript, + ) + : _buildClassifyPrompt( + effectiveTranscript, + effectiveCtx: effectiveCtx, + ); + final structuredResult = await _generateStructuredJsonWithRetry( + prompt, + promptStrategy: (promptTemplate != null && promptTemplate.isNotEmpty) + ? (promptStrategyOverride ?? 'custom-template') + : usesFullPrompt(effectiveCtx) + ? 'full+/no_think' + : usesEmergencyCompactPrompt(effectiveCtx) + ? 'emergency-compact/no_think (nCtx=$effectiveCtx)' + : 'shortened/no_think (nCtx=$effectiveCtx)', + phase: 'classifying', + onProgress: onProgress, + ); + final raw = structuredResult.raw; + final classifyMetrics = structuredResult.metrics; + + debugPrint( + '[LlmService] Classify done at ${totalStopwatch.elapsedMilliseconds}ms', + ); + debugPrint('[LlmService] Raw AI response: $raw'); + + // --- Parse JSON from output --- + final result = structuredResult.result; + + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.ready), + ); + + return TranscriptResult( + summary: result.summary, + category: result.category, + actions: result.actions, + actionChronoDetails: result.actionChronoDetails, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + originalTranscription: transcript, + correctedTranscription: correctedTranscription, + correctionMetrics: correctionMetrics, + classifyMetrics: classifyMetrics, + ); + } catch (e) { + debugPrint('[LlmService] Failed to process transcript: $e'); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); + rethrow; + } + } + + /// Cancel a running inference (best-effort). + void cancelInference() { + if (_runningRequestId >= 0) { + fllamaCancelInference(_runningRequestId); + _runningRequestId = -1; + } + } + + void dispose() { + cancelInference(); + _stateSubject.close(); + } + + // ---- Prompt construction ---- + + String _buildCorrectionPrompt(String transcript) { + return CorrectionPromptTemplate.render( + CorrectionPromptTemplate.defaultTemplate, + transcript: transcript, + ); + } + + String _buildClassifyPrompt(String transcript, {int? effectiveCtx}) { + final template = ChronoPromptTemplate.templateForContextSize( + effectiveCtx ?? nCtx, + ); + return _renderClassifyPromptTemplate(template, transcript: transcript); + } + + String _renderClassifyPromptTemplate( + String template, { + required String transcript, + }) { + return ChronoPromptTemplate.render(template, transcript: transcript); + } + + /// Word-count threshold for brain dump mode. Transcripts with more + /// words than this use the brain dump prompt instead of the standard + /// classify prompt. + static const int brainDumpWordThreshold = 50; + + String _buildBrainDumpPrompt(String transcript) { + final localNow = DateTime.now(); + final weekday = const [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ][localNow.weekday - 1]; + final iso = localNow.toIso8601String(); + final tzOffset = localNow.timeZoneOffset; + final tzSign = tzOffset.isNegative ? '-' : '+'; + final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); + final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft( + 2, + '0', + ); + final tz = '$tzSign$tzHours:$tzMinutes'; + + return ''' +You are a voice-note summarization assistant specializing in long, unstructured recordings. + +Current local date/time: $iso ($weekday), timezone UTC$tz. +Use this to resolve relative references like "tomorrow", "next Tuesday", "in 30 minutes", etc. + +The following transcript is from a "brain dump" — a long, stream-of-consciousness voice recording. It may contain: +- Multiple unrelated topics +- Rambling or repeated ideas +- Filler words and false starts +- Mixed actionable items and general thoughts + +Return EXACTLY ONE valid JSON object. +Do not include markdown fences, explanations, or any text before/after the JSON. + +Your job: +1. Produce a concise executive summary (2-3 sentences max) +2. Group the content into logical sections with headers +3. Extract any actionable items mentioned anywhere in the transcript +4. Assign the category "brain_dump" + +Use this exact schema: +{ + "summary": "concise 2-3 sentence executive summary in the original language", + "category": "brain_dump", + "sections": [ + { + "header": "Topic or theme heading", + "bullets": ["key point 1", "key point 2"] + } + ], + "actions": [ + { + "type": "task" | "reminder" | "calendar_event", + "title": "short action title in the original language", + "notes": "optional extra details" | null, + "due_date": "ISO-8601 datetime" | null, + "start_time": "ISO-8601 datetime" | null, + "end_time": "ISO-8601 datetime" | null, + "location": "location text" | null, + "priority": "low" | "medium" | "high" | null, + "reminder_minutes": number | null + } + ] +} + +Rules: +- Keep sections to 4 or fewer. +- Keep bullets concise (one line each). +- Extract ALL actionable items regardless of where they appear. +- If no actions exist, return an empty array. +- Preserve the transcript language. +- Do not invent dates, times, or locations. Use null when unknown. + +Transcript: "$transcript" +JSON: + +/no_think'''; + } + + /// Determine whether a transcript should use brain dump mode. + bool isBrainDump(String transcript) { + final wordCount = transcript.trim().split(RegExp(r'\s+')).length; + return wordCount >= brainDumpWordThreshold; + } + + /// Process a transcript using the brain dump prompt for long recordings. + Future processTranscriptBrainDump( + String transcript, { + bool correctTranscription = true, + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.processing), + ); + + try { + debugPrint( + '[LlmService] Processing brain dump transcript (${transcript.length} chars)', + ); + + String effectiveTranscript = transcript; + LlmInferenceMetrics? correctionMetrics; + String? correctedTranscription; + + // --- Step 1: Correct transcription errors if enabled --- + if (correctTranscription) { + final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionMaxTokens = CorrectionPromptTemplate.estimateMaxTokens( + transcript, + ); + final correctionResult = await _generate( + correctionPrompt, + overrideMaxTokens: correctionMaxTokens, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('correcting', partial, tokens), + ); + + final corrected = correctionResult.text.trim(); + correctionMetrics = correctionResult.metrics; + + if (corrected.isNotEmpty && + !corrected.startsWith('{') && + corrected.length > 5) { + correctedTranscription = corrected; + effectiveTranscript = corrected; + } + } + + await Future.delayed(const Duration(milliseconds: 500)); + + // --- Step 2: Brain dump extraction prompt --- + final prompt = _buildBrainDumpPrompt(effectiveTranscript); + final structuredResult = await _generateStructuredJsonWithRetry( + prompt, + overrideMaxTokens: 768, + promptStrategy: 'brain_dump+/no_think', + phase: 'summarizing', + onProgress: onProgress, + ); + final raw = structuredResult.raw; + final classifyMetrics = structuredResult.metrics; + + debugPrint('[LlmService] Raw brain dump response: $raw'); + + // Parse using the same JSON extraction logic + final result = structuredResult.result; + final jsonStr = classifyMetrics.parsedJson; + + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.ready), + ); + + // Build a rich summary including sections if present + String richSummary = result.summary; + if (jsonStr != null) { + try { + final parsed = jsonDecode(jsonStr) as Map; + final sections = parsed['sections'] as List?; + if (sections != null && sections.isNotEmpty) { + final buf = StringBuffer(result.summary); + buf.writeln(); + for (final section in sections.whereType>()) { + final header = section['header'] as String?; + final bullets = + (section['bullets'] as List?) + ?.whereType() + .toList() ?? + []; + if (header != null) { + buf.writeln('\n## $header'); + for (final bullet in bullets) { + buf.writeln('• $bullet'); + } + } + } + richSummary = buf.toString().trim(); + } + } catch (_) { + // Fall back to plain summary + } + } + + return TranscriptResult( + summary: richSummary, + category: 'brain_dump', + actions: result.actions, + actionChronoDetails: result.actionChronoDetails, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + originalTranscription: transcript, + correctedTranscription: correctedTranscription, + correctionMetrics: correctionMetrics, + classifyMetrics: classifyMetrics, + ); + } catch (e) { + debugPrint('[LlmService] Failed to process brain dump: $e'); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); + rethrow; + } + } + + // ---- Output parsing ---- + + Future< + ({ + String raw, + TranscriptResult result, + LlmInferenceMetrics metrics, + int attempts, + }) + > + _generateStructuredJsonWithRetry( + String prompt, { + int? overrideMaxTokens, + required String promptStrategy, + String phase = 'classifying', + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + String raw = ''; + TranscriptResult parsed = const TranscriptResult( + summary: '', + category: 'note', + ); + LlmInferenceMetrics? lastMetrics; + Duration totalWallTime = Duration.zero; + var totalCompletionTokens = 0; + var attempts = 0; + + while (attempts < _maxStructuredOutputAttempts) { + attempts++; + final genResult = await _generate( + prompt, + overrideMaxTokens: overrideMaxTokens, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress(phase, partial, tokens), + ); + raw = genResult.text; + parsed = _parseTranscriptResult(raw); + lastMetrics = genResult.metrics; + totalWallTime += genResult.metrics.wallTime; + totalCompletionTokens += genResult.metrics.completionTokens; + + final needsRetry = _shouldRetryStructuredOutput(raw, parsed); + + if (!needsRetry || attempts >= _maxStructuredOutputAttempts) { + break; + } + + debugPrint( + '[LlmService] Retrying invalid structured output ' + '(attempt ${attempts + 1}/$_maxStructuredOutputAttempts)', + ); + await Future.delayed(const Duration(milliseconds: 300)); + } + + final parsedJson = + _chronoLlmParser.extractFirstJsonArray(raw) ?? + _extractFirstJsonObject(raw); + final metrics = LlmInferenceMetrics( + modelName: _selectedModelName, + rawPrompt: prompt, + rawResponse: raw, + parsedJson: parsedJson, + wallTime: totalWallTime, + completionTokens: totalCompletionTokens, + tokensPerSecond: lastMetrics?.tokensPerSecond ?? 0.0, + attempts: attempts, + promptStrategy: promptStrategy, + retryEnabled: _maxStructuredOutputAttempts > 1, + ); + + return (raw: raw, result: parsed, metrics: metrics, attempts: attempts); + } + + String _sanitizeModelOutput(String raw) { + return _chronoLlmParser.sanitizeModelOutput(raw); + } + + bool _shouldRetryStructuredOutput(String raw, TranscriptResult result) { + final cleaned = _sanitizeModelOutput(raw); + final jsonStr = + _chronoLlmParser.extractFirstJsonArray(cleaned) ?? + _extractFirstJsonObject(cleaned); + + if (jsonStr == null) { + return true; + } + + if (result.summary.trim().isEmpty) { + return true; + } + + if (result.summary.trim() == cleaned && result.actions.isEmpty) { + return true; + } + + if (result.category == 'note' && + result.actions.isEmpty && + (result.summary.trim() == cleaned || + result.summary.trim() == jsonStr.trim())) { + return true; + } + + return false; + } + + String? _extractFirstJsonObject(String raw) { + return _chronoLlmParser.extractFirstJsonObject(raw); + } + + String _normalizeCategory(String? rawCategory) { + switch ((rawCategory ?? '').trim().toLowerCase()) { + case 'todo': + case 'task': + return 'task'; + case 'reminder': + return 'reminder'; + case 'event': + case 'meeting': + case 'calendar_event': + return 'meeting'; + case 'idea': + return 'idea'; + default: + return 'note'; + } + } + + String _normalizeActionType(String? rawType, String category) { + switch ((rawType ?? '').trim().toLowerCase()) { + case 'calendar_event': + case 'event': + case 'meeting': + case 'schedule': + return 'calendar_event'; + case 'reminder': + return 'reminder'; + case 'task': + case 'todo': + return 'task'; + default: + return category == 'meeting' ? 'calendar_event' : 'task'; + } + } + + TranscriptResult _buildTranscriptResultFromChronoExtractions( + List extractions, + String raw, + ) { + final actions = []; + final chronoDetails = []; + String? firstResolvedDateTime; + String? firstResolverMethod; + + for (final extraction in extractions) { + final title = extraction.title.isNotEmpty ? extraction.title : raw.trim(); + + if (extraction.intent == 'note') { + // Notes don't produce actions with time resolution + actions.add( + ExtractedActionResult( + type: 'task', + title: title, + notes: extraction.datetimeExpressionOriginal, + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + ), + ); + continue; + } + + final englishExpression = extraction.datetimeExpressionEnglish; + final resolved = englishExpression == null + ? null + : _timeExpressionResolver.resolve(englishExpression); + + firstResolvedDateTime ??= resolved?.dateTime.toIso8601String(); + firstResolverMethod ??= resolved?.method; + + actions.add( + ExtractedActionResult( + type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', + title: title, + notes: extraction.datetimeExpressionOriginal, + dueDate: extraction.intent == 'reminder' + ? resolved?.dateTime.toIso8601String() + : null, + startTime: extraction.intent == 'event' + ? resolved?.dateTime.toIso8601String() + : null, + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + resolvedDateTime: resolved?.dateTime.toIso8601String(), + resolverMethod: resolved?.method, + ), + ); + } + + final first = extractions.first; + final summary = first.title.isNotEmpty ? first.title : raw.trim(); + final category = switch (first.intent) { + 'event' => 'meeting', + 'reminder' => 'reminder', + _ => 'note', + }; + + return TranscriptResult( + summary: summary, + category: category, + actions: actions, + actionChronoDetails: chronoDetails, + extractedIntent: first.intent, + extractedTitle: first.title, + datetimeExpressionOriginal: first.datetimeExpressionOriginal, + datetimeExpressionEnglish: first.datetimeExpressionEnglish, + resolvedDateTime: firstResolvedDateTime, + resolverMethod: firstResolverMethod, + ); + } + + TranscriptResult _parseTranscriptResult(String raw) { + final cleaned = _sanitizeModelOutput(raw); + + // Try parsing via chrono parser first (handles both array and object) + final chronoResult = _chronoLlmParser.parse(cleaned); + if (chronoResult.extractions.isNotEmpty) { + return _buildTranscriptResultFromChronoExtractions( + chronoResult.extractions, + raw, + ); + } + + final jsonStr = _extractFirstJsonObject(cleaned); + + if (jsonStr == null) { + debugPrint( + '[LlmService] Failed to parse AI response: ' + 'FormatException: No JSON object found', + ); + return TranscriptResult(summary: cleaned.trim(), category: 'note'); + } + + try { + final parsed = jsonDecode(jsonStr) as Map; + + final category = _normalizeCategory(parsed['category'] as String?); + final summary = (parsed['summary'] as String?)?.trim(); + final title = (parsed['title'] as String?)?.trim(); + + final actions = []; + + final parsedActions = parsed['actions']; + if (parsedActions is List) { + for (final action in parsedActions.whereType>()) { + final actionTitle = + ((action['title'] ?? action['summary']) as String?)?.trim() ?? ''; + if (actionTitle.isEmpty) { + continue; + } + + actions.add( + ExtractedActionResult( + type: _normalizeActionType(action['type'] as String?, category), + title: actionTitle, + notes: ((action['notes'] ?? action['body']) as String?)?.trim(), + dueDate: (action['due_date'] ?? action['dueDate']) as String?, + startTime: + (action['start_time'] ?? action['startTime']) as String?, + location: (action['location'] as String?)?.trim(), + ), + ); + } + } + + if (actions.isEmpty) { + final actionItems = + (parsed['action_items'] as List?) + ?.whereType() + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList() ?? + const []; + + for (final item in actionItems) { + actions.add( + ExtractedActionResult( + type: category == 'meeting' ? 'calendar_event' : 'task', + title: item, + ), + ); + } + } + + final resolvedSummary = (summary != null && summary.isNotEmpty) + ? summary + : (title ?? '').trim(); + + return TranscriptResult( + summary: resolvedSummary.isEmpty ? raw.trim() : resolvedSummary, + category: category, + actions: actions, + ); + } catch (e) { + debugPrint('[LlmService] Failed to parse AI response: $e'); + return TranscriptResult(summary: jsonStr, category: 'note'); + } + } +} diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart new file mode 100644 index 0000000..e4f1883 --- /dev/null +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; +import '../voice_memo/transcription_engine.dart'; +import 'ai_debug_info.dart'; +import 'llm_service.dart'; + +// --------------------------------------------------------------------------- +// State model +// --------------------------------------------------------------------------- + +/// Top-level state for the benchmark section. +class BenchmarkState { + final bool isRunning; + + /// Which test is currently running ('transcription' or 'ai'), null if idle. + final String? runningTestType; + final AiDebugInfo? current; + + const BenchmarkState({ + this.isRunning = false, + this.runningTestType, + this.current, + }); + + BenchmarkState copyWith({ + bool? isRunning, + String? runningTestType, + AiDebugInfo? current, + }) => BenchmarkState( + isRunning: isRunning ?? this.isRunning, + runningTestType: runningTestType ?? this.runningTestType, + current: current ?? this.current, + ); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Benchmarks transcription and AI models so users can gauge performance on +/// their hardware. Streams [BenchmarkState] with live progress that the UI +/// renders in the same style as the voice-memo debug sheet. +class ModelBenchmarkService { + final _stateSubject = BehaviorSubject.seeded( + const BenchmarkState(), + ); + + /// Set to `true` when the user requests an abort. Checked between phases + /// and after async work completes. Note: Whisper and fllama FFI calls are + /// blocking and cannot be interrupted mid-inference, so the abort takes + /// effect as soon as the current native call returns. + bool _abortRequested = false; + + Stream get stateStream => _stateSubject.stream; + BenchmarkState get currentState => _stateSubject.value; + + /// Request the current benchmark run to abort. + void abort() { + if (!currentState.isRunning) return; + _abortRequested = true; + final current = currentState.current; + if (current != null) { + _emit( + AiDebugInfo( + testType: current.testType, + modelName: current.modelName, + phase: 'running', + partialOutput: 'Aborting after current operation…', + tokens: current.tokens, + elapsed: current.elapsed, + tokensPerSecond: current.tokensPerSecond, + ), + ); + } + } + + // ---- Transcription benchmark ---- + + /// Benchmark the selected transcription engine using a real voice recording. + /// + /// [audioFilePath] must point to an existing audio file (typically an .ogg + /// from the voice memos directory). + Future benchmarkTranscription( + TranscriptionEngineType type, + String audioFilePath, + ) async { + _abortRequested = false; + final info = TranscriptionModelCatalog.info(type); + final engine = createTranscriptionEngine(type); + StreamSubscription? engineSub; + + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'loading', + partialOutput: 'Checking model availability…', + ), + ); + + try { + // Verify the audio file exists + if (!File(audioFilePath).existsSync()) { + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Audio file not found: $audioFilePath', + ), + ); + return; + } + + final available = await engine.isAvailable(); + if (!available) { + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Model not downloaded – download it first.', + ), + ); + return; + } + + // Listen to engine state for status updates (loading model, transcribing) + engineSub = engine.stateStream.listen((engineState) { + final statusText = switch (engineState.status) { + TranscriptionEngineStatus.downloading => + 'Downloading model (${(engineState.downloadProgress * 100).toInt()}%)…', + TranscriptionEngineStatus.transcribing => 'Transcribing audio…', + TranscriptionEngineStatus.ready => 'Model ready', + TranscriptionEngineStatus.error => + 'Engine error: ${engineState.errorMessage ?? "unknown"}', + _ => 'Initializing…', + }; + // Only emit running-phase status updates while we're still running + if (!currentState.current!.isComplete) { + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: statusText, + ), + ); + } + }); + + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: 'Starting transcription…', + ), + ); + + final sw = Stopwatch()..start(); + final output = await engine.transcribe(audioFilePath); + sw.stop(); + + if (_abortRequested) { + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'done', + partialOutput: '(aborted)\n${output.isEmpty ? '' : output}', + elapsed: sw.elapsed, + ), + ); + return; + } + + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'done', + partialOutput: output.isEmpty ? '(no speech detected)' : output, + elapsed: sw.elapsed, + ), + ); + } catch (e) { + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: e.toString(), + ), + ); + } finally { + await engineSub?.cancel(); + engine.dispose(); + _stateSubject.add( + BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + ), + ); + } + } + + // ---- AI benchmark ---- + + /// Run a single short transcript through the normal app AI flow to test + /// speed and behavior. The prompt stays fixed to the app's shared chrono + /// extraction prompt; only the sample input text is variable. + Future benchmarkAiModel( + LlmService llmService, { + String? testInput, + bool correctTranscription = false, + }) async { + _abortRequested = false; + final modelName = llmService.modelName; + + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'loading', + partialOutput: 'Loading model…', + ), + ); + + try { + final isDownloaded = await llmService.isModelDownloaded(); + if (!isDownloaded) { + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: 'Model not downloaded – download it first.', + ), + ); + return; + } + + final benchmarkInput = (testInput != null && testInput.trim().isNotEmpty) + ? testInput.trim() + : 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; + + debugPrint('[ModelBenchmark] Running AI benchmark with: $modelName'); + final sw = Stopwatch()..start(); + + String lastRawOutput = ''; + final result = await llmService.processTranscript( + benchmarkInput, + correctTranscription: correctTranscription, + onProgress: (phase, partial, tokens) { + lastRawOutput = partial; + final tps = sw.elapsedMilliseconds > 0 + ? tokens / (sw.elapsedMilliseconds / 1000.0) + : 0.0; + // Map LlmService phases to benchmark phases + final benchPhase = switch (phase) { + 'correcting' => 'correcting', + 'classifying' => 'classifying', + _ => 'running', + }; + final mem = llmService.lastInferenceMemoryInfo; + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + promptStrategy: 'shared-chrono-flow', + retryEnabled: true, + phase: benchPhase, + partialOutput: partial, + rawOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceMaxTokensCap: mem?.maxTokensCap, + ), + ); + }, + ); + sw.stop(); + + // Use the raw classify response when available + final rawResponse = result.classifyMetrics?.rawResponse ?? lastRawOutput; + + // Helper to extract correction metrics from result + final mem = llmService.lastInferenceMemoryInfo; + AiDebugInfo buildAiResult({ + required String phase, + required String partialOutput, + }) { + return AiDebugInfo( + testType: 'ai', + modelName: modelName, + promptStrategy: result.classifyMetrics?.promptStrategy, + rawPrompt: result.classifyMetrics?.rawPrompt, + parsedJson: result.classifyMetrics?.parsedJson, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + extractedActions: result.actionChronoDetails, + attempts: result.classifyMetrics?.attempts ?? 1, + retryEnabled: result.classifyMetrics?.retryEnabled ?? false, + phase: phase, + partialOutput: partialOutput, + rawOutput: rawResponse, + tokens: result.classifyMetrics?.completionTokens ?? 0, + elapsed: sw.elapsed, + tokensPerSecond: result.classifyMetrics?.tokensPerSecond ?? 0.0, + correctedTranscription: result.correctedTranscription, + correctionTokens: result.correctionMetrics?.completionTokens ?? 0, + correctionElapsed: + result.correctionMetrics?.wallTime ?? Duration.zero, + correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceMaxTokensCap: mem?.maxTokensCap, + ); + } + + if (_abortRequested) { + _emit(buildAiResult(phase: 'done', partialOutput: '(aborted)')); + return; + } + + _emit( + buildAiResult( + phase: 'done', + partialOutput: + 'Category: ${result.category}\n' + 'Summary: ${result.summary}\n' + 'Actions: ${result.actions.length}', + ), + ); + } catch (e) { + debugPrint('[ModelBenchmark] AI benchmark error: $e'); + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: e.toString(), + ), + ); + } finally { + _stateSubject.add( + BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + ), + ); + } + } + + /// Reset state to initial (no results). + void clear() { + _stateSubject.add(const BenchmarkState()); + } + + void dispose() { + _stateSubject.close(); + } + + // ---- Helpers ---- + + void _emit(AiDebugInfo progress) { + _stateSubject.add( + BenchmarkState( + isRunning: !progress.isComplete, + runningTestType: progress.isComplete ? null : progress.testType, + current: progress, + ), + ); + } +} diff --git a/zswatch_app/lib/services/ai/time_expression_resolver.dart b/zswatch_app/lib/services/ai/time_expression_resolver.dart new file mode 100644 index 0000000..99afb42 --- /dev/null +++ b/zswatch_app/lib/services/ai/time_expression_resolver.dart @@ -0,0 +1,2 @@ +export 'package:chrono_ai_flow/chrono_ai_flow.dart' + show ResolvedTime, TimeExpressionResolver; diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart new file mode 100644 index 0000000..37d83b1 --- /dev/null +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -0,0 +1,306 @@ +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data/models/extracted_action.dart'; +import '../../data/repositories/extracted_action_repository.dart'; +import '../../data/repositories/voice_memo_repository.dart'; +import 'ai_debug_info.dart'; +import 'llm_service.dart'; + +/// Orchestrates AI processing of voice memo transcripts. +/// +/// After a transcript is available, this pipeline: +/// 1. Sends the transcript to the LLM for summarization, categorization, +/// and action extraction (single pass) +/// 2. Persists results in the database +/// 3. Creates extracted action records for user review +class VoiceNoteAiPipeline { + final LlmService _llmService; + final VoiceMemoRepository _memoRepository; + final ExtractedActionRepository _actionRepository; + + /// Whether to run the LLM correction step before classification. + bool correctTranscription; + + /// Called after successful AI processing with (filename, summary, actionType, datetime). + /// Used to send the result toast back to the watch. + void Function( + String filename, + String title, + String? actionType, + String? datetime, + )? + onProcessingComplete; + + /// Stream of debug info from the most recent AI processing runs. + final _debugInfoSubject = BehaviorSubject.seeded(null); + Stream get debugInfoStream => _debugInfoSubject.stream; + AiDebugInfo? get lastDebugInfo => _debugInfoSubject.value; + + /// Completed debug info stored per filename so the UI can retrieve results + /// for a specific voice note rather than only the latest global run. + final Map _debugInfoByFile = {}; + + /// Get the most recent completed debug info for [filename], or null. + AiDebugInfo? getDebugInfoForFile(String filename) => + _debugInfoByFile[filename]; + + VoiceNoteAiPipeline({ + required LlmService llmService, + required VoiceMemoRepository memoRepository, + required ExtractedActionRepository actionRepository, + this.correctTranscription = false, + }) : _llmService = llmService, + _memoRepository = memoRepository, + _actionRepository = actionRepository; + + /// Process a single voice memo's transcript with the local LLM. + /// + /// Updates the processing status incrementally and persists results. + /// Returns true if processing succeeded. + Future processMemo({ + required int memoId, + required String filename, + required String transcript, + }) async { + if (transcript.trim().isEmpty) { + debugPrint( + '[VoiceNoteAiPipeline] Skipping empty transcript for $filename', + ); + return false; + } + + try { + // Update status: summarizing (covers the single-pass processing) + await _memoRepository.updateProcessingStatus( + filename: filename, + status: 'summarizing', + ); + + // Publish initial loading state so the debug sheet shows something + // immediately (before the model finishes loading / first token arrives). + _debugInfoSubject.add( + AiDebugInfo( + filename: filename, + modelName: _llmService.modelName, + transcriptionResult: transcript, + phase: 'loading', + partialOutput: '', + tokens: 0, + timestamp: DateTime.now(), + ), + ); + + // Route to brain dump prompt for long transcripts (Feature 6) + final useBrainDump = _llmService.isBrainDump(transcript); + debugPrint( + '[VoiceNoteAiPipeline] Brain dump routing: ' + '${useBrainDump ? "YES" : "NO"} for $filename', + ); + + // Stopwatch to compute live elapsed time & tokens-per-second + final sw = Stopwatch()..start(); + + // Helper that emits a live progress update with timing metrics. + void emitLive(String phase, String partial, int tokens) { + final elapsedMs = sw.elapsedMilliseconds; + final tps = elapsedMs > 0 ? tokens / (elapsedMs / 1000.0) : 0.0; + final mem = _llmService.lastInferenceMemoryInfo; + _debugInfoSubject.add( + AiDebugInfo( + filename: filename, + modelName: _llmService.modelName, + transcriptionResult: transcript, + phase: phase, + partialOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, + timestamp: DateTime.now(), + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceMaxTokensCap: mem?.maxTokensCap, + ), + ); + } + + // Run the LLM processing with live progress updates + final result = useBrainDump + ? await _llmService.processTranscriptBrainDump( + transcript, + correctTranscription: correctTranscription, + onProgress: (phase, partial, tokens) { + emitLive(phase, partial, tokens); + }, + ) + : await _llmService.processTranscript( + transcript, + correctTranscription: correctTranscription, + onProgress: (phase, partial, tokens) { + emitLive(phase, partial, tokens); + }, + ); + sw.stop(); + + debugPrint( + '[VoiceNoteAiPipeline] Processed $filename: ' + 'summary="${result.summary}", category=${result.category}, ' + '${result.actions.length} actions', + ); + + // If the LLM corrected the transcription, update the transcript as well + if (result.correctedTranscription != null && + result.correctedTranscription!.isNotEmpty) { + await _memoRepository.updateTranscription( + filename: filename, + transcription: result.correctedTranscription!, + ); + } + + // Persist AI results on the memo + await _memoRepository.updateAiResults( + filename: filename, + summary: result.summary, + category: result.category, + aiModel: _llmService.modelName, + ); + + // Replace any previous extracted actions for this memo before inserting + // the latest set, so re-processing never duplicates suggestions. + await _actionRepository.deleteActionsForMemo(memoId); + + // Persist extracted actions + for (final action in result.actions) { + final actionType = _mapActionType(action.type); + await _actionRepository.insertAction( + memoId: memoId, + actionType: actionType, + title: action.title, + notes: action.notes, + dueDate: _tryParseDate(action.dueDate), + startTime: _tryParseDate(action.startTime), + location: action.location, + ); + } + + // Notify watch with round-trip confirmation toast + final firstAction = result.actions.isNotEmpty + ? result.actions.first + : null; + final actionDatetime = firstAction?.startTime ?? firstAction?.dueDate; + onProcessingComplete?.call( + filename, + result.summary, + firstAction?.type, + actionDatetime, + ); + + // Publish final debug info and store per-file + final mem = _llmService.lastInferenceMemoryInfo; + final finalDebug = AiDebugInfo( + filename: filename, + modelName: _llmService.modelName, + rawPrompt: result.classifyMetrics?.rawPrompt, + promptStrategy: result.classifyMetrics?.promptStrategy, + attempts: result.classifyMetrics?.attempts ?? 1, + retryEnabled: result.classifyMetrics?.retryEnabled ?? false, + transcriptionResult: result.originalTranscription, + correctedTranscription: result.correctedTranscription, + rawOutput: result.classifyMetrics?.rawResponse, + parsedJson: result.classifyMetrics?.parsedJson, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + extractedActions: result.actionChronoDetails, + summary: result.summary, + category: result.category, + actionCount: result.actions.length, + correctionElapsed: result.correctionMetrics?.wallTime ?? Duration.zero, + correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, + elapsed: result.classifyMetrics?.wallTime ?? Duration.zero, + tokensPerSecond: result.classifyMetrics?.tokensPerSecond, + correctionTokens: result.correctionMetrics?.completionTokens ?? 0, + tokens: result.classifyMetrics?.completionTokens ?? 0, + phase: 'done', + timestamp: DateTime.now(), + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceMaxTokensCap: mem?.maxTokensCap, + ); + _debugInfoByFile[filename] = finalDebug; + _debugInfoSubject.add(finalDebug); + + return true; + } catch (e) { + debugPrint('[VoiceNoteAiPipeline] Failed to process $filename: $e'); + await _memoRepository.updateProcessingStatus( + filename: filename, + status: 'failed', + ); + return false; + } + } + + /// Process all transcribed but unprocessed memos + Future processAllUnprocessed() async { + final unprocessed = await _memoRepository.getUnprocessedMemos(); + if (unprocessed.isEmpty) { + debugPrint('[VoiceNoteAiPipeline] No unprocessed memos'); + return 0; + } + + debugPrint('[VoiceNoteAiPipeline] Processing ${unprocessed.length} memos'); + int processed = 0; + + for (final memo in unprocessed) { + if (memo.transcription == null || memo.transcription!.trim().isEmpty) { + continue; + } + + final success = await processMemo( + memoId: memo.id, + filename: memo.filename, + transcript: memo.transcription!, + ); + + if (success) processed++; + } + + return processed; + } + + ExtractedActionType _mapActionType(String type) { + switch (type.toLowerCase()) { + case 'task': + return ExtractedActionType.task; + case 'calendar_event': + return ExtractedActionType.calendarEvent; + case 'reminder': + return ExtractedActionType.reminder; + default: + return ExtractedActionType.task; + } + } + + DateTime? _tryParseDate(String? value) { + if (value == null || value.trim().isEmpty) return null; + try { + return DateTime.parse(value); + } catch (_) { + // Natural language dates like "tomorrow" need more sophisticated parsing. + // For v1, we just return null and let the user edit. + return null; + } + } +} diff --git a/zswatch_app/lib/services/analytics/battery_storage_service.dart b/zswatch_app/lib/services/analytics/battery_storage_service.dart index 8e0a873..484ebc5 100644 --- a/zswatch_app/lib/services/analytics/battery_storage_service.dart +++ b/zswatch_app/lib/services/analytics/battery_storage_service.dart @@ -22,11 +22,11 @@ class BatteryStorageService { StreamSubscription? _batterySubscription; StreamSubscription? _connectionSubscription; - + String? _currentWatchId; int? _lastStoredLevel; DateTime? _lastStoredTime; - + /// Minimum time between stored readings (5 minutes) /// This prevents storing too many readings if the watch sends frequent updates static const _minStorageInterval = Duration(minutes: 5); @@ -36,14 +36,16 @@ class BatteryStorageService { /// Start listening for battery updates void start() { // Track connection to get watch ID and charging status - _connectionSubscription = _watchService.connectionStream.listen((connection) { + _connectionSubscription = _watchService.connectionStream.listen(( + connection, + ) { if (connection.isConnected) { _currentWatchId = connection.watchId; } else { _currentWatchId = null; } }); - + // Store initial watch ID if already connected if (_watchService.isConnected) { _currentWatchId = _watchService.currentConnection.watchId; @@ -64,11 +66,11 @@ class BatteryStorageService { Future _onBatteryUpdate(int level) async { final watchId = _currentWatchId; if (watchId == null || watchId.isEmpty) return; - + // Throttle storage: only store if enough time has passed or level changed significantly final now = DateTime.now(); final shouldStore = _shouldStoreReading(level, now); - + if (!shouldStore) return; final isCharging = _watchService.currentConnection.isCharging; @@ -79,29 +81,27 @@ class BatteryStorageService { level: level, isCharging: isCharging, ); - + _lastStoredLevel = level; _lastStoredTime = now; - - debugPrint('[BatteryStorage] Stored: level=$level%, charging=$isCharging'); } catch (e) { debugPrint('[BatteryStorage] Failed to store: $e'); } } - + bool _shouldStoreReading(int level, DateTime now) { // Always store first reading if (_lastStoredTime == null) return true; - + // Store if enough time has passed if (now.difference(_lastStoredTime!) >= _minStorageInterval) return true; - + // Store if level changed significantly (5% or more) if (_lastStoredLevel != null) { final delta = (level - _lastStoredLevel!).abs(); if (delta >= 5) return true; } - + return false; } @@ -115,13 +115,13 @@ class BatteryStorageService { final batteryStorageServiceProvider = Provider((ref) { final watchService = ref.watch(watchServiceProvider); final batteryRepository = ref.watch(batteryRepositoryProvider); - + final service = BatteryStorageService(watchService, batteryRepository); service.start(); - + ref.onDispose(() { service.dispose(); }); - + return service; }); diff --git a/zswatch_app/lib/services/analytics/connection_analytics_service.dart b/zswatch_app/lib/services/analytics/connection_analytics_service.dart index f5113d6..e327491 100644 --- a/zswatch_app/lib/services/analytics/connection_analytics_service.dart +++ b/zswatch_app/lib/services/analytics/connection_analytics_service.dart @@ -21,8 +21,8 @@ class ConnectionAnalyticsService { final WatchService _watchService; final ConnectionAnalyticsRepository _repository; - StreamSubscription? _connectionSubscription; - + StreamSubscription? _connectionSubscription; + // Track current session for duration calculation String? _currentSessionId; DateTime? _sessionStartTime; @@ -33,7 +33,9 @@ class ConnectionAnalyticsService { /// Start tracking connection events void start() { - _connectionSubscription = _watchService.connectionStream.listen(_onConnectionChange); + _connectionSubscription = _watchService.connectionStream.listen( + _onConnectionChange, + ); // If already connected, start a session if (_watchService.isConnected) { @@ -46,24 +48,26 @@ class ConnectionAnalyticsService { void stop() { _connectionSubscription?.cancel(); _connectionSubscription = null; - + // End any active session if (_currentSessionId != null && _lastWatchId != null) { _endSession(_lastWatchId!, DisconnectReason.appTerminated); } } - void _onConnectionChange(Connection connection) async { + Future _onConnectionChange(Connection connection) async { final state = connection.state; final watchId = connection.watchId; - + // Skip if no watch ID if (watchId.isEmpty) return; - + // Skip if state hasn't actually changed if (state == _lastState) return; - - debugPrint('[ConnectionAnalytics] State change: ${_lastState?.name ?? 'null'} -> ${state.name}'); + + debugPrint( + '[ConnectionAnalytics] State change: ${_lastState?.name ?? 'null'} -> ${state.name}', + ); // Handle state transitions switch (state) { @@ -77,7 +81,7 @@ class ConnectionAnalyticsService { case WatchConnectionState.reconnecting: // Record reconnect attempt - if (_lastState == WatchConnectionState.connected || + if (_lastState == WatchConnectionState.connected || _lastState == WatchConnectionState.error) { await _recordReconnectAttempt(watchId); } @@ -125,12 +129,12 @@ class ConnectionAnalyticsService { return DisconnectReason.unknown; } } - + // If we're in error state without a specific error type if (connection.state == WatchConnectionState.error) { return DisconnectReason.unknown; } - + // Clean disconnect return DisconnectReason.userRequested; } @@ -145,7 +149,9 @@ class ConnectionAnalyticsService { void _endSession(String watchId, DisconnectReason reason) { if (_sessionStartTime != null) { final duration = DateTime.now().difference(_sessionStartTime!); - debugPrint('[ConnectionAnalytics] Ended session $_currentSessionId - duration: ${duration.inMinutes} minutes, reason: ${reason.name}'); + debugPrint( + '[ConnectionAnalytics] Ended session $_currentSessionId - duration: ${duration.inMinutes} minutes, reason: ${reason.name}', + ); } _currentSessionId = null; _sessionStartTime = null; @@ -177,7 +183,9 @@ class ConnectionAnalyticsService { sessionId: _currentSessionId, ); await _repository.recordEvent(event); - debugPrint('[ConnectionAnalytics] Recorded: disconnected (${reason.name})'); + debugPrint( + '[ConnectionAnalytics] Recorded: disconnected (${reason.name})', + ); } catch (e) { debugPrint('[ConnectionAnalytics] Failed to record disconnected: $e'); } @@ -192,7 +200,9 @@ class ConnectionAnalyticsService { await _repository.recordEvent(event); debugPrint('[ConnectionAnalytics] Recorded: reconnect attempt'); } catch (e) { - debugPrint('[ConnectionAnalytics] Failed to record reconnect attempt: $e'); + debugPrint( + '[ConnectionAnalytics] Failed to record reconnect attempt: $e', + ); } } @@ -203,16 +213,18 @@ class ConnectionAnalyticsService { } /// Provider for connection analytics service -final connectionAnalyticsServiceProvider = Provider((ref) { - final watchService = ref.watch(watchServiceProvider); - final repository = ref.watch(connectionAnalyticsRepositoryProvider); - - final service = ConnectionAnalyticsService(watchService, repository); - service.start(); - - ref.onDispose(() { - service.dispose(); - }); - - return service; -}); +final connectionAnalyticsServiceProvider = Provider( + (ref) { + final watchService = ref.watch(watchServiceProvider); + final repository = ref.watch(connectionAnalyticsRepositoryProvider); + + final service = ConnectionAnalyticsService(watchService, repository); + service.start(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; + }, +); diff --git a/zswatch_app/lib/services/background/foreground_service.dart b/zswatch_app/lib/services/background/foreground_service.dart index fc0102f..84c0ef6 100644 --- a/zswatch_app/lib/services/background/foreground_service.dart +++ b/zswatch_app/lib/services/background/foreground_service.dart @@ -38,7 +38,8 @@ class ForegroundService { final _disconnectRequestedController = StreamController.broadcast(); /// Stream that emits when user taps "Disconnect" on the notification - Stream get onDisconnectRequested => _disconnectRequestedController.stream; + Stream get onDisconnectRequested => + _disconnectRequestedController.stream; bool _isRunning = false; @@ -49,7 +50,9 @@ class ForegroundService { Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'onDisconnectRequested': - debugPrint('[ForegroundService] Disconnect requested from notification'); + debugPrint( + '[ForegroundService] Disconnect requested from notification', + ); _disconnectRequestedController.add(null); break; } @@ -110,9 +113,10 @@ class ForegroundService { 'watchName': watchName, 'connectionState': _stateToString(state), }); - debugPrint('[ForegroundService] Notification updated: $watchName, $state'); } on PlatformException catch (e) { - debugPrint('[ForegroundService] Failed to update notification: ${e.message}'); + debugPrint( + '[ForegroundService] Failed to update notification: ${e.message}', + ); } } @@ -142,9 +146,14 @@ class ForegroundService { } try { - return await _channel.invokeMethod('isBatteryOptimizationDisabled') ?? false; + return await _channel.invokeMethod( + 'isBatteryOptimizationDisabled', + ) ?? + false; } on PlatformException catch (e) { - debugPrint('[ForegroundService] Failed to check battery optimization: ${e.message}'); + debugPrint( + '[ForegroundService] Failed to check battery optimization: ${e.message}', + ); return false; } } @@ -162,9 +171,14 @@ class ForegroundService { } try { - return await _channel.invokeMethod('requestDisableBatteryOptimization') ?? false; + return await _channel.invokeMethod( + 'requestDisableBatteryOptimization', + ) ?? + false; } on PlatformException catch (e) { - debugPrint('[ForegroundService] Failed to request battery optimization: ${e.message}'); + debugPrint( + '[ForegroundService] Failed to request battery optimization: ${e.message}', + ); return false; } } @@ -179,9 +193,14 @@ class ForegroundService { } try { - return await _channel.invokeMethod('openBatteryOptimizationSettings') ?? false; + return await _channel.invokeMethod( + 'openBatteryOptimizationSettings', + ) ?? + false; } on PlatformException catch (e) { - debugPrint('[ForegroundService] Failed to open battery settings: ${e.message}'); + debugPrint( + '[ForegroundService] Failed to open battery settings: ${e.message}', + ); return false; } } diff --git a/zswatch_app/lib/services/ble/auto_reconnect_service.dart b/zswatch_app/lib/services/ble/auto_reconnect_service.dart index 4fd7564..eda0036 100644 --- a/zswatch_app/lib/services/ble/auto_reconnect_service.dart +++ b/zswatch_app/lib/services/ble/auto_reconnect_service.dart @@ -45,6 +45,7 @@ class AutoReconnectService { bool _isEnabled = true; bool _isCancelled = false; + /// Whether auto-reconnect is suppressed for this session (until app restart) /// Set when user explicitly cancels or disconnects bool _isSuppressedForSession = false; @@ -73,12 +74,13 @@ class AutoReconnectService { bool get isReconnecting => _state == AutoReconnectState.waiting; AutoReconnectService({ - required Future Function(String watchId, {bool autoConnect}) connectById, + required Future Function(String watchId, {bool autoConnect}) + connectById, required Future Function() getLastConnectedWatch, required void Function() cancelConnection, - }) : _connectById = connectById, - _getLastConnectedWatch = getLastConnectedWatch, - _cancelConnection = cancelConnection; + }) : _connectById = connectById, + _getLastConnectedWatch = getLastConnectedWatch, + _cancelConnection = cancelConnection; /// Start auto-reconnect to last connected watch (FR-071) /// @@ -87,15 +89,19 @@ class AutoReconnectService { /// - System handles reconnection when device appears /// - Doesn't time out Future startAutoReconnect() async { - debugPrint('[AutoReconnect:$hashCode] startAutoReconnect() called: _isEnabled=$_isEnabled, _isSuppressedForSession=$_isSuppressedForSession, _state=$_state'); - + debugPrint( + '[AutoReconnect:$hashCode] startAutoReconnect() called: _isEnabled=$_isEnabled, _isSuppressedForSession=$_isSuppressedForSession, _state=$_state', + ); + if (!_isEnabled) { debugPrint('[AutoReconnect:$hashCode] Disabled, skipping'); return; } if (_isSuppressedForSession) { - debugPrint('[AutoReconnect:$hashCode] Suppressed for this session (user cancelled/disconnected)'); + debugPrint( + '[AutoReconnect:$hashCode] Suppressed for this session (user cancelled/disconnected)', + ); return; } @@ -115,7 +121,9 @@ class AutoReconnectService { return; } - debugPrint('[AutoReconnect] Starting auto-connect for ${_targetWatch!.displayName}'); + debugPrint( + '[AutoReconnect] Starting auto-connect for ${_targetWatch!.displayName}', + ); // Wait initial delay before attempting await Future.delayed(AutoReconnectConfig.initialDelay); @@ -131,7 +139,9 @@ class AutoReconnectService { // Use flutter_blue_plus's autoConnect feature // This returns immediately and the system handles reconnection await _connectById(_targetWatch!.id, autoConnect: true); - debugPrint('[AutoReconnect] Auto-connect initiated for ${_targetWatch!.id}'); + debugPrint( + '[AutoReconnect] Auto-connect initiated for ${_targetWatch!.id}', + ); // Note: We stay in 'waiting' state until connection actually happens // The watch_service will notify us when connected } catch (e) { @@ -145,7 +155,9 @@ class AutoReconnectService { /// Call this when user manually selects a different watch or cancels reconnection. /// This suppresses auto-reconnect for the rest of this session. void cancel() { - debugPrint('[AutoReconnect:$hashCode] Cancelled by user - setting _isSuppressedForSession=true'); + debugPrint( + '[AutoReconnect:$hashCode] Cancelled by user - setting _isSuppressedForSession=true', + ); _isCancelled = true; _isSuppressedForSession = true; // Don't auto-restart until app restart _cancelConnection(); diff --git a/zswatch_app/lib/services/ble/ble_connection_manager.dart b/zswatch_app/lib/services/ble/ble_connection_manager.dart index 51ef919..b134d67 100644 --- a/zswatch_app/lib/services/ble/ble_connection_manager.dart +++ b/zswatch_app/lib/services/ble/ble_connection_manager.dart @@ -39,9 +39,8 @@ class BleConnectionManager { int _reconnectAttempts = 0; static const int _maxReconnectAttempts = 5; - BleConnectionManager({ - FlutterSecureStorage? secureStorage, - }) : _secureStorage = secureStorage ?? const FlutterSecureStorage(); + BleConnectionManager({FlutterSecureStorage? secureStorage}) + : _secureStorage = secureStorage ?? const FlutterSecureStorage(); /// Stream of connection state changes Stream get connectionStream => _connectionController.stream; @@ -74,7 +73,10 @@ class BleConnectionManager { } /// Connect to a device by ID (for reconnecting to saved devices) - Future connectById(String deviceId, {bool autoReconnect = false}) async { + Future connectById( + String deviceId, { + bool autoReconnect = false, + }) async { _autoReconnect = autoReconnect; _reconnectAttempts = 0; _isCancelled = false; // Reset for new connection @@ -87,16 +89,17 @@ class BleConnectionManager { Future _connectToDevice(BluetoothDevice device, String watchId) async { // Don't connect if user has cancelled if (_isCancelled) { - debugPrint('[BleConnectionManager] _connectToDevice skipped - cancelled by user'); + debugPrint( + '[BleConnectionManager] _connectToDevice skipped - cancelled by user', + ); return; } try { // Update state to connecting - _updateConnection(Connection( - watchId: watchId, - state: WatchConnectionState.connecting, - )); + _updateConnection( + Connection(watchId: watchId, state: WatchConnectionState.connecting), + ); // Cancel any existing subscriptions await _connectionStateSubscription?.cancel(); @@ -117,14 +120,15 @@ class BleConnectionManager { // Perform post-connection setup await _performPostConnectionSetup(watchId); - } catch (e) { debugPrint('Connection error: $e'); - _updateConnection(Connection.error( - watchId, - ConnectionErrorType.timeout, - details: e.toString(), - )); + _updateConnection( + Connection.error( + watchId, + ConnectionErrorType.timeout, + details: e.toString(), + ), + ); rethrow; } } @@ -132,9 +136,9 @@ class BleConnectionManager { Future _performPostConnectionSetup(String watchId) async { try { // Update state to bonding - _updateConnection(_currentConnection.copyWith( - state: WatchConnectionState.bonding, - )); + _updateConnection( + _currentConnection.copyWith(state: WatchConnectionState.bonding), + ); // Ensure bonding (may prompt user) if (_connectedDevice != null) { @@ -142,9 +146,11 @@ class BleConnectionManager { } // Update state to discovering services - _updateConnection(_currentConnection.copyWith( - state: WatchConnectionState.discoveringServices, - )); + _updateConnection( + _currentConnection.copyWith( + state: WatchConnectionState.discoveringServices, + ), + ); // Discover services _services = await _connectedDevice?.discoverServices(); @@ -155,27 +161,30 @@ class BleConnectionManager { } // Update state to negotiating - _updateConnection(_currentConnection.copyWith( - state: WatchConnectionState.negotiating, - )); + _updateConnection( + _currentConnection.copyWith(state: WatchConnectionState.negotiating), + ); // Optimize connection parameters await _optimizeConnection(watchId); // All done - update to connected state - _updateConnection(_currentConnection.copyWith( - state: WatchConnectionState.connected, - connectedAt: DateTime.now(), - lastActivityAt: DateTime.now(), - )); - + _updateConnection( + _currentConnection.copyWith( + state: WatchConnectionState.connected, + connectedAt: DateTime.now(), + lastActivityAt: DateTime.now(), + ), + ); } catch (e) { debugPrint('Post-connection setup error: $e'); - _updateConnection(Connection.error( - watchId, - ConnectionErrorType.serviceDiscoveryFailed, - details: e.toString(), - )); + _updateConnection( + Connection.error( + watchId, + ConnectionErrorType.serviceDiscoveryFailed, + details: e.toString(), + ), + ); await disconnect(); rethrow; } @@ -204,9 +213,7 @@ class BleConnectionManager { void enableDle() { // DLE is typically enabled automatically with MTU negotiation // on modern BLE stacks. This just updates our tracking. - _updateConnection(_currentConnection.copyWith( - dleEnabled: true, - )); + _updateConnection(_currentConnection.copyWith(dleEnabled: true)); } Future _optimizeConnection(String watchId) async { @@ -223,7 +230,6 @@ class BleConnectionManager { // Mark DLE as enabled (automatic with MTU negotiation) enableDle(); - } catch (e) { debugPrint('Connection optimization warning: $e'); // Non-fatal - continue with default parameters @@ -252,7 +258,6 @@ class BleConnectionManager { key: 'bond_$watchId', value: DateTime.now().toIso8601String(), ); - } catch (e) { debugPrint('Bonding error: $e'); // Continue anyway - some devices work without explicit bonding @@ -297,27 +302,32 @@ class BleConnectionManager { void _handleDisconnect(String watchId) { // If user has cancelled, don't attempt reconnection if (_isCancelled) { - debugPrint('[BleConnectionManager] Disconnect ignored - cancelled by user'); - _updateConnection(Connection( - watchId: watchId, - state: WatchConnectionState.disconnected, - )); + debugPrint( + '[BleConnectionManager] Disconnect ignored - cancelled by user', + ); + _updateConnection( + Connection(watchId: watchId, state: WatchConnectionState.disconnected), + ); _cleanup(); return; } final wasConnected = _currentConnection.isConnected; - if (wasConnected && _autoReconnect && _reconnectAttempts < _maxReconnectAttempts) { + if (wasConnected && + _autoReconnect && + _reconnectAttempts < _maxReconnectAttempts) { // Attempt reconnection _attemptReconnect(watchId); } else { // Final disconnect - _updateConnection(Connection( - watchId: watchId, - state: WatchConnectionState.disconnected, - reconnectionCount: _reconnectAttempts, - )); + _updateConnection( + Connection( + watchId: watchId, + state: WatchConnectionState.disconnected, + reconnectionCount: _reconnectAttempts, + ), + ); _cleanup(); } } @@ -325,34 +335,44 @@ class BleConnectionManager { void _attemptReconnect(String watchId) { // If user has cancelled, don't attempt reconnection if (_isCancelled) { - debugPrint('[BleConnectionManager] Reconnect skipped - cancelled by user'); + debugPrint( + '[BleConnectionManager] Reconnect skipped - cancelled by user', + ); return; } _reconnectAttempts++; - _updateConnection(_currentConnection.copyWith( - state: WatchConnectionState.reconnecting, - reconnectionCount: _reconnectAttempts, - )); + _updateConnection( + _currentConnection.copyWith( + state: WatchConnectionState.reconnecting, + reconnectionCount: _reconnectAttempts, + ), + ); // Delay before reconnect attempt _reconnectTimer = Timer(BleConfig.reconnectionDelay, () async { // Double-check cancellation inside timer callback if (_isCancelled) { - debugPrint('[BleConnectionManager] Reconnect timer skipped - cancelled by user'); + debugPrint( + '[BleConnectionManager] Reconnect timer skipped - cancelled by user', + ); return; } if (_connectedDevice != null && _autoReconnect) { try { await _connectToDevice(_connectedDevice!, watchId); } catch (e) { - debugPrint('[BleConnectionManager] Reconnect attempt $_reconnectAttempts failed: $e'); + debugPrint( + '[BleConnectionManager] Reconnect attempt $_reconnectAttempts failed: $e', + ); if (_reconnectAttempts >= _maxReconnectAttempts) { - _updateConnection(Connection.error( - watchId, - ConnectionErrorType.maxReconnectionsReached, - )); + _updateConnection( + Connection.error( + watchId, + ConnectionErrorType.maxReconnectionsReached, + ), + ); _cleanup(); } } @@ -367,10 +387,9 @@ class BleConnectionManager { } final rssi = await _connectedDevice!.readRssi(); - _updateConnection(_currentConnection.copyWith( - rssi: rssi, - lastActivityAt: DateTime.now(), - )); + _updateConnection( + _currentConnection.copyWith(rssi: rssi, lastActivityAt: DateTime.now()), + ); return rssi; } @@ -380,15 +399,17 @@ class BleConnectionManager { _autoReconnect = false; _isCancelled = true; _reconnectTimer?.cancel(); - + final device = _connectedDevice; _cleanup(); device?.disconnect(); - - _updateConnection(Connection( - watchId: _currentConnection.watchId, - state: WatchConnectionState.disconnected, - )); + + _updateConnection( + Connection( + watchId: _currentConnection.watchId, + state: WatchConnectionState.disconnected, + ), + ); } /// Disconnect from current device @@ -410,10 +431,9 @@ class BleConnectionManager { } } - _updateConnection(Connection( - watchId: watchId, - state: WatchConnectionState.disconnected, - )); + _updateConnection( + Connection(watchId: watchId, state: WatchConnectionState.disconnected), + ); } void _cleanup() { @@ -433,18 +453,18 @@ class BleConnectionManager { /// Update last activity timestamp void updateActivity() { if (_currentConnection.isConnected) { - _updateConnection(_currentConnection.copyWith( - lastActivityAt: DateTime.now(), - )); + _updateConnection( + _currentConnection.copyWith(lastActivityAt: DateTime.now()), + ); } } /// Find a service by UUID BluetoothService? findService(Guid uuid) { return _services?.cast().firstWhere( - (s) => s?.uuid == uuid, - orElse: () => null, - ); + (s) => s?.uuid == uuid, + orElse: () => null, + ); } /// Find a characteristic by UUID within a service @@ -453,9 +473,9 @@ class BleConnectionManager { Guid uuid, ) { return service.characteristics.cast().firstWhere( - (c) => c?.uuid == uuid, - orElse: () => null, - ); + (c) => c?.uuid == uuid, + orElse: () => null, + ); } /// Dispose resources @@ -464,4 +484,3 @@ class BleConnectionManager { await _connectionController.close(); } } - diff --git a/zswatch_app/lib/services/ble/ble_connection_service.dart b/zswatch_app/lib/services/ble/ble_connection_service.dart new file mode 100644 index 0000000..d671763 --- /dev/null +++ b/zswatch_app/lib/services/ble/ble_connection_service.dart @@ -0,0 +1,656 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../core/constants/app_constants.dart'; +import '../../core/constants/ble_constants.dart'; +import '../../data/models/connection.dart'; +import '../../data/models/connection_phase.dart'; +import '../../data/models/connection_state.dart'; +import 'ble_scanner.dart'; + +/// Single source of truth for BLE connection lifecycle. +/// +/// Replaces the connection/reconnect logic that was previously scattered +/// across WatchService, BleConnectionManager, and WatchStateProvider. +/// +/// All connection-related providers derive their state from this service's +/// [phaseStream] and [connectionStream]. +class BleConnectionService { + BluetoothDevice? _device; + List? _services; + StreamSubscription? _connectionSubscription; + Timer? _reconnectTimer; + Timer? _rssiTimer; + + // Phase is the primary state — no boolean flags needed. + final _phaseController = BehaviorSubject.seeded( + const ConnectionPhase.disconnected(), + ); + + // Connection model for backwards compatibility with UI code. + final _connectionController = BehaviorSubject.seeded( + const Connection(watchId: '', state: WatchConnectionState.disconnected), + ); + + // Callback for post-connection setup (NUS, battery, sync) — owned by WatchService. + Future Function( + BluetoothDevice device, + List services, + String watchId, + String name, + )? + onSetupRequired; + + int _reconnectAttempts = 0; + static const int _maxQuickReconnectAttempts = 3; + + // Minimal flags that can't be expressed in ConnectionPhase: + bool _isCancelled = false; // User requested cancellation + bool _autoReconnect = true; + bool _setupInProgress = false; // Guards against concurrent _runSetup calls + String _watchId = ''; + String _watchName = ''; + + // --- Public API --- + + /// Stream of connection phase changes (primary state). + Stream get phaseStream => _phaseController.stream; + + /// Current connection phase. + ConnectionPhase get currentPhase => _phaseController.value; + + /// Stream of Connection model changes (for backwards compat). + Stream get connectionStream => _connectionController.stream; + + /// Current Connection model. + Connection get currentConnection => _connectionController.value; + + /// Currently connected BLE device (for GATT operations by WatchService). + BluetoothDevice? get device => _device; + + /// Discovered BLE services (for GATT operations by WatchService). + List? get services => _services; + + /// Whether fully connected and operational. + bool get isConnected => currentPhase is Connected; + + /// Whether the underlying BLE device has an active GATT connection, + /// regardless of the app-level phase (e.g. still in SettingUp). + /// Use this for guarding NUS writes that happen during setup. + bool get isDeviceConnected => _device?.isConnected ?? false; + + /// The watch ID we're connected/connecting to. + String get watchId => _watchId; + + /// Connect to a scanned device. + Future connect( + ScannedWatch scannedDevice, { + bool autoConnect = false, + }) async { + if (!autoConnect) { + _isCancelled = false; + } + await _connectToDevice( + scannedDevice.device, + scannedDevice.id, + scannedDevice.name, + autoConnect: autoConnect, + ); + } + + /// Connect by device ID (for saved watches / auto-reconnect). + Future connectById(String deviceId, {bool autoConnect = false}) async { + if (!autoConnect) { + _isCancelled = false; + } + final device = BluetoothDevice.fromId(deviceId); + await _connectToDevice( + device, + deviceId, + 'ZSWatch', + autoConnect: autoConnect, + ); + } + + /// Cancel any pending connection attempt. + void cancelPendingConnection() { + debugPrint('[BleConnectionService] cancelPendingConnection()'); + _isCancelled = true; + _autoReconnect = false; + _reconnectTimer?.cancel(); + + final phase = currentPhase; + if (phase.isTryingToConnect || phase is PhaseError) { + final device = _device; + if (device != null) { + device.disconnect(); + } else if (_watchId.isNotEmpty) { + BluetoothDevice.fromId(_watchId).disconnect(); + } + _cleanup(); + _setPhase(const ConnectionPhase.disconnected()); + } + } + + /// Disconnect from current device. + Future disconnect() async { + _autoReconnect = false; + _isCancelled = true; + _reconnectTimer?.cancel(); + + final device = _device; + _cleanup(); + + if (device != null) { + try { + await device.disconnect(); + } catch (e) { + debugPrint('[BleConnectionService] disconnect() error (ignored): $e'); + } + } + + _setPhase(const ConnectionPhase.disconnected()); + } + + /// Re-discover BLE services on the connected device. + Future rediscoverServices() async { + if (_device == null || !isConnected) return false; + debugPrint('[BleConnectionService] Re-discovering services...'); + try { + _services = await _device!.discoverServices(); + final hasSmp = _findService(_guid(McumgrUuids.service)) != null; + debugPrint( + '[BleConnectionService] Re-discovery complete. SMP available: $hasSmp', + ); + return hasSmp; + } catch (e) { + debugPrint('[BleConnectionService] Re-discovery failed: $e'); + return false; + } + } + + /// Whether SMP/MCUmgr service is available. + bool get hasSmpService => _findService(_guid(McumgrUuids.service)) != null; + + /// Read RSSI and update connection model. + Future readAndUpdateRssi() async { + if (_device == null || !isConnected) return; + try { + final rssi = await _device!.readRssi(); + _updateConnectionModel(currentConnection.copyWith(rssi: rssi)); + } catch (e) { + debugPrint('[BleConnectionService] Failed to read RSSI: $e'); + // Device likely disconnected — stop polling to avoid spam + _stopRssiUpdates(); + } + } + + /// Dispose resources. + Future dispose() async { + await disconnect(); + await _phaseController.close(); + await _connectionController.close(); + } + + // --- Internal connection logic --- + + Future _connectToDevice( + BluetoothDevice device, + String watchId, + String name, { + bool autoConnect = false, + bool isReconnectAttempt = false, + }) async { + debugPrint( + '[BleConnectionService] _connectToDevice: watchId=$watchId, ' + 'autoConnect=$autoConnect, isReconnect=$isReconnectAttempt, ' + 'phase=$currentPhase', + ); + + if (_isCancelled) return; + + // Already connected to this device + if (isConnected && _device?.remoteId.str == watchId) return; + + // Already in a connecting sub-state + final phase = currentPhase; + if (phase is Connecting || phase is SettingUp) return; + + if (!isReconnectAttempt) { + _autoReconnect = true; + _reconnectAttempts = 0; + } + + _watchId = watchId; + _watchName = name; + + try { + _setPhase(const ConnectionPhase.connecting()); + + // Cancel old subscription before creating a new one to avoid + // reacting to stale events from a previous connection. + await _connectionSubscription?.cancel(); + _connectionSubscription = null; + + // Force-close any stale GATT handle from a previous connection. + // This handles: (1) autoConnect GATT handles that survive disconnect + // events, (2) stale connections left after setup errors (e.g. MTU + // timeout where GATT stays connected but app phase is error). + try { + await device.disconnect(); + } catch (e) { + debugPrint( + '[BleConnectionService] Pre-connect disconnect (ignored): $e', + ); + } + + _device = device; + + if (autoConnect) { + if (_isCancelled) return; + // For autoConnect, set up the listener first — connect() is + // fire-and-forget and we rely on the listener for state changes. + _connectionSubscription = device.connectionState + .skip(1) // Skip initial state emission from FBP + .listen((state) => _handleBleStateChange(state, watchId, name)); + unawaited( + device + .connect( + license: License.free, + timeout: const Duration(seconds: 0), + mtu: null, + autoConnect: true, + ) + .catchError((Object e) { + debugPrint( + '[BleConnectionService] AutoConnect error (ignored): $e', + ); + }), + ); + } else { + if (_isCancelled) return; + // For direct connect, set up the listener with skip(1) to avoid + // the initial disconnected state emission triggering a false + // _handleDisconnect while connect() is still in progress. + _connectionSubscription = device.connectionState + .skip(1) // Skip initial state emission from FBP + .listen((state) => _handleBleStateChange(state, watchId, name)); + final timeout = isReconnectAttempt + ? const Duration(seconds: 10) + : BleConfig.connectionTimeout; + await device.connect( + license: License.free, + timeout: timeout, + autoConnect: false, + ); + await _runSetup(watchId, name); + } + } catch (e) { + _setPhase( + ConnectionPhase.error( + type: ConnectionErrorType.timeout, + details: e.toString(), + ), + ); + rethrow; + } + } + + /// Run post-connection setup (bonding, service discovery, MTU, then + /// hand off to WatchService for NUS/battery/sync via callback). + Future _runSetup(String watchId, String name) async { + if (_isCancelled || _device == null || !_device!.isConnected) return; + + // Prevent concurrent setup calls — both _connectToDevice (await path) + // and _handleBleStateChange(connected) can trigger _runSetup. + if (_setupInProgress) return; + _setupInProgress = true; + + _setPhase(const ConnectionPhase.settingUp(step: SetupStep.bonding)); + + try { + // Bonding (Android only) + if (Platform.isAndroid) { + if (!_shouldContinue()) return; + final bondState = await _device!.bondState.first; + if (!_shouldContinue()) return; + if (bondState != BluetoothBondState.bonded) { + await _device!.createBond(); + if (!_shouldContinue()) return; + } + } + + // Service discovery + _setPhase( + const ConnectionPhase.settingUp(step: SetupStep.discoveringServices), + ); + if (!_shouldContinue()) return; + _services = await _device!.discoverServices(); + + // MTU negotiation + int mtu; + if (Platform.isAndroid) { + _setPhase(const ConnectionPhase.settingUp(step: SetupStep.negotiating)); + if (!_shouldContinue()) return; + mtu = await _device!.requestMtu(BleConfig.preferredMtu); + } else { + mtu = AppConstants.minimumAcceptableMtu; + } + + // Syncing phase — hand off to WatchService for NUS, battery, time sync + _setPhase(const ConnectionPhase.settingUp(step: SetupStep.syncing)); + if (!_shouldContinue()) return; + + // Update connection model with MTU before callback + _updateConnectionModel( + currentConnection.copyWith(mtu: mtu, connectedAt: DateTime.now()), + ); + + // Let WatchService do NUS setup, battery, time sync, device info + if (onSetupRequired != null) { + await onSetupRequired!(_device!, _services!, watchId, name); + } + + if (!_shouldContinue()) return; + + // Fully connected + _setPhase(const ConnectionPhase.connected()); + _reconnectAttempts = 0; + + // Start RSSI updates + await readAndUpdateRssi(); + _startRssiUpdates(); + } catch (e) { + debugPrint('[BleConnectionService] Setup error: $e'); + if (_device != null && _device!.isConnected) { + // Device still connected but setup failed — disconnect and let + // the BLE state listener trigger _handleDisconnect → reconnect + try { + await _device!.disconnect(); + } catch (_) {} + } + // If device already disconnected, the BLE state listener has already + // fired _handleDisconnect which handles reconnection. Don't schedule + // a duplicate reconnect here — it races with _handleDisconnect and + // causes the reconnect timer to be overwritten. + } finally { + _setupInProgress = false; + } + } + + bool _shouldContinue() { + if (_isCancelled) return false; + if (_device == null) return false; + if (!_device!.isConnected) return false; + return true; + } + + void _handleBleStateChange( + BluetoothConnectionState state, + String watchId, + String name, + ) { + debugPrint( + '[BleConnectionService] BLE state: $state, phase: $currentPhase', + ); + + if (_isCancelled) { + if (state == BluetoothConnectionState.connected) { + BluetoothDevice.fromId(watchId).disconnect(); + } + return; + } + + switch (state) { + case BluetoothConnectionState.connected: + final phase = currentPhase; + // For autoConnect (initial connect) or quick reconnect — BLE layer + // reports connected and we need to kick off setup. + // Background reconnect is excluded here: _scheduleBackgroundAttempt + // awaits _runSetup() directly after device.connect() returns, so + // triggering it here too would cause two concurrent setup passes. + final isBackgroundReconnect = + phase is Reconnecting && phase.isBackground; + if ((phase is Connecting || phase is Reconnecting) && + phase is! SettingUp && + !isBackgroundReconnect) { + if (!_autoReconnect) { + _device?.disconnect(); + _cleanup(); + _setPhase(const ConnectionPhase.disconnected()); + return; + } + _runSetup(watchId, name); + } + break; + + case BluetoothConnectionState.disconnected: + _handleDisconnect(watchId, name); + break; + + // ignore: deprecated_member_use + case BluetoothConnectionState.connecting: + // ignore: deprecated_member_use + case BluetoothConnectionState.disconnecting: + break; + } + } + + void _handleDisconnect(String watchId, String name) { + debugPrint( + '[BleConnectionService] _handleDisconnect: phase=$currentPhase, ' + 'cancelled=$_isCancelled, autoReconnect=$_autoReconnect, ' + 'attempts=$_reconnectAttempts', + ); + + if (_isCancelled) { + _setPhase(const ConnectionPhase.disconnected()); + _cleanup(); + return; + } + + final phase = currentPhase; + + // If we're in background reconnect, stay in reconnecting state. + // The background attempt's catch block handles rescheduling. + if (phase is Reconnecting && phase.isBackground) { + return; + } + + // If we're in Connecting phase, the connect() call is still in progress. + // Don't interfere — let connect() resolve or timeout on its own. + // Its error will be caught by the caller (_scheduleReconnect or + // _connectToDevice) which will handle the next reconnect attempt. + if (phase is Connecting) { + return; + } + + // Stop RSSI timer on any real disconnect + _stopRssiUpdates(); + + // Was previously connected, setting up, or in a connecting state + final shouldReconnect = + _autoReconnect && + (phase is Connected || phase is SettingUp || phase.isTryingToConnect); + + if (shouldReconnect && _reconnectAttempts < _maxQuickReconnectAttempts) { + _scheduleReconnect(watchId, name); + } else if (shouldReconnect) { + _startBackgroundReconnect(watchId, name); + } else { + _setPhase(const ConnectionPhase.disconnected()); + _cleanup(); + } + } + + void _scheduleReconnect(String watchId, String name) { + if (_isCancelled) return; + _reconnectTimer?.cancel(); + _reconnectAttempts++; + + _setPhase(ConnectionPhase.reconnecting(attempt: _reconnectAttempts)); + + _reconnectTimer = Timer(BleConfig.reconnectionDelay, () async { + if (_isCancelled) return; + if (!_autoReconnect) return; + + try { + final device = BluetoothDevice.fromId(watchId); + await _connectToDevice(device, watchId, name, isReconnectAttempt: true); + } catch (e) { + debugPrint( + '[BleConnectionService] Reconnect attempt $_reconnectAttempts failed: $e', + ); + if (_isCancelled) { + _cleanup(); + return; + } + if (_reconnectAttempts >= _maxQuickReconnectAttempts) { + _startBackgroundReconnect(watchId, name); + } else { + _scheduleReconnect(watchId, name); + } + } + }); + } + + void _startBackgroundReconnect(String watchId, String name) { + if (_isCancelled) return; + _reconnectTimer?.cancel(); + + _setPhase( + ConnectionPhase.reconnecting( + attempt: _reconnectAttempts, + isBackground: true, + ), + ); + + _scheduleBackgroundAttempt(watchId, name); + } + + void _scheduleBackgroundAttempt(String watchId, String name) { + if (_isCancelled) return; + final phase = currentPhase; + if (phase is! Reconnecting || !phase.isBackground) return; + + final attemptNumber = _reconnectAttempts - _maxQuickReconnectAttempts; + final delaySeconds = (5 + (attemptNumber * 5)).clamp(5, 15); + + _reconnectTimer = Timer(Duration(seconds: delaySeconds), () async { + if (_isCancelled) return; + final p = currentPhase; + if (p is! Reconnecting || !p.isBackground) return; + + _reconnectAttempts++; + debugPrint( + '[BleConnectionService] Background reconnect attempt $_reconnectAttempts', + ); + + // Cancel old subscription — we create a new one below. + await _connectionSubscription?.cancel(); + _connectionSubscription = null; + + final device = BluetoothDevice.fromId(watchId); + // Note: no pre-reconnect disconnect here (unlike _connectToDevice). + // By the time we're in background reconnect the stale autoConnect GATT + // handle from the initial connection is long gone, and calling + // disconnect() unnecessarily triggers FBP's 2000ms disconnect gap which + // delays setup cleanup when the watch drops during service discovery. + + _device = device; + + // Set up listener with skip(1) to avoid initial state emission + _connectionSubscription = device.connectionState + .skip(1) + .listen((state) => _handleBleStateChange(state, watchId, name)); + + try { + await device.connect( + license: License.free, + timeout: const Duration(seconds: 10), + autoConnect: false, + ); + // Connection succeeded — run setup directly since we own the + // connect() call here (not going through _connectToDevice). + await _runSetup(watchId, name); + } catch (e) { + debugPrint('[BleConnectionService] Background reconnect failed: $e'); + if (!_isCancelled) { + final cp = currentPhase; + if (cp is Reconnecting && cp.isBackground) { + _scheduleBackgroundAttempt(watchId, name); + } + } + } + }); + } + + void _startRssiUpdates() { + _rssiTimer?.cancel(); + _rssiTimer = Timer.periodic(const Duration(seconds: 5), (_) { + readAndUpdateRssi(); + }); + } + + void _stopRssiUpdates() { + _rssiTimer?.cancel(); + _rssiTimer = null; + } + + void _cleanup() { + _connectionSubscription?.cancel(); + _connectionSubscription = null; + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _setupInProgress = false; + _stopRssiUpdates(); + _device = null; + _services = null; + } + + void _setPhase(ConnectionPhase phase) { + _phaseController.add(phase); + // Keep Connection model in sync for backwards compat + _updateConnectionModel( + Connection( + watchId: _watchId, + watchName: _watchName, + state: phase.watchConnectionState, + rssi: currentConnection.rssi, + mtu: currentConnection.mtu, + phyMode: currentConnection.phyMode, + dleEnabled: currentConnection.dleEnabled, + isCharging: currentConnection.isCharging, + reconnectionCount: _reconnectAttempts, + connectedAt: phase is Connected ? currentConnection.connectedAt : null, + lastActivityAt: currentConnection.lastActivityAt, + errorType: phase is PhaseError ? phase.type : null, + errorDetails: phase is PhaseError ? phase.details : null, + ), + ); + } + + /// Update connection model fields (e.g. isCharging from protocol messages). + /// Used by WatchService to update charging state from protocol data. + void updateConnectionField(Connection Function(Connection) updater) { + final updated = updater(_connectionController.value); + _connectionController.add(updated); + } + + void _updateConnectionModel(Connection connection) { + _connectionController.add(connection); + } + + BluetoothService? _findService(Guid uuid) { + return _services?.cast().firstWhere( + (s) => s?.uuid == uuid, + orElse: () => null, + ); + } +} + +Guid _guid(String uuid) => Guid(uuid); diff --git a/zswatch_app/lib/services/ble/ble_scanner.dart b/zswatch_app/lib/services/ble/ble_scanner.dart index b2838e3..1bcc8a9 100644 --- a/zswatch_app/lib/services/ble/ble_scanner.dart +++ b/zswatch_app/lib/services/ble/ble_scanner.dart @@ -63,9 +63,7 @@ class ScannedWatch { /// Display name without status suffix String get displayName { // Remove (Connected) or (Paired) suffix if present - return name - .replaceAll(' (Connected)', '') - .replaceAll(' (Paired)', ''); + return name.replaceAll(' (Connected)', '').replaceAll(' (Paired)', ''); } /// Status text for display @@ -104,7 +102,8 @@ class ScannedWatch { /// BLE Scanner for discovering ZSWatch devices class BleScanner { final _scannedDevices = {}; - final _scanResultsController = StreamController>.broadcast(); + final _scanResultsController = + StreamController>.broadcast(); StreamSubscription>? _scanSubscription; Timer? _staleDeviceTimer; bool _isScanning = false; @@ -116,14 +115,14 @@ class BleScanner { Stream> get scanResults => _scanResultsController.stream; /// Current list of discovered devices - List get discoveredDevices => _scannedDevices.values.toList() - ..sort((a, b) { - // Connected devices first, then by RSSI - if (a.isConnected != b.isConnected) { - return a.isConnected ? -1 : 1; - } - return b.rssi.compareTo(a.rssi); - }); + List get discoveredDevices => + _scannedDevices.values.toList()..sort((a, b) { + // Connected devices first, then by RSSI + if (a.isConnected != b.isConnected) { + return a.isConnected ? -1 : 1; + } + return b.rssi.compareTo(a.rssi); + }); /// Whether currently scanning bool get isScanning => _isScanning; @@ -179,17 +178,19 @@ class BleScanner { try { // Get system-connected devices that we know about final connectedDevices = FlutterBluePlus.connectedDevices; - + for (final device in connectedDevices) { final deviceId = device.remoteId.str; - final deviceName = device.advName.isNotEmpty - ? device.advName + final deviceName = device.advName.isNotEmpty + ? device.advName : device.platformName; - + // Only show connected devices that are in our database OR are ZSWatch devices final isKnown = _knownWatchIds.contains(deviceId); - final isZsWatch = deviceName.toLowerCase().contains(BleConfig.deviceNamePrefix.toLowerCase()); - + final isZsWatch = deviceName.toLowerCase().contains( + BleConfig.deviceNamePrefix.toLowerCase(), + ); + if (isKnown || isZsWatch) { _scannedDevices[deviceId] = ScannedWatch( id: deviceId, @@ -207,29 +208,29 @@ class BleScanner { // Only show bonded devices that are in our app database if (_knownWatchIds.isNotEmpty) { final bondedDevices = await FlutterBluePlus.bondedDevices; - + for (final device in bondedDevices) { final deviceId = device.remoteId.str; - + // Skip if already added as connected if (_scannedDevices.containsKey(deviceId)) continue; - + // Only show if it's in our database if (_knownWatchIds.contains(deviceId)) { - final deviceName = device.advName.isNotEmpty - ? device.advName + final deviceName = device.advName.isNotEmpty + ? device.advName : device.platformName; - - _scannedDevices[deviceId] = ScannedWatch( - id: deviceId, - name: deviceName.isNotEmpty ? deviceName : 'ZSWatch', - rssi: -100, // Low priority for paired-only devices - device: device, - discoveredAt: DateTime.now(), - isConnected: false, - isBonded: true, - isAdvertising: false, // Not from scan, from bonded list - ); + + _scannedDevices[deviceId] = ScannedWatch( + id: deviceId, + name: deviceName.isNotEmpty ? deviceName : 'ZSWatch', + rssi: -100, // Low priority for paired-only devices + device: device, + discoveredAt: DateTime.now(), + isConnected: false, + isBonded: true, + isAdvertising: false, // Not from scan, from bonded list + ); } } } @@ -250,7 +251,9 @@ class BleScanner { // Filter for ZSWatch devices if (deviceName.isEmpty || - !deviceName.toLowerCase().contains(BleConfig.deviceNamePrefix.toLowerCase())) { + !deviceName.toLowerCase().contains( + BleConfig.deviceNamePrefix.toLowerCase(), + )) { continue; } @@ -258,8 +261,9 @@ class BleScanner { if (_scannedDevices.containsKey(deviceId)) { // Update existing device - _scannedDevices[deviceId] = - _scannedDevices[deviceId]!.updateRssi(result.rssi); + _scannedDevices[deviceId] = _scannedDevices[deviceId]!.updateRssi( + result.rssi, + ); } else { // Add new device _scannedDevices[deviceId] = ScannedWatch( @@ -322,4 +326,3 @@ class BleScanner { await _scanResultsController.close(); } } - diff --git a/zswatch_app/lib/services/ble/ble_service.dart b/zswatch_app/lib/services/ble/ble_service.dart index 25401b6..f0e3289 100644 --- a/zswatch_app/lib/services/ble/ble_service.dart +++ b/zswatch_app/lib/services/ble/ble_service.dart @@ -15,10 +15,7 @@ enum BleConnectionState { } /// PHY mode for BLE connection -enum BlePhyMode { - phy1M, - phy2M, -} +enum BlePhyMode { phy1M, phy2M } /// Connection metadata class BleConnectionInfo { @@ -142,10 +139,7 @@ abstract class BleService { /// /// [device] - The device to connect to /// [autoReconnect] - Whether to auto-reconnect on disconnect - Future connect( - BluetoothDevice device, { - bool autoReconnect = true, - }); + Future connect(BluetoothDevice device, {bool autoReconnect = true}); /// Disconnect from current device Future disconnect(); @@ -202,4 +196,3 @@ abstract class BleService { Guid characteristicUuid, ); } - diff --git a/zswatch_app/lib/services/ble/ble_service_impl.dart b/zswatch_app/lib/services/ble/ble_service_impl.dart index 7371be1..c362a0c 100644 --- a/zswatch_app/lib/services/ble/ble_service_impl.dart +++ b/zswatch_app/lib/services/ble/ble_service_impl.dart @@ -75,10 +75,12 @@ class BleServiceImpl implements BleService { _isScanning = true; _updateConnectionState( - (_currentConnection ?? const BleConnectionInfo( - deviceId: '', - state: BleConnectionState.disconnected, - )).copyWith(state: BleConnectionState.scanning), + (_currentConnection ?? + const BleConnectionInfo( + deviceId: '', + state: BleConnectionState.disconnected, + )) + .copyWith(state: BleConnectionState.scanning), ); _scanSubscription = FlutterBluePlus.scanResults.listen((results) { @@ -107,18 +109,20 @@ class BleServiceImpl implements BleService { ); // Auto-stop tracking when scan completes - unawaited(Future.delayed(timeout, () { - if (_isScanning) { - _isScanning = false; - if (_currentConnection?.state == BleConnectionState.scanning) { - _updateConnectionState( - _currentConnection!.copyWith( - state: BleConnectionState.disconnected, - ), - ); + unawaited( + Future.delayed(timeout, () { + if (_isScanning) { + _isScanning = false; + if (_currentConnection?.state == BleConnectionState.scanning) { + _updateConnectionState( + _currentConnection!.copyWith( + state: BleConnectionState.disconnected, + ), + ); + } } - } - })); + }), + ); } @override @@ -137,16 +141,19 @@ class BleServiceImpl implements BleService { _autoReconnect = autoReconnect; _reconnectionCount = 0; - _updateConnectionState(BleConnectionInfo( - deviceId: device.remoteId.str, - state: BleConnectionState.connecting, - )); + _updateConnectionState( + BleConnectionInfo( + deviceId: device.remoteId.str, + state: BleConnectionState.connecting, + ), + ); try { // Listen to connection state changes _connectionSubscription?.cancel(); - _connectionSubscription = - device.connectionState.listen(_handleConnectionStateChange); + _connectionSubscription = device.connectionState.listen( + _handleConnectionStateChange, + ); await device.connect( license: License.free, @@ -157,19 +164,23 @@ class BleServiceImpl implements BleService { _connectedDevice = device; - _updateConnectionState(BleConnectionInfo( - deviceId: device.remoteId.str, - state: BleConnectionState.connected, - connectedAt: DateTime.now(), - lastActivityAt: DateTime.now(), - reconnectionCount: _reconnectionCount, - )); + _updateConnectionState( + BleConnectionInfo( + deviceId: device.remoteId.str, + state: BleConnectionState.connected, + connectedAt: DateTime.now(), + lastActivityAt: DateTime.now(), + reconnectionCount: _reconnectionCount, + ), + ); } catch (e) { - _updateConnectionState(BleConnectionInfo( - deviceId: device.remoteId.str, - state: BleConnectionState.error, - reconnectionCount: _reconnectionCount, - )); + _updateConnectionState( + BleConnectionInfo( + deviceId: device.remoteId.str, + state: BleConnectionState.error, + reconnectionCount: _reconnectionCount, + ), + ); rethrow; } } @@ -180,14 +191,16 @@ class BleServiceImpl implements BleService { switch (state) { case BluetoothConnectionState.connected: _updateConnectionState( - (_currentConnection ?? BleConnectionInfo( - deviceId: _connectedDevice!.remoteId.str, - state: BleConnectionState.connected, - )).copyWith( - state: BleConnectionState.connected, - connectedAt: _currentConnection?.connectedAt ?? DateTime.now(), - lastActivityAt: DateTime.now(), - ), + (_currentConnection ?? + BleConnectionInfo( + deviceId: _connectedDevice!.remoteId.str, + state: BleConnectionState.connected, + )) + .copyWith( + state: BleConnectionState.connected, + connectedAt: _currentConnection?.connectedAt ?? DateTime.now(), + lastActivityAt: DateTime.now(), + ), ); case BluetoothConnectionState.disconnected: if (_autoReconnect && @@ -195,24 +208,24 @@ class BleServiceImpl implements BleService { _handleReconnection(); } else { _updateConnectionState( - (_currentConnection ?? BleConnectionInfo( - deviceId: _connectedDevice!.remoteId.str, - state: BleConnectionState.disconnected, - )).copyWith( - state: BleConnectionState.disconnected, - ), + (_currentConnection ?? + BleConnectionInfo( + deviceId: _connectedDevice!.remoteId.str, + state: BleConnectionState.disconnected, + )) + .copyWith(state: BleConnectionState.disconnected), ); _cleanupConnection(); } // ignore: deprecated_member_use case BluetoothConnectionState.connecting: _updateConnectionState( - (_currentConnection ?? BleConnectionInfo( - deviceId: _connectedDevice!.remoteId.str, - state: BleConnectionState.connecting, - )).copyWith( - state: BleConnectionState.connecting, - ), + (_currentConnection ?? + BleConnectionInfo( + deviceId: _connectedDevice!.remoteId.str, + state: BleConnectionState.connecting, + )) + .copyWith(state: BleConnectionState.connecting), ); // ignore: deprecated_member_use case BluetoothConnectionState.disconnecting: @@ -224,13 +237,15 @@ class BleServiceImpl implements BleService { Future _handleReconnection() async { _reconnectionCount++; _updateConnectionState( - (_currentConnection ?? BleConnectionInfo( - deviceId: _connectedDevice!.remoteId.str, - state: BleConnectionState.reconnecting, - )).copyWith( - state: BleConnectionState.reconnecting, - reconnectionCount: _reconnectionCount, - ), + (_currentConnection ?? + BleConnectionInfo( + deviceId: _connectedDevice!.remoteId.str, + state: BleConnectionState.reconnecting, + )) + .copyWith( + state: BleConnectionState.reconnecting, + reconnectionCount: _reconnectionCount, + ), ); await Future.delayed(BleConfig.reconnectionDelay); @@ -239,8 +254,10 @@ class BleServiceImpl implements BleService { _reconnectionCount < BleConfig.maxReconnectionAttempts) { try { await _connectedDevice!.connect( + license: License.free, timeout: BleConfig.connectionTimeout, autoConnect: true, + mtu: null, ); } catch (_) { // Will be handled by connection state listener @@ -283,9 +300,7 @@ class BleServiceImpl implements BleService { mtu = 185; } - _updateConnectionState( - _currentConnection?.copyWith(mtu: mtu), - ); + _updateConnectionState(_currentConnection?.copyWith(mtu: mtu)); return mtu; } @@ -306,9 +321,7 @@ class BleServiceImpl implements BleService { Future enableDle() async { // DLE is typically enabled automatically with MTU negotiation // on modern BLE stacks. This is a placeholder for explicit DLE handling. - _updateConnectionState( - _currentConnection?.copyWith(dleEnabled: true), - ); + _updateConnectionState(_currentConnection?.copyWith(dleEnabled: true)); } @override @@ -342,10 +355,7 @@ class BleServiceImpl implements BleService { Uint8List data, { bool withResponse = true, }) async { - await characteristic.write( - data, - withoutResponse: !withResponse, - ); + await characteristic.write(data, withoutResponse: !withResponse); _updateConnectionState( _currentConnection?.copyWith(lastActivityAt: DateTime.now()), @@ -370,8 +380,9 @@ class BleServiceImpl implements BleService { BluetoothCharacteristic characteristic, ) async { await characteristic.setNotifyValue(true); - return characteristic.onValueReceived - .map((data) => Uint8List.fromList(data)); + return characteristic.onValueReceived.map( + (data) => Uint8List.fromList(data), + ); } @override @@ -395,9 +406,9 @@ class BleServiceImpl implements BleService { @override BluetoothService? findService(Guid serviceUuid) { return _discoveredServices?.cast().firstWhere( - (s) => s?.uuid == serviceUuid, - orElse: () => null, - ); + (s) => s?.uuid == serviceUuid, + orElse: () => null, + ); } @override @@ -406,9 +417,9 @@ class BleServiceImpl implements BleService { Guid characteristicUuid, ) { return service.characteristics.cast().firstWhere( - (c) => c?.uuid == characteristicUuid, - orElse: () => null, - ); + (c) => c?.uuid == characteristicUuid, + orElse: () => null, + ); } void _updateConnectionState(BleConnectionInfo? state) { @@ -416,4 +427,3 @@ class BleServiceImpl implements BleService { _connectionStateController.add(state); } } - diff --git a/zswatch_app/lib/services/ble/gatt_operations.dart b/zswatch_app/lib/services/ble/gatt_operations.dart index e164d29..b2f0bf2 100644 --- a/zswatch_app/lib/services/ble/gatt_operations.dart +++ b/zswatch_app/lib/services/ble/gatt_operations.dart @@ -35,20 +35,21 @@ class GattOperations { // Find NUS service final nusService = services.cast().firstWhere( - (s) => s?.uuid == NusUuids.service, - orElse: () => null, - ); + (s) => s?.uuid == NusUuids.service, + orElse: () => null, + ); if (nusService == null) { throw StateError('NUS service not found on device'); } // Find RX characteristic (notify) - final rxChar = - nusService.characteristics.cast().firstWhere( - (c) => c?.uuid == NusUuids.rxCharacteristic, - orElse: () => null, - ); + final rxChar = nusService.characteristics + .cast() + .firstWhere( + (c) => c?.uuid == NusUuids.rxCharacteristic, + orElse: () => null, + ); if (rxChar == null) { throw StateError('NUS RX characteristic not found'); @@ -121,8 +122,10 @@ class GattOperations { final service = _bleService.findService(HeartRateUuids.service); if (service == null) return null; - final char = - _bleService.findCharacteristic(service, HeartRateUuids.measurement); + final char = _bleService.findCharacteristic( + service, + HeartRateUuids.measurement, + ); if (char == null) return null; final stream = await _bleService.subscribeToCharacteristic(char); @@ -221,4 +224,3 @@ class GattOperations { /// Check if Sensor service is available bool get hasSensorService => hasService(SensorServiceUuids.service); } - diff --git a/zswatch_app/lib/services/ble/sensor_gatt_service.dart b/zswatch_app/lib/services/ble/sensor_gatt_service.dart index 54b2376..bc436c9 100644 --- a/zswatch_app/lib/services/ble/sensor_gatt_service.dart +++ b/zswatch_app/lib/services/ble/sensor_gatt_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -25,6 +24,7 @@ Guid _guid(String uuid) => Guid(uuid); /// /// Data is streamed via GATT notifications at ~10Hz when enabled. class SensorGattService { + // ignore: unused_field final BluetoothDevice _device; List? _services; @@ -154,15 +154,17 @@ class SensorGattService { SensorServiceUuids.sensorFusionChar, ); - debugPrint('[SensorGatt] Found sensors: ' - 'temp=${_tempChar != null}, ' - 'accel=${_accelChar != null}, ' - 'light=${_lightChar != null}, ' - 'gyro=${_gyroChar != null}, ' - 'mag=${_magChar != null}, ' - 'humidity=${_humidityChar != null}, ' - 'pressure=${_pressureChar != null}, ' - 'fusion=${_fusionChar != null}'); + debugPrint( + '[SensorGatt] Found sensors: ' + 'temp=${_tempChar != null}, ' + 'accel=${_accelChar != null}, ' + 'light=${_lightChar != null}, ' + 'gyro=${_gyroChar != null}, ' + 'mag=${_magChar != null}, ' + 'humidity=${_humidityChar != null}, ' + 'pressure=${_pressureChar != null}, ' + 'fusion=${_fusionChar != null}', + ); _isConnected = true; return true; @@ -177,15 +179,15 @@ class SensorGattService { String charUuid, ) { final service = _services?.cast().firstWhere( - (s) => s?.uuid == _guid(serviceUuid), - orElse: () => null, - ); + (s) => s?.uuid == _guid(serviceUuid), + orElse: () => null, + ); if (service == null) return null; return service.characteristics.cast().firstWhere( - (c) => c?.uuid == _guid(charUuid), - orElse: () => null, - ); + (c) => c?.uuid == _guid(charUuid), + orElse: () => null, + ); } // ========================================================================= @@ -214,7 +216,12 @@ class SensorGattService { _tempSubscription = null; try { await _tempChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleTempData(List data) { @@ -253,7 +260,12 @@ class SensorGattService { _accelSubscription = null; try { await _accelChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleAccelData(List data) { @@ -294,7 +306,12 @@ class SensorGattService { _lightSubscription = null; try { await _lightChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleLightData(List data) { @@ -333,7 +350,12 @@ class SensorGattService { _gyroSubscription = null; try { await _gyroChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleGyroData(List data) { @@ -374,7 +396,12 @@ class SensorGattService { _magSubscription = null; try { await _magChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleMagData(List data) { @@ -402,8 +429,9 @@ class SensorGattService { try { await _humidityChar!.setNotifyValue(true); - _humiditySubscription = - _humidityChar!.onValueReceived.listen(_handleHumidityData); + _humiditySubscription = _humidityChar!.onValueReceived.listen( + _handleHumidityData, + ); debugPrint('[SensorGatt] Humidity streaming started'); } catch (e) { debugPrint('[SensorGatt] Failed to start humidity: $e'); @@ -416,7 +444,12 @@ class SensorGattService { _humiditySubscription = null; try { await _humidityChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleHumidityData(List data) { @@ -442,8 +475,9 @@ class SensorGattService { try { await _pressureChar!.setNotifyValue(true); - _pressureSubscription = - _pressureChar!.onValueReceived.listen(_handlePressureData); + _pressureSubscription = _pressureChar!.onValueReceived.listen( + _handlePressureData, + ); debugPrint('[SensorGatt] Pressure streaming started'); } catch (e) { debugPrint('[SensorGatt] Failed to start pressure: $e'); @@ -456,7 +490,12 @@ class SensorGattService { _pressureSubscription = null; try { await _pressureChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handlePressureData(List data) { @@ -482,8 +521,9 @@ class SensorGattService { try { await _fusionChar!.setNotifyValue(true); - _fusionSubscription = - _fusionChar!.onValueReceived.listen(_handleFusionData); + _fusionSubscription = _fusionChar!.onValueReceived.listen( + _handleFusionData, + ); debugPrint('[SensorGatt] Sensor fusion streaming started'); } catch (e) { debugPrint('[SensorGatt] Failed to start sensor fusion: $e'); @@ -496,7 +536,12 @@ class SensorGattService { _fusionSubscription = null; try { await _fusionChar?.setNotifyValue(false); - } catch (_) {} + } catch (e) { + // setNotifyValue(false) may fail if device already disconnected — non-fatal cleanup. + debugPrint( + '[SensorGatt] setNotifyValue(false) failed during stop (ignored): $e', + ); + } } void _handleFusionData(List data) { @@ -561,10 +606,7 @@ class StreamGroup { final subscriptions = >[]; for (final stream in streams) { - final sub = stream.listen( - controller.add, - onError: controller.addError, - ); + final sub = stream.listen(controller.add, onError: controller.addError); subscriptions.add(sub); } @@ -572,6 +614,7 @@ class StreamGroup { for (final sub in subscriptions) { await sub.cancel(); } + await controller.close(); }; return controller.stream; diff --git a/zswatch_app/lib/services/coredump/coredump_api_service.dart b/zswatch_app/lib/services/coredump/coredump_api_service.dart new file mode 100644 index 0000000..4a8f34c --- /dev/null +++ b/zswatch_app/lib/services/coredump/coredump_api_service.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../../data/models/coredump_analysis.dart'; +import '../../data/models/crash_summary.dart'; + +/// Service for communicating with the ZSWatch backend coredump API. +/// +/// Endpoints: +/// - POST /api/coredump/analyze — analyze a coredump.txt with the matching ELF +class CoredumpApiService { + static const String _defaultBaseUrl = + 'https://zswatch-production.up.railway.app'; + static const Duration _analyzeTimeout = Duration(seconds: 60); + + final String baseUrl; + + CoredumpApiService({this.baseUrl = _defaultBaseUrl}); + + /// Analyze a coredump by uploading its text content to the backend. + /// + /// [coredumpTxt] — raw contents of /user/coredump.txt from the watch + /// [summary] — crash summary with FW version, commit SHA, etc. + Future analyze({ + required String coredumpTxt, + required CrashSummary summary, + String? elfHash, + bool useLatestElf = false, + }) async { + final uri = Uri.parse('$baseUrl/api/coredump/analyze'); + + final body = jsonEncode({ + 'coredump_txt': coredumpTxt, + 'fw_commit_sha': summary.fwCommitSha, + if (elfHash != null) 'elf_hash': elfHash, + 'use_latest_elf': useLatestElf, + 'fw_version': summary.fwVersion, + 'board': summary.board, + 'build_type': summary.buildType, + 'crash_file': summary.file, + 'crash_line': summary.line, + 'crash_time': summary.time, + }); + + debugPrint('[CoredumpApiService] POST $uri'); + debugPrint( + '[CoredumpApiService] commit=${summary.fwCommitSha}, ' + 'version=${summary.fwVersion}, elfHash=$elfHash, ' + 'useLatest=$useLatestElf, coredump=${coredumpTxt.length} chars', + ); + + final stopwatch = Stopwatch()..start(); + final http.Response response; + try { + response = await http + .post(uri, headers: {'Content-Type': 'application/json'}, body: body) + .timeout(_analyzeTimeout); + } on TimeoutException { + debugPrint( + '[CoredumpApiService] TIMEOUT after ${stopwatch.elapsedMilliseconds}ms', + ); + return const CoredumpAnalysis( + success: false, + error: + 'Server did not respond within 60s. ' + 'It may be trying to fetch the ELF from GitHub releases.', + ); + } on SocketException catch (e) { + debugPrint('[CoredumpApiService] SocketException: $e'); + return CoredumpAnalysis( + success: false, + error: 'Could not reach coredump server at $baseUrl', + ); + } on Exception catch (e) { + debugPrint('[CoredumpApiService] Exception: $e'); + return CoredumpAnalysis( + success: false, + error: 'Connection failed: ${e.runtimeType}: $e', + ); + } + + debugPrint( + '[CoredumpApiService] Response ${response.statusCode} ' + 'in ${stopwatch.elapsedMilliseconds}ms', + ); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + final result = CoredumpAnalysis.fromJson(json); + debugPrint( + '[CoredumpApiService] success=${result.success}, ' + 'elfAvailable=${result.elfAvailable}, elfHash=${result.elfHash}', + ); + return result; + } else { + debugPrint( + '[CoredumpApiService] Error ${response.statusCode}: ${response.body}', + ); + return CoredumpAnalysis( + success: false, + error: 'Server error ${response.statusCode}: ${response.reasonPhrase}', + ); + } + } +} diff --git a/zswatch_app/lib/services/coredump/coredump_service.dart b/zswatch_app/lib/services/coredump/coredump_service.dart new file mode 100644 index 0000000..256c397 --- /dev/null +++ b/zswatch_app/lib/services/coredump/coredump_service.dart @@ -0,0 +1,286 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data/models/coredump_analysis.dart'; +import '../../data/models/crash_summary.dart'; +import '../dfu/firmware_manager.dart'; +import '../watch_service.dart'; +import 'coredump_api_service.dart'; + +/// Phase of the coredump analysis pipeline. +enum CoredumpAnalysisPhase { + idle, + enablingSmp, + downloading, + uploading, + analyzing, + completed, + failed, +} + +/// Current state of a coredump analysis operation. +class CoredumpAnalysisState { + final CoredumpAnalysisPhase phase; + final double downloadProgress; + final CoredumpAnalysis? result; + final String? errorMessage; + + const CoredumpAnalysisState({ + this.phase = CoredumpAnalysisPhase.idle, + this.downloadProgress = 0.0, + this.result, + this.errorMessage, + }); + + CoredumpAnalysisState copyWith({ + CoredumpAnalysisPhase? phase, + double? downloadProgress, + CoredumpAnalysis? result, + String? errorMessage, + }) { + return CoredumpAnalysisState( + phase: phase ?? this.phase, + downloadProgress: downloadProgress ?? this.downloadProgress, + result: result ?? this.result, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isRunning => + phase != CoredumpAnalysisPhase.idle && + phase != CoredumpAnalysisPhase.completed && + phase != CoredumpAnalysisPhase.failed; +} + +/// Orchestrates the full coredump analysis pipeline: +/// 1. Enable SMP on the watch +/// 2. Download /user/coredump.txt via MCUmgr FS +/// 3. Upload to backend for analysis +/// 4. Return backtrace/registers +class CoredumpService { + static const String _coredumpPath = '/user/coredump.txt'; + + final WatchService _watchService; + final CoredumpApiService _apiService; + final FirmwareManager _firmwareManager; + + FsManager? _fsManager; + StreamSubscription? _downloadSubscription; + Completer? _downloadCompleter; + + final _state = BehaviorSubject.seeded( + const CoredumpAnalysisState(), + ); + + /// The raw coredump text from the last analysis (for export). + String? lastCoredumpTxt; + + CoredumpService(this._watchService, this._apiService, this._firmwareManager); + + Stream get stateStream => _state.stream; + CoredumpAnalysisState get currentState => _state.value; + + /// Run the full analysis pipeline: download from watch → send to backend. + /// Set [useLatestElf] to true for dev builds to skip hash/commit matching. + Future analyze( + CrashSummary summary, { + bool useLatestElf = false, + }) async { + if (currentState.isRunning) { + debugPrint('[CoredumpService] Analysis already in progress'); + return null; + } + + try { + // Step 1: Enable SMP + _state.add( + const CoredumpAnalysisState(phase: CoredumpAnalysisPhase.enablingSmp), + ); + await _watchService.enableSmp(); + // Give the watch time to enable SMP service + await Future.delayed(const Duration(seconds: 2)); + + // Step 2: Download coredump.txt + _state.add( + const CoredumpAnalysisState(phase: CoredumpAnalysisPhase.downloading), + ); + final coredumpBytes = await _downloadCoredump(); + if (coredumpBytes == null) { + _state.add( + const CoredumpAnalysisState( + phase: CoredumpAnalysisPhase.failed, + errorMessage: 'Failed to download coredump from watch', + ), + ); + return null; + } + + // The file has a binary header (zsw_coredump_sumary_t struct) before the + // text coredump data. Find the #CD:BEGIN# marker and strip everything before it. + const marker = '#CD:BEGIN#'; + final markerBytes = marker.codeUnits; + int markerIndex = -1; + for (int i = 0; i <= coredumpBytes.length - markerBytes.length; i++) { + bool found = true; + for (int j = 0; j < markerBytes.length; j++) { + if (coredumpBytes[i + j] != markerBytes[j]) { + found = false; + break; + } + } + if (found) { + markerIndex = i; + break; + } + } + + final String coredumpTxt; + if (markerIndex >= 0) { + coredumpTxt = String.fromCharCodes(coredumpBytes, markerIndex); + debugPrint( + '[CoredumpService] Stripped $markerIndex byte binary header', + ); + } else { + coredumpTxt = String.fromCharCodes(coredumpBytes); + debugPrint('[CoredumpService] No #CD:BEGIN# marker found, sending raw'); + } + debugPrint( + '[CoredumpService] Downloaded coredump: ${coredumpBytes.length} bytes, ' + 'text portion: ${coredumpTxt.length} chars', + ); + + // Store for export + lastCoredumpTxt = coredumpTxt; + + // Look up elf_hash from local cache (set when firmware was downloaded via app) + final elfHash = await _firmwareManager.getCachedElfHash( + summary.fwCommitSha, + ); + if (elfHash != null) { + debugPrint('[CoredumpService] Found cached elf_hash: $elfHash'); + } + + // Step 3: Send to backend + _state.add( + const CoredumpAnalysisState(phase: CoredumpAnalysisPhase.analyzing), + ); + final result = await _apiService.analyze( + coredumpTxt: coredumpTxt, + summary: summary, + elfHash: elfHash, + useLatestElf: useLatestElf, + ); + + // Note: ELF upload is handled by CI or the upload_elf.py script. + // The app only consumes the analysis endpoint. + + _state.add( + CoredumpAnalysisState( + phase: CoredumpAnalysisPhase.completed, + result: result, + ), + ); + + // Disable SMP after we're done + try { + await _watchService.disableSmp(); + } catch (_) {} + + return result; + } catch (e, st) { + debugPrint('[CoredumpService] Analysis failed: $e\n$st'); + _state.add( + CoredumpAnalysisState( + phase: CoredumpAnalysisPhase.failed, + errorMessage: e.toString(), + ), + ); + // Try to disable SMP even on failure + try { + await _watchService.disableSmp(); + } catch (_) {} + return null; + } + } + + Future _downloadCoredump() async { + final device = _watchService.device; + if (device == null) { + debugPrint('[CoredumpService] No device connected'); + return null; + } + + await _resetFsManager(); + _fsManager = FsManager(device.remoteId.str); + + _downloadCompleter = Completer(); + await _downloadSubscription?.cancel(); + _downloadSubscription = _fsManager!.downloadCallbacks.listen( + (callback) { + switch (callback) { + case OnDownloadProgressChanged(): + final progress = callback.total > 0 + ? callback.current / callback.total + : 0.0; + _state.add(currentState.copyWith(downloadProgress: progress)); + case OnDownloadCompleted(): + _downloadCompleter?.complete(callback.data); + _downloadCompleter = null; + case OnDownloadFailed(): + debugPrint('[CoredumpService] Download failed: ${callback.cause}'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + case OnDownloadCancelled(): + debugPrint('[CoredumpService] Download cancelled'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + } + }, + onError: (Object error) { + debugPrint('[CoredumpService] Download callback error: $error'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + }, + ); + + await _fsManager!.download(_coredumpPath); + + final data = await _downloadCompleter!.future.timeout( + const Duration(minutes: 2), + onTimeout: () { + debugPrint('[CoredumpService] Download timed out'); + return null; + }, + ); + + await _resetFsManager(); + return data; + } + + Future _resetFsManager() async { + await _downloadSubscription?.cancel(); + _downloadSubscription = null; + + final manager = _fsManager; + _fsManager = null; + if (manager != null) { + try { + await manager.kill(); + } catch (e) { + debugPrint('[CoredumpService] FsManager cleanup failed: $e'); + } + } + } + + void reset() { + _state.add(const CoredumpAnalysisState()); + } + + void dispose() { + _resetFsManager(); + _state.close(); + } +} diff --git a/zswatch_app/lib/services/dfu/dfu_service.dart b/zswatch_app/lib/services/dfu/dfu_service.dart index 831302f..7ae1ee0 100644 --- a/zswatch_app/lib/services/dfu/dfu_service.dart +++ b/zswatch_app/lib/services/dfu/dfu_service.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_slow_async_io import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -6,6 +7,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; import 'package:mcumgr_flutter/models/firmware_upgrade_mode.dart'; import 'package:mcumgr_flutter/models/image_upload_alignment.dart'; +// ignore: implementation_imports import 'package:mcumgr_flutter/src/mcumgr_update_manager.dart'; import 'package:rxdart/rxdart.dart'; @@ -24,6 +26,9 @@ class DfuService { Timer? _speedTimer; int _lastBytesTransferred = 0; DateTime? _lastSpeedUpdate; + final List _speedSamples = []; + static const int _speedWindowSize = 5; + List _imageNames = []; final _stateController = BehaviorSubject.seeded(DfuState.idle); final _logController = StreamController.broadcast(); @@ -78,18 +83,27 @@ class DfuService { // Initialize update manager with device ID try { - _updateManager = await DeviceUpdateManager.getInstance(device.remoteId.str); + _updateManager = await DeviceUpdateManager.getInstance( + device.remoteId.str, + ); } catch (e) { // If manager already exists, try to kill it and recreate _log('Manager may already exist, attempting cleanup: $e'); try { - final existingManager = await DeviceUpdateManager.getInstance(device.remoteId.str); + final existingManager = await DeviceUpdateManager.getInstance( + device.remoteId.str, + ); await existingManager.kill(); - } catch (_) {} + } catch (e) { + // kill() may fail if the manager is in a bad state — non-fatal, we'll retry. + _log('Cleanup of existing manager failed (ignored): $e'); + } // Retry after small delay await Future.delayed(const Duration(milliseconds: 500)); try { - _updateManager = await DeviceUpdateManager.getInstance(device.remoteId.str); + _updateManager = await DeviceUpdateManager.getInstance( + device.remoteId.str, + ); } catch (e2) { // If we still can't get a manager, SMP is likely not available throw SmpNotAvailableException(e2); @@ -100,7 +114,8 @@ class DfuService { // Calculate total size and prepare images int totalSize = 0; final List mcuImages = []; - + _imageNames = firmwareImages.map((e) => e.name).toList(); + for (int i = 0; i < firmwareImages.length; i++) { final image = firmwareImages[i]; final file = File(image.filePath); @@ -112,24 +127,25 @@ class DfuService { } final Uint8List bytes = await file.readAsBytes(); totalSize += bytes.length; - + final imageIndex = image.slot ?? i; - _log('Preparing image: ${image.name} -> slot $imageIndex (${bytes.length} bytes)'); - mcuImages.add(Image( - image: imageIndex, - data: bytes, - )); + _log( + 'Preparing image: ${image.name} -> slot $imageIndex (${bytes.length} bytes)', + ); + mcuImages.add(Image(image: imageIndex, data: bytes)); } _log('Total firmware size: ${_formatBytes(totalSize)}'); // Start the upload - _updateState(DfuState.uploading( - totalBytes: totalSize, - totalImages: firmwareImages.length, - currentImageName: firmwareImages.first.name, - startedAt: startTime, - )); + _updateState( + DfuState.uploading( + totalBytes: totalSize, + totalImages: firmwareImages.length, + currentImageName: firmwareImages.first.name, + startedAt: startTime, + ), + ); // Start speed calculation timer _startSpeedTimer(); @@ -148,18 +164,35 @@ class DfuService { ); // Subscribe to progress updates - _progressSubscription = _updateManager!.progressStream.listen( - (ProgressUpdate progress) { - final double overallProgress = progress.bytesSent / progress.imageSize; - // Preserve speed value during progress updates - _updateState(currentState.copyWith( + _progressSubscription = _updateManager!.progressStream.listen(( + ProgressUpdate progress, + ) { + var nextImage = currentState.currentImage; + var nextName = currentState.currentImageName; + + // Detect image switch (bytesSent resets for new image) + if (progress.bytesSent < currentState.bytesTransferred && + currentState.bytesTransferred > 1000) { + nextImage = currentState.currentImage + 1; + if (nextImage - 1 < _imageNames.length) { + nextName = _imageNames[nextImage - 1]; + } + _lastBytesTransferred = 0; + _speedSamples.clear(); + } + + final double overallProgress = progress.bytesSent / progress.imageSize; + _updateState( + currentState.copyWith( progress: overallProgress.clamp(0.0, 1.0), bytesTransferred: progress.bytesSent, totalBytes: progress.imageSize, - speedBytesPerSecond: currentState.speedBytesPerSecond, // Preserve speed - )); - }, - ); + currentImage: nextImage, + currentImageName: nextName, + speedBytesPerSecond: currentState.speedBytesPerSecond, + ), + ); + }); // Configure upgrade mode - use confirmOnly to confirm images immediately after upload // This ensures the device boots the new firmware after reset @@ -183,15 +216,11 @@ class DfuService { } _log('DFU upload initiated'); - } on DfuException { rethrow; } catch (e) { _log('DFU failed: $e'); - _updateState(DfuState.failed( - e.toString(), - startedAt: startTime, - )); + _updateState(DfuState.failed(e.toString(), startedAt: startTime)); rethrow; } } @@ -240,17 +269,29 @@ class DfuService { void _startSpeedTimer() { _lastBytesTransferred = 0; _lastSpeedUpdate = DateTime.now(); + _speedSamples.clear(); _speedTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); final elapsed = now.difference(_lastSpeedUpdate!).inMilliseconds / 1000; if (elapsed > 0 && currentState.status.isInProgress) { - final bytesThisInterval = currentState.bytesTransferred - _lastBytesTransferred; + final bytesThisInterval = + currentState.bytesTransferred - _lastBytesTransferred; final speed = (bytesThisInterval / elapsed).round(); - // Only update speed if we have actual progress, otherwise keep the last value if (bytesThisInterval > 0 || currentState.speedBytesPerSecond == 0) { - _updateState(currentState.copyWith(speedBytesPerSecond: speed)); + _speedSamples.add(speed); + if (_speedSamples.length > _speedWindowSize) { + _speedSamples.removeAt(0); + } + final avg = + _speedSamples.reduce((a, b) => a + b) ~/ _speedSamples.length; + _updateState( + currentState.copyWith( + speedBytesPerSecond: avg, + speedHistory: [...currentState.speedHistory, avg], + ), + ); } _lastBytesTransferred = currentState.bytesTransferred; diff --git a/zswatch_app/lib/services/dfu/filesystem_upload_service.dart b/zswatch_app/lib/services/dfu/filesystem_upload_service.dart index 4a2edaa..ff6c678 100644 --- a/zswatch_app/lib/services/dfu/filesystem_upload_service.dart +++ b/zswatch_app/lib/services/dfu/filesystem_upload_service.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_slow_async_io import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -9,7 +10,7 @@ import '../../data/models/filesystem_image.dart'; import 'smp_not_available_exception.dart'; /// Service for uploading filesystem images to the watch via MCUmgr -/// +/// /// Uses the FsManager from mcumgr_flutter to upload files to the watch's /// filesystem via the MCUmgr filesystem commands. class FilesystemUploadService { @@ -18,9 +19,11 @@ class FilesystemUploadService { Timer? _speedTimer; int _lastBytesTransferred = 0; DateTime? _lastSpeedUpdate; + final List _speedSamples = []; + static const int _speedWindowSize = 5; final _stateController = BehaviorSubject.seeded( - FilesystemUploadState.idle, + const FilesystemUploadState(), ); final _logController = StreamController.broadcast(); @@ -37,7 +40,7 @@ class FilesystemUploadService { bool get isInProgress => currentState.status.isInProgress; /// Start uploading a filesystem image - /// + /// /// [deviceId] - The BLE device remote ID (MAC address or UUID) /// [image] - The filesystem image to upload Future startUpload({ @@ -55,20 +58,20 @@ class FilesystemUploadService { // Verify file exists final file = File(image.filePath); if (!await file.exists()) { - throw FilesystemUploadException( - 'File not found: ${image.filePath}', - ); + throw FilesystemUploadException('File not found: ${image.filePath}'); } // Read file data final Uint8List data = await file.readAsBytes(); _log('Read ${data.length} bytes from ${image.name}'); - _updateState(FilesystemUploadState.uploading( - totalBytes: data.length, - imageName: image.name, - startedAt: startTime, - )); + _updateState( + FilesystemUploadState.uploading( + totalBytes: data.length, + imageName: image.name, + startedAt: startTime, + ), + ); // Get FsManager instance try { @@ -83,10 +86,12 @@ class FilesystemUploadService { (callback) => _handleUploadCallback(callback, startTime), onError: (Object error) { _log('Upload error: $error'); - _updateState(FilesystemUploadState.failed( - error.toString(), - startedAt: startTime, - )); + _updateState( + FilesystemUploadState.failed( + error.toString(), + startedAt: startTime, + ), + ); }, ); @@ -98,15 +103,13 @@ class FilesystemUploadService { await _fsManager!.upload(image.targetPath, data); _log('Upload initiated'); - } on FilesystemUploadException { rethrow; } catch (e) { _log('Upload failed: $e'); - _updateState(FilesystemUploadState.failed( - e.toString(), - startedAt: startTime, - )); + _updateState( + FilesystemUploadState.failed(e.toString(), startedAt: startTime), + ); rethrow; } } @@ -114,14 +117,16 @@ class FilesystemUploadService { void _handleUploadCallback(UploadCallback callback, DateTime startTime) { switch (callback) { case OnUploadProgressChanged(): - final progress = callback.total > 0 - ? callback.current / callback.total + final progress = callback.total > 0 + ? callback.current / callback.total : 0.0; - _updateState(currentState.copyWith( - progress: progress.clamp(0.0, 1.0), - bytesTransferred: callback.current, - totalBytes: callback.total, - )); + _updateState( + currentState.copyWith( + progress: progress.clamp(0.0, 1.0), + bytesTransferred: callback.current, + totalBytes: callback.total, + ), + ); break; case OnUploadCompleted(): _stopSpeedTimer(); @@ -133,10 +138,12 @@ class FilesystemUploadService { case OnUploadFailed(): _stopSpeedTimer(); _log('Upload failed: ${callback.cause}'); - _updateState(FilesystemUploadState.failed( - callback.cause ?? 'Unknown error', - startedAt: startTime, - )); + _updateState( + FilesystemUploadState.failed( + callback.cause ?? 'Unknown error', + startedAt: startTime, + ), + ); _cleanup(); break; case OnUploadCancelled(): @@ -169,7 +176,7 @@ class FilesystemUploadService { /// Pause the current upload Future pause() async { if (!isInProgress) return; - + _log('Pausing upload...'); try { await _fsManager?.pauseTransfer(); @@ -191,16 +198,29 @@ class FilesystemUploadService { void _startSpeedTimer() { _lastBytesTransferred = 0; _lastSpeedUpdate = DateTime.now(); + _speedSamples.clear(); _speedTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); final elapsed = now.difference(_lastSpeedUpdate!).inMilliseconds / 1000; if (elapsed > 0 && currentState.status.isInProgress) { - final bytesThisInterval = currentState.bytesTransferred - _lastBytesTransferred; + final bytesThisInterval = + currentState.bytesTransferred - _lastBytesTransferred; final speed = (bytesThisInterval / elapsed).round(); if (bytesThisInterval > 0 || currentState.speedBytesPerSecond == 0) { - _updateState(currentState.copyWith(speedBytesPerSecond: speed)); + _speedSamples.add(speed); + if (_speedSamples.length > _speedWindowSize) { + _speedSamples.removeAt(0); + } + final avg = + _speedSamples.reduce((a, b) => a + b) ~/ _speedSamples.length; + _updateState( + currentState.copyWith( + speedBytesPerSecond: avg, + speedHistory: [...currentState.speedHistory, avg], + ), + ); } _lastBytesTransferred = currentState.bytesTransferred; @@ -234,7 +254,7 @@ class FilesystemUploadService { /// Reset to idle state void reset() { if (isInProgress) return; - _updateState(FilesystemUploadState.idle); + _updateState(const FilesystemUploadState()); } /// Dispose resources @@ -256,4 +276,3 @@ class FilesystemUploadException implements Exception { @override String toString() => 'FilesystemUploadException: $message'; } - diff --git a/zswatch_app/lib/services/dfu/firmware_manager.dart b/zswatch_app/lib/services/dfu/firmware_manager.dart index e34a1f6..c7d4c3b 100644 --- a/zswatch_app/lib/services/dfu/firmware_manager.dart +++ b/zswatch_app/lib/services/dfu/firmware_manager.dart @@ -1,8 +1,10 @@ +// ignore_for_file: avoid_slow_async_io import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; @@ -24,7 +26,7 @@ class FirmwareManager { static const String _owner = 'ZSWatch'; static const String _repo = 'ZSWatch'; static const String _apiBase = 'https://api.github.com'; - + /// Artifact name filters to match ZSWatch firmware artifacts static const List _artifactFilters = ['watchdk@1', '@5']; @@ -62,7 +64,9 @@ class FirmwareManager { String? boardPrefix, ) { if (boardPrefix == null) return assets; - final compatible = assets.where((a) => isAssetCompatible(a.name, boardPrefix)).toList(); + final compatible = assets + .where((a) => isAssetCompatible(a.name, boardPrefix)) + .toList(); return compatible.isNotEmpty ? compatible : assets; } @@ -74,7 +78,9 @@ class FirmwareManager { String? boardPrefix, ) { if (boardPrefix == null) return artifacts; - final compatible = artifacts.where((a) => a.name.contains(boardPrefix)).toList(); + final compatible = artifacts + .where((a) => a.name.contains(boardPrefix)) + .toList(); return compatible.isNotEmpty ? compatible : artifacts; } @@ -103,9 +109,7 @@ class FirmwareManager { try { final response = await http.get( Uri.parse('$_apiBase/repos/$_owner/$_repo/releases?per_page=$limit'), - headers: { - 'Accept': 'application/vnd.github.v3+json', - }, + headers: {'Accept': 'application/vnd.github.v3+json'}, ); if (response.statusCode != 200) { @@ -120,30 +124,37 @@ class FirmwareManager { for (final release in data) { final Map releaseMap = release as Map; final assets = releaseMap['assets'] as List? ?? []; - + // Collect all firmware assets for this release final releaseAssets = []; for (final asset in assets) { final Map assetMap = asset as Map; final assetName = assetMap['name'] as String? ?? ''; if (_isFirmwareAsset(assetName)) { - releaseAssets.add(ReleaseAsset( - name: assetName, - downloadUrl: assetMap['browser_download_url'] as String, - size: assetMap['size'] as int? ?? 0, - )); + releaseAssets.add( + ReleaseAsset( + name: assetName, + downloadUrl: assetMap['browser_download_url'] as String, + size: assetMap['size'] as int? ?? 0, + ), + ); } } if (releaseAssets.isNotEmpty) { - releases.add(GitHubRelease( - tagName: releaseMap['tag_name'] as String? ?? '', - name: releaseMap['name'] as String? ?? releaseMap['tag_name'] as String? ?? '', - body: releaseMap['body'] as String?, - isPrerelease: releaseMap['prerelease'] as bool? ?? false, - publishedAt: DateTime.parse(releaseMap['published_at'] as String), - assets: releaseAssets, - )); + releases.add( + GitHubRelease( + tagName: releaseMap['tag_name'] as String? ?? '', + name: + releaseMap['name'] as String? ?? + releaseMap['tag_name'] as String? ?? + '', + body: releaseMap['body'] as String?, + isPrerelease: releaseMap['prerelease'] as bool? ?? false, + publishedAt: DateTime.parse(releaseMap['published_at'] as String), + assets: releaseAssets, + ), + ); } } @@ -156,7 +167,7 @@ class FirmwareManager { } /// Fetch workflow runs with artifacts from GitHub Actions - /// + /// /// Similar to the website implementation, this fetches recent successful builds /// and prioritizes the main branch. Future> fetchWorkflowRuns({int numRuns = 5}) async { @@ -166,11 +177,15 @@ class FirmwareManager { // Fetch recent runs plus explicitly grab the latest successful run on main final responses = await Future.wait([ http.get( - Uri.parse('$_apiBase/repos/$_owner/$_repo/actions/runs?per_page=${numRuns * 2}'), + Uri.parse( + '$_apiBase/repos/$_owner/$_repo/actions/runs?per_page=${numRuns * 2}', + ), headers: {'Accept': 'application/vnd.github.v3+json'}, ), http.get( - Uri.parse('$_apiBase/repos/$_owner/$_repo/actions/runs?branch=main&status=success&per_page=1'), + Uri.parse( + '$_apiBase/repos/$_owner/$_repo/actions/runs?branch=main&status=success&per_page=1', + ), headers: {'Accept': 'application/vnd.github.v3+json'}, ), ]); @@ -185,10 +200,12 @@ class FirmwareManager { } final runsData = jsonDecode(runsResponse.body) as Map; - final mainRunData = jsonDecode(mainRunResponse.body) as Map; + final mainRunData = + jsonDecode(mainRunResponse.body) as Map; final workflowRuns = (runsData['workflow_runs'] as List?) ?? []; - final mainBranchRuns = (mainRunData['workflow_runs'] as List?) ?? []; + final mainBranchRuns = + (mainRunData['workflow_runs'] as List?) ?? []; if (workflowRuns.isEmpty && mainBranchRuns.isEmpty) { _log('No workflow runs found'); @@ -200,9 +217,11 @@ class FirmwareManager { // Filter for successful runs, excluding gh-pages final successfulRuns = workflowRuns .map((r) => r as Map) - .where((run) => - run['conclusion'] == 'success' && - run['head_branch'] != 'gh-pages') + .where( + (run) => + run['conclusion'] == 'success' && + run['head_branch'] != 'gh-pages', + ) .toList(); // Find latest main run @@ -219,12 +238,15 @@ class FirmwareManager { // Remove duplicates final seen = {}; - final uniqueRuns = runsWithMain.where((run) { - final id = run['id'] as int; - if (seen.contains(id)) return false; - seen.add(id); - return true; - }).take(numRuns).toList(); + final uniqueRuns = runsWithMain + .where((run) { + final id = run['id'] as int; + if (seen.contains(id)) return false; + seen.add(id); + return true; + }) + .take(numRuns) + .toList(); _log('Fetching artifacts from ${uniqueRuns.length} workflow runs...'); @@ -233,10 +255,12 @@ class FirmwareManager { for (final run in uniqueRuns) { final runId = run['id'] as int; final artifacts = await _fetchArtifactsForRun(runId); - + // Filter artifacts to match ZSWatch firmware final filteredArtifacts = artifacts.where((artifact) { - return _artifactFilters.any((filter) => artifact.name.contains(filter)); + return _artifactFilters.any( + (filter) => artifact.name.contains(filter), + ); }).toList(); if (filteredArtifacts.isEmpty) { @@ -245,15 +269,25 @@ class FirmwareManager { } final headCommit = run['head_commit'] as Map?; - runs.add(WorkflowRun( - id: runId, - branch: run['head_branch'] as String? ?? 'unknown', - user: (run['actor'] as Map?)?['login'] as String? ?? 'unknown', - commitSha: run['head_sha'] as String? ?? headCommit?['id'] as String? ?? '', - commitMessage: headCommit?['message'] as String? ?? run['display_title'] as String? ?? '', - createdAt: DateTime.parse(run['created_at'] as String), - artifacts: filteredArtifacts, - )); + runs.add( + WorkflowRun( + id: runId, + branch: run['head_branch'] as String? ?? 'unknown', + user: + (run['actor'] as Map?)?['login'] as String? ?? + 'unknown', + commitSha: + run['head_sha'] as String? ?? + headCommit?['id'] as String? ?? + '', + commitMessage: + headCommit?['message'] as String? ?? + run['display_title'] as String? ?? + '', + createdAt: DateTime.parse(run['created_at'] as String), + artifacts: filteredArtifacts, + ), + ); } _log('Found ${runs.length} runs with firmware artifacts'); @@ -268,37 +302,50 @@ class FirmwareManager { Future> _fetchArtifactsForRun(int runId) async { try { final response = await http.get( - Uri.parse('$_apiBase/repos/$_owner/$_repo/actions/runs/$runId/artifacts'), + Uri.parse( + '$_apiBase/repos/$_owner/$_repo/actions/runs/$runId/artifacts', + ), headers: {'Accept': 'application/vnd.github.v3+json'}, ); if (response.statusCode != 200) { - _log('Failed to fetch artifacts for run $runId: ${response.statusCode}'); + _log( + 'Failed to fetch artifacts for run $runId: ${response.statusCode}', + ); return []; } final data = jsonDecode(response.body) as Map; final artifacts = (data['artifacts'] as List?) ?? []; - - print('[FirmwareManager] Found ${artifacts.length} artifacts for run $runId'); - - return artifacts.map((a) { - final artifact = a as Map; - print('[FirmwareManager] Artifact: ${artifact['name']}'); - print('[FirmwareManager] ID: ${artifact['id']}'); - print('[FirmwareManager] Size: ${artifact['size_in_bytes']}'); - print('[FirmwareManager] Expired: ${artifact['expired']}'); - print('[FirmwareManager] archive_download_url: ${artifact['archive_download_url']}'); - - return WorkflowArtifact( - id: artifact['id'] as int, - name: artifact['name'] as String? ?? 'unknown', - sizeInBytes: artifact['size_in_bytes'] as int? ?? 0, - createdAt: DateTime.parse(artifact['created_at'] as String), - expired: artifact['expired'] as bool? ?? false, - archiveDownloadUrl: artifact['archive_download_url'] as String?, - ); - }).where((a) => !a.expired).toList(); + + debugPrint( + '[FirmwareManager] Found ${artifacts.length} artifacts for run $runId', + ); + + return artifacts + .map((a) { + final artifact = a as Map; + debugPrint('[FirmwareManager] Artifact: ${artifact['name']}'); + debugPrint('[FirmwareManager] ID: ${artifact['id']}'); + debugPrint( + '[FirmwareManager] Size: ${artifact['size_in_bytes']}', + ); + debugPrint('[FirmwareManager] Expired: ${artifact['expired']}'); + debugPrint( + '[FirmwareManager] archive_download_url: ${artifact['archive_download_url']}', + ); + + return WorkflowArtifact( + id: artifact['id'] as int, + name: artifact['name'] as String? ?? 'unknown', + sizeInBytes: artifact['size_in_bytes'] as int? ?? 0, + createdAt: DateTime.parse(artifact['created_at'] as String), + expired: artifact['expired'] as bool? ?? false, + archiveDownloadUrl: artifact['archive_download_url'] as String?, + ); + }) + .where((a) => !a.expired) + .toList(); } catch (e) { _log('Error fetching artifacts for run $runId: $e'); return []; @@ -306,73 +353,93 @@ class FirmwareManager { } /// Download firmware from a GitHub Actions artifact - /// + /// /// Uses the same URL format as the website: /// https://github.com/{owner}/{repo}/actions/runs/{runId}/artifacts/{artifactId} /// This triggers GitHub's download mechanism which works for public repos. - Future downloadArtifact(WorkflowRun run, WorkflowArtifact artifact) async { - print('[FirmwareManager] downloadArtifact called'); - print('[FirmwareManager] Artifact: ${artifact.name}, ID: ${artifact.id}'); - print('[FirmwareManager] Run ID: ${run.id}, Branch: ${run.branch}'); - print('[FirmwareManager] Archive Download URL from API: ${artifact.archiveDownloadUrl}'); - + Future downloadArtifact( + WorkflowRun run, + WorkflowArtifact artifact, + ) async { + debugPrint('[FirmwareManager] downloadArtifact called'); + debugPrint( + '[FirmwareManager] Artifact: ${artifact.name}, ID: ${artifact.id}', + ); + debugPrint('[FirmwareManager] Run ID: ${run.id}, Branch: ${run.branch}'); + debugPrint( + '[FirmwareManager] Archive Download URL from API: ${artifact.archiveDownloadUrl}', + ); + _log('Downloading artifact: ${artifact.name} from run ${run.id}'); _cancelDownload = false; // Try the GitHub web UI URL format first (same as website) // This URL format: https://github.com/{owner}/{repo}/actions/runs/{runId}/artifacts/{artifactId} - final webUiUrl = 'https://github.com/$_owner/$_repo/actions/runs/${run.id}/artifacts/${artifact.id}'; - + final webUiUrl = + 'https://github.com/$_owner/$_repo/actions/runs/${run.id}/artifacts/${artifact.id}'; + // Also have the API URL as fallback - final apiUrl = artifact.archiveDownloadUrl ?? + final apiUrl = + artifact.archiveDownloadUrl ?? '$_apiBase/repos/$_owner/$_repo/actions/artifacts/${artifact.id}/zip'; - - print('[FirmwareManager] Web UI URL: $webUiUrl'); - print('[FirmwareManager] API URL: $apiUrl'); + + debugPrint('[FirmwareManager] Web UI URL: $webUiUrl'); + debugPrint('[FirmwareManager] API URL: $apiUrl'); _log('Web UI URL: $webUiUrl'); _log('API URL: $apiUrl'); - + // Start with web UI URL (same as website does) - var downloadUrl = webUiUrl; - - _updateProgress(DownloadProgress(0, artifact.sizeInBytes, DownloadStatus.downloading)); + final downloadUrl = webUiUrl; + + _updateProgress( + DownloadProgress(0, artifact.sizeInBytes, DownloadStatus.downloading), + ); try { _httpClient = http.Client(); - + // Follow redirects manually to handle GitHub's redirect chain var currentUrl = downloadUrl; http.StreamedResponse? response; - Map cookies = {}; - + final Map cookies = {}; + for (int redirectCount = 0; redirectCount < 15; redirectCount++) { - print('[FirmwareManager] Request #$redirectCount: GET $currentUrl'); + debugPrint( + '[FirmwareManager] Request #$redirectCount: GET $currentUrl', + ); _log('Request #$redirectCount: GET $currentUrl'); - + final request = http.Request('GET', Uri.parse(currentUrl)); // Don't follow redirects automatically so we can handle them request.followRedirects = false; - + // Add cookies from previous responses if (cookies.isNotEmpty) { - request.headers['Cookie'] = cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); - print('[FirmwareManager] Sending cookies: ${request.headers['Cookie']}'); + request.headers['Cookie'] = cookies.entries + .map((e) => '${e.key}=${e.value}') + .join('; '); + debugPrint( + '[FirmwareManager] Sending cookies: ${request.headers['Cookie']}', + ); _log('Sending cookies: ${request.headers['Cookie']}'); } - + // Add common browser headers - request.headers['User-Agent'] = 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36'; + request.headers['User-Agent'] = + 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36'; request.headers['Accept'] = '*/*'; - + response = await _httpClient!.send(request); - - print('[FirmwareManager] Response: ${response.statusCode} ${response.reasonPhrase}'); + + debugPrint( + '[FirmwareManager] Response: ${response.statusCode} ${response.reasonPhrase}', + ); _log('Response: ${response.statusCode} ${response.reasonPhrase}'); _log('Response headers:'); response.headers.forEach((key, value) { _log(' $key: $value'); }); - + // Collect any cookies final setCookie = response.headers['set-cookie']; if (setCookie != null) { @@ -383,24 +450,29 @@ class FirmwareManager { cookies[cookieParts[0]] = cookieParts.sublist(1).join('='); } } - + if (response.statusCode == 200) { // Success - download the file - print('[FirmwareManager] Success! Starting file download...'); + debugPrint('[FirmwareManager] Success! Starting file download...'); _log('Success! Starting file download...'); final contentLength = response.headers['content-length']; - print('[FirmwareManager] Content-Length: $contentLength'); + debugPrint('[FirmwareManager] Content-Length: $contentLength'); _log('Content-Length: $contentLength'); break; - } else if (response.statusCode == 302 || response.statusCode == 301 || response.statusCode == 307 || response.statusCode == 308) { + } else if (response.statusCode == 302 || + response.statusCode == 301 || + response.statusCode == 307 || + response.statusCode == 308) { // Follow redirect (301, 302, 307, 308 are all redirect codes) final location = response.headers['location']; if (location == null) { - print('[FirmwareManager] ERROR: Redirect without location header!'); + debugPrint( + '[FirmwareManager] ERROR: Redirect without location header!', + ); _log('ERROR: Redirect without location header!'); throw FirmwareDownloadException('Redirect without location header'); } - + // Handle relative URLs if (location.startsWith('/')) { final uri = Uri.parse(currentUrl); @@ -408,26 +480,32 @@ class FirmwareManager { } else { currentUrl = location; } - - print('[FirmwareManager] Following ${response.statusCode} redirect to: $currentUrl'); + + debugPrint( + '[FirmwareManager] Following ${response.statusCode} redirect to: $currentUrl', + ); _log('Following ${response.statusCode} redirect to: $currentUrl'); // Drain the response body before following redirect await response.stream.drain(); } else if (response.statusCode == 404) { - print('[FirmwareManager] ERROR: Artifact not found (404)'); - print('[FirmwareManager] URL was: $currentUrl'); + debugPrint('[FirmwareManager] ERROR: Artifact not found (404)'); + debugPrint('[FirmwareManager] URL was: $currentUrl'); _log('ERROR: Artifact not found (404)'); throw FirmwareDownloadException( 'Artifact not found. It may have expired.', ); } else if (response.statusCode == 401 || response.statusCode == 403) { - print('[FirmwareManager] ERROR: Authentication required (${response.statusCode})'); + debugPrint( + '[FirmwareManager] ERROR: Authentication required (${response.statusCode})', + ); _log('ERROR: Authentication required (${response.statusCode})'); throw FirmwareDownloadException( 'Authentication required. GitHub Actions artifacts may require login.', ); } else { - print('[FirmwareManager] ERROR: Unexpected status code ${response.statusCode}'); + debugPrint( + '[FirmwareManager] ERROR: Unexpected status code ${response.statusCode}', + ); _log('ERROR: Unexpected status code ${response.statusCode}'); throw FirmwareDownloadException( 'Download failed with status ${response.statusCode}', @@ -436,7 +514,9 @@ class FirmwareManager { } if (response == null || response.statusCode != 200) { - throw FirmwareDownloadException('Failed to download after following redirects'); + throw FirmwareDownloadException( + 'Failed to download after following redirects', + ); } final image = await _downloadFromStream( @@ -461,10 +541,12 @@ class FirmwareManager { run.shortSha, run.branch, ); - // Clean up the outer zip + // Clean up the outer zip — ignore errors (file may already be gone). try { await file.delete(); - } catch (_) {} + } catch (e) { + _log('Temp zip cleanup failed (ignored): $e'); + } return result; } } catch (e) { @@ -474,7 +556,9 @@ class FirmwareManager { return ReleaseExtractionResult(firmwareImage: image); } catch (e) { - _updateProgress(DownloadProgress(0, 0, DownloadStatus.failed, error: e.toString())); + _updateProgress( + DownloadProgress(0, 0, DownloadStatus.failed, error: e.toString()), + ); _log('Download error: $e'); rethrow; } finally { @@ -507,14 +591,22 @@ class FirmwareManager { sink.add(chunk); bytesReceived += chunk.length; - _updateProgress(DownloadProgress(bytesReceived, expectedSize, DownloadStatus.downloading)); + _updateProgress( + DownloadProgress( + bytesReceived, + expectedSize, + DownloadStatus.downloading, + ), + ); } await sink.close(); _log('Download complete: ${file.path}'); final actualSize = await file.length(); - _updateProgress(DownloadProgress(actualSize, actualSize, DownloadStatus.completed)); + _updateProgress( + DownloadProgress(actualSize, actualSize, DownloadStatus.completed), + ); return FirmwareImage.fromGitHub( name: artifactName, @@ -527,14 +619,14 @@ class FirmwareManager { } /// Get the browser download URL for an artifact - /// + /// /// This can be used to open in browser for manual download String getArtifactBrowserUrl(WorkflowRun run, WorkflowArtifact artifact) { return 'https://github.com/$_owner/$_repo/actions/runs/${run.id}/artifacts/${artifact.id}'; } /// Download firmware from a GitHub release asset - /// + /// /// Downloads the selected asset (e.g., watchdk@1_nrf5340_cpuapp_debug.zip), /// extracts it to find dfu_application.zip (and optionally lvgl_resources_raw.bin), /// and returns the extraction result. @@ -545,7 +637,9 @@ class FirmwareManager { _log('Downloading release asset: ${asset.name}'); _cancelDownload = false; - _updateProgress(DownloadProgress(0, asset.size, DownloadStatus.downloading)); + _updateProgress( + DownloadProgress(0, asset.size, DownloadStatus.downloading), + ); try { _httpClient = http.Client(); @@ -576,7 +670,13 @@ class FirmwareManager { sink.add(chunk); bytesReceived += chunk.length; - _updateProgress(DownloadProgress(bytesReceived, totalBytes, DownloadStatus.downloading)); + _updateProgress( + DownloadProgress( + bytesReceived, + totalBytes, + DownloadStatus.downloading, + ), + ); } await sink.close(); @@ -590,16 +690,22 @@ class FirmwareManager { release.tagName, ); - // Clean up outer zip + // Clean up outer zip — ignore errors (file may already be gone). try { await outerZipFile.delete(); - } catch (_) {} + } catch (e) { + _log('Outer zip cleanup failed (ignored): $e'); + } - _updateProgress(DownloadProgress(totalBytes, totalBytes, DownloadStatus.completed)); + _updateProgress( + DownloadProgress(totalBytes, totalBytes, DownloadStatus.completed), + ); return result; } catch (e) { - _updateProgress(DownloadProgress(0, 0, DownloadStatus.failed, error: e.toString())); + _updateProgress( + DownloadProgress(0, 0, DownloadStatus.failed, error: e.toString()), + ); _log('Download error: $e'); rethrow; } finally { @@ -620,13 +726,18 @@ class FirmwareManager { final bytes = await outerZipFile.readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); + // Cache ELF from archive if manifest.json + zephyr.elf.gz are present + await _cacheElfFromArchive(archive); + final downloadDir = await _getDownloadDirectory(); FirmwareImage? standardFirmwareImage; FirmwareImage? rotatedFirmwareImage; FilesystemImage? filesystemImage; - _log('Extracting firmware variants (rotated preferred: $useRotatedFirmware)'); + _log( + 'Extracting firmware variants (rotated preferred: $useRotatedFirmware)', + ); // Extract both DFU variants (if present) and filesystem image for (final file in archive) { @@ -661,7 +772,10 @@ class FirmwareManager { // Rotated DFU firmware if (baseName == 'dfu_application_rotated.zip') { - final rotatedZipPath = path.join(downloadDir.path, 'dfu_application_rotated.zip'); + final rotatedZipPath = path.join( + downloadDir.path, + 'dfu_application_rotated.zip', + ); final rotatedZipFile = File(rotatedZipPath); await rotatedZipFile.writeAsBytes(file.content as List); @@ -737,7 +851,7 @@ class FirmwareManager { } /// Extract firmware images from a zip file - /// + /// /// Parses the manifest.json inside the zip to get image_index for each file. Future> extractZip(FirmwareImage zipImage) async { if (!zipImage.filePath.toLowerCase().endsWith('.zip')) { @@ -781,10 +895,10 @@ class FirmwareManager { } final fileName = path.basename(file.name); - + // Look up in manifest first final manifestEntry = manifestEntries?[fileName.toLowerCase()]; - + if (manifestEntry != null) { // Extract this file - manifest tells us everything final outputPath = path.join(extractPath, fileName); @@ -792,16 +906,20 @@ class FirmwareManager { await outputFile.create(recursive: true); await outputFile.writeAsBytes(file.content as List); - _log('Extracted: $fileName (image_index: ${manifestEntry.imageIndex})'); + _log( + 'Extracted: $fileName (image_index: ${manifestEntry.imageIndex})', + ); - images.add(FirmwareImage.fromLocalFile( - name: fileName, - filePath: outputPath, - size: file.size, - version: manifestEntry.version ?? zipImage.version, - slot: manifestEntry.imageIndex, - board: manifestEntry.board, - )); + images.add( + FirmwareImage.fromLocalFile( + name: fileName, + filePath: outputPath, + size: file.size, + version: manifestEntry.version ?? zipImage.version, + slot: manifestEntry.imageIndex, + board: manifestEntry.board, + ), + ); } else if (manifestEntries == null) { // No manifest - fall back to filename-based detection (legacy support) if (_isFirmwareFile(file.name)) { @@ -812,12 +930,14 @@ class FirmwareManager { _log('Extracted (no manifest): $fileName'); - images.add(FirmwareImage.fromLocalFile( - name: fileName, - filePath: outputPath, - size: file.size, - version: zipImage.version, - )); + images.add( + FirmwareImage.fromLocalFile( + name: fileName, + filePath: outputPath, + size: file.size, + version: zipImage.version, + ), + ); } } else { _log('Skipping $fileName - not in manifest'); @@ -835,7 +955,9 @@ class FirmwareManager { return slotA.compareTo(slotB); }); - _log('Extracted ${images.length} firmware image(s): ${images.map((i) => '${i.name}(slot:${i.slot})').join(', ')}'); + _log( + 'Extracted ${images.length} firmware image(s): ${images.map((i) => '${i.name}(slot:${i.slot})').join(', ')}', + ); return images; } catch (e) { _log('Extraction error: $e'); @@ -847,7 +969,7 @@ class FirmwareManager { Map _parseManifest(String jsonContent) { final json = jsonDecode(jsonContent) as Map; final files = json['files'] as List?; - + if (files == null) { return {}; } @@ -860,15 +982,16 @@ class FirmwareManager { // image_index can be int or string final imageIndexRaw = entry['image_index']; - final imageIndex = imageIndexRaw is int - ? imageIndexRaw + final imageIndex = imageIndexRaw is int + ? imageIndexRaw : int.tryParse(imageIndexRaw?.toString() ?? ''); entries[fileName.toLowerCase()] = ManifestEntry( file: fileName, imageIndex: imageIndex ?? 0, size: entry['size'] as int?, - version: entry['version_MCUBOOT'] as String? ?? entry['version'] as String?, + version: + entry['version_MCUBOOT'] as String? ?? entry['version'] as String?, type: entry['type'] as String?, board: entry['board'] as String?, ); @@ -955,7 +1078,9 @@ class FirmwareManager { // Validate single file final file = File(selectedImage.filePath); if (!await file.exists()) { - throw FirmwareDownloadException('File not found: ${selectedImage.filePath}'); + throw FirmwareDownloadException( + 'File not found: ${selectedImage.filePath}', + ); } return [selectedImage]; @@ -1029,10 +1154,9 @@ class FirmwareManager { try { final downloadDir = await _getDownloadDirectory(); final entities = await downloadDir.list().toList(); - + for (final entity in entities) { - if (entity is Directory && - entity.path.contains('extracted_')) { + if (entity is Directory && entity.path.contains('extracted_')) { final stat = await entity.stat(); final age = DateTime.now().difference(stat.modified); if (age.inHours > 24) { @@ -1082,6 +1206,128 @@ class FirmwareManager { _updateProgress(const DownloadProgress(0, 0, DownloadStatus.idle)); } + // ==================== ELF Cache for Coredump Analysis ==================== + + static const String _elfCacheDir = 'elf_cache'; + static const int _maxCachedElfs = 5; + + /// Get the ELF cache directory. + Future _getElfCacheDirectory() async { + final appDir = await getApplicationDocumentsDirectory(); + final dir = Directory(path.join(appDir.path, _elfCacheDir)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Cache an ELF (gzipped bytes) keyed by commit SHA. + Future cacheElf(String commitSha, List elfGzBytes) async { + final dir = await _getElfCacheDirectory(); + final file = File(path.join(dir.path, '$commitSha.elf.gz')); + await file.writeAsBytes(elfGzBytes); + _log('Cached ELF for commit $commitSha (${elfGzBytes.length} bytes)'); + + // Evict old entries (keep last N by modification time) + final entries = dir.listSync().whereType().toList() + ..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); + for (var i = _maxCachedElfs; i < entries.length; i++) { + _log('Evicting old ELF cache entry: ${entries[i].path}'); + await entries[i].delete(); + } + } + + /// Get a cached ELF file for the given commit SHA (gzipped). + /// Returns null if not cached. Supports prefix matching for truncated SHAs + /// (the watch reports a short SHA, but the cache uses the full SHA from CI). + Future getCachedElf(String commitSha) async { + final dir = await _getElfCacheDirectory(); + // Try exact match first + final file = File(path.join(dir.path, '$commitSha.elf.gz')); + if (await file.exists()) { + _log('ELF cache hit for commit $commitSha'); + return file; + } + // Prefix match for truncated SHAs + final entries = dir.listSync().whereType().where( + (f) => f.path.endsWith('.elf.gz'), + ); + for (final entry in entries) { + final name = path.basenameWithoutExtension( + path.basenameWithoutExtension(entry.path), + ); // strip .elf.gz + if (name.startsWith(commitSha) || commitSha.startsWith(name)) { + _log('ELF cache prefix hit: $commitSha matched $name'); + return entry; + } + } + return null; + } + + /// Get the cached elf_hash for a given commit SHA, if known from manifest. + /// Supports prefix matching for truncated SHAs. + Future getCachedElfHash(String commitSha) async { + final dir = await _getElfCacheDirectory(); + // Try exact match first + final metaFile = File(path.join(dir.path, '$commitSha.meta')); + if (await metaFile.exists()) { + return (await metaFile.readAsString()).trim(); + } + // Prefix match for truncated SHAs + final entries = dir.listSync().whereType().where( + (f) => f.path.endsWith('.meta'), + ); + for (final entry in entries) { + final name = path.basenameWithoutExtension(entry.path); // strip .meta + if (name.startsWith(commitSha) || commitSha.startsWith(name)) { + _log('ELF meta prefix hit: $commitSha matched $name'); + return (await entry.readAsString()).trim(); + } + } + return null; + } + + /// Extract and cache ELF from a release/CI artifact zip if manifest.json + /// and zephyr.elf.gz are present. + Future _cacheElfFromArchive(Archive archive) async { + // Look for manifest.json to get commit_sha and elf_hash + String? commitSha; + String? elfHash; + List? elfGzBytes; + + for (final file in archive) { + if (!file.isFile) continue; + final baseName = path.basename(file.name).toLowerCase(); + + if (baseName == 'manifest.json') { + try { + final content = utf8.decode(file.content as List); + final manifest = jsonDecode(content) as Map; + commitSha = manifest['commit_sha'] as String?; + elfHash = manifest['elf_hash'] as String?; + _log('Found manifest.json: commit_sha=$commitSha elf_hash=$elfHash'); + } catch (e) { + _log('Failed to parse manifest.json: $e'); + } + } + + if (baseName == 'zephyr.elf.gz') { + elfGzBytes = file.content as List; + _log('Found zephyr.elf.gz (${elfGzBytes.length} bytes)'); + } + } + + if (commitSha != null && commitSha.isNotEmpty && elfGzBytes != null) { + await cacheElf(commitSha, elfGzBytes); + // Store elf_hash metadata alongside the cached ELF + if (elfHash != null && elfHash.isNotEmpty) { + final dir = await _getElfCacheDirectory(); + final metaFile = File(path.join(dir.path, '$commitSha.meta')); + await metaFile.writeAsString(elfHash); + } + } + } + /// Dispose resources Future dispose() async { cancelDownload(); @@ -1174,13 +1420,13 @@ class FirmwareDownloadException implements Exception { } /// Result of extracting a release archive -/// +/// /// Contains the firmware image (dfu_application.zip) and optionally /// a filesystem image (lvgl_resources_raw.bin) if found in the archive. class ReleaseExtractionResult { /// The extracted firmware image (dfu_application.zip) final FirmwareImage firmwareImage; - + /// The extracted filesystem image (lvgl_resources_raw.bin), if found final FilesystemImage? filesystemImage; @@ -1227,12 +1473,15 @@ class WorkflowRun { }); /// Short commit SHA (first 7 characters) - String get shortSha => commitSha.length > 7 ? commitSha.substring(0, 7) : commitSha; + String get shortSha => + commitSha.length > 7 ? commitSha.substring(0, 7) : commitSha; /// First line of commit message String get shortCommitMessage { final firstLine = commitMessage.split('\n').first; - return firstLine.length > 60 ? '${firstLine.substring(0, 57)}...' : firstLine; + return firstLine.length > 60 + ? '${firstLine.substring(0, 57)}...' + : firstLine; } /// Whether this is from the main branch @@ -1260,7 +1509,7 @@ class WorkflowArtifact { /// Whether the artifact has expired final bool expired; - + /// GitHub API download URL (requires auth) final String? archiveDownloadUrl; @@ -1289,19 +1538,19 @@ class WorkflowArtifact { class ManifestEntry { /// Filename (e.g., "app.internal.bin") final String file; - + /// MCUmgr image index (0, 1, 2, etc.) final int imageIndex; - + /// File size in bytes final int? size; - + /// Version string (from version_MCUBOOT or version field) final String? version; - + /// Type (e.g., "application") final String? type; - + /// Board identifier final String? board; @@ -1314,4 +1563,3 @@ class ManifestEntry { this.board, }); } - diff --git a/zswatch_app/lib/services/dfu/smp_not_available_exception.dart b/zswatch_app/lib/services/dfu/smp_not_available_exception.dart index 2815554..04aced9 100644 --- a/zswatch_app/lib/services/dfu/smp_not_available_exception.dart +++ b/zswatch_app/lib/services/dfu/smp_not_available_exception.dart @@ -1,16 +1,16 @@ /// Exception thrown when the MCUmgr/SMP service is not available on the watch. /// /// This typically means the watch firmware was built without CONFIG_ZSW_FW_UPDATE, -/// or the user hasn't enabled update mode (Apps → Update) on the watch. +/// or auto-enable failed (e.g. the watch disconnected during the process). class SmpNotAvailableException implements Exception { final String message; final Object? cause; SmpNotAvailableException([this.cause]) - : message = 'SMP service not available on the watch. ' - 'Please enable update mode on the watch: go to Apps → Update ' - 'and set USB/BLE to ON. ' - 'See zswatch.dev/docs/firmware/firmware_updates for details.'; + : message = + 'SMP service not available on the watch. ' + 'The app tried to enable it automatically but failed. ' + 'Try disconnecting and reconnecting, then retry the update.'; @override String toString() => message; diff --git a/zswatch_app/lib/services/health/health_sync_service.dart b/zswatch_app/lib/services/health/health_sync_service.dart index d63ad27..ea2b695 100644 --- a/zswatch_app/lib/services/health/health_sync_service.dart +++ b/zswatch_app/lib/services/health/health_sync_service.dart @@ -10,7 +10,7 @@ import '../../data/repositories/health_repository.dart'; import '../watch_service.dart'; /// Activity states from the watch -/// +/// /// Values match the integer stored in database for activity samples. /// Based on Gadgetbridge ActivityKind.java values. enum ActivityState { @@ -19,16 +19,16 @@ enum ActivityState { deepSleep(2), lightSleep(3), remSleep(4), - still(5), // ACTIVITY in Gadgetbridge + still(5), // ACTIVITY in Gadgetbridge running(6), walking(7), swimming(8), cycling(9), exercise(10); - + final int value; const ActivityState(this.value); - + static ActivityState fromString(String? value) { return switch (value) { 'UNKNOWN' => ActivityState.unknown, @@ -45,14 +45,14 @@ enum ActivityState { _ => ActivityState.unknown, }; } - + static ActivityState fromValue(int value) { return ActivityState.values.firstWhere( (s) => s.value == value, orElse: () => ActivityState.unknown, ); } - + String get displayName { return switch (this) { ActivityState.unknown => 'Unknown', @@ -75,13 +75,13 @@ class ActivityBreakdown { final Map durations; final ActivityState? currentState; final DateTime? lastUpdate; - + const ActivityBreakdown({ this.durations = const {}, this.currentState, this.lastUpdate, }); - + ActivityBreakdown copyWith({ Map? durations, ActivityState? currentState, @@ -93,12 +93,12 @@ class ActivityBreakdown { lastUpdate: lastUpdate ?? this.lastUpdate, ); } - + /// Total tracked duration Duration get totalDuration { return durations.values.fold(Duration.zero, (a, b) => a + b); } - + /// Get percentage for a state (0.0 to 1.0) double getPercentage(ActivityState state) { final total = totalDuration; @@ -124,14 +124,13 @@ class HealthSyncService { // Heart rate streaming state final _heartRateController = BehaviorSubject.seeded(null); final _isStreamingController = BehaviorSubject.seeded(false); - + // Step count state (from activity messages) final _stepsController = BehaviorSubject.seeded(0); - + // Activity state tracking (breakdown is calculated from database) - final _activityBreakdownController = BehaviorSubject.seeded( - const ActivityBreakdown(), - ); + final _activityBreakdownController = + BehaviorSubject.seeded(const ActivityBreakdown()); BluetoothDevice? _device; BluetoothCharacteristic? _hrMeasurementChar; @@ -147,30 +146,34 @@ class HealthSyncService { /// Current streaming state bool get isStreaming => _isStreamingController.value; - + /// Stream of step count updates Stream get stepsStream => _stepsController.stream; - + /// Current step count int get currentSteps => _stepsController.value; - + /// Stream of activity breakdown updates - Stream get activityBreakdownStream => _activityBreakdownController.stream; - + Stream get activityBreakdownStream => + _activityBreakdownController.stream; + /// Current activity breakdown - ActivityBreakdown get currentActivityBreakdown => _activityBreakdownController.value; + ActivityBreakdown get currentActivityBreakdown => + _activityBreakdownController.value; HealthSyncService({ required WatchService watchService, required HealthRepository healthRepository, - }) : _watchService = watchService, - _healthRepository = healthRepository { + }) : _watchService = watchService, + _healthRepository = healthRepository { _initialize(); } void _initialize() { // Listen for activity messages from watch (Gadgetbridge protocol) - _activitySubscription = _watchService.incomingMessages.listen(_handleActivityMessage); + _activitySubscription = _watchService.incomingMessages.listen( + _handleActivityMessage, + ); } /// Start heart rate streaming @@ -189,7 +192,9 @@ class HealthSyncService { // Check if already connected final isConnected = _device!.isConnected; if (!isConnected) { - debugPrint('[HealthSync] Device not connected, cannot start HR streaming'); + debugPrint( + '[HealthSync] Device not connected, cannot start HR streaming', + ); return; } @@ -201,9 +206,9 @@ class HealthSyncService { // Find Heart Rate Service final hrService = services.cast().firstWhere( - (s) => s?.uuid == Guid(HeartRateUuids.service), - orElse: () => null, - ); + (s) => s?.uuid == Guid(HeartRateUuids.service), + orElse: () => null, + ); if (hrService == null) { debugPrint('[HealthSync] Heart Rate Service not found'); @@ -225,16 +230,15 @@ class HealthSyncService { // Subscribe to notifications await _hrMeasurementChar!.setNotifyValue(true); - - _hrSubscription?.cancel(); + + await _hrSubscription?.cancel(); _hrSubscription = _hrMeasurementChar!.onValueReceived.listen( _handleHeartRateData, - onError: (e) => debugPrint('[HealthSync] HR stream error: $e'), + onError: (Object e) => debugPrint('[HealthSync] HR stream error: $e'), ); _isStreamingController.add(true); debugPrint('[HealthSync] HR streaming started'); - } catch (e) { debugPrint('[HealthSync] Failed to start HR streaming: $e'); _isStreamingController.add(false); @@ -246,7 +250,7 @@ class HealthSyncService { if (!_isStreamingController.value) return; try { - _hrSubscription?.cancel(); + await _hrSubscription?.cancel(); _hrSubscription = null; if (_hrMeasurementChar != null) { @@ -262,7 +266,6 @@ class HealthSyncService { _isStreamingController.add(false); _heartRateController.add(null); debugPrint('[HealthSync] HR streaming stopped'); - } catch (e) { debugPrint('[HealthSync] Error stopping HR streaming: $e'); } @@ -306,7 +309,6 @@ class HealthSyncService { // Persist to repository _persistHeartRate(heartRate); - } catch (e) { debugPrint('[HealthSync] Error parsing HR data: $e'); } @@ -327,13 +329,17 @@ class HealthSyncService { debugPrint('[HealthSync] Error persisting HR: $e'); } } - + /// Persist step count to database - /// + /// /// Note: The watch sends cumulative daily steps, so we store /// the latest value with a "daily" granularity, updating the same /// record throughout the day. - Future _persistSteps(String watchId, int steps, [DateTime? timestamp]) async { + Future _persistSteps( + String watchId, + int steps, [ + DateTime? timestamp, + ]) async { try { final ts = timestamp ?? DateTime.now(); await _healthRepository.insertSteps( @@ -378,38 +384,38 @@ class HealthSyncService { // Parse timestamp - use provided ts or current time final tsMillis = message['ts'] as int?; - final timestamp = tsMillis != null + final timestamp = tsMillis != null ? DateTime.fromMillisecondsSinceEpoch(tsMillis) : DateTime.now(); - + final steps = message['stp'] as int?; final heartRate = message['hrm'] as int?; final activityString = message['act'] as String?; if (steps != null) { - debugPrint('[HealthSync] Activity update - steps: $steps, ts: $timestamp'); _stepsController.add(steps); _persistSteps(watchId, steps, timestamp); } if (heartRate != null) { - debugPrint('[HealthSync] Activity update - HR: $heartRate, ts: $timestamp'); + debugPrint( + '[HealthSync] Activity update - HR: $heartRate, ts: $timestamp', + ); _heartRateController.add(heartRate); _persistHeartRate(heartRate, timestamp); } - + // Track activity state if (activityString != null) { final newState = ActivityState.fromString(activityString); - debugPrint('[HealthSync] Activity update - state: $activityString -> $newState, ts: $timestamp'); _persistAndUpdateActivityState(watchId, newState, timestamp); } } - + /// Persist activity state to database and update breakdown Future _persistAndUpdateActivityState( - String watchId, - ActivityState state, + String watchId, + ActivityState state, DateTime timestamp, ) async { try { @@ -419,14 +425,14 @@ class HealthSyncService { activityStateValue: state.value, timestamp: timestamp, ); - + // Refresh breakdown from database await _refreshActivityBreakdown(watchId); } catch (e) { debugPrint('[HealthSync] Error persisting activity: $e'); } } - + /// Refresh activity breakdown from database Future _refreshActivityBreakdown(String watchId) async { try { @@ -435,40 +441,42 @@ class HealthSyncService { watchId: watchId, date: now, ); - + // Convert int keys to ActivityState final durations = {}; for (final entry in breakdownMap.entries) { final state = ActivityState.fromValue(entry.key); durations[state] = entry.value; } - + // Get current state from most recent sample final todayActivity = await _healthRepository.getDailyActivity( watchId: watchId, date: now, ); - final currentState = todayActivity.isNotEmpty + final currentState = todayActivity.isNotEmpty ? ActivityState.fromValue(todayActivity.last.intValue) : null; - - _activityBreakdownController.add(ActivityBreakdown( - durations: durations, - currentState: currentState, - lastUpdate: now, - )); + + _activityBreakdownController.add( + ActivityBreakdown( + durations: durations, + currentState: currentState, + lastUpdate: now, + ), + ); } catch (e) { debugPrint('[HealthSync] Error refreshing activity breakdown: $e'); } } - + /// Load activity breakdown from database (call on startup/reconnect) Future loadActivityBreakdown() async { final watchId = _watchService.currentWatch?.id; if (watchId == null) return; await _refreshActivityBreakdown(watchId); } - + /// Reset activity tracking (e.g., at start of day) void resetActivityTracking() { _activityBreakdownController.add(const ActivityBreakdown()); @@ -495,7 +503,9 @@ class HealthSyncService { /// NOTE: Not yet implemented in watch firmware. /// This would send a request for historical activity data. Future requestActivitySync() async { - debugPrint('[HealthSync] requestActivitySync - Not yet implemented in firmware'); + debugPrint( + '[HealthSync] requestActivitySync - Not yet implemented in firmware', + ); // TODO: Send actfetch request when firmware supports this // await _watchService.sendGb({'t': 'actfetch'}); } diff --git a/zswatch_app/lib/services/http/http_relay_service.dart b/zswatch_app/lib/services/http/http_relay_service.dart index e44c228..ca63c15 100644 --- a/zswatch_app/lib/services/http/http_relay_service.dart +++ b/zswatch_app/lib/services/http/http_relay_service.dart @@ -25,7 +25,8 @@ class HttpRelayResult { factory HttpRelayResult.success(String response) => HttpRelayResult(response: response); - factory HttpRelayResult.failure(String error) => HttpRelayResult(error: error); + factory HttpRelayResult.failure(String error) => + HttpRelayResult(error: error); } /// Service for performing HTTP relay requests on behalf of the watch. @@ -66,8 +67,10 @@ class HttpRelayService { /// [request] contains the URL, optional XPath, and insecure flag. /// Returns [HttpRelayResult] with either response or error. Future performRequest(HttpRequest request) async { - debugPrint('[HttpRelayService] Performing request: ${request.url}, ' - 'xpath: ${request.xpath}, insecure: ${request.insecure}'); + debugPrint( + '[HttpRelayService] Performing request: ${request.url}, ' + 'xpath: ${request.xpath}, insecure: ${request.insecure}', + ); // Validate URL final uri = Uri.tryParse(request.url); @@ -86,10 +89,10 @@ class HttpRelayService { // Perform GET request final response = await client - .get(uri, headers: { - 'User-Agent': 'ZSWatch-Companion/1.0', - 'Accept': '*/*', - }) + .get( + uri, + headers: {'User-Agent': 'ZSWatch-Companion/1.0', 'Accept': '*/*'}, + ) .timeout(_defaultTimeout); debugPrint('[HttpRelayService] Response status: ${response.statusCode}'); @@ -97,7 +100,8 @@ class HttpRelayService { // Check for HTTP errors if (response.statusCode < 200 || response.statusCode >= 300) { return HttpRelayResult.failure( - 'HTTP ${response.statusCode}: ${response.reasonPhrase}'); + 'HTTP ${response.statusCode}: ${response.reasonPhrase}', + ); } String body = response.body; diff --git a/zswatch_app/lib/services/location/gps_service.dart b/zswatch_app/lib/services/location/gps_service.dart index ee9241d..15e8e45 100644 --- a/zswatch_app/lib/services/location/gps_service.dart +++ b/zswatch_app/lib/services/location/gps_service.dart @@ -63,17 +63,14 @@ class GpsService { return GpsResult.failure(GpsError.serviceDisabled); } - // Check permission - var permission = await checkPermission(); + // Check permission — never request here. + // Permission must be granted during onboarding, because this method can + // be triggered from the background when the watch requests GPS. + final permission = await checkPermission(); if (permission == LocationPermission.denied) { - // Request permission - permission = await requestPermission(); - if (permission == LocationPermission.denied) { - debugPrint('[GpsService] Location permission denied'); - return GpsResult.failure(GpsError.permissionDenied); - } + debugPrint('[GpsService] Location permission denied'); + return GpsResult.failure(GpsError.permissionDenied); } - if (permission == LocationPermission.deniedForever) { debugPrint('[GpsService] Location permission denied forever'); return GpsResult.failure(GpsError.permissionDeniedForever); @@ -88,7 +85,8 @@ class GpsService { ), ); debugPrint( - '[GpsService] Got position: ${position.latitude}, ${position.longitude}'); + '[GpsService] Got position: ${position.latitude}, ${position.longitude}', + ); return GpsResult.success(position); } catch (e) { debugPrint('[GpsService] Error getting location: $e'); @@ -121,14 +119,14 @@ class GpsService { } /// Open app settings so user can manually enable location permission - /// + /// /// Returns true if settings were opened successfully. Future openAppSettings() async { return Geolocator.openAppSettings(); } /// Open location settings (system location toggle) - /// + /// /// Returns true if settings were opened successfully. Future openLocationSettings() async { return Geolocator.openLocationSettings(); diff --git a/zswatch_app/lib/services/media/media_service.dart b/zswatch_app/lib/services/media/media_service.dart index 84a896c..0bf6f27 100644 --- a/zswatch_app/lib/services/media/media_service.dart +++ b/zswatch_app/lib/services/media/media_service.dart @@ -29,7 +29,8 @@ class MediaPlaybackState { } @override - String toString() => 'MediaPlaybackState(state: $state, position: $positionSeconds)'; + String toString() => + 'MediaPlaybackState(state: $state, position: $positionSeconds)'; } /// Media track metadata @@ -78,7 +79,8 @@ class MediaService { static const _methodChannel = MethodChannel('dev.zswatch.app/media'); static const _eventChannel = EventChannel('dev.zswatch.app/media_events'); - final _playbackStateController = StreamController.broadcast(); + final _playbackStateController = + StreamController.broadcast(); final _metadataController = StreamController.broadcast(); StreamSubscription? _eventSubscription; @@ -88,7 +90,8 @@ class MediaService { MediaMetadata? _currentMetadata; /// Stream of playback state changes - Stream get playbackStateStream => _playbackStateController.stream; + Stream get playbackStateStream => + _playbackStateController.stream; /// Stream of metadata changes Stream get metadataStream => _metadataController.stream; @@ -151,7 +154,9 @@ class MediaService { case 'playbackState': if (data != null) { try { - _currentState = MediaPlaybackState.fromMap(Map.from(data)); + _currentState = MediaPlaybackState.fromMap( + Map.from(data), + ); _playbackStateController.add(_currentState!); debugPrint('Playback state: ${_currentState!.state}'); } catch (e) { @@ -163,7 +168,9 @@ class MediaService { case 'metadata': if (data != null) { try { - _currentMetadata = MediaMetadata.fromMap(Map.from(data)); + _currentMetadata = MediaMetadata.fromMap( + Map.from(data), + ); _metadataController.add(_currentMetadata!); debugPrint('Metadata: ${_currentMetadata!.track}'); } catch (e) { @@ -176,18 +183,24 @@ class MediaService { Future _fetchCurrentState() async { try { - final result = await _methodChannel.invokeMethod>('getCurrentState'); + final result = await _methodChannel.invokeMethod>( + 'getCurrentState', + ); if (result != null) { final playback = result['playback'] as Map?; final metadata = result['metadata'] as Map?; if (playback != null) { - _currentState = MediaPlaybackState.fromMap(Map.from(playback)); + _currentState = MediaPlaybackState.fromMap( + Map.from(playback), + ); _playbackStateController.add(_currentState!); } if (metadata != null) { - _currentMetadata = MediaMetadata.fromMap(Map.from(metadata)); + _currentMetadata = MediaMetadata.fromMap( + Map.from(metadata), + ); _metadataController.add(_currentMetadata!); } } @@ -197,7 +210,7 @@ class MediaService { } /// Fetch current state from native side with freshly calculated position. - /// + /// /// This queries the native MediaSession to get the current state with /// position calculated based on elapsed time since last update. /// Use this when you need an up-to-date position (e.g., for initial sync). @@ -295,7 +308,9 @@ class MediaService { Future seekTo(int positionSeconds) async { if (!Platform.isAndroid) return false; try { - final result = await _methodChannel.invokeMethod('seekTo', {'position': positionSeconds}); + final result = await _methodChannel.invokeMethod('seekTo', { + 'position': positionSeconds, + }); return result ?? false; } catch (e) { debugPrint('Error sending seekTo: $e'); @@ -307,7 +322,9 @@ class MediaService { Future hasActiveSession() async { if (!Platform.isAndroid) return false; try { - final result = await _methodChannel.invokeMethod('hasActiveSession'); + final result = await _methodChannel.invokeMethod( + 'hasActiveSession', + ); return result ?? false; } catch (e) { debugPrint('Error checking active session: $e'); @@ -331,4 +348,3 @@ class MediaService { _initialized = false; } } - diff --git a/zswatch_app/lib/services/notification/notification_service.dart b/zswatch_app/lib/services/notification/notification_service.dart index 6c5c39e..73c7f88 100644 --- a/zswatch_app/lib/services/notification/notification_service.dart +++ b/zswatch_app/lib/services/notification/notification_service.dart @@ -17,9 +17,12 @@ import '../../data/models/notification.dart'; /// - This service provides a no-op implementation class NotificationService { static const _methodChannel = MethodChannel('dev.zswatch.app/notifications'); - static const _eventChannel = EventChannel('dev.zswatch.app/notification_events'); + static const _eventChannel = EventChannel( + 'dev.zswatch.app/notification_events', + ); - final _notificationPostedController = StreamController.broadcast(); + final _notificationPostedController = + StreamController.broadcast(); final _notificationRemovedController = StreamController.broadcast(); final _permissionStatusController = StreamController.broadcast(); @@ -28,7 +31,8 @@ class NotificationService { bool _permissionGranted = false; /// Stream of posted notifications - Stream get notificationPosted => _notificationPostedController.stream; + Stream get notificationPosted => + _notificationPostedController.stream; /// Stream of removed notification IDs Stream get notificationRemoved => _notificationRemovedController.stream; @@ -69,7 +73,9 @@ class NotificationService { ); _initialized = true; - debugPrint('NotificationService initialized, permission: $_permissionGranted'); + debugPrint( + 'NotificationService initialized, permission: $_permissionGranted', + ); } catch (e) { debugPrint('NotificationService initialization failed: $e'); rethrow; @@ -90,7 +96,9 @@ class NotificationService { Map.from(notificationData), ); _notificationPostedController.add(notification); - debugPrint('Notification posted: ${notification.appName} - ${notification.title}'); + debugPrint( + 'Notification posted: ${notification.appName} - ${notification.title}', + ); } catch (e) { debugPrint('Error parsing notification: $e'); } @@ -112,7 +120,9 @@ class NotificationService { if (!Platform.isAndroid) return false; try { - final result = await _methodChannel.invokeMethod('isNotificationAccessEnabled'); + final result = await _methodChannel.invokeMethod( + 'isNotificationAccessEnabled', + ); _permissionGranted = result ?? false; return _permissionGranted; } catch (e) { @@ -137,7 +147,9 @@ class NotificationService { if (!Platform.isAndroid) return false; try { - final result = await _methodChannel.invokeMethod('isServiceRunning'); + final result = await _methodChannel.invokeMethod( + 'isServiceRunning', + ); return result ?? false; } catch (e) { debugPrint('Error checking service status: $e'); @@ -150,12 +162,16 @@ class NotificationService { if (!Platform.isAndroid) return []; try { - final result = await _methodChannel.invokeMethod>('getActiveNotifications'); + final result = await _methodChannel.invokeMethod>( + 'getActiveNotifications', + ); if (result == null) return []; return result .whereType>() - .map((map) => PhoneNotification.fromMap(Map.from(map))) + .map( + (map) => PhoneNotification.fromMap(Map.from(map)), + ) .toList(); } catch (e) { debugPrint('Error getting active notifications: $e'); @@ -168,7 +184,9 @@ class NotificationService { if (!Platform.isAndroid) return; try { - await _methodChannel.invokeMethod('dismissNotification', {'key': key}); + await _methodChannel.invokeMethod('dismissNotification', { + 'key': key, + }); } catch (e) { debugPrint('Error dismissing notification: $e'); } @@ -179,12 +197,17 @@ class NotificationService { if (!Platform.isAndroid) return []; try { - final result = await _methodChannel.invokeMethod>('getNotificationApps'); + final result = await _methodChannel.invokeMethod>( + 'getNotificationApps', + ); if (result == null) return []; return result .whereType>() - .map((map) => AppNotificationFilter.fromMap(Map.from(map))) + .map( + (map) => + AppNotificationFilter.fromMap(Map.from(map)), + ) .toList(); } catch (e) { debugPrint('Error getting notification apps: $e'); @@ -202,10 +225,7 @@ class NotificationService { try { final result = await _methodChannel.invokeMapMethod( 'sendTestNotification', - { - 'title': title, - 'body': body, - }, + {'title': title, 'body': body}, ); return result; diff --git a/zswatch_app/lib/services/permission/permission_service.dart b/zswatch_app/lib/services/permission/permission_service.dart index 4cf9512..afcdd29 100644 --- a/zswatch_app/lib/services/permission/permission_service.dart +++ b/zswatch_app/lib/services/permission/permission_service.dart @@ -69,8 +69,9 @@ class AppPermissionsStatus { List get missingRecommendedPermissions { final missing = []; if (!bluetoothGranted) missing.add('Bluetooth'); - if (!isNotificationGranted && Platform.isAndroid) + if (!isNotificationGranted && Platform.isAndroid) { missing.add('Notifications'); + } if (!batteryOptimizationDisabled && Platform.isAndroid) { missing.add('Battery Optimization'); } @@ -82,8 +83,9 @@ class AppPermissionsStatus { final missing = []; if (!bluetoothGranted) missing.add('Bluetooth'); if (!isLocationGranted) missing.add('Location'); - if (!isNotificationGranted && Platform.isAndroid) + if (!isNotificationGranted && Platform.isAndroid) { missing.add('Notifications'); + } if (!notificationListenerEnabled && Platform.isAndroid) { missing.add('Notification Access'); } diff --git a/zswatch_app/lib/services/protocol/gadgetbridge_protocol.dart b/zswatch_app/lib/services/protocol/gadgetbridge_protocol.dart index e4853e1..d66e17e 100644 --- a/zswatch_app/lib/services/protocol/gadgetbridge_protocol.dart +++ b/zswatch_app/lib/services/protocol/gadgetbridge_protocol.dart @@ -114,6 +114,17 @@ class GadgetbridgeProtocol implements ProtocolService { rawMessage: rawMessage, firmwareVersion: json['fw'] as String? ?? 'unknown', hardwareVersion: json['hw'] as String?, + commitSha: json['sha'] as String?, + isDebug: json['dbg'] == 1, + ); + + case 'coredump': + return CoredumpMessage( + rawMessage: rawMessage, + available: json['available'] == true, + file: json['file'] as String? ?? '', + line: json['line'] as int? ?? 0, + time: json['time'] as String? ?? '', ); case 'status': @@ -127,10 +138,7 @@ class GadgetbridgeProtocol implements ProtocolService { case 'music': final action = _parseMusicControlAction(json['n'] as String?); if (action != null) { - return MusicControlMessage( - rawMessage: rawMessage, - action: action, - ); + return MusicControlMessage(rawMessage: rawMessage, action: action); } return null; @@ -154,7 +162,8 @@ class GadgetbridgeProtocol implements ProtocolService { return NotificationActionMessage( rawMessage: rawMessage, notificationId: json['id'] as int? ?? 0, - action: _parseNotificationAction(json['n'] as String?) ?? + action: + _parseNotificationAction(json['n'] as String?) ?? NotificationAction.dismiss, replyMessage: json['msg'] as String?, phoneNumber: json['tel'] as String?, @@ -178,14 +187,10 @@ class GadgetbridgeProtocol implements ProtocolService { ); case 'force_calendar_sync': - final ids = (json['ids'] as List?) - ?.map((e) => e as int) - .toList() ?? + final ids = + (json['ids'] as List?)?.map((e) => e as int).toList() ?? []; - return CalendarSyncMessage( - rawMessage: rawMessage, - existingIds: ids, - ); + return CalendarSyncMessage(rawMessage: rawMessage, existingIds: ids); case 'http': return HttpRequestMessage( @@ -335,7 +340,9 @@ class GadgetbridgeProtocol implements ProtocolService { if (notification.body != null) data['body'] = notification.body; if (notification.sender != null) data['sender'] = notification.sender; if (notification.subject != null) data['subject'] = notification.subject; - if (notification.phoneNumber != null) data['tel'] = notification.phoneNumber; + if (notification.phoneNumber != null) { + data['tel'] = notification.phoneNumber; + } if (notification.canReply) data['reply'] = true; await _sendGb(data); @@ -343,11 +350,7 @@ class GadgetbridgeProtocol implements ProtocolService { @override Future updateNotification(int id, String body) async { - await _sendGb({ - 't': 'notify~', - 'id': id, - 'body': body, - }); + await _sendGb({'t': 'notify~', 'id': id, 'body': body}); } @override @@ -406,12 +409,14 @@ class GadgetbridgeProtocol implements ProtocolService { @override Future setAlarms(List alarms) async { final alarmList = alarms - .map((a) => { - 'h': a.hour, - 'm': a.minute, - 'rep': a.repeatDaysMask, - 'on': a.enabled ? 1 : 0, - }) + .map( + (a) => { + 'h': a.hour, + 'm': a.minute, + 'rep': a.repeatDaysMask, + 'on': a.enabled ? 1 : 0, + }, + ) .toList(); await _sendGb({'t': 'alarm', 'd': alarmList}); @@ -441,11 +446,7 @@ class GadgetbridgeProtocol implements ProtocolService { bool steps = false, int? intervalSeconds, }) async { - final data = { - 't': 'act', - 'hrm': heartRate, - 'stp': steps, - }; + final data = {'t': 'act', 'hrm': heartRate, 'stp': steps}; if (intervalSeconds != null) data['int'] = intervalSeconds; await _sendGb(data); @@ -453,10 +454,7 @@ class GadgetbridgeProtocol implements ProtocolService { @override Future fetchActivityData({int? sinceTimestampMs}) async { - await _sendGb({ - 't': 'actfetch', - 'ts': sinceTimestampMs ?? 0, - }); + await _sendGb({'t': 'actfetch', 'ts': sinceTimestampMs ?? 0}); } @override @@ -485,10 +483,7 @@ class GadgetbridgeProtocol implements ProtocolService { @override Future sendCall(WatchCall call) async { - final data = { - 't': 'call', - 'cmd': call.state.name, - }; + final data = {'t': 'call', 'cmd': call.state.name}; if (call.name != null) data['name'] = call.name; if (call.number != null) data['number'] = call.number; @@ -513,20 +508,11 @@ class GadgetbridgeProtocol implements ProtocolService { @override Future sendHttpResponse(String requestId, String response) async { - await _sendGb({ - 't': 'http', - 'id': requestId, - 'resp': response, - }); + await _sendGb({'t': 'http', 'id': requestId, 'resp': response}); } @override Future sendHttpError(String requestId, String error) async { - await _sendGb({ - 't': 'http', - 'id': requestId, - 'err': error, - }); + await _sendGb({'t': 'http', 'id': requestId, 'err': error}); } } - diff --git a/zswatch_app/lib/services/protocol/message_types.dart b/zswatch_app/lib/services/protocol/message_types.dart index df796f5..006dc2c 100644 --- a/zswatch_app/lib/services/protocol/message_types.dart +++ b/zswatch_app/lib/services/protocol/message_types.dart @@ -4,18 +4,10 @@ library; /// Direction of BLE communication -enum CommDirection { - incoming, - outgoing, -} +enum CommDirection { incoming, outgoing } /// Protocol type for BLE messages -enum ProtocolType { - gadgetbridge, - extended, - mcumgr, - unknown, -} +enum ProtocolType { gadgetbridge, extended, mcumgr, unknown } /// Gadgetbridge message types (Phone → Watch) enum GadgetbridgeOutgoingType { @@ -56,6 +48,7 @@ enum GadgetbridgeIncomingType { httpRequest, intent, fileWrite, + coredump, info, warning, error, @@ -74,49 +67,19 @@ enum ExtendedApiType { } /// Music player state -enum MusicState { - play, - pause, - stop, -} +enum MusicState { play, pause, stop } /// Music control actions (from watch) -enum MusicControlAction { - play, - pause, - next, - previous, - volumeUp, - volumeDown, -} +enum MusicControlAction { play, pause, next, previous, volumeUp, volumeDown } /// Call state -enum CallState { - accept, - incoming, - outgoing, - reject, - start, - end, - ignore, -} +enum CallState { accept, incoming, outgoing, reject, start, end, ignore } /// Notification action (from watch) -enum NotificationAction { - dismiss, - dismissAll, - open, - mute, - reply, -} +enum NotificationAction { dismiss, dismissAll, open, mute, reply } /// Log level for streaming logs -enum LogLevel { - debug, - info, - warning, - error, -} +enum LogLevel { debug, info, warning, error } /// Activity type from watch enum ActivityType { @@ -152,21 +115,39 @@ abstract class GadgetbridgeMessage { final String rawMessage; final DateTime receivedAt; - GadgetbridgeMessage({ - required this.rawMessage, - DateTime? receivedAt, - }) : receivedAt = receivedAt ?? DateTime.now(); + GadgetbridgeMessage({required this.rawMessage, DateTime? receivedAt}) + : receivedAt = receivedAt ?? DateTime.now(); } -/// Version message from watch +/// Version message from watch (includes fw_info fields: sha, dbg) class VersionMessage extends GadgetbridgeMessage { final String firmwareVersion; final String? hardwareVersion; + final String? commitSha; + final bool isDebug; VersionMessage({ required super.rawMessage, required this.firmwareVersion, this.hardwareVersion, + this.commitSha, + this.isDebug = false, + }); +} + +/// Coredump availability message from watch +class CoredumpMessage extends GadgetbridgeMessage { + final bool available; + final String file; + final int line; + final String time; + + CoredumpMessage({ + required super.rawMessage, + required this.available, + required this.file, + required this.line, + required this.time, }); } @@ -188,10 +169,7 @@ class StatusMessage extends GadgetbridgeMessage { class MusicControlMessage extends GadgetbridgeMessage { final MusicControlAction action; - MusicControlMessage({ - required super.rawMessage, - required this.action, - }); + MusicControlMessage({required super.rawMessage, required this.action}); } /// Activity data message from watch @@ -218,10 +196,7 @@ class ActivityDataMessage extends GadgetbridgeMessage { class FindPhoneMessage extends GadgetbridgeMessage { final bool findEnabled; - FindPhoneMessage({ - required super.rawMessage, - required this.findEnabled, - }); + FindPhoneMessage({required super.rawMessage, required this.findEnabled}); } /// Notification action message from watch @@ -244,10 +219,7 @@ class NotificationActionMessage extends GadgetbridgeMessage { class CallControlMessage extends GadgetbridgeMessage { final CallState callState; - CallControlMessage({ - required super.rawMessage, - required this.callState, - }); + CallControlMessage({required super.rawMessage, required this.callState}); } /// Info/Warning/Error message from watch @@ -282,19 +254,12 @@ class HttpRequestMessage extends GadgetbridgeMessage { class GpsPowerMessage extends GadgetbridgeMessage { final bool enabled; - GpsPowerMessage({ - required super.rawMessage, - required this.enabled, - }); + GpsPowerMessage({required super.rawMessage, required this.enabled}); } /// Calendar sync request message from watch class CalendarSyncMessage extends GadgetbridgeMessage { final List existingIds; - CalendarSyncMessage({ - required super.rawMessage, - required this.existingIds, - }); + CalendarSyncMessage({required super.rawMessage, required this.existingIds}); } - diff --git a/zswatch_app/lib/services/protocol/protocol_service.dart b/zswatch_app/lib/services/protocol/protocol_service.dart index e26d5d1..9b4b28f 100644 --- a/zswatch_app/lib/services/protocol/protocol_service.dart +++ b/zswatch_app/lib/services/protocol/protocol_service.dart @@ -134,11 +134,7 @@ class WatchCall { final String? name; final String? number; - const WatchCall({ - required this.state, - this.name, - this.number, - }); + const WatchCall({required this.state, this.name, this.number}); } /// Navigation instruction to send to watch @@ -259,4 +255,3 @@ abstract class ProtocolService { /// Send HTTP error (for watch HTTP requests) Future sendHttpError(String requestId, String error); } - diff --git a/zswatch_app/lib/services/shell/shell_service.dart b/zswatch_app/lib/services/shell/shell_service.dart new file mode 100644 index 0000000..400d606 --- /dev/null +++ b/zswatch_app/lib/services/shell/shell_service.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; + +/// Service for executing shell commands on the watch via SMP (group 9). +/// +/// Uses the mcumgr_flutter ShellManager which communicates over BLE +/// using the SMP protocol. +class ShellService { + String? _deviceId; + bool _disposed = false; + + /// Set the device to communicate with. + void setDevice(String deviceId) { + if (_deviceId != null && _deviceId != deviceId) { + ShellManager.kill(_deviceId!); + } + _deviceId = deviceId; + } + + /// Execute a shell command and return the result. + /// + /// Throws if no device is set or on communication error. + Future execute(String command) async { + final deviceId = _deviceId; + if (deviceId == null) { + throw StateError('No device connected for shell commands'); + } + + debugPrint('[ShellService] Execute: $command'); + final result = await ShellManager.execute(deviceId, command); + debugPrint( + '[ShellService] Result (rc=${result.returnCode}): ${result.output.length} chars', + ); + return result; + } + + void dispose() { + if (_disposed) return; + _disposed = true; + if (_deviceId != null) { + ShellManager.kill(_deviceId!); + _deviceId = null; + } + } +} diff --git a/zswatch_app/lib/services/sync/initial_sync_service.dart b/zswatch_app/lib/services/sync/initial_sync_service.dart index f58539a..2a5b33c 100644 --- a/zswatch_app/lib/services/sync/initial_sync_service.dart +++ b/zswatch_app/lib/services/sync/initial_sync_service.dart @@ -38,8 +38,8 @@ class InitialSyncService { InitialSyncService({ required WatchService watchService, required MediaService mediaService, - }) : _watchService = watchService, - _mediaService = mediaService; + }) : _watchService = watchService, + _mediaService = mediaService; /// Perform all initial sync operations /// @@ -47,7 +47,7 @@ class InitialSyncService { /// Non-critical sync failures (like music state) don't cause overall failure. Future performInitialSync() async { debugPrint('[InitialSync] Starting initial sync operations'); - + var allSucceeded = true; // 1. Time sync (FR-085) - Critical @@ -101,7 +101,9 @@ class InitialSyncService { // Check if media service is initialized if (!_mediaService.isInitialized) { - debugPrint('[InitialSync] Media service not initialized, attempting init'); + debugPrint( + '[InitialSync] Media service not initialized, attempting init', + ); await _mediaService.initialize(); } @@ -110,9 +112,8 @@ class InitialSyncService { final metadata = _mediaService.currentMetadata; // Only send if there's something playing or paused (FR-083) - if (playbackState != null && + if (playbackState != null && (playbackState.isPlaying || playbackState.isPaused)) { - // Send playback state await _watchService.sendMusicState( state: playbackState.state, @@ -130,12 +131,16 @@ class InitialSyncService { trackNumber: metadata.trackNumber, trackCount: metadata.trackCount, ); - debugPrint('[InitialSync] Sent music info: ${metadata.artist} - ${metadata.track}'); + debugPrint( + '[InitialSync] Sent music info: ${metadata.artist} - ${metadata.track}', + ); } - + return SyncResult.success; } else { - debugPrint('[InitialSync] No active media playback, skipping music sync'); + debugPrint( + '[InitialSync] No active media playback, skipping music sync', + ); return SyncResult.skipped; } } catch (e) { diff --git a/zswatch_app/lib/services/time/time_sync_service.dart b/zswatch_app/lib/services/time/time_sync_service.dart index 0e39807..eeee106 100644 --- a/zswatch_app/lib/services/time/time_sync_service.dart +++ b/zswatch_app/lib/services/time/time_sync_service.dart @@ -29,8 +29,12 @@ class TimeSyncService { } /// Sync a specific time to the watch - Future syncSpecificTime(DateTime time, {double? timezoneOffsetHours}) async { - final tzOffset = timezoneOffsetHours ?? time.timeZoneOffset.inMinutes / 60.0; + Future syncSpecificTime( + DateTime time, { + double? timezoneOffsetHours, + }) async { + final tzOffset = + timezoneOffsetHours ?? time.timeZoneOffset.inMinutes / 60.0; debugPrint('Syncing specific time: $time (TZ offset: $tzOffset hours)'); @@ -74,4 +78,3 @@ extension TimeSyncServiceExtension on ProtocolService { return TimeSyncService(this); } } - diff --git a/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart b/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart new file mode 100644 index 0000000..c78a551 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart @@ -0,0 +1,247 @@ +import 'dart:typed_data'; + +import 'zsw_opus_parser.dart'; + +/// Converts ZSWatch custom .zsw_opus files to standard Ogg/Opus format +/// for playback with standard audio players. +/// +/// Ogg container spec: RFC 3533 +/// Opus in Ogg spec: RFC 7845 +/// +/// The ZSWatch firmware encodes audio as: +/// - 16 kHz, mono, 32 kbps, 10 ms frames +/// - OPUS_APPLICATION_RESTRICTED_LOWDELAY mode +/// - Pre-skip ≈ 120 samples at 48 kHz +class OggOpusWriter { + // Samples per Opus frame at the 48 kHz Ogg granule rate. + // 10 ms frame at 48 kHz = 480 samples. + static const int _samplesPerFrame48k = 480; + + // Pre-skip for OPUS_APPLICATION_RESTRICTED_LOWDELAY at 16 kHz. + // The encoder's algorithmic delay is ~2.5 ms ≈ 120 samples at 48 kHz. + static const int _preSkip = 120; + + // Maximum Opus frames per Ogg page (~2 s of audio at 10 ms/frame). + static const int _framesPerPage = 200; + + // Arbitrary but fixed serial number for the logical bitstream. + static const int _serialNumber = 0x5A535731; // "ZSW1" + + /// Convert a parsed .zsw_opus result into a standard Ogg/Opus byte buffer + /// that can be played by any Opus-capable audio player. + static Uint8List convert(ZswOpusParseResult parsed) { + final builder = BytesBuilder(copy: false); + int pageSeq = 0; + + // ── Page 1: OpusHead (BOS) ────────────────────────────── + final opusHead = _buildOpusHead( + channels: 1, + preSkip: _preSkip, + inputSampleRate: parsed.header.sampleRate, + ); + builder.add( + _buildOggPage( + granulePosition: 0, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: 0x02, // BOS + packets: [opusHead], + ), + ); + + // ── Page 2: OpusTags ──────────────────────────────────── + final opusTags = _buildOpusTags(); + builder.add( + _buildOggPage( + granulePosition: 0, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: 0x00, + packets: [opusTags], + ), + ); + + // ── Pages 3+: Audio data ──────────────────────────────── + int totalSamples = 0; + + for (int i = 0; i < parsed.frames.length; i += _framesPerPage) { + final end = (i + _framesPerPage).clamp(0, parsed.frames.length); + final pageFrames = parsed.frames.sublist(i, end); + totalSamples += pageFrames.length * _samplesPerFrame48k; + + final isLast = end >= parsed.frames.length; + builder.add( + _buildOggPage( + granulePosition: totalSamples, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: isLast ? 0x04 : 0x00, // EOS on last page + packets: pageFrames.map((f) => f.data).toList(), + ), + ); + } + + return builder.toBytes(); + } + + // ════════════════════════════════════════════════════════════ + // Opus header packets + // ════════════════════════════════════════════════════════════ + + /// Build the 19-byte OpusHead identification header (RFC 7845 §5.1). + static Uint8List _buildOpusHead({ + required int channels, + required int preSkip, + required int inputSampleRate, + }) { + final buf = ByteData(19); + // "OpusHead" magic + final magic = 'OpusHead'.codeUnits; + for (int i = 0; i < 8; i++) { + buf.setUint8(i, magic[i]); + } + buf.setUint8(8, 1); // Version + buf.setUint8(9, channels); // Channel count + buf.setUint16(10, preSkip, Endian.little); // Pre-skip + buf.setUint32(12, inputSampleRate, Endian.little); // Input sample rate + buf.setInt16(16, 0, Endian.little); // Output gain (dB Q7.8) + buf.setUint8(18, 0); // Channel mapping family (0 = mono/stereo) + return buf.buffer.asUint8List(); + } + + /// Build the OpusTags comment header (RFC 7845 §5.2). + static Uint8List _buildOpusTags() { + const vendor = 'ZSWatch'; + final vendorBytes = vendor.codeUnits; + // 8 (magic) + 4 (vendor len) + vendor + 4 (comment count) + final length = 8 + 4 + vendorBytes.length + 4; + final buf = ByteData(length); + + // "OpusTags" magic + final magic = 'OpusTags'.codeUnits; + for (int i = 0; i < 8; i++) { + buf.setUint8(i, magic[i]); + } + buf.setUint32(8, vendorBytes.length, Endian.little); + for (int i = 0; i < vendorBytes.length; i++) { + buf.setUint8(12 + i, vendorBytes[i]); + } + buf.setUint32(12 + vendorBytes.length, 0, Endian.little); // No comments + + return buf.buffer.asUint8List(); + } + + // ════════════════════════════════════════════════════════════ + // Ogg page builder + // ════════════════════════════════════════════════════════════ + + /// Build a single Ogg page containing one or more packets. + /// + /// [headerType] flags: 0x01 = continuation, 0x02 = BOS, 0x04 = EOS. + static Uint8List _buildOggPage({ + required int granulePosition, + required int serialNumber, + required int pageSequence, + required int headerType, + required List packets, + }) { + // Build segment table: each packet is split into 255-byte segments + // with a final segment < 255 (or 0 if exactly a multiple of 255). + final segmentTable = []; + for (final packet in packets) { + int remaining = packet.length; + while (remaining >= 255) { + segmentTable.add(255); + remaining -= 255; + } + segmentTable.add(remaining); // final segment (0–254) + } + + final numSegments = segmentTable.length; + final headerSize = 27 + numSegments; + final dataSize = packets.fold(0, (sum, p) => sum + p.length); + final pageSize = headerSize + dataSize; + + final page = Uint8List(pageSize); + final bd = ByteData.sublistView(page); + + // Capture pattern: "OggS" + page[0] = 0x4F; // O + page[1] = 0x67; // g + page[2] = 0x67; // g + page[3] = 0x53; // S + + // Stream structure version + bd.setUint8(4, 0); + + // Header type flag + bd.setUint8(5, headerType); + + // Granule position (64-bit LE) + bd.setUint32(6, granulePosition & 0xFFFFFFFF, Endian.little); + bd.setUint32(10, (granulePosition >> 32) & 0xFFFFFFFF, Endian.little); + + // Serial number + bd.setUint32(14, serialNumber, Endian.little); + + // Page sequence number + bd.setUint32(18, pageSequence, Endian.little); + + // CRC32 — set to 0 initially, computed over the full page + bd.setUint32(22, 0, Endian.little); + + // Number of page segments + bd.setUint8(26, numSegments); + + // Segment table + for (int i = 0; i < numSegments; i++) { + page[27 + i] = segmentTable[i]; + } + + // Packet data + int offset = headerSize; + for (final packet in packets) { + page.setRange(offset, offset + packet.length, packet); + offset += packet.length; + } + + // Compute and insert CRC32 + final crc = _oggCrc32(page); + bd.setUint32(22, crc, Endian.little); + + return page; + } + + // ════════════════════════════════════════════════════════════ + // Ogg CRC-32 + // ════════════════════════════════════════════════════════════ + + /// Ogg uses CRC-32 with polynomial 0x04C11DB7 (unreflected), + /// initial value 0, no final XOR — different from the common + /// "CRC-32" (ISO 3309 / ITU-T V.42) which uses reflected I/O. + static int _oggCrc32(Uint8List data) { + int crc = 0; + for (final byte in data) { + crc = (_crcTable[((crc >> 24) ^ byte) & 0xFF] ^ (crc << 8)) & 0xFFFFFFFF; + } + return crc; + } + + static final List _crcTable = _generateCrcTable(); + + static List _generateCrcTable() { + final table = List.filled(256, 0); + for (int i = 0; i < 256; i++) { + int crc = i << 24; + for (int j = 0; j < 8; j++) { + if (crc & 0x80000000 != 0) { + crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF; + } else { + crc = (crc << 1) & 0xFFFFFFFF; + } + } + table[i] = crc; + } + return table; + } +} diff --git a/zswatch_app/lib/services/voice_memo/transcription_engine.dart b/zswatch_app/lib/services/voice_memo/transcription_engine.dart new file mode 100644 index 0000000..deccd83 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/transcription_engine.dart @@ -0,0 +1,939 @@ +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_new_min/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_min/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_min/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:whisper_ggml_plus/whisper_ggml_plus.dart'; + +/// Discriminates between available offline transcription engine variants. +/// +/// Persisted in SharedPreferences so the user's choice survives restarts. +enum TranscriptionEngineType { + /// Tiny English-only Whisper model (~75 MB). Downloaded from whisper.cpp CDN. + whisperTinyEn, + + /// Base English-only Whisper model (~142 MB). Noticeably better than Tiny. + whisperBaseEn, + + /// Small English-only Whisper model (~466 MB). Highest-fidelity English + /// model at a manageable size. ~800 MB RAM needed. + whisperSmallEn, + + /// KB-Whisper Base fine-tuned on 50 k+ hours of Swedish speech (~147 MB, + /// q5_0 quantised). Downloaded from HuggingFace on first use. + kbWhisperBase, + + /// KB-Whisper Small q5_0 quantised (~175 MB). Massive accuracy improvement + /// over Base for Swedish at nearly the same size. ~500 MB RAM needed. + kbWhisperSmallQ5, + + /// KB-Whisper Small full GGML checkpoint (~488 MB). Highest fidelity + /// available from the upstream GGML release. ~1 GB RAM needed. + kbWhisperSmallQ8, + + /// Whisper Base multilingual model forced to German (~142 MB). + /// Solid German accuracy at a compact size. + whisperBaseMultiDe, + + /// Whisper Small multilingual model forced to German (~244 MB). + /// Higher-fidelity German, noticeably better than Base. ~500 MB RAM. + whisperSmallMultiDe, + + /// Whisper Large-v3-Turbo q5_0 quantised (~547 MB). Near cloud-level + /// accuracy, optimised for speed. Needs ~1 GB RAM — modern flagships only. + whisperLargeV3TurboQ5, +} + +/// Static metadata for a selectable transcription model. +class TranscriptionModelInfo { + final TranscriptionEngineType type; + final String name; + final String language; + final String sourceUrl; + final String fileName; + final int expectedSizeBytes; + final String description; + + const TranscriptionModelInfo({ + required this.type, + required this.name, + required this.language, + required this.sourceUrl, + required this.fileName, + required this.expectedSizeBytes, + required this.description, + }); +} + +/// Catalog of selectable offline models shown in settings. +abstract final class TranscriptionModelCatalog { + static const _tinyEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperTinyEn, + name: 'Whisper Tiny (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin', + fileName: 'ggml-tiny.en.bin', + expectedSizeBytes: 75 * 1024 * 1024, + description: + 'Good English (75 MB, fast) — smallest download, lowest accuracy, best for short clear speech.', + ); + + static const _baseEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperBaseEn, + name: 'Whisper Base (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin', + fileName: 'ggml-base.en.bin', + expectedSizeBytes: 142 * 1024 * 1024, + description: + 'Better English (142 MB) — noticeably more accurate than Tiny, still compact.', + ); + + static const _smallEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperSmallEn, + name: 'Whisper Small (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin', + fileName: 'ggml-small.en.bin', + expectedSizeBytes: 466 * 1024 * 1024, + description: + 'Best English (466 MB, slow) — highest-fidelity English, significant accuracy boost over Base. ~800 MB RAM.', + ); + + static const _kbWhisperBase = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperBase, + name: 'KB-Whisper Base (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main/ggml-model-q5_0.bin', + fileName: 'ggml-kb-whisper-base-q5_0.bin', + expectedSizeBytes: 147 * 1024 * 1024, + description: + 'Good (147 MB) — solid Swedish accuracy, fine-tuned on 50k+ hours of speech.', + ); + + static const _kbWhisperSmallQ5 = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperSmallQ5, + name: 'KB-Whisper Small · Q5_0 (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model-q5_0.bin', + fileName: 'ggml-kb-whisper-small-q5_0.bin', + expectedSizeBytes: 175 * 1024 * 1024, + description: + 'Better (175 MB) — noticeably better Swedish than Good at nearly the same size. ~500 MB RAM.', + ); + + static const _kbWhisperSmallQ8 = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperSmallQ8, + name: 'KB-Whisper Small · Full (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model.bin', + fileName: 'ggml-kb-whisper-small.bin', + expectedSizeBytes: 488 * 1024 * 1024, + description: + 'Best (488 MB, slow) — highest-fidelity Swedish, full-precision model. Needs ~1 GB RAM.', + ); + + static const _baseMultiDe = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperBaseMultiDe, + name: 'Whisper Base (German)', + language: 'de', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', + fileName: 'ggml-base-multi-de.bin', + expectedSizeBytes: 142 * 1024 * 1024, + description: + 'Good German (142 MB) — multilingual Whisper forced to German, solid accuracy.', + ); + + static const _smallMultiDe = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperSmallMultiDe, + name: 'Whisper Small (German)', + language: 'de', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', + fileName: 'ggml-small-multi-de.bin', + expectedSizeBytes: 244 * 1024 * 1024, + description: + 'Better German (244 MB) — higher-fidelity German, noticeably better than Base. ~500 MB RAM.', + ); + + static const _whisperLargeV3TurboQ5 = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperLargeV3TurboQ5, + name: 'Whisper Large-v3-Turbo · Q5_0 (Multilingual)', + language: 'auto', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin', + fileName: 'ggml-large-v3-turbo-q5_0.bin', + expectedSizeBytes: 547 * 1024 * 1024, + description: + 'Multilingual (547 MB, slow) — near cloud-level accuracy for any language. Needs ~1 GB RAM.', + ); + + // Models grouped by language: English → Swedish → German → Multilingual, + // ordered best→worst within each group. + static const List all = [ + _smallEn, + _baseEn, + _tinyEn, + _kbWhisperSmallQ8, + _kbWhisperSmallQ5, + _kbWhisperBase, + _smallMultiDe, + _baseMultiDe, + _whisperLargeV3TurboQ5, + ]; + + static TranscriptionModelInfo info(TranscriptionEngineType type) { + switch (type) { + case TranscriptionEngineType.whisperTinyEn: + return _tinyEn; + case TranscriptionEngineType.whisperBaseEn: + return _baseEn; + case TranscriptionEngineType.whisperSmallEn: + return _smallEn; + case TranscriptionEngineType.kbWhisperBase: + return _kbWhisperBase; + case TranscriptionEngineType.kbWhisperSmallQ5: + return _kbWhisperSmallQ5; + case TranscriptionEngineType.kbWhisperSmallQ8: + return _kbWhisperSmallQ8; + case TranscriptionEngineType.whisperBaseMultiDe: + return _baseMultiDe; + case TranscriptionEngineType.whisperSmallMultiDe: + return _smallMultiDe; + case TranscriptionEngineType.whisperLargeV3TurboQ5: + return _whisperLargeV3TurboQ5; + } + } +} + +TranscriptionEngine createTranscriptionEngine(TranscriptionEngineType type) { + switch (type) { + case TranscriptionEngineType.whisperTinyEn: + return WhisperEngine(); + case TranscriptionEngineType.whisperBaseEn: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin', + modelFileName: 'ggml-base.en.bin', + languageCode: 'en', + displayName: 'Whisper Base (English)', + ); + case TranscriptionEngineType.whisperSmallEn: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin', + modelFileName: 'ggml-small.en.bin', + languageCode: 'en', + displayName: 'Whisper Small (English)', + ); + case TranscriptionEngineType.kbWhisperBase: + return KbWhisperEngines.base(); + case TranscriptionEngineType.kbWhisperSmallQ5: + return KbWhisperEngines.smallQ5(); + case TranscriptionEngineType.kbWhisperSmallQ8: + return KbWhisperEngines.smallQ8(); + case TranscriptionEngineType.whisperBaseMultiDe: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', + modelFileName: 'ggml-base-multi-de.bin', + languageCode: 'de', + displayName: 'Whisper Base (German)', + ); + case TranscriptionEngineType.whisperSmallMultiDe: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', + modelFileName: 'ggml-small-multi-de.bin', + languageCode: 'de', + displayName: 'Whisper Small (German)', + ); + case TranscriptionEngineType.whisperLargeV3TurboQ5: + return KbWhisperEngines.largeV3TurboQ5(); + } +} + +/// Abstract interface for speech-to-text transcription engines +/// +/// Implementations: +/// - [WhisperEngine]: Offline transcription using whisper_ggml_plus (default) +/// - [CustomGgmlWhisperEngine]: Any custom GGML model file via download URL +/// - Future: CloudSttEngine for Google Cloud STT / OpenAI Whisper API +/// - Future: PlatformSttEngine for iOS SFSpeechRecognizer +abstract class TranscriptionEngine { + /// Transcribe audio from a file path. + /// + /// [audioFilePath] can be a 16 kHz 16-bit mono WAV file, or any format + /// supported by the engine (Ogg/Opus if FFmpeg converter is registered). + /// + /// Returns the transcribed text, or empty string if nothing was recognized. + /// Throws on engine errors (model not loaded, file not found, etc.). + Future transcribe(String audioFilePath); + + /// Whether this engine is currently available (model downloaded, etc.) + Future isAvailable(); + + /// Human-readable engine name for display + String get engineName; + + /// Stream of engine status changes (model downloading, ready, etc.) + Stream get stateStream; + + /// Current state + TranscriptionEngineState get currentState; + + /// Ensure the engine is ready (download model if needed) + Future initialize(); + + /// Clean up resources + void dispose(); + + /// URL of the model source used for downloads. + String get modelSourceUrl; + + /// Expected model size (bytes) shown in setup UI. + int get expectedModelSizeBytes; + + /// Local path to the model file. + Future modelFilePath(); + + /// Delete local model file if present. + Future deleteModel(); +} + +/// State of a transcription engine +enum TranscriptionEngineStatus { + /// Not initialized yet + uninitialized, + + /// Model is being downloaded + downloading, + + /// Ready to transcribe + ready, + + /// An error occurred (model download failed, etc.) + error, + + /// Currently transcribing + transcribing, +} + +/// Transcription engine state with progress info +class TranscriptionEngineState { + final TranscriptionEngineStatus status; + final double downloadProgress; // 0.0 - 1.0, only valid during downloading + final String? errorMessage; + + const TranscriptionEngineState({ + this.status = TranscriptionEngineStatus.uninitialized, + this.downloadProgress = 0.0, + this.errorMessage, + }); + + TranscriptionEngineState copyWith({ + TranscriptionEngineStatus? status, + double? downloadProgress, + String? errorMessage, + }) { + return TranscriptionEngineState( + status: status ?? this.status, + downloadProgress: downloadProgress ?? this.downloadProgress, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +/// Offline Whisper engine using whisper_ggml_plus +/// +/// Downloads the tiny.en model (~75 MB) on first use. Subsequent +/// transcriptions use the cached model. The WhisperController keeps +/// the model in memory for fast back-to-back transcriptions. +class WhisperEngine implements TranscriptionEngine { + static const WhisperModel _defaultModel = WhisperModel.tinyEn; + + final WhisperController _controller = WhisperController(); + final _state = BehaviorSubject.seeded( + const TranscriptionEngineState(), + ); + + @override + String get engineName => 'Whisper (Offline)'; + + @override + String get modelSourceUrl => _defaultModel.modelUri.toString(); + + @override + int get expectedModelSizeBytes => TranscriptionModelCatalog.info( + TranscriptionEngineType.whisperTinyEn, + ).expectedSizeBytes; + + @override + Stream get stateStream => _state.stream; + + @override + TranscriptionEngineState get currentState => _state.value; + + @override + Future isAvailable() async { + try { + final modelFile = File(await modelFilePath()); + return modelFile.existsSync(); + } catch (_) { + return false; + } + } + + @override + Future initialize() async { + if (currentState.status == TranscriptionEngineStatus.downloading) return; + + try { + final available = await isAvailable(); + if (available) { + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + ), + ); + return; + } + + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + ), + ); + + await _downloadModel(); + + _state.add( + const TranscriptionEngineState(status: TranscriptionEngineStatus.ready), + ); + } catch (e) { + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + ), + ); + rethrow; + } + } + + @override + Future transcribe(String audioFilePath) async { + if (!File(audioFilePath).existsSync()) { + throw Exception('Audio file not found: $audioFilePath'); + } + + if (!await isAvailable()) { + throw Exception( + 'Transcription model not downloaded. Configure in Settings > Voice Memos.', + ); + } + + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.transcribing, + ), + ); + + try { + debugPrint('[WhisperEngine] Transcribing: $audioFilePath'); + + final result = await _controller.transcribe( + model: _defaultModel, + audioPath: audioFilePath, + lang: 'en', + ); + + _state.add( + const TranscriptionEngineState(status: TranscriptionEngineStatus.ready), + ); + + if (result == null) { + debugPrint('[WhisperEngine] Transcription returned null'); + return ''; + } + + final text = result.transcription.text.trim(); + debugPrint('[WhisperEngine] Result: $text'); + return text; + } catch (e) { + debugPrint('[WhisperEngine] Error: $e'); + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + ), + ); + rethrow; + } + } + + @override + Future modelFilePath() async { + return _controller.getPath(_defaultModel); + } + + Future _downloadModel() async { + final filePath = await modelFilePath(); + final tmpPath = '$filePath.${DateTime.now().microsecondsSinceEpoch}.tmp'; + final tmpFile = File(tmpPath); + final finalFile = File(filePath); + + if (!finalFile.parent.existsSync()) { + finalFile.parent.createSync(recursive: true); + } + + final client = http.Client(); + IOSink? sink; + try { + debugPrint( + '[WhisperEngine] Downloading ${_defaultModel.modelName} from ${_defaultModel.modelUri}', + ); + final request = http.Request('GET', _defaultModel.modelUri); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode} downloading model'); + } + + final totalBytes = response.contentLength ?? 0; + var received = 0; + + sink = tmpFile.openWrite(); + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (totalBytes > 0) { + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + downloadProgress: received / totalBytes, + ), + ); + } + } + await sink.close(); + sink = null; + + if (finalFile.existsSync()) { + await finalFile.delete(); + } + + if (tmpFile.existsSync()) { + await tmpFile.rename(filePath); + } else if (!finalFile.existsSync()) { + throw Exception('Downloaded model temp file missing before finalize'); + } + + debugPrint('[WhisperEngine] Model saved to $filePath'); + } catch (e) { + if (sink != null) { + await sink.close(); + } + if (tmpFile.existsSync()) { + tmpFile.deleteSync(); + } + rethrow; + } finally { + client.close(); + } + } + + @override + Future deleteModel() async { + final modelFile = File(await modelFilePath()); + if (modelFile.existsSync()) { + await modelFile.delete(); + } + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.uninitialized, + ), + ); + } + + @override + void dispose() { + _state.close(); + } +} + +// --------------------------------------------------------------------------- +// Generic GGML engine — downloads any .bin from a URL on first use +// --------------------------------------------------------------------------- + +/// Offline Whisper engine for custom GGML models downloaded at runtime. +/// +/// The model file is downloaded from [modelUrl] to app-support/whisper_models/ +/// on the first call to [initialize] (or lazily on first [transcribe]). +/// Subsequent runs reuse the cached file. +/// +/// Use [KbWhisperEngines] for pre-configured Swedish KB-Whisper variants. +class CustomGgmlWhisperEngine implements TranscriptionEngine { + final String _modelUrl; + final String _modelFileName; + final String _languageCode; + final String _displayName; + + final _state = BehaviorSubject.seeded( + const TranscriptionEngineState(), + ); + + CustomGgmlWhisperEngine({ + required String modelUrl, + required String modelFileName, + required String languageCode, + required String displayName, + }) : _modelUrl = modelUrl, + _modelFileName = modelFileName, + _languageCode = languageCode, + _displayName = displayName; + + @override + String get engineName => _displayName; + + @override + String get modelSourceUrl => _modelUrl; + + @override + int get expectedModelSizeBytes { + // Try to look up from the catalog by filename + for (final info in TranscriptionModelCatalog.all) { + if (info.fileName == _modelFileName) { + return info.expectedSizeBytes; + } + } + return 0; + } + + @override + Stream get stateStream => _state.stream; + + @override + TranscriptionEngineState get currentState => _state.value; + + @override + Future isAvailable() async { + try { + return File(await _modelFilePath()).existsSync(); + } catch (_) { + return false; + } + } + + @override + Future initialize() async { + if (currentState.status == TranscriptionEngineStatus.ready) return; + if (currentState.status == TranscriptionEngineStatus.downloading) return; + + try { + if (await isAvailable()) { + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + ), + ); + return; + } + + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + ), + ); + + await _downloadModel(); + + _state.add( + const TranscriptionEngineState(status: TranscriptionEngineStatus.ready), + ); + } catch (e) { + debugPrint('[CustomGgmlWhisperEngine] Download error: $e'); + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + ), + ); + rethrow; + } + } + + Future _downloadModel() async { + final filePath = await _modelFilePath(); + final tmpPath = '$filePath.${DateTime.now().microsecondsSinceEpoch}.tmp'; + final tmpFile = File(tmpPath); + final finalFile = File(filePath); + + if (finalFile.parent.existsSync() == false) { + finalFile.parent.createSync(recursive: true); + } + + final client = http.Client(); + try { + debugPrint( + '[CustomGgmlWhisperEngine] Downloading $_modelFileName from $_modelUrl', + ); + final request = http.Request('GET', Uri.parse(_modelUrl)); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode} downloading model'); + } + + final totalBytes = response.contentLength ?? 0; + int received = 0; + + final sink = tmpFile.openWrite(); + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (totalBytes > 0) { + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + downloadProgress: received / totalBytes, + ), + ); + } + } + await sink.close(); + + // Atomically rename tmp → final + if (finalFile.existsSync()) { + await finalFile.delete(); + } + + if (tmpFile.existsSync()) { + await tmpFile.rename(filePath); + } else if (!finalFile.existsSync()) { + throw Exception('Downloaded model temp file missing before finalize'); + } + + debugPrint('[CustomGgmlWhisperEngine] Model saved to $filePath'); + } catch (e) { + // Clean up incomplete download + if (tmpFile.existsSync()) tmpFile.deleteSync(); + rethrow; + } finally { + client.close(); + } + } + + @override + Future transcribe(String audioFilePath) async { + if (!File(audioFilePath).existsSync()) { + throw Exception('Audio file not found: $audioFilePath'); + } + + final modelPath = await _modelFilePath(); + if (!File(modelPath).existsSync()) { + throw Exception( + 'Transcription model not downloaded. Configure in Settings > Voice Memos.', + ); + } + + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.transcribing, + ), + ); + + String? convertedAudioPath; + try { + final transcriptionAudioPath = await _prepareAudioForWhisper( + audioFilePath, + ); + if (transcriptionAudioPath != audioFilePath) { + convertedAudioPath = transcriptionAudioPath; + } + + debugPrint( + '[CustomGgmlWhisperEngine] Transcribing: $transcriptionAudioPath', + ); + + // Use Whisper directly so we can pass an arbitrary modelPath string. + // WhisperController.transcribe() only accepts a WhisperModel enum, but + // Whisper.transcribe() accepts any modelPath — which is what we need for + // custom GGML models downloaded from HuggingFace. + const whisper = Whisper(model: WhisperModel.base); + final response = await whisper.transcribe( + transcribeRequest: TranscribeRequest( + audio: transcriptionAudioPath, + language: _languageCode, + ), + modelPath: modelPath, + ); + + _state.add( + const TranscriptionEngineState(status: TranscriptionEngineStatus.ready), + ); + + final text = response.text.trim(); + debugPrint('[CustomGgmlWhisperEngine] Result: $text'); + return text; + } catch (e) { + debugPrint('[CustomGgmlWhisperEngine] Error: $e'); + _state.add( + TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + ), + ); + rethrow; + } finally { + if (convertedAudioPath != null) { + try { + final convertedFile = File(convertedAudioPath); + if (convertedFile.existsSync()) { + convertedFile.deleteSync(); + } + } catch (e) { + debugPrint('[CustomGgmlWhisperEngine] Failed to delete temp WAV: $e'); + } + } + } + } + + Future _prepareAudioForWhisper(String audioFilePath) async { + if (audioFilePath.toLowerCase().endsWith('.wav')) { + return audioFilePath; + } + + final input = File(audioFilePath); + final outputPath = '${input.path}.wav'; + final output = File(outputPath); + + if (output.existsSync()) { + output.deleteSync(); + } + + final arguments = [ + '-y', + '-i', + '"${input.path}"', + '-ar', + '16000', + '-ac', + '1', + '-c:a', + 'pcm_s16le', + '"$outputPath"', + ]; + + debugPrint( + '[CustomGgmlWhisperEngine] Converting audio for Whisper: ${input.path} -> $outputPath', + ); + + final FFmpegSession session = await FFmpegKit.execute(arguments.join(' ')); + final ReturnCode? returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode) && output.existsSync()) { + return outputPath; + } + + final logs = await session.getOutput(); + throw Exception( + 'Audio conversion failed (returnCode=${returnCode?.getValue()}): ${logs ?? 'no FFmpeg logs'}', + ); + } + + Future _modelFilePath() async { + final appDir = await getApplicationSupportDirectory(); + final modelDir = Directory('${appDir.path}/whisper_models'); + if (!modelDir.existsSync()) { + modelDir.createSync(recursive: true); + } + return '${modelDir.path}/$_modelFileName'; + } + + @override + Future modelFilePath() => _modelFilePath(); + + @override + Future deleteModel() async { + final modelFile = File(await _modelFilePath()); + if (modelFile.existsSync()) { + await modelFile.delete(); + } + _state.add( + const TranscriptionEngineState( + status: TranscriptionEngineStatus.uninitialized, + ), + ); + } + + @override + void dispose() { + _state.close(); + } +} + +// --------------------------------------------------------------------------- +// Pre-configured KB-Whisper engines (National Library of Sweden) +// --------------------------------------------------------------------------- + +/// Factory for KBLab/KB-Whisper model variants. +/// +/// KB-Whisper is trained on 50 000+ hours of Swedish speech and vastly +/// outperforms stock OpenAI Whisper on Swedish (WER: 9.1 vs 39.6 for base). +/// Apache-2.0 licensed. Models are hosted on HuggingFace. +abstract final class KbWhisperEngines { + static const String _hfBase = + 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main'; + static const String _hfSmall = + 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main'; + static const String _hfWhisperCpp = + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main'; + + /// Base model, q5_0 quantised (~147 MB). + /// Recommended: good accuracy, reasonable size for mobile. + static CustomGgmlWhisperEngine base() => CustomGgmlWhisperEngine( + modelUrl: '$_hfBase/ggml-model-q5_0.bin', + modelFileName: 'ggml-kb-whisper-base-q5_0.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Base (Swedish)', + ); + + /// Small model, q5_0 quantised (~175 MB). + /// Big accuracy leap over Base at nearly the same size. ~500 MB RAM. + static CustomGgmlWhisperEngine smallQ5() => CustomGgmlWhisperEngine( + modelUrl: '$_hfSmall/ggml-model-q5_0.bin', + modelFileName: 'ggml-kb-whisper-small-q5_0.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Small · Q5_0 (Swedish)', + ); + + /// Small model, full GGML checkpoint (~488 MB). + /// Highest fidelity Small variant currently published for whisper.cpp. + static CustomGgmlWhisperEngine smallQ8() => CustomGgmlWhisperEngine( + modelUrl: '$_hfSmall/ggml-model.bin', + modelFileName: 'ggml-kb-whisper-small.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Small · Full GGML (Swedish)', + ); + + /// Whisper Large-v3-Turbo, q5_0 quantised (~547 MB). + /// Near-cloud accuracy, heavily optimised. ~1 GB RAM — flagship devices. + static CustomGgmlWhisperEngine largeV3TurboQ5() => CustomGgmlWhisperEngine( + modelUrl: '$_hfWhisperCpp/ggml-large-v3-turbo-q5_0.bin', + modelFileName: 'ggml-large-v3-turbo-q5_0.bin', + languageCode: 'auto', + displayName: 'Whisper Large-v3-Turbo · Q5_0', + ); +} diff --git a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart new file mode 100644 index 0000000..2ee32aa --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart @@ -0,0 +1,622 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data/models/connection.dart'; +import '../../data/models/voice_memo.dart'; +import '../../data/repositories/voice_memo_repository.dart'; +import '../watch_service.dart'; +import 'ogg_opus_writer.dart'; +import 'zsw_opus_parser.dart'; + +/// State of the voice memo sync process +enum VoiceMemoSyncPhase { + idle, + fetchingList, + downloading, + verifying, + deleting, + completed, + failed, +} + +/// Current sync state +class VoiceMemoSyncState { + final VoiceMemoSyncPhase phase; + final String? currentFilename; + final int totalToSync; + final int completedCount; + final double downloadProgress; // 0.0 - 1.0 + final String? errorMessage; + + const VoiceMemoSyncState({ + this.phase = VoiceMemoSyncPhase.idle, + this.currentFilename, + this.totalToSync = 0, + this.completedCount = 0, + this.downloadProgress = 0.0, + this.errorMessage, + }); + + VoiceMemoSyncState copyWith({ + VoiceMemoSyncPhase? phase, + String? currentFilename, + int? totalToSync, + int? completedCount, + double? downloadProgress, + String? errorMessage, + }) { + return VoiceMemoSyncState( + phase: phase ?? this.phase, + currentFilename: currentFilename ?? this.currentFilename, + totalToSync: totalToSync ?? this.totalToSync, + completedCount: completedCount ?? this.completedCount, + downloadProgress: downloadProgress ?? this.downloadProgress, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isSyncing => + phase != VoiceMemoSyncPhase.idle && + phase != VoiceMemoSyncPhase.completed && + phase != VoiceMemoSyncPhase.failed; +} + +/// Recording metadata from watch list response +class WatchRecordingInfo { + final String filename; + final int durationMs; + final int sizeBytes; + final int timestamp; + + const WatchRecordingInfo({ + required this.filename, + required this.durationMs, + required this.sizeBytes, + required this.timestamp, + }); + + factory WatchRecordingInfo.fromJson(Map json) { + return WatchRecordingInfo( + filename: json['filename'] as String, + durationMs: json['duration_ms'] as int, + sizeBytes: json['size_bytes'] as int, + timestamp: json['timestamp'] as int, + ); + } +} + +/// Service for syncing voice memos from ZSWatch via MCUmgr FS +/// +/// Uses a hybrid protocol: +/// - Extended API (JSON over NUS) for metadata (list, delete, new notification) +/// - MCUmgr FS for actual binary file download +/// +/// Sync flow: +/// 1. Request recording list from watch +/// 2. For each new recording, download via MCUmgr FS +/// 3. Verify downloaded file (size check + header validation) +/// 4. Send delete command to watch after successful verification +class VoiceMemoSyncService { + static const String _recordingDir = '/user/recordings'; + + final WatchService _watchService; + final VoiceMemoRepository _repository; + + /// Called after sync completes with the number of newly downloaded memos. + /// Used to trigger auto-transcription from the provider layer. + Future Function(int downloadedCount)? onSyncCompleted; + + final _syncState = BehaviorSubject.seeded( + const VoiceMemoSyncState(), + ); + + StreamSubscription>? _messageSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _downloadSubscription; + FsManager? _fsManager; + Completer>? _listCompleter; + Completer? _downloadCompleter; + bool _hasAutoSynced = false; + + VoiceMemoSyncService({ + required WatchService watchService, + required VoiceMemoRepository repository, + }) : _watchService = watchService, + _repository = repository { + _messageSubscription = _watchService.incomingMessages.listen( + _handleMessage, + ); + _connectionSubscription = _watchService.connectionStream.listen( + _handleConnectionChange, + ); + } + + void _handleConnectionChange(Connection connection) { + if (connection.isConnected && !_hasAutoSynced) { + _hasAutoSynced = true; + // Delay slightly to allow BLE services to settle + Future.delayed(const Duration(seconds: 3), () { + _log('Auto-syncing voice memos on watch connect'); + syncRecordings(); + }); + } else if (connection.isDisconnected) { + _hasAutoSynced = false; + unawaited(_resetFsManager()); + } + } + + /// Stream of sync state changes + Stream get syncState => _syncState.stream; + + /// Current sync state + VoiceMemoSyncState get currentState => _syncState.value; + + /// Handle a new recording notification from the watch + Future handleNewRecording(Map message) async { + final info = WatchRecordingInfo.fromJson(message); + await _repository.upsertFromWatch( + filename: info.filename, + timestampUtc: info.timestamp, + durationMs: info.durationMs, + sizeBytes: info.sizeBytes, + ); + _log('New recording notification: ${info.filename}'); + + // Auto-download the new recording + if (_watchService.isConnected && !currentState.isSyncing) { + _log('Auto-downloading new recording: ${info.filename}'); + unawaited(syncRecordings()); + } + } + + /// Handle recording list response from the watch + Future handleListResult(Map message) async { + // Guard against duplicate BLE notifications — FBP can deliver the same + // NUS RX packet twice. Grab and null the completer synchronously (before + // the first await) so any second call finds null and exits immediately. + if (_listCompleter == null) return; + final completer = _listCompleter!; + _listCompleter = null; + + final recordings = + (message['recordings'] as List?) + ?.map((r) => WatchRecordingInfo.fromJson(r as Map)) + .toList() ?? + []; + + _log('Received ${recordings.length} recordings from watch'); + + // Update database with watch recordings + try { + for (final rec in recordings) { + await _repository.upsertFromWatch( + filename: rec.filename, + timestampUtc: rec.timestamp, + durationMs: rec.durationMs, + sizeBytes: rec.sizeBytes, + ); + } + } catch (e) { + _log('Error upserting recordings to DB: $e'); + } + + completer.complete(recordings); + } + + /// Request recording list from the watch and sync new recordings + Future syncRecordings() async { + if (currentState.isSyncing) { + _log('Sync already in progress, skipping'); + return; + } + + if (!_watchService.isConnected) { + _log('Not connected, cannot sync'); + return; + } + + try { + _updateState( + const VoiceMemoSyncState(phase: VoiceMemoSyncPhase.fetchingList), + ); + + // Request recording list from watch + await _watchService.sendVoiceMemoCommand('list'); + + // Wait for list response (with timeout) + _listCompleter = Completer>(); + final recordings = await _listCompleter!.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + _log('List request timed out'); + return []; + }, + ); + _log('Fetched ${recordings.length} recordings from list'); + + // Find recordings not yet downloaded + final undownloaded = await _repository.getUndownloadedMemos(); + if (undownloaded.isEmpty) { + _log('All recordings already synced'); + _updateState( + const VoiceMemoSyncState(phase: VoiceMemoSyncPhase.completed), + ); + _resetStateAfterDelay(); + return; + } + + _updateState( + VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.downloading, + totalToSync: undownloaded.length, + completedCount: 0, + ), + ); + + // Enable SMP server on the watch (required for MCUmgr file transfers) + _log('Enabling SMP server for file download'); + await _watchService.enableSmp(); + // Give the watch time to register the SMP transport before rediscovering. + await Future.delayed(const Duration(seconds: 2)); + final smpReady = await _watchService.rediscoverServices(); + if (!smpReady) { + throw Exception( + 'SMP service did not become available after enabling it on the watch', + ); + } + + // Download each new recording + int completed = 0; + try { + for (final memo in undownloaded) { + _updateState( + currentState.copyWith( + currentFilename: memo.filename, + downloadProgress: 0.0, + ), + ); + + final success = await _downloadRecording(memo); + if (success) { + completed++; + _updateState(currentState.copyWith(completedCount: completed)); + } + } + } finally { + // Always disable SMP server when done, even on error + _log('Disabling SMP server'); + try { + await _watchService.disableSmp(); + } catch (e) { + _log('Failed to disable SMP: $e'); + } + await _resetFsManager(); + } + + _updateState( + VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.completed, + totalToSync: undownloaded.length, + completedCount: completed, + ), + ); + + // Notify listeners that new memos were downloaded (triggers auto-transcribe) + if (completed > 0) { + await onSyncCompleted?.call(completed); + } + } catch (e) { + _log('Sync failed: $e'); + _updateState( + VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.failed, + errorMessage: e.toString(), + ), + ); + } + + _resetStateAfterDelay(); + } + + /// Download a specific recording from the watch via MCUmgr FS + Future _downloadRecording(VoiceMemo memo) async { + try { + // Initialize FsManager if needed + final device = _watchService.device; + if (device == null) { + _log('No device connected'); + return false; + } + + // Recreate FsManager for each transfer attempt to avoid stale native + // transport handles after SMP disable/enable or reconnection cycles. + await _resetFsManager(); + _fsManager = FsManager(device.remoteId.str); + + final remotePath = '$_recordingDir/${memo.filename}.zsw_opus'; + _log('Downloading: $remotePath'); + + // Preflight check to distinguish path/FS errors from transfer errors. + try { + final remoteSize = await _fsManager!.status(remotePath); + _log('Remote file status: $remoteSize bytes'); + } catch (e) { + _log('Remote file status check failed: $e'); + } + + // Set up download completion listener + _downloadCompleter = Completer(); + await _downloadSubscription?.cancel(); + _downloadSubscription = _fsManager!.downloadCallbacks.listen( + (callback) { + switch (callback) { + case OnDownloadProgressChanged(): + final progress = callback.total > 0 + ? callback.current / callback.total + : 0.0; + _updateState(currentState.copyWith(downloadProgress: progress)); + case OnDownloadCompleted(): + _downloadCompleter?.complete(callback.data); + _downloadCompleter = null; + case OnDownloadFailed(): + _log('Download failed: ${callback.cause}'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + case OnDownloadCancelled(): + _log('Download cancelled'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + } + }, + onError: (Object error) { + _log('Download callback stream error: $error'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + }, + ); + + // Start download + await _fsManager!.download(remotePath); + + // Wait for completion + final data = await _downloadCompleter!.future.timeout( + const Duration(minutes: 5), + onTimeout: () { + _log('Download timed out'); + return null; + }, + ); + + if (data == null) { + _log('Download returned no data'); + return false; + } + + // Verify download + _updateState(currentState.copyWith(phase: VoiceMemoSyncPhase.verifying)); + + if (!ZswOpusParser.validateDownload( + data, + expectedSizeBytes: memo.sizeBytes, + )) { + _log('Download verification failed for ${memo.filename}'); + return false; + } + + // Save to local storage + final localPath = await _saveToLocalStorage(memo.filename, data); + + // Convert to Ogg/Opus for standard playback + String? convertedPath; + try { + convertedPath = await _convertToOgg(memo.filename, data); + _log('Converted to Ogg: $convertedPath'); + } catch (e) { + _log('Ogg conversion failed (non-fatal): $e'); + } + + // Update database + await _repository.markDownloaded( + filename: memo.filename, + localFilePath: localPath, + ); + + if (convertedPath != null) { + await _repository.updateConvertedPath( + filename: memo.filename, + convertedFilePath: convertedPath, + ); + } + + // Delete from watch after successful verification + _updateState(currentState.copyWith(phase: VoiceMemoSyncPhase.deleting)); + + await _watchService.sendVoiceMemoCommand( + 'delete', + extraData: {'filename': memo.filename}, + ); + await _repository.markDeletedOnWatch(memo.filename); + + _log('Successfully synced: ${memo.filename}'); + return true; + } catch (e) { + _log('Error downloading ${memo.filename}: $e'); + await _resetFsManager(); + return false; + } + } + + Future _resetFsManager() async { + await _downloadSubscription?.cancel(); + _downloadSubscription = null; + + _downloadCompleter?.complete(null); + _downloadCompleter = null; + + final manager = _fsManager; + _fsManager = null; + if (manager != null) { + try { + await manager.kill(); + } catch (e) { + _log('FsManager cleanup failed: $e'); + } + } + } + + /// Save downloaded recording to app's local storage + Future _saveToLocalStorage(String filename, Uint8List data) async { + final appDir = await getApplicationDocumentsDirectory(); + final voiceDir = Directory(p.join(appDir.path, 'voice_memos')); + if (!voiceDir.existsSync()) { + voiceDir.createSync(recursive: true); + } + + final file = File(p.join(voiceDir.path, '$filename.zsw_opus')); + await file.writeAsBytes(data); + return file.path; + } + + /// Convert .zsw_opus to standard Ogg/Opus for playback + Future _convertToOgg(String filename, Uint8List zswOpusData) async { + final parsed = ZswOpusParser.parse(zswOpusData); + if (parsed == null || !parsed.isValid) { + throw Exception('Failed to parse .zsw_opus data for conversion'); + } + + final oggData = OggOpusWriter.convert(parsed); + + final appDir = await getApplicationDocumentsDirectory(); + final voiceDir = Directory(p.join(appDir.path, 'voice_memos')); + if (!voiceDir.existsSync()) { + voiceDir.createSync(recursive: true); + } + + final file = File(p.join(voiceDir.path, '$filename.ogg')); + await file.writeAsBytes(oggData); + return file.path; + } + + void _handleMessage(Map message) { + final type = message['t'] as String?; + if (type != 'voice_memo') return; + + final action = message['action'] as String?; + switch (action) { + case 'new': + handleNewRecording(message); + case 'list_result': + handleListResult(message); + case 'undo_last': + unawaited(_handleUndoLast(message)); + } + } + + void _updateState(VoiceMemoSyncState state) { + _syncState.add(state); + } + + void _resetStateAfterDelay() { + Future.delayed(const Duration(seconds: 3), () { + if (!currentState.isSyncing) { + _updateState(const VoiceMemoSyncState()); + } + }); + } + + /// Send AI processing result back to the watch for toast confirmation. + /// + /// The watch displays the parsed title with an Undo button for 3 seconds. + /// If [onConfirmed] is provided, it will be called after [confirmationTimeout] + /// unless the watch sends an undo_last for this filename. + Future sendResultToWatch( + String filename, + String title, { + String? actionType, + String? datetime, + Duration confirmationTimeout = const Duration(seconds: 20), + Future Function(String filename)? onConfirmed, + }) async { + if (!_watchService.isConnected) { + _log('Cannot send result to watch — not connected'); + // Still auto-create if watch is disconnected and callback is set + if (onConfirmed != null) { + _log('Watch disconnected — auto-creating actions immediately'); + unawaited(onConfirmed(filename)); + } + return; + } + try { + await _watchService.sendVoiceMemoCommand( + 'result', + extraData: { + 'text': title, + 'filename': filename, + if (actionType != null) 'action_type': actionType, + if (datetime != null) 'datetime': datetime, + }, + ); + _log('Sent AI result to watch: $filename → "$title"'); + } catch (e) { + _log('Failed to send result to watch: $e'); + } + + if (onConfirmed != null) { + // Cancel any previous timer for this file + _pendingConfirmations[filename]?.cancel(); + _pendingConfirmations[filename] = Timer(confirmationTimeout, () { + _pendingConfirmations.remove(filename); + _log('No undo received for $filename — auto-creating actions'); + unawaited(onConfirmed(filename)); + }); + } + } + + /// Timers waiting for undo — keyed by filename. + final Map _pendingConfirmations = {}; + + /// Handle the undo_last command from the watch. + /// + /// Deletes AI-parsed results (summary, category, actions) but keeps the + /// raw audio and transcription intact. + Future _handleUndoLast(Map message) async { + final filename = message['filename'] as String?; + if (filename == null || filename.isEmpty) { + _log('undo_last: missing filename'); + return; + } + _log('Undo requested for: $filename'); + // Cancel any pending auto-create for this file + _pendingConfirmations[filename]?.cancel(); + _pendingConfirmations.remove(filename); + try { + await _repository.clearAiResults(filename); + _log('Cleared AI results for: $filename'); + } catch (e) { + _log('Failed to undo AI results for $filename: $e'); + } + } + + void _log(String message) { + debugPrint('[VoiceMemoSync] $message'); + } + + /// Clean up resources + void dispose() { + _messageSubscription?.cancel(); + _connectionSubscription?.cancel(); + for (final timer in _pendingConfirmations.values) { + timer.cancel(); + } + _pendingConfirmations.clear(); + unawaited(_resetFsManager()); + _listCompleter?.complete([]); + _syncState.close(); + } +} diff --git a/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart b/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart new file mode 100644 index 0000000..e882936 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart @@ -0,0 +1,174 @@ +import 'dart:typed_data'; + +/// Parsed header from a .zsw_opus file +class ZswOpusHeader { + /// File format magic bytes (should be "ZSWO") + final String magic; + + /// Format version (currently 1) + final int version; + + /// Audio sample rate in Hz (normally 16000) + final int sampleRate; + + /// Samples per Opus frame (normally 160) + final int frameSize; + + /// Encoding bitrate in bps (normally 32000) + final int bitrate; + + /// Recording start timestamp (Unix epoch seconds) + final int timestamp; + + /// Total encoded frames (0xFFFFFFFF if dirty stop) + final int totalFrames; + + /// Recording duration in ms (0xFFFFFFFF if dirty stop) + final int durationMs; + + const ZswOpusHeader({ + required this.magic, + required this.version, + required this.sampleRate, + required this.frameSize, + required this.bitrate, + required this.timestamp, + required this.totalFrames, + required this.durationMs, + }); + + /// Whether this file had a dirty stop (crash/reset during recording) + bool get isDirtyStop => totalFrames == 0xFFFFFFFF || durationMs == 0xFFFFFFFF; +} + +/// A single Opus frame extracted from a .zsw_opus file +class OpusFrame { + /// Offset in the file where this frame starts (including length prefix) + final int fileOffset; + + /// Encoded Opus data bytes + final Uint8List data; + + const OpusFrame({required this.fileOffset, required this.data}); +} + +/// Result of parsing a .zsw_opus file +class ZswOpusParseResult { + final ZswOpusHeader header; + final List frames; + + /// Computed duration in milliseconds (from frame count, not header) + int get computedDurationMs { + if (header.sampleRate == 0) return 0; + return (frames.length * header.frameSize * 1000) ~/ header.sampleRate; + } + + /// Whether the file appears valid + bool get isValid => header.magic == 'ZSWO' && frames.isNotEmpty; + + const ZswOpusParseResult({required this.header, required this.frames}); +} + +/// Parser for the ZSWatch .zsw_opus custom container format +/// +/// File layout: +/// Header (32 bytes, fixed): +/// [4B magic "ZSWO"] +/// [2B version LE] +/// [2B sample_rate LE] +/// [2B frame_size LE] +/// [2B reserved] +/// [4B bitrate LE] +/// [4B timestamp LE] +/// [4B total_frames LE] +/// [4B duration_ms LE] +/// [4B reserved] +/// Body (packed frames): +/// [2B frame_length LE][N bytes opus data] ... +class ZswOpusParser { + static const int headerSize = 32; + static const String expectedMagic = 'ZSWO'; + + /// Parse a .zsw_opus file from raw bytes. + /// + /// Returns null if the file is too small or has an invalid magic. + static ZswOpusParseResult? parse(Uint8List data) { + if (data.length < headerSize) return null; + + final header = _parseHeader(data); + if (header == null) return null; + + final frames = _parseFrames(data); + return ZswOpusParseResult(header: header, frames: frames); + } + + /// Parse only the header (for quick validation without reading all frames). + static ZswOpusHeader? parseHeader(Uint8List data) { + if (data.length < headerSize) return null; + return _parseHeader(data); + } + + /// Validate file integrity: parse header + walk all frames. + /// Returns true if magic is valid and all frames are well-formed. + static bool validate(Uint8List data) { + final result = parse(data); + if (result == null) return false; + if (result.header.magic != expectedMagic) return false; + // Verify frame data covers the full body (no trailing garbage beyond + // what could be a partial frame from a dirty stop) + return result.frames.isNotEmpty; + } + + /// Validate that a downloaded file matches expected metadata. + /// Used for post-download verification before deleting from watch. + static bool validateDownload( + Uint8List data, { + required int expectedSizeBytes, + }) { + if (data.length != expectedSizeBytes) return false; + return validate(data); + } + + static ZswOpusHeader? _parseHeader(Uint8List data) { + final bd = ByteData.sublistView(data, 0, headerSize); + + final magic = String.fromCharCodes(data.sublist(0, 4)); + if (magic != expectedMagic) return null; + + return ZswOpusHeader( + magic: magic, + version: bd.getUint16(4, Endian.little), + sampleRate: bd.getUint16(6, Endian.little), + frameSize: bd.getUint16(8, Endian.little), + bitrate: bd.getUint32(12, Endian.little), + timestamp: bd.getUint32(16, Endian.little), + totalFrames: bd.getUint32(20, Endian.little), + durationMs: bd.getUint32(24, Endian.little), + ); + } + + static List _parseFrames(Uint8List data) { + final frames = []; + int offset = headerSize; + + while (offset + 2 <= data.length) { + final bd = ByteData.sublistView(data, offset, offset + 2); + final frameLen = bd.getUint16(0, Endian.little); + + if (frameLen == 0) break; // Zero-length frame = end marker or corruption + if (offset + 2 + frameLen > data.length) { + break; // Truncated frame (dirty stop) + } + + final frameData = Uint8List.sublistView( + data, + offset + 2, + offset + 2 + frameLen, + ); + frames.add(OpusFrame(fileOffset: offset, data: frameData)); + offset += 2 + frameLen; + } + + return frames; + } +} diff --git a/zswatch_app/lib/services/watch_service.dart b/zswatch_app/lib/services/watch_service.dart index 4c14544..7333e05 100644 --- a/zswatch_app/lib/services/watch_service.dart +++ b/zswatch_app/lib/services/watch_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -8,628 +7,476 @@ import 'package:rxdart/rxdart.dart'; import '../core/constants/ble_constants.dart'; import '../data/models/connection.dart'; -import '../data/models/connection_state.dart'; +import '../data/models/connection_phase.dart'; +import '../data/models/crash_summary.dart'; import '../data/models/watch.dart'; +import 'ble/ble_connection_service.dart'; import 'ble/ble_scanner.dart'; import 'protocol/protocol_service.dart'; // Convert String UUIDs to Guid for flutter_blue_plus Guid _guid(String uuid) => Guid(uuid); -/// Unified watch service that handles: -/// - BLE connection management -/// - Device info retrieval -/// - Protocol communication (Gadgetbridge) -/// - Battery monitoring -/// - Raw data streaming for log viewer +/// Watch communication service. +/// +/// Handles protocol communication (Gadgetbridge), message parsing/routing, +/// battery monitoring, and raw data streams for developer tools. +/// +/// Connection lifecycle (connect, reconnect, disconnect) is delegated to +/// [BleConnectionService]. class WatchService { - BluetoothDevice? _device; - List? _services; - StreamSubscription? _connectionSubscription; + final BleConnectionService _ble; + + WatchService(this._ble) { + // Register our setup callback so BleConnectionService calls us + // after BLE-level setup (bonding, service discovery, MTU) is done. + _ble.onSetupRequired = _onSetupRequired; + + // Mirror phase changes to keep our streams updated + _phaseSubscription = _ble.phaseStream.listen(_onPhaseChanged); + } + + StreamSubscription? _phaseSubscription; StreamSubscription>? _nusSubscription; + BluetoothCharacteristic? _nusRxChar; StreamSubscription>? _batterySubscription; - Timer? _reconnectTimer; - Timer? _rssiTimer; + BluetoothCharacteristic? _batteryLevelChar; - // Use BehaviorSubject to cache last value for new subscribers - final _connectionController = BehaviorSubject.seeded( - const Connection(watchId: '', state: WatchConnectionState.disconnected), - ); final _watchInfoController = BehaviorSubject.seeded(null); final _batteryController = BehaviorSubject.seeded(0); - final _incomingMessageController = StreamController>.broadcast(); - + final _crashSummaryController = BehaviorSubject.seeded(null); + final _incomingMessageController = + StreamController>.broadcast(); + // Raw data streams for developer tools (FR-035a) final _rawIncomingDataController = StreamController.broadcast(); final _rawOutgoingDataController = StreamController.broadcast(); - + // Log streaming state (FR-035c, FR-035d) bool _logStreamingEnabled = false; - + // Buffer for multi-packet JSON messages String _messageBuffer = ''; - bool _autoReconnect = true; - int _reconnectAttempts = 0; - static const int _maxQuickReconnectAttempts = 3; // Quick retries for momentary disconnects - bool _isSettingUp = false; // Prevent concurrent setup calls - bool _isInBackgroundReconnect = false; // Track if we're using OS-level autoConnect - bool _isCancelled = false; // Track if user has cancelled the connection - bool _isReconnecting = false; // Track if reconnect is in progress (timer scheduled or running) - bool _isInitialConnection = false; // Track if this is the first connection attempt (show Connecting not Reconnecting) - bool _isWaitingForAutoConnect = false; // Track when waiting for autoConnect to establish connection - bool _isInitiatingConnection = false; // Track when we're in the process of starting a connection (ignore initial disconnect) - bool _pendingReconnectAfterSetup = false; // Track if we need to trigger reconnect after setup completes - String? _pendingReconnectWatchId; // Watch ID for pending reconnect - String? _pendingReconnectWatchName; // Watch name for pending reconnect - - /// Stream of connection state changes - Stream get connectionStream => _connectionController.stream; - - /// Stream of watch info updates + // Tracks if we're currently inside a section (may span multiple chunks) + bool _inBleLog = false; + + // --- Public API: streams --- + + /// Stream of connection state changes (delegates to BleConnectionService). + Stream get connectionStream => _ble.connectionStream; + + /// Stream of watch info updates. Stream get watchInfoStream => _watchInfoController.stream; - /// Stream of battery level updates + /// Stream of battery level updates. Stream get batteryStream => _batteryController.stream; - /// Stream of incoming messages from watch - Stream> get incomingMessages => _incomingMessageController.stream; + /// Stream of incoming parsed messages from watch. + Stream> get incomingMessages => + _incomingMessageController.stream; - /// Stream of ALL raw incoming BLE NUS data for log viewer (FR-035a) - /// Includes both logs and protocol messages + /// Stream of ALL raw incoming BLE NUS data for log viewer (FR-035a). Stream get rawIncomingData => _rawIncomingDataController.stream; - /// Stream of ALL raw outgoing BLE NUS data for log viewer + /// Stream of ALL raw outgoing BLE NUS data for log viewer. Stream get rawOutgoingData => _rawOutgoingDataController.stream; - /// Whether log streaming is enabled on watch + /// Stream of crash summary received on BLE connect (null if no crash). + Stream get crashSummaryStream => + _crashSummaryController.stream; + + /// Current crash summary. + CrashSummary? get currentCrashSummary => _crashSummaryController.value; + + // Firmware identity from last 'ver' message + String? _fwCommitSha; + bool _fwIsDebug = false; + + /// Commit SHA from last firmware version message. + String? get fwCommitSha => _fwCommitSha; + + // --- Public API: getters --- + + /// Whether log streaming is enabled on watch. bool get logStreamingEnabled => _logStreamingEnabled; - /// Current connection state - Connection get currentConnection => _connectionController.value; + /// Current connection state (delegates to BleConnectionService). + Connection get currentConnection => _ble.currentConnection; + + /// Current connection phase. + ConnectionPhase get currentPhase => _ble.currentPhase; - /// Current watch info + /// Current watch info. Watch? get currentWatch => _watchInfoController.value; - /// Whether connected - bool get isConnected => _connectionController.value.isConnected; + /// Whether connected. + bool get isConnected => _ble.isConnected; - /// Current BLE device (for sensor GATT service initialization) - BluetoothDevice? get device => _device; + /// Current BLE device (for sensor GATT service initialization). + BluetoothDevice? get device => _ble.device; - /// Discovered BLE services (for sensor GATT service initialization) - List? get services => _services; + /// Discovered BLE services (for sensor GATT service initialization). + List? get services => _ble.services; - /// Whether the connected device has the MCUmgr/SMP service available - /// (required for DFU and filesystem uploads) - bool get hasSmpService => _findService(_guid(McumgrUuids.service)) != null; + /// Whether the connected device has the MCUmgr/SMP service available. + bool get hasSmpService => _ble.hasSmpService; - /// Re-discover BLE services on the connected device. - /// Useful if the user enables SMP on the watch while already connected. - /// Returns true if SMP service is found after re-discovery. - Future rediscoverServices() async { - if (_device == null || !isConnected) return false; - debugPrint('[WatchService] Re-discovering services...'); - try { - _services = await _device!.discoverServices(); - final hasSmp = hasSmpService; - debugPrint('[WatchService] Re-discovery complete. SMP available: $hasSmp'); - return hasSmp; - } catch (e) { - debugPrint('[WatchService] Re-discovery failed: $e'); - return false; - } + // --- Public API: connection (delegates to BleConnectionService) --- + + /// Connect to a scanned device. + Future connect( + ScannedWatch scannedDevice, { + bool autoConnect = false, + }) => _ble.connect(scannedDevice, autoConnect: autoConnect); + + /// Connect by device ID (for saved watches). + Future connectById(String deviceId, {bool autoConnect = false}) => + _ble.connectById(deviceId, autoConnect: autoConnect); + + /// Cancel any pending connection. + void cancelPendingConnection() => _ble.cancelPendingConnection(); + + /// Disconnect from current device. + Future disconnect() async { + _cleanupProtocol(); + await _ble.disconnect(); } - /// Connect to a scanned device - Future connect(ScannedWatch scannedDevice, {bool autoConnect = false}) async { - debugPrint('[WatchService] connect() called: autoConnect=$autoConnect, _isCancelled=$_isCancelled'); - // Only reset _isCancelled for truly user-initiated connections - // Don't reset if this might be from an auto-reconnect attempt - if (!autoConnect) { - _isCancelled = false; - debugPrint('[WatchService] connect() - reset _isCancelled to false (user-initiated)'); - } - await _connectToDevice(scannedDevice.device, scannedDevice.id, scannedDevice.name, autoConnect: autoConnect); + /// Re-discover BLE services. + Future rediscoverServices() => _ble.rediscoverServices(); + + // --- Public API: protocol commands --- + + /// Request device info from watch. + Future requestDeviceInfo() async { + await _sendGb({'t': 'ver'}); } - /// Connect by device ID (for saved watches) - /// - /// [autoConnect] - If true, uses flutter_blue_plus's autoConnect feature which: - /// - Returns immediately (non-blocking) - /// - System handles reconnection when device appears - /// - Doesn't time out - keeps trying until device available - /// - Convenient for auto-reconnect on app launch - Future connectById(String deviceId, {bool autoConnect = false}) async { - debugPrint('[WatchService] connectById() called: deviceId=$deviceId, autoConnect=$autoConnect, _isCancelled=$_isCancelled'); - // Only reset _isCancelled for truly user-initiated connections - // Don't reset if this might be from an auto-reconnect attempt - if (!autoConnect) { - _isCancelled = false; - debugPrint('[WatchService] connectById() - reset _isCancelled to false (user-initiated)'); - } - final device = BluetoothDevice.fromId(deviceId); - await _connectToDevice(device, deviceId, 'ZSWatch', autoConnect: autoConnect); + /// Sync time to watch. + Future syncTime() async { + final now = DateTime.now(); + final timestamp = now.millisecondsSinceEpoch ~/ 1000; + final tz = now.timeZoneOffset.inMinutes / 60.0; + await _sendNus('setTime($timestamp);E.setTimeZone($tz);'); } - Future _connectToDevice(BluetoothDevice device, String watchId, String name, {bool autoConnect = false, bool isReconnectAttempt = false}) async { - debugPrint('[WatchService:$hashCode] _connectToDevice called: watchId=$watchId, autoConnect=$autoConnect, isReconnectAttempt=$isReconnectAttempt, _isCancelled=$_isCancelled, _autoReconnect=$_autoReconnect, currentState=${currentConnection.state}'); - - // Don't connect if user has cancelled - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] _connectToDevice skipped - cancelled by user'); - return; - } + /// Send notification to watch. + Future sendNotification({ + required int id, + required String source, + String? title, + String? body, + String? sender, + String? subject, + String? phoneNumber, + bool canReply = false, + }) async { + final data = {'t': 'notify', 'id': id, 'src': source}; + if (title != null) data['title'] = title; + if (body != null) data['body'] = body; + if (sender != null) data['sender'] = sender; + if (subject != null) data['subject'] = subject; + if (phoneNumber != null) data['tel'] = phoneNumber; + if (canReply) data['reply'] = true; + await _sendGb(data); + } - // Don't reconnect if already connected to this device - if (isConnected && _device?.remoteId.str == watchId) { - debugPrint('[WatchService:$hashCode] _connectToDevice skipped - already connected'); - return; - } - - // Don't start a new connection if already connecting - final currentState = currentConnection.state; - if (currentState == WatchConnectionState.connecting || - currentState == WatchConnectionState.bonding || - currentState == WatchConnectionState.discoveringServices || - currentState == WatchConnectionState.negotiating) { - debugPrint('[WatchService:$hashCode] _connectToDevice skipped - already in state: $currentState'); - return; - } + /// Update an existing notification on watch. + Future updateNotification(int id, String body) async { + await _sendGb({'t': 'notify~', 'id': id, 'body': body}); + } - // Only reset these for fresh connections, not reconnect attempts - if (!isReconnectAttempt) { - _autoReconnect = true; - _reconnectAttempts = 0; - _isInitialConnection = true; // Mark as initial connection - _isInBackgroundReconnect = false; - } - // Note: Only reset _isCancelled for user-initiated connections, not internal reconnects - // The public connect methods should reset this flag + /// Remove a notification from watch. + Future removeNotification(int id) async { + await _sendGb({'t': 'notify-', 'id': id}); + } - try { - _updateConnection(Connection( - watchId: watchId, - watchName: name, - state: WatchConnectionState.connecting, - )); - - // Cancel existing subscriptions - await _connectionSubscription?.cancel(); - - // Mark that we're initiating a connection - ignore initial disconnect events - // The BLE subscription may fire with the current state (disconnected) immediately - // before the actual connection is established - _isInitiatingConnection = true; - - // Subscribe to connection state BEFORE connecting - _connectionSubscription = device.connectionState.listen( - (state) => _handleConnectionStateChange(state, watchId, name), - ); + /// Send music playback state to watch. + Future sendMusicState({ + required String state, + int? positionSeconds, + bool shuffle = false, + bool repeat = false, + }) async { + final data = {'t': 'musicstate', 'state': state}; + if (positionSeconds != null) data['position'] = positionSeconds; + if (shuffle) data['shuffle'] = 1; + if (repeat) data['repeat'] = 1; + await _sendGb(data); + } - _device = device; - - // Connect with optional autoConnect - // When autoConnect is true: - // - We don't await - let it run in background - // - System will connect when device is available - // - Connection events come through connectionState listener - // Note: autoConnect is incompatible with mtu argument, so we request MTU after connection - if (autoConnect) { - // Final check before BLE call - user might have cancelled during setup - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] autoConnect skipped - cancelled just before BLE call'); - _isInitiatingConnection = false; - return; - } - // Mark that we're waiting for autoConnect - ignore initial disconnect events - _isWaitingForAutoConnect = true; - // Don't await - autoConnect runs in background - // The connectionState listener will handle the connection event - unawaited(device.connect( - license: License.free, - timeout: const Duration(seconds: 0), - mtu: null, // autoConnect is incompatible with mtu - autoConnect: true, - ).catchError((e) { - // Ignore errors for autoConnect - connection state listener handles everything - debugPrint('[WatchService] AutoConnect error (ignored): $e'); - _isWaitingForAutoConnect = false; - _isInitiatingConnection = false; - })); - } else { - // Final check before BLE call - user might have cancelled during setup - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] connect skipped - cancelled just before BLE call'); - _isInitiatingConnection = false; - return; - } - // Use shorter timeout for reconnect attempts to cycle through them faster - final timeout = isReconnectAttempt - ? const Duration(seconds: 10) - : BleConfig.connectionTimeout; - debugPrint('[WatchService:$hashCode] About to call device.connect(autoConnect: false, timeout: ${timeout.inSeconds}s)'); - await device.connect( - license: License.free, - timeout: timeout, - autoConnect: false, - ); - // Clear the initiating flag - we're now connected - _isInitiatingConnection = false; - // Perform post-connection setup only for direct connections - await _setupAfterConnect(watchId, name); - } + /// Send music track info to watch. + Future sendMusicInfo({ + String? artist, + String? album, + String? track, + int? durationSeconds, + int? trackNumber, + int? trackCount, + }) async { + final data = {'t': 'musicinfo'}; + if (artist != null) data['artist'] = artist; + if (album != null) data['album'] = album; + if (track != null) data['track'] = track; + if (durationSeconds != null) data['dur'] = durationSeconds; + if (trackCount != null) data['c'] = trackCount; + if (trackNumber != null) data['n'] = trackNumber; + await _sendGb(data); + } - } catch (e) { - _updateConnection(Connection.error( - watchId, - ConnectionErrorType.timeout, - details: e.toString(), - )); - rethrow; - } + /// Start/stop find device (vibrate watch). + Future findDevice(bool enabled) async { + await _sendGb({'t': 'find', 'n': enabled}); } - /// Helper to check if device is still connected and setup should continue - bool _shouldContinueSetup() { - if (_isCancelled) { - debugPrint('[Setup] Aborting - user cancelled'); - return false; - } - if (_device == null) { - debugPrint('[Setup] Aborting - device is null'); - return false; - } - if (!_device!.isConnected) { - debugPrint('[Setup] Aborting - device disconnected'); - return false; - } - return true; + /// Vibrate watch with pattern. + Future vibrate(int pattern) async { + await _sendGb({'t': 'vibrate', 'n': pattern}); } - Future _setupAfterConnect(String watchId, String name) async { - // Prevent concurrent setup calls (can happen on rapid reconnects) - if (_isSettingUp) { - debugPrint('Setup already in progress, skipping duplicate call'); - return; - } - - // Check if user has cancelled - if (_isCancelled) { - debugPrint('Setup cancelled - user cancelled connection'); - return; - } - - // Check if device is still valid and connected - if (_device == null) { - debugPrint('Setup cancelled - device is null'); - return; - } - - if (!_device!.isConnected) { - debugPrint('Setup cancelled - device not connected'); - return; - } - - _isSettingUp = true; + /// Send GPS data to watch. + Future sendGpsData(WatchGpsData data) async { + final gpsData = { + 't': 'gps', + 'lat': data.latitude, + 'lon': data.longitude, + 'externalSource': true, + }; + if (data.altitude != null) gpsData['alt'] = data.altitude; + if (data.speedKph != null) gpsData['speed'] = data.speedKph; + if (data.courseDegrees != null) gpsData['course'] = data.courseDegrees; + if (data.timestampMs != null) gpsData['time'] = data.timestampMs; + if (data.satellites != null) gpsData['satellites'] = data.satellites; + if (data.hdop != null) gpsData['hdop'] = data.hdop; + if (data.source != null) gpsData['gpsSource'] = data.source; + await _sendGb(gpsData); + } - try { - // Bonding (Android only - iOS handles bonding automatically) - if (Platform.isAndroid) { - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.bonding, - )); - - // Re-check device in case user cancelled during state updates - if (!_shouldContinueSetup()) return; - - final bondState = await _device!.bondState.first; - if (!_shouldContinueSetup()) return; - - if (bondState != BluetoothBondState.bonded) { - await _device!.createBond(); - if (!_shouldContinueSetup()) return; - } - } + /// Send HTTP response to watch. + Future sendHttpResponse(String requestId, String response) async { + final data = {'t': 'http', 'resp': response}; + if (requestId.isNotEmpty) data['id'] = requestId; + await _sendGb(data); + } - // Discover services - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.discoveringServices, - )); + /// Send HTTP error to watch. + Future sendHttpError(String requestId, String error) async { + final data = {'t': 'http', 'err': error}; + if (requestId.isNotEmpty) data['id'] = requestId; + await _sendGb(data); + } - if (!_shouldContinueSetup()) return; - _services = await _device!.discoverServices(); + /// Enable/disable log streaming from watch. + Future setLogStreaming(bool enabled) async { + await _sendGb({'t': 'log', 'status': enabled}); + _logStreamingEnabled = enabled; + } - // Negotiate MTU (Android only - iOS negotiates automatically) - int mtu; - if (Platform.isAndroid) { - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.negotiating, - )); + /// Enable log streaming from watch. + Future enableLogStreaming() => setLogStreaming(true); - if (!_shouldContinueSetup()) return; - mtu = await _device!.requestMtu(BleConfig.preferredMtu); - } else { - // On iOS, MTU is negotiated automatically (typically 185-512) - mtu = 185; - } + /// Disable log streaming from watch. + Future disableLogStreaming() => setLogStreaming(false); - // Note: We don't request connection priority here. - // The watch manages connection intervals based on its current needs - // (e.g., short intervals during DFU, longer intervals when idle). - // Forcing high priority from the phone would override the watch's - // power-saving preferences. - - if (!_shouldContinueSetup()) return; - - // Create or update watch object - preserve existing firmware/battery info - final existingWatch = currentWatch; - final watch = existingWatch != null && existingWatch.id == watchId - ? existingWatch.copyWith(lastConnectedAt: DateTime.now()) - : Watch( - id: watchId, - name: name, - createdAt: DateTime.now(), - lastConnectedAt: DateTime.now(), - ); - _watchInfoController.add(watch); - - // Setup NUS for Gadgetbridge protocol (needed for sync) - if (!_shouldContinueSetup()) return; - await _setupNus(); - - // Subscribe to battery service - if (!_shouldContinueSetup()) return; - await _setupBatteryNotifications(); - - // Read initial RSSI and start periodic updates - if (_shouldContinueSetup()) { - await _readAndUpdateRssi(); - _startRssiUpdates(); - } + /// Send a voice memo command to the watch. + Future sendVoiceMemoCommand( + String action, { + Map? extraData, + }) async { + final data = {'t': 'voice_memo', 'action': action}; + if (extraData != null) data.addAll(extraData); + await _sendGb(data); + } - // Transition to syncing state (FR-088) - // Connection is established but initial sync not yet complete - if (!_shouldContinueSetup()) return; - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.syncing, - mtu: mtu, - connectedAt: DateTime.now(), - )); - - // Perform initial sync operations (FR-084 to FR-087) - // Time sync (FR-085) - if (_shouldContinueSetup()) { - await syncTime(); - } - - // Request device info via Gadgetbridge - if (_shouldContinueSetup()) { - await requestDeviceInfo(); - } + /// Enable MCUmgr/SMP on the watch. + Future enableSmp() => _sendGb({'t': 'smp', 'status': true}); + + /// Disable MCUmgr/SMP on the watch. + Future disableSmp() => _sendGb({'t': 'smp', 'status': false}); - // Note: Music state sync (FR-086) is handled by MediaControlNotifier - // which listens to connectionStream and syncs when state becomes connected + /// Request the watch to perform a cold reboot. + Future resetWatch() => _sendGb({'t': 'reset'}); - // Mark as fully connected and ready (FR-088) - if (!_shouldContinueSetup()) return; - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.connected, - )); + /// Erase the coredump on the watch. + Future eraseCoredump() async { + await _sendGb({'t': 'coredump_erase'}); + _crashSummaryController.add(null); + } - // Reset reconnect attempts and flags on successful setup - _reconnectAttempts = 0; - _isInitialConnection = false; - _isInBackgroundReconnect = false; + // --- Setup callback (called by BleConnectionService) --- + Future _onSetupRequired( + BluetoothDevice device, + List services, + String watchId, + String name, + ) async { + // Create or update watch object + final existingWatch = currentWatch; + final watch = existingWatch != null && existingWatch.id == watchId + ? existingWatch.copyWith(lastConnectedAt: DateTime.now()) + : Watch( + id: watchId, + name: name, + createdAt: DateTime.now(), + lastConnectedAt: DateTime.now(), + ); + _watchInfoController.add(watch); + + // Setup NUS for Gadgetbridge protocol + try { + await _setupNus(services); } catch (e) { - debugPrint('[Setup] Error during setup: $e'); - // Only report error and trigger reconnect if we're still supposed to be connected - // If device disconnected during setup, let the disconnect handler manage state - if (_device != null && _device!.isConnected) { - _updateConnection(Connection.error( - watchId, - ConnectionErrorType.serviceDiscoveryFailed, - details: e.toString(), - )); - // Disconnect the BLE device but DON'T call disconnect() which would set - // _isCancelled=true and prevent auto-reconnect. Instead, just disconnect - // the underlying device and let _handleDisconnect manage reconnection. - try { - await _device!.disconnect(); - } catch (_) { - // Ignore disconnect errors - } - // Don't rethrow - we've handled the error by disconnecting and letting - // the auto-reconnect mechanism try again - } else { - debugPrint('[Setup] Error during setup but device disconnected - marking for reconnect after setup completes: $e'); - // Device already disconnected - _handleDisconnect may have already been called - // and scheduled a timer that checked _isSettingUp (which was true). - // Mark that we need to trigger reconnect from the finally block. - if (_autoReconnect && !_isCancelled) { - _pendingReconnectAfterSetup = true; - _pendingReconnectWatchId = watchId; - _pendingReconnectWatchName = name; - } - } - } finally { - _isSettingUp = false; - - // Check if we need to trigger reconnect (setup failed while device was already disconnected) - if (_pendingReconnectAfterSetup) { - _pendingReconnectAfterSetup = false; - final pendingWatchId = _pendingReconnectWatchId; - final pendingWatchName = _pendingReconnectWatchName; - _pendingReconnectWatchId = null; - _pendingReconnectWatchName = null; - - if (pendingWatchId != null && pendingWatchName != null && !_isCancelled && _autoReconnect) { - debugPrint('[Setup] Setup completed - triggering deferred reconnect for $pendingWatchId'); - // Use a short delay to let any pending state settle - Timer(const Duration(milliseconds: 100), () { - if (!_isCancelled && _autoReconnect && !_isReconnecting && !_isSettingUp) { - _attemptReconnect(pendingWatchId, pendingWatchName); - } - }); - } - } + debugPrint('[WatchService] NUS setup failed: $e'); } - } - /// Read RSSI and update connection state - Future _readAndUpdateRssi() async { - if (_device == null || !isConnected) return; - + // Subscribe to battery service try { - final rssi = await _device!.readRssi(); - _updateConnection(currentConnection.copyWith(rssi: rssi)); + await _setupBatteryNotifications(services); } catch (e) { - debugPrint('[WatchService] Failed to read RSSI: $e'); + debugPrint('[WatchService] Battery setup failed: $e'); } - } - /// Start periodic RSSI updates (every 5 seconds) - void _startRssiUpdates() { - _rssiTimer?.cancel(); - _rssiTimer = Timer.periodic(const Duration(seconds: 5), (_) { - _readAndUpdateRssi(); - }); + // Time sync + device info — await these before returning so they + // complete while the connection is still being set up. Previously + // these were fire-and-forget, but that caused silent failures + // (e.g. time never synced on reconnect). + try { + await syncTime(); + } catch (e) { + debugPrint('[WatchService] syncTime failed: $e'); + } + try { + await requestDeviceInfo(); + } catch (e) { + debugPrint('[WatchService] requestDeviceInfo failed: $e'); + } } - /// Stop RSSI updates - void _stopRssiUpdates() { - _rssiTimer?.cancel(); - _rssiTimer = null; + // --- Phase change handler --- + + void _onPhaseChanged(ConnectionPhase phase) { + if (phase is Disconnected || phase is PhaseError) { + _cleanupProtocol(); + } } - Future _setupNus() async { + // --- Internal: NUS and message handling --- + + Future _setupNus(List services) async { await _nusSubscription?.cancel(); _nusSubscription = null; - - final nusService = _findService(_guid(NusUuids.service)); + try { + await _nusRxChar?.setNotifyValue(false); + } catch (_) {} + _nusRxChar = null; + + // Reset protocol state — critical for auto-reconnect where + // _cleanupProtocol() is not called (phase goes Reconnecting, not + // Disconnected). Without this, a crash mid-BLE-log-send leaves + // _inBleLog=true, silently filtering all subsequent NUS data. + _messageBuffer = ''; + _inBleLog = false; + + final nusService = _findServiceIn(services, _guid(NusUuids.service)); if (nusService == null) return; - final rxChar = _findCharacteristic(nusService, _guid(NusUuids.rxCharacteristic)); + final rxChar = _findCharacteristic( + nusService, + _guid(NusUuids.rxCharacteristic), + ); if (rxChar == null) return; + _nusRxChar = rxChar; await rxChar.setNotifyValue(true); _nusSubscription = rxChar.onValueReceived.listen(_handleNusData); } - // Tracks if we're currently inside a section (may span multiple chunks) - bool _inBleLog = false; - void _handleNusData(List data) { try { final chunk = utf8.decode(data); if (chunk.isEmpty) return; - debugPrint('[BLE RX] $chunk'); - // Emit to raw data stream for log viewer (FR-035a) _rawIncomingDataController.add(chunk); - // Filter out ... sections before adding to message buffer. - // These firmware debug logs contain curly braces in hex dumps that confuse - // the JSON parser. They're still emitted to rawIncomingData for the log viewer. + // Filter out ... sections final filteredChunk = _filterBleLogSections(chunk); - + if (filteredChunk.isNotEmpty) { - // Buffer data and process complete JSON messages _messageBuffer += filteredChunk; - - // Process all complete JSON messages in the buffer _processMessageBuffer(); } } catch (e) { - // Binary data that can't be decoded as UTF-8 - log raw bytes debugPrint('[BLE RX] Raw bytes: $data'); - // Still emit to raw stream for debugging _rawIncomingDataController.add('RAW: $data'); } } - /// Filter out ... sections from incoming data. - /// These sections contain firmware debug logs that shouldn't be parsed as JSON. - /// Handles sections that span multiple BLE packets. String _filterBleLogSections(String chunk) { const bleLogStart = ''; const bleLogEnd = ''; - - var result = StringBuffer(); + + final result = StringBuffer(); var remaining = chunk; - + while (remaining.isNotEmpty) { if (_inBleLog) { - // We're inside a BLELOG section - look for the end tag final endIndex = remaining.indexOf(bleLogEnd); - if (endIndex == -1) { - // End tag not found in this chunk - discard everything - break; - } - // Found end tag - skip past it and continue processing + if (endIndex == -1) break; remaining = remaining.substring(endIndex + bleLogEnd.length); _inBleLog = false; } else { - // Look for start of a BLELOG section final startIndex = remaining.indexOf(bleLogStart); if (startIndex == -1) { - // No BLELOG section in remaining data - keep it all result.write(remaining); break; } - // Found start tag - keep everything before it result.write(remaining.substring(0, startIndex)); remaining = remaining.substring(startIndex + bleLogStart.length); _inBleLog = true; } } - + return result.toString(); } - /// Process the message buffer to extract complete JSON messages. - /// Handles multi-packet messages that arrive in chunks due to BLE MTU limits. void _processMessageBuffer() { while (_messageBuffer.isNotEmpty) { - // Find the start of a JSON object final jsonStart = _messageBuffer.indexOf('{'); if (jsonStart == -1) { - // No JSON start found, clear buffer (non-JSON data) _messageBuffer = ''; break; } - - // Skip any data before the JSON start + if (jsonStart > 0) { _messageBuffer = _messageBuffer.substring(jsonStart); } - - // Try to find a complete JSON object by counting braces + var braceCount = 0; var inString = false; var escaped = false; var jsonEnd = -1; - + for (var i = 0; i < _messageBuffer.length; i++) { final char = _messageBuffer[i]; - + if (escaped) { escaped = false; continue; } - + if (char == r'\' && inString) { escaped = true; continue; } - + if (char == '"') { inString = !inString; continue; } - + if (!inString) { if (char == '{') { braceCount++; @@ -642,33 +489,26 @@ class WatchService { } } } - + if (jsonEnd == -1) { - // Incomplete JSON, wait for more data - debugPrint('[BLE RX] Buffering incomplete JSON (${_messageBuffer.length} bytes)'); + debugPrint( + '[BLE RX] Buffering incomplete JSON (${_messageBuffer.length} bytes)', + ); break; } - - // Extract the complete JSON string + final jsonStr = _messageBuffer.substring(0, jsonEnd); _messageBuffer = _messageBuffer.substring(jsonEnd); - - debugPrint('[BLE RX] Complete message: ${jsonStr.length} bytes'); - - // Parse and handle the message + _parseAndHandleJson(jsonStr); } } - /// Parse a JSON string and handle it as a Gadgetbridge message void _parseAndHandleJson(String jsonStr) { try { final json = jsonDecode(jsonStr) as Map; _handleGadgetbridgeMessage(json); } catch (e) { - // Watch sometimes sends malformed JSON with unquoted values like: - // {"t":"music", "n": play} instead of {"t":"music", "n": "play"} - // Try to fix and reparse final fixedMessage = _fixMalformedJson(jsonStr); if (fixedMessage != null) { try { @@ -683,20 +523,13 @@ class WatchService { } } - /// Attempt to fix malformed JSON with unquoted keys and values. - /// - /// Handles: - /// - Unquoted string values: {"t":"music", "n": play} -> {"t":"music", "n": "play"} - /// - Unquoted keys: {id:"4"} -> {"id":"4"} String? _fixMalformedJson(String message) { var fixed = message; var wasModified = false; - - // Fix unquoted keys: { id:"value" or , id:"value" - // Pattern: after { or , and optional whitespace, find an unquoted key followed by : + final unquotedKeyRegex = RegExp(r'([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:'); final fixedKeys = fixed.replaceAllMapped(unquotedKeyRegex, (match) { - final prefix = match.group(1)!; // { or , + final prefix = match.group(1)!; final key = match.group(2)!; return '$prefix"$key":'; }); @@ -704,15 +537,11 @@ class WatchService { fixed = fixedKeys; wasModified = true; } - - // Fix unquoted string values: : value, or : value} - // Pattern: after a colon and optional whitespace, find an unquoted word - // that isn't a number, true, false, or null + final unquotedValueRegex = RegExp(r':\s*([a-zA-Z_][a-zA-Z0-9_]*)(\s*[,}])'); final fixedValues = fixed.replaceAllMapped(unquotedValueRegex, (match) { final value = match.group(1)!; final suffix = match.group(2)!; - // Don't quote true, false, null if (value == 'true' || value == 'false' || value == 'null') { return ': $value$suffix'; } @@ -722,7 +551,7 @@ class WatchService { fixed = fixedValues; wasModified = true; } - + return wasModified ? fixed : null; } @@ -734,20 +563,42 @@ class WatchService { switch (type) { case 'ver': - // Device info response + debugPrint( + '[WatchService] Ver response: sha=${message['sha']}, dbg=${message['dbg']}', + ); final fw = message['fw'] as String?; final hw = message['hw'] as String?; + _fwCommitSha = message['sha'] as String?; + _fwIsDebug = message['dbg'] == 1; final watch = currentWatch; if (watch != null) { - _watchInfoController.add(watch.copyWith( - firmwareVersion: fw, - hardwareVersion: hw, - )); + _watchInfoController.add( + watch.copyWith(firmwareVersion: fw, hardwareVersion: hw), + ); + } + break; + + case 'coredump': + debugPrint('[WatchService] Coredump message: $message'); + final available = message['available'] == true; + if (available) { + _crashSummaryController.add( + CrashSummary( + file: message['file'] as String? ?? '', + line: message['line'] as int? ?? 0, + time: message['time'] as String? ?? '', + fwVersion: currentWatch?.firmwareVersion ?? '', + fwCommitSha: _fwCommitSha ?? '', + board: currentWatch?.hardwareVersion ?? 'watchdk', + buildType: _fwIsDebug ? 'debug' : 'release', + ), + ); + } else { + _crashSummaryController.add(null); } break; case 'status': - // Status update (includes battery) final battery = message['bat'] as int?; final isCharging = message['chg'] == 1; if (battery != null) { @@ -756,20 +607,38 @@ class WatchService { if (watch != null) { _watchInfoController.add(watch.copyWith(batteryLevel: battery)); } - _updateConnection(currentConnection.copyWith(isCharging: isCharging)); + _ble.updateConnectionField((c) => c.copyWith(isCharging: isCharging)); } break; + + case 'voice_memo': + debugPrint('[WatchService] Voice memo message: ${message['action']}'); + break; } } - Future _setupBatteryNotifications() async { + // --- Internal: battery --- + + Future _setupBatteryNotifications( + List services, + ) async { await _batterySubscription?.cancel(); _batterySubscription = null; - - final batteryService = _findService(_guid(BatteryUuids.service)); + try { + await _batteryLevelChar?.setNotifyValue(false); + } catch (_) {} + _batteryLevelChar = null; + + final batteryService = _findServiceIn( + services, + _guid(BatteryUuids.service), + ); if (batteryService == null) return; - final levelChar = _findCharacteristic(batteryService, _guid(BatteryUuids.level)); + final levelChar = _findCharacteristic( + batteryService, + _guid(BatteryUuids.level), + ); if (levelChar == null) return; // Read initial value @@ -783,10 +652,13 @@ class WatchService { _watchInfoController.add(watch.copyWith(batteryLevel: level)); } } - } catch (_) {} + } catch (e) { + debugPrint('[WatchService] Battery initial read failed (ignored): $e'); + } // Subscribe to notifications try { + _batteryLevelChar = levelChar; await levelChar.setNotifyValue(true); _batterySubscription = levelChar.onValueReceived.listen((data) { if (data.isNotEmpty) { @@ -798,658 +670,124 @@ class WatchService { } } }); - } catch (_) {} + } catch (e) { + debugPrint('[WatchService] Battery notify setup failed (ignored): $e'); + } } - /// Send Gadgetbridge command + // --- Internal: NUS send --- + Future _sendGb(Map data) async { final json = jsonEncode(data); await _sendNus('GB($json)'); } - /// Send raw NUS data - /// - /// Automatically chunks large messages to fit within BLE MTU. - /// Watch can receive up to 2000 bytes total, but each BLE packet - /// is limited by MTU (typically 244 bytes usable). Future _sendNus(String data) async { - final nusService = _findService(_guid(NusUuids.service)); + if (!_ble.isDeviceConnected) return; + final services = _ble.services; + if (services == null) return; + + final nusService = _findServiceIn(services, _guid(NusUuids.service)); if (nusService == null) return; - final txChar = _findCharacteristic(nusService, _guid(NusUuids.txCharacteristic)); + final txChar = _findCharacteristic( + nusService, + _guid(NusUuids.txCharacteristic), + ); if (txChar == null) return; debugPrint('[BLE TX] $data'); - - // Emit to raw data stream for log viewer (FR-035a) _rawOutgoingDataController.add(data); final bytes = utf8.encode(data); - - // Get current MTU from connection, default to minimum if not set - final currentMtu = _connectionController.value.mtu ?? BleConfig.minimumMtu; - // Usable payload is MTU - 3 (ATT header) + + final currentMtu = _ble.currentConnection.mtu ?? BleConfig.minimumMtu; final maxChunkSize = currentMtu - 3; - - // If data fits in one packet, send directly + if (bytes.length <= maxChunkSize) { - await txChar.write(bytes, withoutResponse: txChar.properties.writeWithoutResponse); + await txChar.write( + bytes, + withoutResponse: txChar.properties.writeWithoutResponse, + ); return; } - - // Check if total data exceeds watch's max buffer (2000 bytes) + if (bytes.length > 2000) { - debugPrint('[BLE TX] WARNING: Data exceeds watch max buffer (${bytes.length} > 2000), truncating'); - // Truncate to fit - this shouldn't happen for notifications if we handle it properly + debugPrint( + '[BLE TX] WARNING: Data exceeds watch max buffer (${bytes.length} > 2000)', + ); } - - // Split into chunks and send sequentially + int offset = 0; int chunkNum = 0; while (offset < bytes.length) { final end = (offset + maxChunkSize).clamp(0, bytes.length); final chunk = bytes.sublist(offset, end); - - chunkNum++; - debugPrint('[BLE TX] Chunk $chunkNum: ${chunk.length} bytes (offset $offset)'); - - await txChar.write(chunk, withoutResponse: txChar.properties.writeWithoutResponse); - - // Small delay between chunks to allow BLE stack to process - if (end < bytes.length) { - await Future.delayed(const Duration(milliseconds: 10)); - } - - offset = end; - } - - debugPrint('[BLE TX] Sent ${bytes.length} bytes in $chunkNum chunks'); - } - - /// Request device info from watch - Future requestDeviceInfo() async { - await _sendGb({'t': 'ver'}); - } - - /// Sync time to watch - Future syncTime() async { - final now = DateTime.now(); - final timestamp = now.millisecondsSinceEpoch ~/ 1000; - final tz = now.timeZoneOffset.inMinutes / 60.0; - await _sendNus('setTime($timestamp);E.setTimeZone($tz);'); - } - - /// Send notification to watch - Future sendNotification({ - required int id, - required String source, - String? title, - String? body, - String? sender, - String? subject, - String? phoneNumber, - bool canReply = false, - }) async { - final data = { - 't': 'notify', - 'id': id, - 'src': source, - }; - if (title != null) data['title'] = title; - if (body != null) data['body'] = body; - if (sender != null) data['sender'] = sender; - if (subject != null) data['subject'] = subject; - if (phoneNumber != null) data['tel'] = phoneNumber; - if (canReply) data['reply'] = true; - - await _sendGb(data); - } - /// Update an existing notification on watch - Future updateNotification(int id, String body) async { - await _sendGb({ - 't': 'notify~', - 'id': id, - 'body': body, - }); - } - - /// Remove a notification from watch - Future removeNotification(int id) async { - await _sendGb({'t': 'notify-', 'id': id}); - } - - /// Send music playback state to watch - Future sendMusicState({ - required String state, - int? positionSeconds, - bool shuffle = false, - bool repeat = false, - }) async { - final data = { - 't': 'musicstate', - 'state': state, - }; - if (positionSeconds != null) data['position'] = positionSeconds; - if (shuffle) data['shuffle'] = 1; - if (repeat) data['repeat'] = 1; - - await _sendGb(data); - } - - /// Send music track info to watch - Future sendMusicInfo({ - String? artist, - String? album, - String? track, - int? durationSeconds, - int? trackNumber, - int? trackCount, - }) async { - final data = {'t': 'musicinfo'}; - if (artist != null) data['artist'] = artist; - if (album != null) data['album'] = album; - if (track != null) data['track'] = track; - if (durationSeconds != null) data['dur'] = durationSeconds; - if (trackCount != null) data['c'] = trackCount; - if (trackNumber != null) data['n'] = trackNumber; - - await _sendGb(data); - } - - /// Start/stop find device (vibrate watch) - Future findDevice(bool enabled) async { - await _sendGb({'t': 'find', 'n': enabled}); - } - - /// Vibrate watch with pattern - Future vibrate(int pattern) async { - await _sendGb({'t': 'vibrate', 'n': pattern}); - } - - /// Send GPS data to watch (Gadgetbridge format) - /// - /// Called in response to watch GPS power request ({"t":"gps_power","status":true}) - Future sendGpsData(WatchGpsData data) async { - final gpsData = { - 't': 'gps', - 'lat': data.latitude, - 'lon': data.longitude, - 'externalSource': true, - }; - if (data.altitude != null) gpsData['alt'] = data.altitude; - if (data.speedKph != null) gpsData['speed'] = data.speedKph; - if (data.courseDegrees != null) gpsData['course'] = data.courseDegrees; - if (data.timestampMs != null) gpsData['time'] = data.timestampMs; - if (data.satellites != null) gpsData['satellites'] = data.satellites; - if (data.hdop != null) gpsData['hdop'] = data.hdop; - if (data.source != null) gpsData['gpsSource'] = data.source; - - await _sendGb(gpsData); - } - - /// Send HTTP response to watch (for HTTP relay) - /// - /// Used when watch requests a URL via `t:"http"` and app successfully fetches it. - /// [requestId] is echoed back to match responses with requests. - /// [response] is the response body or XPath-evaluated result. - Future sendHttpResponse(String requestId, String response) async { - final data = { - 't': 'http', - 'resp': response, - }; - if (requestId.isNotEmpty) { - data['id'] = requestId; - } - await _sendGb(data); - } - - /// Send HTTP error to watch (for HTTP relay) - /// - /// Used when watch requests a URL via `t:"http"` and app fails to fetch it. - /// [requestId] is echoed back to match responses with requests. - /// [error] describes what went wrong. - Future sendHttpError(String requestId, String error) async { - final data = { - 't': 'http', - 'err': error, - }; - if (requestId.isNotEmpty) { - data['id'] = requestId; - } - await _sendGb(data); - } - - /// Enable/disable log streaming from watch (FR-035c, FR-035d) - /// - /// Sends {"t":"log","status":true/false} to watch. - /// Note: Watch may also have its own setting for this, so logs may be - /// received even if the app hasn't explicitly requested them (FR-035e). - Future setLogStreaming(bool enabled) async { - await _sendGb({'t': 'log', 'status': enabled}); - _logStreamingEnabled = enabled; - } - - /// Enable log streaming from watch - Future enableLogStreaming() => setLogStreaming(true); - - /// Disable log streaming from watch - Future disableLogStreaming() => setLogStreaming(false); - - void _handleConnectionStateChange( - BluetoothConnectionState state, - String watchId, - String name, - ) { - debugPrint('[WatchService:$hashCode] _handleConnectionStateChange: state=$state, _isCancelled=$_isCancelled, _isWaitingForAutoConnect=$_isWaitingForAutoConnect, _isInitiatingConnection=$_isInitiatingConnection'); - - // If user has cancelled, ignore all connection events and disconnect - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Ignoring connection event - cancelled by user'); - if (state == BluetoothConnectionState.connected) { - // Force disconnect if BLE layer reports connected after cancel - BluetoothDevice.fromId(watchId).disconnect(); - } - return; - } - - switch (state) { - case BluetoothConnectionState.connected: - // Clear the waiting/initiating flags - we're now connected - _isWaitingForAutoConnect = false; - _isInitiatingConnection = false; - - // For autoConnect or background reconnect, we need to run setup when connection happens - // Check if we're in connecting/reconnecting state (waiting for connection) - final currentState = currentConnection.state; - final isWaitingForConnection = currentState == WatchConnectionState.connecting || - currentState == WatchConnectionState.reconnecting; - - if (isWaitingForConnection && !_isSettingUp) { - // Also check _autoReconnect - if false, user cancelled and we should disconnect - if (!_autoReconnect) { - debugPrint('[WatchService] Connection arrived but cancelled - disconnecting'); - _device?.disconnect(); - _cleanup(); - _updateConnection(Connection( - watchId: watchId, - watchName: name, - state: WatchConnectionState.disconnected, - )); - return; - } - debugPrint('[WatchService] AutoConnect/BackgroundReconnect triggered - running setup'); - _setupAfterConnect(watchId, name); - } - break; - case BluetoothConnectionState.disconnected: - _handleDisconnect(watchId, name); - break; - // ignore: deprecated_member_use - case BluetoothConnectionState.connecting: - // ignore: deprecated_member_use - case BluetoothConnectionState.disconnecting: - // Transient states - no action needed - break; - } - } - - void _handleDisconnect(String watchId, String name) { - debugPrint('[WatchService:$hashCode] _handleDisconnect: _isCancelled=$_isCancelled, _autoReconnect=$_autoReconnect, _reconnectAttempts=$_reconnectAttempts, _isReconnecting=$_isReconnecting, _isSettingUp=$_isSettingUp, _isWaitingForAutoConnect=$_isWaitingForAutoConnect, _isInitiatingConnection=$_isInitiatingConnection, _isInBackgroundReconnect=$_isInBackgroundReconnect'); - - // If user has cancelled, don't attempt reconnection - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Disconnect ignored - cancelled by user'); - _isReconnecting = false; - _isWaitingForAutoConnect = false; - _isInitiatingConnection = false; - _isInBackgroundReconnect = false; - _updateConnection(Connection( - watchId: watchId, - watchName: name, - state: WatchConnectionState.disconnected, - )); - _cleanup(); - return; - } - - // If we're in background reconnect mode, the OS is handling reconnection - // Stay in reconnecting state and wait for the device to appear - // This check must come BEFORE the _isWaitingForAutoConnect check - if (_isInBackgroundReconnect) { - debugPrint('[WatchService:$hashCode] Disconnect during background reconnect - OS will keep trying'); - // Keep the reconnecting state visible to user - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.reconnecting, - )); - return; - } - - // If we're initiating a connection (either autoConnect or regular), this is just - // the initial state notification (BLE layer reports disconnected when we first - // subscribe to connection state). Don't treat this as a real disconnect. - if (_isWaitingForAutoConnect || _isInitiatingConnection) { - debugPrint('[WatchService:$hashCode] Disconnect ignored - connection in progress (waiting for BLE to connect)'); - return; - } - - // Don't start another reconnect if one is already in progress - if (_isReconnecting) { - debugPrint('[WatchService:$hashCode] Disconnect ignored - reconnect already in progress'); - return; - } - - // If setup is in progress, it will detect the disconnect via _shouldContinueSetup() - // and exit gracefully. The setup's finally block will trigger reconnect if needed. - // We just mark the pending reconnect info so setup knows what to reconnect to. - if (_isSettingUp) { - debugPrint('[WatchService:$hashCode] Setup in progress - setup will handle reconnect when it completes'); - // Mark pending reconnect - setup's finally block will check this - if (_autoReconnect && !_isCancelled) { - _pendingReconnectAfterSetup = true; - _pendingReconnectWatchId = watchId; - _pendingReconnectWatchName = name; - } - return; - } - - final wasConnected = currentConnection.isConnected || - currentConnection.state == WatchConnectionState.connecting || - currentConnection.state == WatchConnectionState.bonding || - currentConnection.state == WatchConnectionState.discoveringServices || - currentConnection.state == WatchConnectionState.negotiating || - currentConnection.state == WatchConnectionState.syncing || - currentConnection.state == WatchConnectionState.error; - - if (wasConnected && _autoReconnect && _reconnectAttempts < _maxQuickReconnectAttempts) { - // First try quick reconnects for momentary disconnects - _attemptReconnect(watchId, name); - } else if (wasConnected && _autoReconnect && !_isInBackgroundReconnect) { - // After quick retries fail, switch to background auto-connect mode - // This lets the OS handle reconnection when the device becomes available - _startBackgroundReconnect(watchId, name); - } else { - _updateConnection(Connection( - watchId: watchId, - watchName: name, - state: WatchConnectionState.disconnected, - )); - _cleanup(); - } - } - - /// Start background reconnection using periodic retries - /// - /// This is used after quick reconnect attempts fail. Will keep retrying - /// with increasing delays until connected or cancelled. - void _startBackgroundReconnect(String watchId, String name) { - debugPrint('[WatchService:$hashCode] Starting background reconnect for $watchId'); - - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Background reconnect skipped - cancelled by user'); - return; - } - - // Clean up any existing state first - _reconnectTimer?.cancel(); - _reconnectTimer = null; - - _isInBackgroundReconnect = true; - _isReconnecting = false; // Clear quick reconnect flag - we're now in background mode - _isWaitingForAutoConnect = false; - _isInitiatingConnection = false; - - // Show reconnecting state to user - _updateConnection(Connection( - watchId: watchId, - watchName: name, - state: WatchConnectionState.reconnecting, - reconnectionCount: _reconnectAttempts, - )); - - // Start periodic reconnect attempts - _scheduleBackgroundReconnectAttempt(watchId, name); - } - - /// Schedule a single background reconnect attempt - void _scheduleBackgroundReconnectAttempt(String watchId, String name) { - if (_isCancelled || !_isInBackgroundReconnect) { - debugPrint('[WatchService:$hashCode] Background reconnect cancelled - stopping'); - return; - } - - // Use exponential backoff with a cap: 5s, 10s, 15s, then stay at 15s - final attemptNumber = _reconnectAttempts - _maxQuickReconnectAttempts; - final delaySeconds = (5 + (attemptNumber * 5)).clamp(5, 15); - final delay = Duration(seconds: delaySeconds); - - debugPrint('[WatchService:$hashCode] Scheduling background reconnect in ${delay.inSeconds}s (attempt $attemptNumber)'); - - _reconnectTimer = Timer(delay, () async { - if (_isCancelled || !_isInBackgroundReconnect) { - debugPrint('[WatchService:$hashCode] Background reconnect timer cancelled'); - return; - } - - _reconnectAttempts++; - debugPrint('[WatchService:$hashCode] Background reconnect attempt $_reconnectAttempts'); - - // Create fresh device instance - _device = BluetoothDevice.fromId(watchId); - - // Cancel any existing subscription and create a new one - await _connectionSubscription?.cancel(); - _connectionSubscription = _device!.connectionState.listen( - (state) => _handleConnectionStateChange(state, watchId, name), + chunkNum++; + debugPrint( + '[BLE TX] Chunk $chunkNum: ${chunk.length} bytes (offset $offset)', ); - - try { - // Try to connect with a short timeout - await _device!.connect( - license: License.free, - timeout: const Duration(seconds: 10), - autoConnect: false, - ); - // If we get here, connection succeeded - setup will handle the rest - debugPrint('[WatchService:$hashCode] Background reconnect connected!'); - _isInBackgroundReconnect = false; - } catch (e) { - debugPrint('[WatchService:$hashCode] Background reconnect attempt failed: $e'); - // Schedule next attempt if not cancelled - if (!_isCancelled && _isInBackgroundReconnect) { - _scheduleBackgroundReconnectAttempt(watchId, name); - } - } - }); - } - void _attemptReconnect(String watchId, String name) { - debugPrint('[WatchService:$hashCode] _attemptReconnect: _isCancelled=$_isCancelled, _autoReconnect=$_autoReconnect, _reconnectAttempts=$_reconnectAttempts'); - - // If user has cancelled, don't attempt reconnection - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Reconnect skipped - cancelled by user'); - _isReconnecting = false; - return; - } - - // Cancel any existing reconnect timer to prevent stacking - _reconnectTimer?.cancel(); - - _reconnectAttempts++; - _isReconnecting = true; // Mark that we're in reconnect flow - debugPrint('[WatchService:$hashCode] Scheduling reconnect timer (attempt $_reconnectAttempts)'); - - // Only show "Reconnecting" if we've actually been connected before - // For initial connection failures (e.g., autoConnect assertion error), keep showing "Connecting" - if (_isInitialConnection) { - debugPrint('[WatchService:$hashCode] Initial connection - keeping Connecting state'); - // Don't change state, keep showing "Connecting" - } else { - _updateConnection(currentConnection.copyWith( - state: WatchConnectionState.reconnecting, - reconnectionCount: _reconnectAttempts, - )); - } - - _reconnectTimer = Timer(BleConfig.reconnectionDelay, () async { - debugPrint('[WatchService:$hashCode] Reconnect timer fired: _isCancelled=$_isCancelled, _autoReconnect=$_autoReconnect'); - // Double-check cancellation inside timer callback - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Reconnect timer skipped - cancelled by user'); - _isReconnecting = false; - return; - } - if (_device != null && _autoReconnect && !_isSettingUp) { - try { - await _connectToDevice(_device!, watchId, name, isReconnectAttempt: true); - _isReconnecting = false; // Clear flag on successful connect start - } catch (e) { - debugPrint('[WatchService:$hashCode] Reconnect attempt $_reconnectAttempts failed: $e'); - _isReconnecting = false; // Clear flag to allow next attempt - _isInitiatingConnection = false; // Clear flag since connection attempt is done - - // Check if cancelled during the await - if so, don't continue reconnect logic - if (_isCancelled) { - debugPrint('[WatchService:$hashCode] Reconnect cancelled during attempt - cleaning up'); - _cleanup(); - return; - } - - if (_reconnectAttempts >= _maxQuickReconnectAttempts) { - // Quick retries exhausted - switch to background reconnect - debugPrint('[WatchService:$hashCode] Quick retries exhausted - switching to background reconnect'); - _startBackgroundReconnect(watchId, name); - } else { - // More quick attempts remaining - schedule next one - debugPrint('[WatchService:$hashCode] Scheduling next quick reconnect attempt'); - _attemptReconnect(watchId, name); - } - } - } else { - _isReconnecting = false; - } - }); - } + await txChar.write( + chunk, + withoutResponse: txChar.properties.writeWithoutResponse, + ); - /// Cancel any pending connection (for autoConnect scenarios) - /// This prevents the connection from being established even if the device appears - void cancelPendingConnection() { - debugPrint('[WatchService:$hashCode] cancelPendingConnection() called - setting _isCancelled=true, _autoReconnect=false'); - _autoReconnect = false; - _isCancelled = true; // Mark as cancelled to ignore future connection events - _isReconnecting = false; // Clear reconnecting flag - _isWaitingForAutoConnect = false; // Clear waiting flag - _isInitiatingConnection = false; // Clear initiating flag - _isInBackgroundReconnect = false; // Clear background reconnect flag - _reconnectTimer?.cancel(); - - // If we're in any connecting state, transition to disconnected - final state = currentConnection.state; - if (state.isConnectingOrReconnecting) { - final watchId = currentConnection.watchId; - final watchName = currentConnection.watchName; - - // IMPORTANT: Call disconnect BEFORE cleanup to cancel any pending BLE operation - // Use both the stored device reference AND create one from ID for maximum coverage - final device = _device; - if (device != null) { - debugPrint('[WatchService:$hashCode] Disconnecting device to cancel pending connection'); - device.disconnect(); - } else if (watchId.isNotEmpty) { - // If _device is null but we have a watchId, create device from ID and disconnect - debugPrint('[WatchService:$hashCode] Creating device from ID to cancel: $watchId'); - BluetoothDevice.fromId(watchId).disconnect(); + if (end < bytes.length) { + await Future.delayed(const Duration(milliseconds: 10)); } - - _cleanup(); - _updateConnection(Connection( - watchId: watchId, - watchName: watchName, - state: WatchConnectionState.disconnected, - )); - } - } - - /// Disconnect from current device - Future disconnect() async { - _autoReconnect = false; - _isCancelled = true; // Mark as cancelled to ignore reconnection attempts - _isInBackgroundReconnect = false; // Clear background reconnect flag - _reconnectTimer?.cancel(); - - final device = _device; - final watchId = currentConnection.watchId; - final watchName = currentConnection.watchName; - _cleanup(); - - if (device != null) { - try { - await device.disconnect(); - } catch (_) {} + offset = end; } - _updateConnection(Connection( - watchId: watchId, - watchName: watchName, - state: WatchConnectionState.disconnected, - )); + debugPrint('[BLE TX] Sent ${bytes.length} bytes in $chunkNum chunks'); } - void _cleanup() { - _connectionSubscription?.cancel(); - _connectionSubscription = null; + // --- Cleanup --- + + void _cleanupProtocol() { _nusSubscription?.cancel(); _nusSubscription = null; + _nusRxChar?.setNotifyValue(false).catchError((_) => false); + _nusRxChar = null; _batterySubscription?.cancel(); _batterySubscription = null; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - _stopRssiUpdates(); - _device = null; - _services = null; - _isSettingUp = false; - _isReconnecting = false; - _isInitialConnection = false; - _isWaitingForAutoConnect = false; - _isInitiatingConnection = false; - _isInBackgroundReconnect = false; - _pendingReconnectAfterSetup = false; - _pendingReconnectWatchId = null; - _pendingReconnectWatchName = null; - _messageBuffer = ''; // Clear any incomplete messages - _inBleLog = false; // Clear BLELOG tracking state + _batteryLevelChar?.setNotifyValue(false).catchError((_) => false); + _batteryLevelChar = null; + _messageBuffer = ''; + _inBleLog = false; } - void _updateConnection(Connection connection) { - _connectionController.add(connection); - } - - BluetoothService? _findService(Guid uuid) { - return _services?.cast().firstWhere( - (s) => s?.uuid == uuid, - orElse: () => null, - ); - } - - BluetoothCharacteristic? _findCharacteristic(BluetoothService service, Guid uuid) { - return service.characteristics.cast().firstWhere( - (c) => c?.uuid == uuid, - orElse: () => null, - ); - } - - /// Dispose resources + /// Dispose resources. Future dispose() async { - await disconnect(); - await _connectionController.close(); + _cleanupProtocol(); + await _phaseSubscription?.cancel(); + // Don't dispose _ble here — it's owned by the provider that created it await _watchInfoController.close(); await _batteryController.close(); + await _crashSummaryController.close(); await _incomingMessageController.close(); await _rawIncomingDataController.close(); await _rawOutgoingDataController.close(); } -} + // --- Helpers --- + BluetoothService? _findServiceIn(List services, Guid uuid) { + return services.cast().firstWhere( + (s) => s?.uuid == uuid, + orElse: () => null, + ); + } + + BluetoothCharacteristic? _findCharacteristic( + BluetoothService service, + Guid uuid, + ) { + return service.characteristics.cast().firstWhere( + (c) => c?.uuid == uuid, + orElse: () => null, + ); + } +} diff --git a/zswatch_app/lib/ui/navigation/app_router.dart b/zswatch_app/lib/ui/navigation/app_router.dart index 7a4981f..cffb0a3 100644 --- a/zswatch_app/lib/ui/navigation/app_router.dart +++ b/zswatch_app/lib/ui/navigation/app_router.dart @@ -4,25 +4,29 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import '../../data/models/connection_state.dart'; +import '../../data/models/voice_memo.dart'; import '../../providers/auto_reconnect_provider.dart'; -import '../../providers/ble_providers.dart'; import '../../providers/foreground_service_providers.dart'; import '../../providers/permission_providers.dart'; import '../../providers/watch_service_provider.dart'; import '../screens/analytics/analytics_screen.dart'; import '../screens/connection/scan_screen.dart'; +import '../screens/crash_report/crash_report_screen.dart'; import '../screens/dashboard/dashboard_screen.dart'; import '../screens/developer/comm_log_screen.dart'; import '../screens/developer/developer_screen.dart'; import '../screens/developer/log_viewer_screen.dart'; import '../screens/developer/sensor_debug_screen.dart'; +import '../screens/developer/shell_screen.dart'; import '../screens/firmware/firmware_update_screen.dart'; import '../screens/health/health_screen.dart'; import '../screens/health/heart_rate_screen.dart'; import '../screens/notifications/notification_settings_screen.dart'; import '../screens/onboarding/permission_onboarding_screen.dart'; +import '../screens/settings/ai_models_settings_screen.dart'; import '../screens/settings/settings_screen.dart'; import '../screens/start/start_page_screen.dart'; +import '../screens/voice_memos/voice_memos_screen.dart'; /// Route names for the app abstract final class AppRoutes { @@ -50,8 +54,16 @@ abstract final class AppRoutes { static const String sensors = '/developer/sensors'; static const String commLog = '/developer/comm-log'; + // Settings sub-routes + static const String aiModels = '/settings/ai-models'; + + // Crash report + static const String crashReport = '/crash-report'; + // Voice routes (placeholder) static const String voiceMemos = '/voice-memos'; + + static String voiceMemoDetail(int id) => '$voiceMemos/$id'; } /// App router configuration using go_router @@ -101,6 +113,13 @@ class AppRouter { path: AppRoutes.settings, name: 'settings', builder: (context, state) => const SettingsScreen(), + routes: [ + GoRoute( + path: 'ai-models', + name: 'ai-models', + builder: (context, state) => const AiModelsSettingsScreen(), + ), + ], ), // Firmware update @@ -152,8 +171,7 @@ class AppRouter { GoRoute( path: 'shell', name: 'shell', - builder: (context, state) => - const _PlaceholderScreen(title: 'Shell Terminal (Not Available)'), + builder: (context, state) => const ShellScreen(), ), GoRoute( path: 'sensors', @@ -168,12 +186,39 @@ class AppRouter { ], ), - // Voice memos (placeholder) + // Crash report + GoRoute( + path: AppRoutes.crashReport, + name: 'crash-report', + builder: (context, state) => const CrashReportScreen(), + ), + + // Voice memos GoRoute( path: AppRoutes.voiceMemos, name: 'voice-memos', - builder: (context, state) => - const _PlaceholderScreen(title: 'Voice Memos'), + builder: (context, state) => const VoiceMemosScreen(), + routes: [ + GoRoute( + path: ':memoId', + name: 'voice-memo-detail', + builder: (context, state) { + final id = int.tryParse(state.pathParameters['memoId'] ?? ''); + if (id == null) { + return const _PlaceholderScreen(title: 'Voice Note Not Found'); + } + + final initialMemo = state.extra is VoiceMemo + ? state.extra! as VoiceMemo + : null; + + return VoiceMemoDetailScreen( + memoId: id, + initialMemo: initialMemo, + ); + }, + ), + ], ), ], errorBuilder: (context, state) => _ErrorScreen(error: state.error), @@ -209,13 +254,11 @@ class _HomeScreen extends ConsumerWidget { // If connecting, show progress if (isConnecting) { - final isReconnecting = connection.state == WatchConnectionState.reconnecting; + final isReconnecting = + connection.state == WatchConnectionState.reconnecting; return Scaffold( appBar: AppBar( - title: SvgPicture.asset( - 'assets/images/ZSWatch_Text.svg', - height: 24, - ), + title: SvgPicture.asset('assets/images/ZSWatch_Text.svg', height: 24), ), body: Center( child: Padding( @@ -234,25 +277,23 @@ class _HomeScreen extends ConsumerWidget { Text( 'Attempt ${connection.reconnectionCount} of 3', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.6), + ), ), ], const SizedBox(height: 32), OutlinedButton( onPressed: () { // Mark as user-initiated disconnect so foreground service stops - ref.read(foregroundServiceNotifierProvider.notifier).markUserDisconnect(); + ref + .read(foregroundServiceNotifierProvider.notifier) + .markUserDisconnect(); // Cancel auto-reconnect and suppress for session ref.read(autoReconnectNotifierProvider.notifier).cancel(); - // Also cancel any pending connection on WatchService + // Cancel any pending connection (goes through BleConnectionService) ref.read(watchServiceProvider).cancelPendingConnection(); - // Also cancel on BleConnectionManager (in case it was used) - ref.read(bleNotifierProvider.notifier).cancelPendingConnection(); }, child: const Text('Cancel'), ), @@ -277,9 +318,7 @@ class _PlaceholderScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(title), - ), + appBar: AppBar(title: Text(title)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -287,23 +326,20 @@ class _PlaceholderScreen extends StatelessWidget { Icon( Icons.construction_rounded, size: 64, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), ), const SizedBox(height: 16), - Text( - title, - style: Theme.of(context).textTheme.headlineSmall, - ), + Text(title, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Text( 'Coming soon...', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.5), - ), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + ), ), ], ), @@ -321,9 +357,7 @@ class _ErrorScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Error'), - ), + appBar: AppBar(title: const Text('Error')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -407,4 +441,3 @@ extension NavigationExtensions on BuildContext { /// Navigate to saved watches void goToSavedWatches() => go(AppRoutes.savedWatches); } - diff --git a/zswatch_app/lib/ui/screens/analytics/analytics_screen.dart b/zswatch_app/lib/ui/screens/analytics/analytics_screen.dart index a08d0ed..b4e8fce 100644 --- a/zswatch_app/lib/ui/screens/analytics/analytics_screen.dart +++ b/zswatch_app/lib/ui/screens/analytics/analytics_screen.dart @@ -1,10 +1,11 @@ +import 'dart:math' as math; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_theme.dart'; import '../../../data/database/app_database.dart'; -import '../../../data/repositories/connection_analytics_repository.dart'; import '../../../providers/analytics_providers.dart'; /// Analytics screen showing Battery and Connection analytics @@ -40,7 +41,7 @@ class _AnalyticsScreenState extends ConsumerState @override Widget build(BuildContext context) { final selectedWatchId = ref.watch(selectedWatchIdProvider); - + // Ensure analytics services are started ref.watch(analyticsServicesInitializedProvider); @@ -95,8 +96,8 @@ class _NoWatchSelected extends StatelessWidget { Text( 'Connect to a watch to view analytics', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), + color: Theme.of(context).colorScheme.outline, + ), ), ], ), @@ -170,7 +171,7 @@ class _BatteryStatsCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Card( child: Padding( padding: const EdgeInsets.all(AppTheme.spacingMd), @@ -179,9 +180,9 @@ class _BatteryStatsCard extends StatelessWidget { children: [ Text( 'Battery Stats', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: AppTheme.spacingMd), Row( @@ -253,15 +254,12 @@ class _BatteryChartCard extends StatelessWidget { children: [ Text( title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: AppTheme.spacingMd), - SizedBox( - height: 200, - child: _buildContent(context, colorScheme), - ), + SizedBox(height: 200, child: _buildContent(context, colorScheme)), ], ), ), @@ -275,10 +273,7 @@ class _BatteryChartCard extends StatelessWidget { if (error != null) { return Center( - child: Text( - error!, - style: TextStyle(color: colorScheme.error), - ), + child: Text(error!, style: TextStyle(color: colorScheme.error)), ); } @@ -287,25 +282,21 @@ class _BatteryChartCard extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.battery_unknown, - size: 48, - color: colorScheme.outline, - ), + Icon(Icons.battery_unknown, size: 48, color: colorScheme.outline), const SizedBox(height: AppTheme.spacingSm), Text( 'No battery data yet', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.outline, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colorScheme.outline), ), const SizedBox(height: AppTheme.spacingXs), Text( 'Battery level is recorded when\nthe watch sends status updates', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.outline, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.outline), ), ], ), @@ -358,8 +349,12 @@ class _BatteryChartCard extends StatelessWidget { }, ), ), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), ), borderData: FlBorderData(show: false), minY: 0, @@ -377,7 +372,7 @@ class _BatteryChartCard extends StatelessWidget { dotData: const FlDotData(show: false), belowBarData: BarAreaData( show: true, - color: colorScheme.primary.withOpacity(0.2), + color: colorScheme.primary.withValues(alpha: 0.2), ), ), ], @@ -421,19 +416,15 @@ class _BatteryInfoCard extends StatelessWidget { padding: const EdgeInsets.all(AppTheme.spacingMd), child: Row( children: [ - Icon( - Icons.info_outline, - color: colorScheme.outline, - size: 20, - ), + Icon(Icons.info_outline, color: colorScheme.outline, size: 20), const SizedBox(width: AppTheme.spacingSm), Expanded( child: Text( 'Battery data is recorded when the watch sends status updates. ' 'More accurate estimates require at least 1 hour of data.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.outline, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.outline), ), ), ], @@ -462,6 +453,7 @@ class _ConnectionAnalyticsTab extends ConsumerWidget { onRefresh: () async { ref.invalidate(connectionStats24HoursProvider(watchId)); ref.invalidate(connectionStats7DaysProvider(watchId)); + ref.invalidate(connectionTimelineProvider(watchId)); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -486,6 +478,11 @@ class _ConnectionAnalyticsTab extends ConsumerWidget { const SizedBox(height: AppTheme.spacingMd), + // Connection Timeline Chart (auto-sized window + clear button) + _ConnectionTimelineCard(watchId: watchId), + + const SizedBox(height: AppTheme.spacingMd), + // Recent Events Card _RecentEventsCard( events: eventsAsync.valueOrNull ?? [], @@ -521,9 +518,9 @@ class _UptimeCard extends StatelessWidget { children: [ Text( 'Connection Uptime', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: AppTheme.spacingMd), if (isLoading) @@ -591,18 +588,15 @@ class _UptimeIndicator extends StatelessWidget { Text( '${percentage.toStringAsFixed(0)}%', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: color, - ), + fontWeight: FontWeight.bold, + color: color, + ), ), ], ), ), const SizedBox(height: AppTheme.spacingSm), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(label, style: Theme.of(context).textTheme.bodySmall), ], ); } @@ -618,10 +612,7 @@ class _ConnectionStatsCard extends StatelessWidget { final ConnectionStats? stats; final bool isLoading; - const _ConnectionStatsCard({ - required this.stats, - required this.isLoading, - }); + const _ConnectionStatsCard({required this.stats, required this.isLoading}); @override Widget build(BuildContext context) { @@ -635,9 +626,9 @@ class _ConnectionStatsCard extends StatelessWidget { children: [ Text( 'Last 24 Hours', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: AppTheme.spacingMd), if (isLoading) @@ -675,7 +666,9 @@ class _ConnectionStatsCard extends StatelessWidget { iconColor: Colors.blue, label: 'Reconnects', value: stats != null - ? (stats!.successfulReconnections + stats!.failedReconnections).toString() + ? (stats!.successfulReconnections + + stats!.failedReconnections) + .toString() : '0', ), ), @@ -717,10 +710,7 @@ class _RecentEventsCard extends StatelessWidget { final List events; final bool isLoading; - const _RecentEventsCard({ - required this.events, - required this.isLoading, - }); + const _RecentEventsCard({required this.events, required this.isLoading}); @override Widget build(BuildContext context) { @@ -734,9 +724,9 @@ class _RecentEventsCard extends StatelessWidget { children: [ Text( 'Recent Events', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: AppTheme.spacingMd), if (isLoading) @@ -747,17 +737,13 @@ class _RecentEventsCard extends StatelessWidget { padding: const EdgeInsets.all(AppTheme.spacingLg), child: Column( children: [ - Icon( - Icons.history, - size: 48, - color: colorScheme.outline, - ), + Icon(Icons.history, size: 48, color: colorScheme.outline), const SizedBox(height: AppTheme.spacingSm), Text( 'No connection events yet', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.outline, - ), + color: colorScheme.outline, + ), ), ], ), @@ -768,7 +754,7 @@ class _RecentEventsCard extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: events.take(10).length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final event = events[index]; return _EventListTile(event: event); @@ -793,7 +779,7 @@ class _EventListTile extends StatelessWidget { return ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( - backgroundColor: color.withOpacity(0.2), + backgroundColor: color.withValues(alpha: 0.2), child: Icon(icon, color: color, size: 20), ), title: Text(title), @@ -819,7 +805,7 @@ class _EventListTile extends StatelessWidget { case 'connected': return (Icons.bluetooth_connected, Colors.green, 'Connected'); case 'disconnected': - return (Icons.bluetooth_disabled, Colors.red, 'Disconnected'); + return (Icons.bluetooth_disabled, Colors.orange, 'Disconnected'); case 'reconnect_attempt': return (Icons.replay, Colors.orange, 'Reconnecting...'); case 'reconnect_failed': @@ -847,6 +833,326 @@ class _EventListTile extends StatelessWidget { } } +// ============================================================================ +// Connection Timeline Chart +// ============================================================================ + +/// Horizontal bar showing connected / disconnected / app-not-running segments. +/// The time window auto-adjusts to the oldest recorded event (capped at 7 days). +/// Includes a clear button to wipe all events and start a fresh sample. +class _ConnectionTimelineCard extends ConsumerWidget { + final String watchId; + + const _ConnectionTimelineCard({required this.watchId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final timelineAsync = ref.watch(connectionTimelineProvider(watchId)); + + return Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row with time range dropdown and clear button + Row( + children: [ + Text( + 'Connection Timeline', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: AppTheme.spacingMd), + _TimeRangeDropdown(watchId: watchId), + const Spacer(), + IconButton( + icon: const Icon(Icons.delete_sweep_outlined), + iconSize: 20, + tooltip: 'Clear connection history', + visualDensity: VisualDensity.compact, + color: colorScheme.outline, + onPressed: () => _confirmClear(context, ref), + ), + ], + ), + const SizedBox(height: AppTheme.spacingXs), + // Legend + Row( + children: [ + _LegendDot( + color: _segmentColor(ConnectionSegmentType.connected), + ), + const SizedBox(width: 4), + Text( + 'Connected', + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(width: AppTheme.spacingMd), + _LegendDot( + color: _segmentColor(ConnectionSegmentType.disconnected), + ), + const SizedBox(width: 4), + Text( + 'Disconnected', + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(width: AppTheme.spacingMd), + _LegendDot( + color: _segmentColor(ConnectionSegmentType.appNotRunning), + ), + const SizedBox(width: 4), + Text( + 'App not running', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + const SizedBox(height: AppTheme.spacingMd), + if (timelineAsync.isLoading) + const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ) + else if (timelineAsync.valueOrNull?.segments.isEmpty ?? true) + SizedBox( + height: 48, + child: Center( + child: Text( + 'No connection data yet', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.outline), + ), + ), + ) + else ...[ + SizedBox( + height: 36, + child: _TimelineBar( + segments: timelineAsync.value!.segments, + windowStart: timelineAsync.value!.windowStart, + windowEnd: timelineAsync.value!.windowEnd, + ), + ), + const SizedBox(height: 6), + _TimelineLabels( + windowStart: timelineAsync.value!.windowStart, + windowEnd: timelineAsync.value!.windowEnd, + colorScheme: colorScheme, + ), + ], + ], + ), + ), + ); + } + + Future _confirmClear(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear connection history?'), + content: const Text( + 'This will delete all recorded connection events for this watch. ' + 'Use this to start a fresh sample after making changes.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repository = ref.read(connectionAnalyticsRepositoryProvider); + await repository.clearAllEvents(watchId); + + // Invalidate all connection-related providers so every card refreshes + ref.invalidate(connectionTimelineProvider(watchId)); + ref.invalidate(connectionStats24HoursProvider(watchId)); + ref.invalidate(connectionStats7DaysProvider(watchId)); + ref.invalidate(connectionEventsStreamProvider(watchId)); + } +} + +Color _segmentColor(ConnectionSegmentType type) { + switch (type) { + case ConnectionSegmentType.connected: + return Colors.green; + case ConnectionSegmentType.disconnected: + return Colors.orange; + case ConnectionSegmentType.appNotRunning: + return Colors.grey.shade500; + } +} + +class _LegendDot extends StatelessWidget { + final Color color; + const _LegendDot({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +class _TimelineBar extends StatelessWidget { + final List segments; + final DateTime windowStart; + final DateTime windowEnd; + + const _TimelineBar({ + required this.segments, + required this.windowStart, + required this.windowEnd, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalMs = windowEnd + .difference(windowStart) + .inMilliseconds + .toDouble(); + if (totalMs <= 0) return const SizedBox.shrink(); + + final barWidth = constraints.maxWidth; + + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + // Background = app-not-running for the whole window + Container( + width: barWidth, + height: 36, + color: _segmentColor( + ConnectionSegmentType.appNotRunning, + ).withValues(alpha: 0.3), + ), + // Segments + for (final seg in segments) + Positioned( + left: math.max(0.0, _xFor(seg.start, totalMs, barWidth)), + width: math.max( + 2.0, + _xFor(seg.end, totalMs, barWidth) - + _xFor(seg.start, totalMs, barWidth), + ), + top: 0, + bottom: 0, + child: Tooltip( + message: _segmentTooltip(seg), + child: Container(color: _segmentColor(seg.type)), + ), + ), + // Current time indicator (right edge tick) + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Container( + width: 2, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + }, + ); + } + + double _xFor(DateTime dt, double totalMs, double barWidth) { + final offsetMs = dt.difference(windowStart).inMilliseconds.toDouble(); + return (offsetMs / totalMs) * barWidth; + } + + String _segmentTooltip(ConnectionSegment seg) { + final start = + '${seg.start.hour.toString().padLeft(2, '0')}:${seg.start.minute.toString().padLeft(2, '0')}'; + final end = + '${seg.end.hour.toString().padLeft(2, '0')}:${seg.end.minute.toString().padLeft(2, '0')}'; + final durationStr = _fmtDuration(seg.duration); + switch (seg.type) { + case ConnectionSegmentType.connected: + return 'Connected $start–$end ($durationStr)'; + case ConnectionSegmentType.disconnected: + return 'Disconnected $start–$end ($durationStr)'; + case ConnectionSegmentType.appNotRunning: + return 'App not running $start–$end ($durationStr)'; + } + } + + String _fmtDuration(Duration d) { + if (d.inHours > 0) return '${d.inHours}h ${d.inMinutes % 60}m'; + if (d.inMinutes > 0) return '${d.inMinutes}m'; + return '${d.inSeconds}s'; + } +} + +/// Adaptive time-axis labels beneath the timeline bar. +/// Chooses a sensible tick interval based on the window duration. +class _TimelineLabels extends StatelessWidget { + final DateTime windowStart; + final DateTime windowEnd; + final ColorScheme colorScheme; + + const _TimelineLabels({ + required this.windowStart, + required this.windowEnd, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.outline); + final ticks = _buildTicks(); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [for (final tick in ticks) Text(_label(tick), style: style)], + ); + } + + /// Pick 5 evenly-spaced tick positions across the window. + List _buildTicks() { + const tickCount = 5; + final totalMs = windowEnd.difference(windowStart).inMilliseconds; + return List.generate(tickCount, (i) { + return windowStart.add( + Duration(milliseconds: (totalMs * i ~/ (tickCount - 1))), + ); + }); + } + + String _label(DateTime dt) { + final duration = windowEnd.difference(windowStart); + if (duration.inDays >= 2) { + // Show day + hour + return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}h'; + } else { + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + } +} + // ============================================================================ // Shared Widgets // ============================================================================ @@ -882,20 +1188,62 @@ class _StatTile extends StatelessWidget { Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), + color: Theme.of(context).colorScheme.outline, + ), ), ], ), const SizedBox(height: AppTheme.spacingXs), Text( value, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ], ), ); } } + +class _TimeRangeDropdown extends ConsumerWidget { + final String watchId; + + const _TimeRangeDropdown({required this.watchId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedRange = ref.watch(connectionTimelineRangeProvider(watchId)); + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingSm), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(AppTheme.radiusSmall), + ), + child: DropdownButton( + value: selectedRange, + underline: const SizedBox.shrink(), + isDense: true, + onChanged: (TimeRangeOption? newValue) { + if (newValue != null) { + ref.read(connectionTimelineRangeProvider(watchId).notifier).state = + newValue; + } + }, + items: TimeRangeOption.values + .map( + (option) => DropdownMenuItem( + value: option, + child: Text( + option.label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/zswatch_app/lib/ui/screens/connection/scan_screen.dart b/zswatch_app/lib/ui/screens/connection/scan_screen.dart index 5474ee9..16460ef 100644 --- a/zswatch_app/lib/ui/screens/connection/scan_screen.dart +++ b/zswatch_app/lib/ui/screens/connection/scan_screen.dart @@ -7,6 +7,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import '../../navigation/app_router.dart'; + import '../../../core/theme/app_theme.dart'; import '../../../data/database/app_database.dart'; import '../../../providers/ble_providers.dart' hide bleScannerProvider; @@ -51,7 +53,7 @@ class _ScanScreenState extends ConsumerState { // First check if we have permissions final notifier = ref.read(bleNotifierProvider.notifier); final hasPerms = await notifier.checkPermissions(); - + if (!hasPerms) { // Don't have permissions if (mounted) { @@ -70,7 +72,7 @@ class _ScanScreenState extends ConsumerState { // Start scanning await _startScan(); - + if (mounted) { setState(() { _hasPermissions = true; @@ -110,7 +112,9 @@ class _ScanScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Bluetooth permission is required to scan for devices'), + content: Text( + 'Bluetooth permission is required to scan for devices', + ), backgroundColor: AppTheme.errorColor, ), ); @@ -149,7 +153,7 @@ class _ScanScreenState extends ConsumerState { backgroundColor: AppTheme.successColor, ), ); - context.go('/'); + context.go(AppRoutes.home); } } catch (e) { if (mounted) { @@ -178,7 +182,7 @@ class _ScanScreenState extends ConsumerState { title: const Text('Add Watch'), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), + onPressed: () => context.go(AppRoutes.home), ), actions: const [ ConnectionStatusPill(compact: true, showIcon: true), diff --git a/zswatch_app/lib/ui/screens/crash_report/crash_report_screen.dart b/zswatch_app/lib/ui/screens/crash_report/crash_report_screen.dart new file mode 100644 index 0000000..fcd8db2 --- /dev/null +++ b/zswatch_app/lib/ui/screens/crash_report/crash_report_screen.dart @@ -0,0 +1,876 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/database/app_database.dart'; +import '../../../data/models/coredump_analysis.dart'; +import '../../../data/models/crash_summary.dart'; +import '../../../providers/coredump_providers.dart'; +import '../../../providers/settings_providers.dart'; +import '../../../providers/watch_service_provider.dart'; +import '../../../services/coredump/coredump_service.dart'; + +class CrashReportScreen extends ConsumerWidget { + const CrashReportScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Crash Reports'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Current'), + Tab(text: 'History'), + ], + ), + ), + body: const TabBarView(children: [_CurrentCrashTab(), _HistoryTab()]), + ), + ); + } +} + +// ========================================================================== +// Current crash tab (existing behavior) +// ========================================================================== + +class _CurrentCrashTab extends ConsumerWidget { + const _CurrentCrashTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summary = ref.watch(crashSummaryProvider); + final analysisAsync = ref.watch(coredumpAnalysisStateProvider); + final analysisState = + analysisAsync.valueOrNull ?? const CoredumpAnalysisState(); + + if (summary == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_outline, + size: 64, + color: AppTheme.successColor.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No active crash', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Crash reports appear here when a crash\nis detected on the watch.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryCard(summary: summary), + const SizedBox(height: AppTheme.spacingLg), + _AnalyzeSection(summary: summary, state: analysisState), + const SizedBox(height: AppTheme.spacingLg), + _ActionButtons(summary: summary), + ], + ), + ); + } +} + +// ========================================================================== +// History tab +// ========================================================================== + +class _HistoryTab extends ConsumerWidget { + const _HistoryTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(crashReportHistoryProvider); + final statsAsync = ref.watch(crashFileStatsProvider); + + return historyAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (reports) { + if (reports.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 64, + color: AppTheme.textSecondary.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No crash history', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Past crashes will be recorded here.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + return CustomScrollView( + slivers: [ + // Stats section + SliverToBoxAdapter( + child: statsAsync.when( + data: (stats) => stats.isEmpty + ? const SizedBox.shrink() + : _StatsCard(stats: stats, totalCrashes: reports.length), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ), + + // History header with clear button + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + ), + sliver: SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingSm, + bottom: AppTheme.spacingSm, + ), + child: Row( + children: [ + Text( + 'Recent Crashes (${reports.length})', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () => _clearHistory(context, ref), + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Clear'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.errorColor, + ), + ), + ], + ), + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _CrashHistoryTile(report: reports[index]), + childCount: reports.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); + }, + ); + } + + Future _clearHistory(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear Crash History?'), + content: const Text( + 'This will delete all stored crash reports from the app. ' + 'This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: AppTheme.errorColor), + child: const Text('Clear All'), + ), + ], + ), + ); + + if (confirmed != true) return; + if (!context.mounted) return; + + final repo = ref.read(crashReportRepositoryProvider); + final count = await repo.deleteAll(); + ref.invalidate(crashFileStatsProvider); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Cleared $count crash reports'))); + } + } +} + +class _StatsCard extends StatelessWidget { + final List stats; + final int totalCrashes; + const _StatsCard({required this.stats, required this.totalCrashes}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bar_chart, color: AppTheme.primaryColor), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Crash Stats', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + Text( + '$totalCrashes total', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const Divider(), + Text( + 'Top crashing files:', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + for (final stat in stats) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + stat.file, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.errorColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${stat.count}x', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: AppTheme.errorColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CrashHistoryTile extends StatelessWidget { + final CrashReportEntity report; + const _CrashHistoryTile({required this.report}); + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('MMM dd, HH:mm'); + final receivedStr = dateFormat.format(report.receivedAt); + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: 2, + ), + child: ExpansionTile( + leading: Icon( + report.analyzed + ? (report.backtrace != null + ? Icons.check_circle + : Icons.info_outline) + : Icons.warning_amber_rounded, + color: report.analyzed + ? (report.backtrace != null + ? AppTheme.successColor + : Colors.amber) + : AppTheme.errorColor, + size: 20, + ), + title: Text( + '${report.file}:${report.line}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + subtitle: Text( + '$receivedStr · ${report.fwVersion}-${_shortSha(report.fwCommitSha)}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + childrenPadding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 0, + AppTheme.spacingMd, + AppTheme.spacingMd, + ), + children: [ + if (report.analyzed && report.backtrace != null) ...[ + _CodeBlock(title: 'Backtrace', content: report.backtrace!), + ], + if (report.analyzed && report.registers != null) ...[ + const SizedBox(height: 4), + _CodeBlock(title: 'Registers', content: report.registers!), + ], + if (report.analyzed && report.analysisError != null) + Text( + report.analysisError!, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.errorColor), + ), + if (!report.analyzed) + Text( + 'Not analyzed', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Watch time: ${report.crashTime}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const Spacer(), + Text( + '${report.board} (${report.buildType})', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + if (report.backtrace != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () { + final text = [ + 'Crash: ${report.file}:${report.line}', + 'Time: ${report.crashTime}', + 'FW: ${report.fwVersion}-${report.fwCommitSha}', + if (report.backtrace != null) + 'Backtrace:\n${report.backtrace}', + if (report.registers != null) + 'Registers:\n${report.registers}', + ].join('\n'); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy'), + ), + ), + ], + ], + ), + ); + } + + String _shortSha(String sha) => sha.length > 7 ? sha.substring(0, 7) : sha; +} + +// ========================================================================== +// Shared widgets (used by Current tab) +// ========================================================================== + +class _SummaryCard extends StatelessWidget { + final CrashSummary summary; + const _SummaryCard({required this.summary}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.errorColor, + size: 28, + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Crash Summary', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.errorColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(), + _InfoRow(label: 'File', value: summary.file), + _InfoRow(label: 'Line', value: summary.line.toString()), + _InfoRow(label: 'Time', value: summary.time), + _InfoRow( + label: 'FW', + value: + '${summary.fwVersion}${summary.fwCommitSha.isNotEmpty ? '-${summary.fwCommitSha}' : ''}', + ), + _InfoRow( + label: 'Board', + value: '${summary.board} (${summary.buildType})', + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60, + child: Text( + '$label:', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + ), + ], + ), + ); + } +} + +class _AnalyzeSection extends ConsumerWidget { + final CrashSummary summary; + final CoredumpAnalysisState state; + const _AnalyzeSection({required this.summary, required this.state}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final useLatestElf = ref.watch(coredumpUseLatestElfProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.isRunning) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + children: [ + Text( + _phaseLabel(state.phase), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppTheme.spacingSm), + if (state.phase == CoredumpAnalysisPhase.downloading) + LinearProgressIndicator(value: state.downloadProgress) + else + const LinearProgressIndicator(), + ], + ), + ), + ), + ] else if (state.phase == CoredumpAnalysisPhase.failed) ...[ + Card( + color: AppTheme.errorColor.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.error_outline, + color: AppTheme.errorColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Analysis Failed', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.errorColor, + ), + ), + ], + ), + if (state.errorMessage != null) ...[ + const SizedBox(height: AppTheme.spacingSm), + Text( + state.errorMessage!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: AppTheme.spacingSm), + FilledButton.icon( + onPressed: () => _startAnalysis(ref), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ] else if (state.result != null) ...[ + _AnalysisResultCard(result: state.result!), + ] else ...[ + CheckboxListTile( + value: useLatestElf, + onChanged: (v) => ref + .read(coredumpUseLatestElfProvider.notifier) + .setEnabled(v ?? false), + title: const Text('Use latest server ELF (dev)'), + subtitle: const Text('Skip hash matching, use last uploaded ELF'), + dense: true, + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: AppTheme.spacingSm), + FilledButton.icon( + onPressed: () => _startAnalysis(ref), + icon: const Icon(Icons.bug_report), + label: const Text('Analyze Coredump'), + ), + ], + ], + ); + } + + void _startAnalysis(WidgetRef ref) { + final service = ref.read(coredumpServiceProvider); + final useLatestElf = ref.read(coredumpUseLatestElfProvider); + service.analyze(summary, useLatestElf: useLatestElf); + } + + String _phaseLabel(CoredumpAnalysisPhase phase) { + switch (phase) { + case CoredumpAnalysisPhase.enablingSmp: + return 'Enabling SMP on watch...'; + case CoredumpAnalysisPhase.downloading: + return 'Downloading coredump from watch...'; + case CoredumpAnalysisPhase.uploading: + return 'Uploading to server...'; + case CoredumpAnalysisPhase.analyzing: + return 'Analyzing coredump on server...'; + default: + return ''; + } + } +} + +class _AnalysisResultCard extends ConsumerWidget { + final CoredumpAnalysis result; + const _AnalysisResultCard({required this.result}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!result.success) { + return Card( + color: AppTheme.errorColor.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + result.error ?? 'Analysis failed', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ); + } + + if (!result.elfAvailable) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.info_outline, color: Colors.amber), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + 'ELF Not Available', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + result.error ?? + 'The ELF file for this firmware is not cached on the server. ' + 'It will be available once CI completes for this commit, ' + 'or upload manually with the upload_elf.py script.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (result.backtrace != null) + _CodeBlock(title: 'Backtrace', content: result.backtrace!), + if (result.registers != null) ...[ + const SizedBox(height: AppTheme.spacingSm), + _CodeBlock(title: 'Registers', content: result.registers!), + ], + if (result.backtrace != null || result.registers != null) ...[ + const SizedBox(height: AppTheme.spacingSm), + OutlinedButton.icon( + onPressed: () { + final text = [ + if (result.backtrace != null) 'Backtrace:\n${result.backtrace}', + if (result.registers != null) 'Registers:\n${result.registers}', + ].join('\n\n'); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Analysis result copied to clipboard'), + ), + ); + }, + icon: const Icon(Icons.copy), + label: const Text('Copy Analysis'), + ), + ], + ], + ); + } +} + +class _CodeBlock extends StatelessWidget { + final String title; + final String content; + const _CodeBlock({required this.title, required this.content}); + + @override + Widget build(BuildContext context) { + return Card( + child: ExpansionTile( + title: Text(title, style: Theme.of(context).textTheme.titleSmall), + initiallyExpanded: true, + childrenPadding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 0, + AppTheme.spacingMd, + AppTheme.spacingMd, + ), + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + content, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.greenAccent, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ActionButtons extends ConsumerWidget { + final CrashSummary summary; + const _ActionButtons({required this.summary}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final service = ref.watch(coredumpServiceProvider); + final hasCoredump = service.lastCoredumpTxt != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: () { + final text = + 'Crash: ${summary.file}:${summary.line}\n' + 'Time: ${summary.time}\n' + 'FW: ${summary.fwVersion}-${summary.fwCommitSha}\n' + 'Board: ${summary.board} (${summary.buildType})'; + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Crash info copied to clipboard')), + ); + }, + icon: const Icon(Icons.copy), + label: const Text('Copy Summary'), + ), + if (hasCoredump) ...[ + const SizedBox(height: AppTheme.spacingSm), + OutlinedButton.icon( + onPressed: () => _exportCoredump(context, ref), + icon: const Icon(Icons.save_alt), + label: const Text('Export Raw Coredump'), + ), + ], + const SizedBox(height: AppTheme.spacingSm), + OutlinedButton.icon( + onPressed: () => _eraseCoredump(context, ref), + icon: const Icon(Icons.delete_outline), + label: const Text('Erase on Watch'), + style: OutlinedButton.styleFrom(foregroundColor: AppTheme.errorColor), + ), + ], + ); + } + + Future _exportCoredump(BuildContext context, WidgetRef ref) async { + final service = ref.read(coredumpServiceProvider); + final coredumpTxt = service.lastCoredumpTxt; + if (coredumpTxt == null) return; + + final savePath = await FilePicker.platform.saveFile( + dialogTitle: 'Save coredump.txt', + fileName: 'coredump_${summary.fwCommitSha.substring(0, 7)}.txt', + ); + + if (savePath == null) return; + if (!context.mounted) return; + + await File(savePath).writeAsString(coredumpTxt); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Coredump saved to $savePath'))); + } + } + + Future _eraseCoredump(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Erase Coredump?'), + content: const Text( + 'This will delete the coredump from the watch. Make sure you have exported it if needed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Erase'), + ), + ], + ), + ); + + if (confirmed != true) return; + if (!context.mounted) return; + + final watchService = ref.read(watchServiceProvider); + await watchService.eraseCoredump(); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Coredump erased'))); + } + } +} diff --git a/zswatch_app/lib/ui/screens/dashboard/dashboard_screen.dart b/zswatch_app/lib/ui/screens/dashboard/dashboard_screen.dart index 6793665..0b85f3b 100644 --- a/zswatch_app/lib/ui/screens/dashboard/dashboard_screen.dart +++ b/zswatch_app/lib/ui/screens/dashboard/dashboard_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; + +import '../../navigation/app_router.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; import '../../../core/theme/app_theme.dart'; @@ -9,6 +11,7 @@ import '../../../data/models/watch.dart'; import '../../../providers/auto_reconnect_provider.dart'; import '../../../providers/demo_mode_provider.dart'; import '../../../providers/health_providers.dart'; +import '../../../data/models/crash_summary.dart'; import '../../../providers/watch_service_provider.dart'; /// Dashboard screen showing connected watch information @@ -70,6 +73,8 @@ class DashboardScreen extends ConsumerWidget { final watch = ref.watch(currentWatchProvider); final connection = ref.watch(watchConnectionProvider); final healthSummary = ref.watch(healthSummaryProvider); + final crashSummary = ref.watch(crashSummaryProvider); + final showCrash = ref.watch(showCrashIndicatorProvider); return Scaffold( appBar: AppBar( @@ -77,7 +82,7 @@ class DashboardScreen extends ConsumerWidget { actions: [ IconButton( icon: const Icon(Icons.settings), - onPressed: () => context.push('/settings'), + onPressed: () => context.push(AppRoutes.settings), ), ], ), @@ -93,6 +98,17 @@ class DashboardScreen extends ConsumerWidget { mtu: connection.mtu, ), + // Crash indicator (between connection and stats) + if (showCrash && crashSummary != null) ...[ + const SizedBox(height: AppTheme.spacingMd), + _CrashDetectedCard( + summary: crashSummary, + onDismiss: () => + ref.read(showCrashIndicatorProvider.notifier).state = false, + onTap: () => context.push(AppRoutes.crashReport), + ), + ], + const SizedBox(height: AppTheme.spacingMd), // Stats row - 4 compact cards @@ -112,12 +128,12 @@ class DashboardScreen extends ConsumerWidget { // Quick actions _QuickActionsSection( - onFirmwareUpdate: () => context.push('/firmware'), + onFirmwareUpdate: () => context.push(AppRoutes.firmware), onDisconnect: () async { // In demo mode, just turn off the flag if (ref.read(demoModeProvider)) { ref.read(demoModeProvider.notifier).state = false; - if (context.mounted) context.go('/'); + if (context.mounted) context.go(AppRoutes.home); return; } // Suppress auto-reconnect when user manually disconnects @@ -127,7 +143,7 @@ class DashboardScreen extends ConsumerWidget { final notifier = ref.read(watchNotifierProvider.notifier); await notifier.disconnect(); if (context.mounted) { - context.go('/'); + context.go(AppRoutes.home); } }, onRestartWatch: ref.watch(hasSmpServiceProvider) @@ -217,34 +233,6 @@ class _ConnectionStatusCard extends StatelessWidget { } } -class _InfoCard extends StatelessWidget { - final String title; - final Widget child; - - const _InfoCard({required this.title, required this.child}); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Column( - children: [ - Text( - title, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: AppTheme.spacingSm), - child, - ], - ), - ), - ); - } -} - /// Compact stats row with 4 mini cards class _StatsRow extends StatelessWidget { final int? batteryLevel; @@ -538,32 +526,32 @@ class _FeatureShortcuts extends StatelessWidget { _FeatureTile( icon: Icons.favorite, label: 'Health', - onTap: () => context.push('/health'), + onTap: () => context.push(AppRoutes.health), ), _FeatureTile( icon: Icons.notifications, label: 'Notifications', - onTap: () => context.push('/notifications'), + onTap: () => context.push(AppRoutes.notifications), ), _FeatureTile( icon: Icons.analytics, label: 'Analytics', - onTap: () => context.push('/analytics'), + onTap: () => context.push(AppRoutes.analytics), ), _FeatureTile( icon: Icons.code, label: 'Developer', - onTap: () => context.push('/developer'), + onTap: () => context.push(AppRoutes.developer), ), _FeatureTile( icon: Icons.mic, label: 'Voice', - onTap: () => context.push('/voice-memos'), + onTap: () => context.push(AppRoutes.voiceMemos), ), _FeatureTile( icon: Icons.settings, label: 'Settings', - onTap: () => context.push('/settings'), + onTap: () => context.push(AppRoutes.settings), ), ], ), @@ -572,6 +560,64 @@ class _FeatureShortcuts extends StatelessWidget { } } +class _CrashDetectedCard extends StatelessWidget { + final CrashSummary summary; + final VoidCallback onDismiss; + final VoidCallback onTap; + + const _CrashDetectedCard({ + required this.summary, + required this.onDismiss, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: AppTheme.errorColor.withValues(alpha: 0.1), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.errorColor, + size: 32, + ), + const SizedBox(width: AppTheme.spacingMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Crash Detected', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.errorColor, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${summary.file}:${summary.line} — ${summary.time}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onDismiss, + ), + ], + ), + ), + ), + ); + } +} + class _FeatureTile extends StatelessWidget { final IconData icon; final String label; diff --git a/zswatch_app/lib/ui/screens/developer/comm_log_screen.dart b/zswatch_app/lib/ui/screens/developer/comm_log_screen.dart index 0f9abba..6cc9477 100644 --- a/zswatch_app/lib/ui/screens/developer/comm_log_screen.dart +++ b/zswatch_app/lib/ui/screens/developer/comm_log_screen.dart @@ -24,7 +24,8 @@ class _CommLogScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); bool _autoScroll = true; bool _isAtBottom = true; - CommDirection? _directionFilter; // null = all, rx = incoming only, tx = outgoing only + CommDirection? + _directionFilter; // null = all, rx = incoming only, tx = outgoing only bool _showHex = false; @override @@ -41,7 +42,8 @@ class _CommLogScreenState extends ConsumerState { } void _onScroll() { - final isAtBottom = _scrollController.position.pixels >= + final isAtBottom = + _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 50; if (isAtBottom != _isAtBottom) { setState(() { @@ -131,7 +133,11 @@ class _CommLogScreenState extends ConsumerState { else const SizedBox(width: 18), const SizedBox(width: 8), - Icon(Icons.arrow_downward, size: 16, color: AppTheme.successColor), + const Icon( + Icons.arrow_downward, + size: 16, + color: AppTheme.successColor, + ), const SizedBox(width: 4), const Text('RX (Incoming)'), ], @@ -146,7 +152,11 @@ class _CommLogScreenState extends ConsumerState { else const SizedBox(width: 18), const SizedBox(width: 8), - Icon(Icons.arrow_upward, size: 16, color: AppTheme.primaryColor), + const Icon( + Icons.arrow_upward, + size: 16, + color: AppTheme.primaryColor, + ), const SizedBox(width: 4), const Text('TX (Outgoing)'), ], @@ -249,16 +259,16 @@ class _CommLogScreenState extends ConsumerState { const SizedBox(height: 16), Text( 'No communication data', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 8), Text( 'BLE traffic will appear here', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), ), ], ), @@ -268,7 +278,9 @@ class _CommLogScreenState extends ConsumerState { void _copyAllEntries(List entries) { final buffer = StringBuffer(); for (final entry in entries) { - buffer.writeln('[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.data}'); + buffer.writeln( + '[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.data}', + ); } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( @@ -305,9 +317,7 @@ class _StatsBar extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - ), + bottom: BorderSide(color: Theme.of(context).dividerColor), ), ), child: Row( @@ -364,24 +374,20 @@ class _StatItem extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Icon( - icon, - size: 16, - color: color ?? AppTheme.textSecondary, - ), + Icon(icon, size: 16, color: color ?? AppTheme.textSecondary), const SizedBox(height: 2), Text( value, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold), ), Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 10, - ), + color: AppTheme.textSecondary, + fontSize: 10, + ), ), ], ); @@ -392,10 +398,7 @@ class _CommLogEntryTile extends StatelessWidget { final CommLogEntry entry; final bool showHex; - const _CommLogEntryTile({ - required this.entry, - required this.showHex, - }); + const _CommLogEntryTile({required this.entry, required this.showHex}); @override Widget build(BuildContext context) { @@ -440,25 +443,27 @@ class _CommLogEntryTile extends StatelessWidget { Text( entry.formattedTimestamp, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: AppTheme.textSecondary, - fontSize: 10, - ), + fontFamily: 'monospace', + color: AppTheme.textSecondary, + fontSize: 10, + ), ), Row( children: [ Icon( isIncoming ? Icons.arrow_downward : Icons.arrow_upward, size: 12, - color: isIncoming ? AppTheme.successColor : AppTheme.primaryColor, + color: isIncoming + ? AppTheme.successColor + : AppTheme.primaryColor, ), const SizedBox(width: 4), Text( '${entry.sizeBytes} B', style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 10, - ), + color: AppTheme.textSecondary, + fontSize: 10, + ), ), ], ), @@ -471,9 +476,9 @@ class _CommLogEntryTile extends StatelessWidget { child: Text( data, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontSize: 11, - ), + fontFamily: 'monospace', + fontSize: 11, + ), maxLines: 5, overflow: TextOverflow.ellipsis, ), @@ -491,10 +496,16 @@ class _CommLogEntryTile extends StatelessWidget { .toUpperCase(); } - void _showFullEntryDialog(BuildContext context, String displayData, bool isIncoming) { - final directionColor = isIncoming ? AppTheme.successColor : AppTheme.primaryColor; + void _showFullEntryDialog( + BuildContext context, + String displayData, + bool isIncoming, + ) { + final directionColor = isIncoming + ? AppTheme.successColor + : AppTheme.primaryColor; - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Row( @@ -518,16 +529,16 @@ class _CommLogEntryTile extends StatelessWidget { Text( entry.formattedTimestamp, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - fontFamily: 'monospace', - ), + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), ), const Spacer(), Text( entry.formattedSize, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -535,9 +546,9 @@ class _CommLogEntryTile extends StatelessWidget { child: SelectableText( displayData, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontSize: 12, - ), + fontFamily: 'monospace', + fontSize: 12, + ), ), ), actions: [ diff --git a/zswatch_app/lib/ui/screens/developer/developer_screen.dart b/zswatch_app/lib/ui/screens/developer/developer_screen.dart index dbbca3f..8f96dd1 100644 --- a/zswatch_app/lib/ui/screens/developer/developer_screen.dart +++ b/zswatch_app/lib/ui/screens/developer/developer_screen.dart @@ -2,21 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../navigation/app_router.dart'; + import '../../../core/theme/app_theme.dart'; +import '../../../data/models/connection.dart'; +import '../../../data/models/crash_summary.dart'; import '../../../providers/developer_providers.dart'; -import '../../../providers/permission_providers.dart'; import '../../../providers/watch_service_provider.dart'; import '../../widgets/developer/music_debug_section.dart'; import '../../widgets/developer/notification_debug_section.dart'; -/// Developer tools hub screen -/// -/// Provides access to: -/// - BLE diagnostics (MTU, PHY, RSSI, reconnection count) -/// - Log viewer (all incoming BLE NUS data) -/// - Sensor debug (real-time sensor charts) -/// - Communication log (TX/RX traffic) -/// - Shell terminal (not implemented - mcumgr doesn't support it) class DeveloperScreen extends ConsumerWidget { const DeveloperScreen({super.key}); @@ -24,168 +19,458 @@ class DeveloperScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final diagnostics = ref.watch(bleDiagnosticsProvider); final connection = ref.watch(watchConnectionProvider); + final crashSummary = ref.watch(crashSummaryProvider); return Scaffold( - appBar: AppBar( - title: const Text('Developer Tools'), + appBar: AppBar(title: const Text('Developer Tools')), + body: _DeveloperToolsBody( + diagnostics: diagnostics, + connection: connection, + crashSummary: crashSummary, ), - body: ListView( - padding: const EdgeInsets.all(AppTheme.spacingMd), - children: [ - // BLE Diagnostics Card - _DiagnosticsCard(diagnostics: diagnostics), + ); + } +} + +class _DeveloperToolsBody extends ConsumerWidget { + final BleDiagnostics diagnostics; + final Connection connection; + final CrashSummary? crashSummary; + const _DeveloperToolsBody({ + required this.diagnostics, + required this.connection, + this.crashSummary, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolTiles = [ + _BlendToolTile( + icon: Icons.article_outlined, + title: 'Log Viewer', + subtitle: 'Firmware logs', + accent: AppTheme.primaryColor, + onTap: () => context.push(AppRoutes.logViewer), + ), + _BlendToolTile( + icon: Icons.swap_horiz, + title: 'Comm Log', + subtitle: 'BLE traffic', + accent: Colors.amberAccent.shade400, + onTap: () => context.push(AppRoutes.commLog), + ), + _BlendToolTile( + icon: Icons.terminal, + title: 'Shell', + subtitle: 'SMP commands', + accent: Colors.lightGreenAccent.shade400, + onTap: connection.isConnected + ? () => context.push(AppRoutes.shell) + : null, + ), + _BlendToolTile( + icon: Icons.bug_report_outlined, + title: 'Crash Reports', + subtitle: 'History & stats', + accent: Colors.redAccent.shade200, + onTap: () => context.push(AppRoutes.crashReport), + ), + _BlendToolTile( + icon: Icons.sensors, + title: 'Sensors', + subtitle: 'Real-time data', + accent: Colors.tealAccent.shade400, + onTap: connection.isConnected + ? () => context.push(AppRoutes.sensors) + : null, + ), + ]; + + return ListView( + padding: const EdgeInsets.all(AppTheme.spacingMd), + children: [ + _DiagnosticsCard(diagnostics: diagnostics), + const SizedBox(height: AppTheme.spacingMd), + if (!connection.isConnected) ...[ + _DisconnectedBanner(), const SizedBox(height: AppTheme.spacingMd), + ], + const _BlendTerminalSectionTitle('TOOLS'), + const SizedBox(height: AppTheme.spacingSm), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 0.88, + children: toolTiles, + ), + const SizedBox(height: AppTheme.spacingLg), + const _BlendTerminalSectionTitle('ACTIONS'), + const SizedBox(height: AppTheme.spacingSm), + Row( + children: [ + Expanded( + child: _BlendActionChip( + icon: Icons.vibration, + label: 'Find', + onTap: connection.isConnected + ? () async { + final watchService = ref.read(watchServiceProvider); + await watchService.findDevice(true); + await Future.delayed(const Duration(seconds: 2)); + await watchService.findDevice(false); + } + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _BlendActionChip( + icon: Icons.sync, + label: 'Sync Time', + onTap: connection.isConnected + ? () async { + final watchService = ref.read(watchServiceProvider); + await watchService.syncTime(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Time synced'), + duration: Duration(seconds: 1), + ), + ); + } + } + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _BlendActionChip( + icon: Icons.info_outline, + label: 'Device Info', + onTap: connection.isConnected + ? () async { + final watchService = ref.read(watchServiceProvider); + await watchService.requestDeviceInfo(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device info requested'), + duration: Duration(seconds: 1), + ), + ); + } + } + : null, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingLg), + const _BlendTerminalSectionTitle('DEBUG'), + const SizedBox(height: AppTheme.spacingSm), + const _CompactDebugToolsHub(), + ], + ); + } +} - // Connection status - if (!connection.isConnected) - Card( - color: AppTheme.warningColor.withValues(alpha: 0.1), - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Row( +class _BlendTerminalSectionTitle extends StatelessWidget { + final String label; + + const _BlendTerminalSectionTitle(this.label); + + @override + Widget build(BuildContext context) { + return Text( + '< $label', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.primaryColor, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 1.3, + ), + ); + } +} + +class _BlendToolTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final Color accent; + final VoidCallback? onTap; + + const _BlendToolTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.accent, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + + return Opacity( + opacity: enabled ? 1.0 : 0.45, + child: Card( + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.warning_amber_rounded, - color: AppTheme.warningColor, - ), - const SizedBox(width: AppTheme.spacingSm), - Expanded( - child: Text( - 'Watch not connected. Some features require an active connection.', - style: Theme.of(context).textTheme.bodyMedium, + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), ), + child: Icon(icon, size: 22, color: accent), ), + const Spacer(), ], ), - ), + const Spacer(), + Text( + title, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 12, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ), + ), + ), + ), + ); + } +} - const SizedBox(height: AppTheme.spacingMd), +class _BlendActionChip extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onTap; - // Developer tools grid - Text( - 'Tools', - style: Theme.of(context).textTheme.titleMedium, + const _BlendActionChip({required this.icon, required this.label, this.onTap}); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + + return Opacity( + opacity: enabled ? 1.0 : 0.45, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: enabled + ? AppTheme.primaryColor.withValues(alpha: 0.3) + : Colors.white.withValues(alpha: 0.1), + ), + color: Colors.white.withValues(alpha: 0.02), ), - const SizedBox(height: AppTheme.spacingSm), - - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: AppTheme.spacingSm, - crossAxisSpacing: AppTheme.spacingSm, - childAspectRatio: 1.3, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - _ToolCard( - icon: Icons.article_outlined, - title: 'Log Viewer', - subtitle: 'All BLE data', - onTap: () => context.push('/developer/logs'), - ), - _ToolCard( - icon: Icons.sensors, - title: 'Sensors', - subtitle: 'Real-time data', - onTap: () => context.push('/developer/sensors'), - enabled: connection.isConnected, - ), - _ToolCard( - icon: Icons.swap_horiz, - title: 'Comm Log', - subtitle: 'TX/RX traffic', - onTap: () => context.push('/developer/comm-log'), - ), - _ToolCard( - icon: Icons.terminal, - title: 'Shell', - subtitle: 'Not available', - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shell commands not supported by mcumgr library'), - duration: Duration(seconds: 2), - ), - ); - }, - enabled: false, + Icon(icon, size: 16), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + ), + ), ), ], ), + ), + ), + ); + } +} - const SizedBox(height: AppTheme.spacingLg), +class _CompactDebugToolsHub extends StatelessWidget { + const _CompactDebugToolsHub(); - // Quick actions - Text( - 'Quick Actions', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppTheme.spacingSm), - - _QuickActionTile( - icon: Icons.vibration, - title: 'Find Watch', - subtitle: 'Vibrate the watch', - onTap: connection.isConnected - ? () async { - final watchService = ref.read(watchServiceProvider); - await watchService.findDevice(true); - await Future.delayed(const Duration(seconds: 2)); - await watchService.findDevice(false); - } - : null, - ), + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.tune, + size: 18, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Debug Tools', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + color: AppTheme.primaryColor, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => _showDebugToolsSheet(context), + child: const Text('Open'), + ), + ], + ), + ), + ); + } - const SizedBox(height: AppTheme.spacingXs), - - _QuickActionTile( - icon: Icons.sync, - title: 'Sync Time', - subtitle: 'Update watch time', - onTap: connection.isConnected - ? () async { - final watchService = ref.read(watchServiceProvider); - await watchService.syncTime(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Time synced'), - duration: Duration(seconds: 1), + void _showDebugToolsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + top: false, + child: FractionallySizedBox( + heightFactor: 0.82, + child: DefaultTabController( + length: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Debug Tools', + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + dividerColor: Colors.transparent, + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), ), - ); - } - } - : null, + labelColor: AppTheme.primaryColor, + unselectedLabelColor: AppTheme.textSecondary, + tabs: const [ + Tab(text: 'Notifications'), + Tab(text: 'Music'), + ], + ), + ), + const SizedBox(height: 16), + const Expanded( + child: TabBarView( + children: [ + _DebugToolSheetPage( + child: NotificationDebugSection(), + ), + _DebugToolSheetPage(child: MusicDebugSection()), + ], + ), + ), + ], + ), + ), + ), ), + ); + }, + ); + } +} - const SizedBox(height: AppTheme.spacingXs), - - _QuickActionTile( - icon: Icons.info_outline, - title: 'Request Device Info', - subtitle: 'Query firmware version', - onTap: connection.isConnected - ? () async { - final watchService = ref.read(watchServiceProvider); - await watchService.requestDeviceInfo(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Device info requested'), - duration: Duration(seconds: 1), - ), - ); - } - } - : null, - ), +class _DebugToolSheetPage extends StatelessWidget { + final Widget child; - const SizedBox(height: AppTheme.spacingLg), + const _DebugToolSheetPage({required this.child}); - // Debug Tools Section (collapsible) - const _DebugToolsSection(), - ], + @override + Widget build(BuildContext context) { + return ScrollConfiguration( + behavior: const MaterialScrollBehavior().copyWith(overscroll: false), + child: SingleChildScrollView(child: child), + ); + } +} + +class _DisconnectedBanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Card( + color: AppTheme.warningColor.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.warningColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + 'Watch not connected. Some features require an active connection.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), ), ); } @@ -234,10 +519,10 @@ class _DiagnosticsCard extends ConsumerWidget { child: Text( diagnostics.isConnected ? 'Connected' : 'Disconnected', style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: diagnostics.isConnected - ? AppTheme.successColor - : AppTheme.errorColor, - ), + color: diagnostics.isConnected + ? AppTheme.successColor + : AppTheme.errorColor, + ), ), ), ], @@ -260,7 +545,8 @@ class _DiagnosticsCard extends ConsumerWidget { ), _DiagnosticRow( label: 'RSSI', - value: '${diagnostics.rssiDisplay} (${diagnostics.signalStrength})', + value: + '${diagnostics.rssiDisplay} (${diagnostics.signalStrength})', icon: Icons.signal_cellular_alt, ), _DiagnosticRow( @@ -296,17 +582,17 @@ class _DiagnosticRow extends StatelessWidget { const SizedBox(width: 8), Text( label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(width: 8), Expanded( child: Text( value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), textAlign: TextAlign.end, overflow: TextOverflow.ellipsis, ), @@ -316,181 +602,3 @@ class _DiagnosticRow extends StatelessWidget { ); } } - -class _ToolCard extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback? onTap; - final bool enabled; - - const _ToolCard({ - required this.icon, - required this.title, - required this.subtitle, - this.onTap, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - final isEnabled = enabled && onTap != null; - - return Card( - child: InkWell( - onTap: isEnabled ? onTap : null, - borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Opacity( - opacity: isEnabled ? 1.0 : 0.5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 32, - color: isEnabled ? AppTheme.primaryColor : AppTheme.textSecondary, - ), - const SizedBox(height: 8), - Text( - title, - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _QuickActionTile extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback? onTap; - - const _QuickActionTile({ - required this.icon, - required this.title, - required this.subtitle, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - child: ListTile( - dense: true, - visualDensity: VisualDensity.compact, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingMd, - vertical: 0, - ), - leading: Icon( - icon, - size: 20, - color: onTap != null ? AppTheme.primaryColor : AppTheme.textSecondary, - ), - title: Text( - title, - style: Theme.of(context).textTheme.bodyMedium, - ), - subtitle: Text( - subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - trailing: Icon( - Icons.chevron_right, - size: 20, - color: AppTheme.textSecondary, - ), - enabled: onTap != null, - onTap: onTap, - ), - ); - } -} - -/// Collapsible debug tools section -class _DebugToolsSection extends ConsumerStatefulWidget { - const _DebugToolsSection(); - - @override - ConsumerState<_DebugToolsSection> createState() => _DebugToolsSectionState(); -} - -class _DebugToolsSectionState extends ConsumerState<_DebugToolsSection> { - bool _isExpanded = false; - - @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: ExpansionTile( - initiallyExpanded: _isExpanded, - onExpansionChanged: (expanded) => setState(() => _isExpanded = expanded), - leading: Icon( - Icons.bug_report, - color: AppTheme.primaryColor, - ), - title: const Text('Debug Tools'), - subtitle: Text( - 'Notification & Music testing', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - 0, - AppTheme.spacingMd, - AppTheme.spacingMd, - ), - child: Column( - children: [ - const NotificationDebugSection(), - const SizedBox(height: AppTheme.spacingMd), - const MusicDebugSection(), - const SizedBox(height: AppTheme.spacingMd), - // Reset permission onboarding button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - await ref.read(permissionNotifierProvider.notifier).resetOnboarding(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Permission onboarding reset. Go to home to see it.'), - duration: Duration(seconds: 2), - ), - ); - } - }, - icon: const Icon(Icons.restart_alt), - label: const Text('Reset Permission Onboarding'), - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/zswatch_app/lib/ui/screens/developer/log_viewer_screen.dart b/zswatch_app/lib/ui/screens/developer/log_viewer_screen.dart index 3aaaa3d..e280427 100644 --- a/zswatch_app/lib/ui/screens/developer/log_viewer_screen.dart +++ b/zswatch_app/lib/ui/screens/developer/log_viewer_screen.dart @@ -44,7 +44,8 @@ class _LogViewerScreenState extends ConsumerState { } void _onScroll() { - final isAtBottom = _scrollController.position.pixels >= + final isAtBottom = + _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 50; if (isAtBottom != _isAtBottom) { setState(() { @@ -106,10 +107,7 @@ class _LogViewerScreenState extends ConsumerState { value: const _LogLevelFilter(null), child: Row( children: [ - Icon( - _levelFilter == null ? Icons.check : null, - size: 18, - ), + Icon(_levelFilter == null ? Icons.check : null, size: 18), const SizedBox(width: 8), const Text('All levels'), ], @@ -154,7 +152,10 @@ class _LogViewerScreenState extends ConsumerState { color: Colors.orange, ), const SizedBox(width: 8), - const Text('Warning', style: TextStyle(color: Colors.orange)), + const Text( + 'Warning', + style: TextStyle(color: Colors.orange), + ), ], ), ), @@ -201,9 +202,9 @@ class _LogViewerScreenState extends ConsumerState { } }, itemBuilder: (context) => [ - PopupMenuItem( + const PopupMenuItem( value: 'copy', - child: const Row( + child: Row( children: [ Icon(Icons.copy, size: 18), SizedBox(width: 8), @@ -220,7 +221,9 @@ class _LogViewerScreenState extends ConsumerState { size: 18, ), const SizedBox(width: 8), - Text(_autoScroll ? 'Pause auto-scroll' : 'Resume auto-scroll'), + Text( + _autoScroll ? 'Pause auto-scroll' : 'Resume auto-scroll', + ), ], ), ), @@ -276,7 +279,9 @@ class _LogViewerScreenState extends ConsumerState { void _copyAllLogs(List entries) { final buffer = StringBuffer(); for (final entry in entries) { - buffer.writeln('[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.message}'); + buffer.writeln( + '[${entry.formattedTimestamp}] ${entry.directionArrow} ${entry.message}', + ); } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( @@ -309,9 +314,7 @@ class _LogStreamingBar extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - ), + bottom: BorderSide(color: Theme.of(context).dividerColor), ), ), child: Row( @@ -336,11 +339,11 @@ class _LogStreamingBar extends StatelessWidget { streamingState.enabledOnWatch ? 'Enabled' : streamingState.pending - ? 'Updating...' - : 'Disabled', + ? 'Updating...' + : 'Disabled', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -373,7 +376,7 @@ class _StatsBar extends StatelessWidget { @override Widget build(BuildContext context) { final isFiltered = levelFilter != null; - + return Container( padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingMd, @@ -382,18 +385,18 @@ class _StatsBar extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - ), + bottom: BorderSide(color: Theme.of(context).dividerColor), ), ), child: Row( children: [ Text( - isFiltered ? '$entryCount / $totalCount entries' : '$entryCount entries', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + isFiltered + ? '$entryCount / $totalCount entries' + : '$entryCount entries', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), if (isFiltered) ...[ const SizedBox(width: 8), @@ -406,9 +409,9 @@ class _StatsBar extends StatelessWidget { child: Text( _getLevelName(levelFilter!), style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: _getLevelColor(levelFilter!), - fontWeight: FontWeight.bold, - ), + color: _getLevelColor(levelFilter!), + fontWeight: FontWeight.bold, + ), ), ), ], @@ -417,7 +420,7 @@ class _StatsBar extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.arrow_downward, size: 12, color: AppTheme.textSecondary, @@ -426,8 +429,8 @@ class _StatsBar extends StatelessWidget { Text( 'Auto-scroll', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -506,10 +509,10 @@ class _LogEntryTile extends StatelessWidget { Text( entry.formattedTimestamp, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: AppTheme.textSecondary, - fontSize: 10, - ), + fontFamily: 'monospace', + color: AppTheme.textSecondary, + fontSize: 10, + ), ), const SizedBox(width: 6), @@ -519,10 +522,10 @@ class _LogEntryTile extends StatelessWidget { child: Text( entry.message, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontSize: 11, - color: levelColor, - ), + fontFamily: 'monospace', + fontSize: 11, + color: levelColor, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -551,7 +554,7 @@ class _LogEntryTile extends StatelessWidget { void _showFullLogDialog(BuildContext context) { final levelColor = _getLevelColor(entry.level); - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Row( @@ -578,9 +581,9 @@ class _LogEntryTile extends StatelessWidget { Text( entry.formattedTimestamp, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - fontFamily: 'monospace', - ), + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), ), ], ), @@ -588,10 +591,10 @@ class _LogEntryTile extends StatelessWidget { child: SelectableText( entry.message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontSize: 12, - color: levelColor, - ), + fontFamily: 'monospace', + fontSize: 12, + color: levelColor, + ), ), ), actions: [ @@ -637,9 +640,9 @@ class _EmptyState extends StatelessWidget { const SizedBox(height: 16), Text( 'No log entries', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 8), Text( @@ -647,8 +650,8 @@ class _EmptyState extends StatelessWidget { ? 'Waiting for data from watch...' : 'Connect to watch to see logs', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), ), ], ), diff --git a/zswatch_app/lib/ui/screens/developer/sensor_debug_screen.dart b/zswatch_app/lib/ui/screens/developer/sensor_debug_screen.dart index 5c5b98b..f461ca1 100644 --- a/zswatch_app/lib/ui/screens/developer/sensor_debug_screen.dart +++ b/zswatch_app/lib/ui/screens/developer/sensor_debug_screen.dart @@ -27,7 +27,7 @@ class SensorDebugScreen extends ConsumerStatefulWidget { class _SensorDebugScreenState extends ConsumerState { final Map> _sensorHistory = {}; final int _maxHistoryLength = 100; - + SensorGattService? _sensorService; final Map _enabledSensors = {}; final Map?> _subscriptions = {}; @@ -46,24 +46,24 @@ class _SensorDebugScreenState extends ConsumerState { _sensorHistory[type] = []; _enabledSensors[type] = false; } - + // Initialize sensor service after frame is built WidgetsBinding.instance.addPostFrameCallback((_) { _initializeSensorService(); }); } - + Future _initializeSensorService() async { final watchService = ref.read(watchServiceProvider); final device = watchService.device; if (device == null) return; - + final services = watchService.services; if (services == null || services.isEmpty) return; - + _sensorService = SensorGattService(device); final success = await _sensorService!.initialize(services); - + if (success && mounted) { setState(() {}); debugPrint('[SensorDebug] Sensor service initialized'); @@ -84,11 +84,11 @@ class _SensorDebugScreenState extends ConsumerState { // Sensor fusion methods Future _toggleSensorFusion(bool enable) async { if (_sensorService == null) return; - + setState(() { _fusionEnabled = enable; }); - + try { if (enable) { await _sensorService!.startSensorFusion(); @@ -139,42 +139,56 @@ class _SensorDebugScreenState extends ConsumerState { Future _toggleSensor(SensorType type, bool enable) async { if (_sensorService == null) return; - + setState(() { _enabledSensors[type] = enable; }); - + try { if (enable) { // Start the sensor and subscribe to its stream switch (type) { case SensorType.temperature: await _sensorService!.startTemperature(); - _subscriptions[type] = _sensorService!.temperatureStream.listen(_addReading); + _subscriptions[type] = _sensorService!.temperatureStream.listen( + _addReading, + ); case SensorType.accelerometer: await _sensorService!.startAccelerometer(); - _subscriptions[type] = _sensorService!.accelerometerStream.listen(_addReading); + _subscriptions[type] = _sensorService!.accelerometerStream.listen( + _addReading, + ); case SensorType.gyroscope: await _sensorService!.startGyroscope(); - _subscriptions[type] = _sensorService!.gyroscopeStream.listen(_addReading); + _subscriptions[type] = _sensorService!.gyroscopeStream.listen( + _addReading, + ); case SensorType.magnetometer: await _sensorService!.startMagnetometer(); - _subscriptions[type] = _sensorService!.magnetometerStream.listen(_addReading); + _subscriptions[type] = _sensorService!.magnetometerStream.listen( + _addReading, + ); case SensorType.light: await _sensorService!.startLight(); - _subscriptions[type] = _sensorService!.lightStream.listen(_addReading); + _subscriptions[type] = _sensorService!.lightStream.listen( + _addReading, + ); case SensorType.humidity: await _sensorService!.startHumidity(); - _subscriptions[type] = _sensorService!.humidityStream.listen(_addReading); + _subscriptions[type] = _sensorService!.humidityStream.listen( + _addReading, + ); case SensorType.pressure: await _sensorService!.startPressure(); - _subscriptions[type] = _sensorService!.pressureStream.listen(_addReading); + _subscriptions[type] = _sensorService!.pressureStream.listen( + _addReading, + ); } } else { // Cancel subscription and stop sensor await _subscriptions[type]?.cancel(); _subscriptions[type] = null; - + switch (type) { case SensorType.temperature: await _sensorService!.stopTemperature(); @@ -271,7 +285,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: true, enabled: _enabledSensors[SensorType.accelerometer]!, available: _sensorService?.hasAccelerometer ?? false, - onToggle: (enable) => _toggleSensor(SensorType.accelerometer, enable), + onToggle: (enable) => + _toggleSensor(SensorType.accelerometer, enable), ), _SensorCard( @@ -283,7 +298,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: true, enabled: _enabledSensors[SensorType.gyroscope]!, available: _sensorService?.hasGyroscope ?? false, - onToggle: (enable) => _toggleSensor(SensorType.gyroscope, enable), + onToggle: (enable) => + _toggleSensor(SensorType.gyroscope, enable), ), _SensorCard( @@ -295,7 +311,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: true, enabled: _enabledSensors[SensorType.magnetometer]!, available: _sensorService?.hasMagnetometer ?? false, - onToggle: (enable) => _toggleSensor(SensorType.magnetometer, enable), + onToggle: (enable) => + _toggleSensor(SensorType.magnetometer, enable), ), _SensorCard( @@ -307,7 +324,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: false, enabled: _enabledSensors[SensorType.temperature]!, available: _sensorService?.hasTemperature ?? false, - onToggle: (enable) => _toggleSensor(SensorType.temperature, enable), + onToggle: (enable) => + _toggleSensor(SensorType.temperature, enable), ), _SensorCard( @@ -319,7 +337,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: false, enabled: _enabledSensors[SensorType.humidity]!, available: _sensorService?.hasHumidity ?? false, - onToggle: (enable) => _toggleSensor(SensorType.humidity, enable), + onToggle: (enable) => + _toggleSensor(SensorType.humidity, enable), ), _SensorCard( @@ -331,7 +350,8 @@ class _SensorDebugScreenState extends ConsumerState { hasXYZ: false, enabled: _enabledSensors[SensorType.pressure]!, available: _sensorService?.hasPressure ?? false, - onToggle: (enable) => _toggleSensor(SensorType.pressure, enable), + onToggle: (enable) => + _toggleSensor(SensorType.pressure, enable), ), _SensorCard( @@ -363,16 +383,16 @@ class _SensorDebugScreenState extends ConsumerState { const SizedBox(height: 16), Text( 'Sensor service not available', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 8), Text( 'Connect to a watch to access sensors', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), ), ], ), @@ -385,24 +405,18 @@ class _SensorDebugScreenState extends ConsumerState { decoration: BoxDecoration( color: AppTheme.infoColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - border: Border.all( - color: AppTheme.infoColor.withValues(alpha: 0.3), - ), + border: Border.all(color: AppTheme.infoColor.withValues(alpha: 0.3)), ), child: Row( children: [ - Icon( - Icons.info_outline, - color: AppTheme.infoColor, - size: 20, - ), + const Icon(Icons.info_outline, color: AppTheme.infoColor, size: 20), const SizedBox(width: 12), Expanded( child: Text( 'Toggle sensors to enable GATT notifications. The watch will stream data when enabled.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), ], @@ -427,7 +441,9 @@ class _SensorDebugScreenState extends ConsumerState { children: [ Icon( Icons.sensors, - color: activeSensors > 0 ? AppTheme.successColor : AppTheme.textSecondary, + color: activeSensors > 0 + ? AppTheme.successColor + : AppTheme.textSecondary, ), const SizedBox(width: 12), Expanded( @@ -441,8 +457,8 @@ class _SensorDebugScreenState extends ConsumerState { Text( '$totalSamples total samples received', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -479,7 +495,6 @@ class _SensorCard extends StatelessWidget { @override Widget build(BuildContext context) { final lastReading = history.isNotEmpty ? history.last : null; - final hasData = history.isNotEmpty; return Card( margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), @@ -493,7 +508,9 @@ class _SensorCard extends StatelessWidget { children: [ Icon( icon, - color: enabled ? AppTheme.primaryColor : AppTheme.textSecondary, + color: enabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, ), const SizedBox(width: 12), Expanded( @@ -507,17 +524,13 @@ class _SensorCard extends StatelessWidget { if (!available) Text( 'Not available on this device', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.warningColor, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.warningColor), ), ], ), ), - Switch( - value: enabled, - onChanged: available ? onToggle : null, - ), + Switch(value: enabled, onChanged: available ? onToggle : null), ], ), @@ -566,10 +579,7 @@ class _SensorCard extends StatelessWidget { // Mini chart SizedBox( height: 60, - child: _MiniChart( - history: history, - hasXYZ: hasXYZ, - ), + child: _MiniChart(history: history, hasXYZ: hasXYZ), ), // Stats @@ -580,15 +590,15 @@ class _SensorCard extends StatelessWidget { Text( '${history.length} samples', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), if (history.isNotEmpty) Text( 'Rate: ${_calculateRate(history).toStringAsFixed(1)} Hz', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -600,7 +610,7 @@ class _SensorCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( + const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( @@ -612,8 +622,8 @@ class _SensorCard extends StatelessWidget { Text( 'Waiting for data...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -666,25 +676,25 @@ class _ValueChip extends StatelessWidget { Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), + color: color, + fontWeight: FontWeight.bold, + ), ), ], Text( value.toStringAsFixed(2), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontSize: large ? 18 : 14, - fontWeight: FontWeight.bold, - ), + fontFamily: 'monospace', + fontSize: large ? 18 : 14, + fontWeight: FontWeight.bold, + ), ), Text( unit, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 10, - ), + color: AppTheme.textSecondary, + fontSize: 10, + ), ), ], ), @@ -696,10 +706,7 @@ class _MiniChart extends StatelessWidget { final List history; final bool hasXYZ; - const _MiniChart({ - required this.history, - required this.hasXYZ, - }); + const _MiniChart({required this.history, required this.hasXYZ}); @override Widget build(BuildContext context) { @@ -708,10 +715,7 @@ class _MiniChart extends StatelessWidget { } return CustomPaint( - painter: _ChartPainter( - history: history, - hasXYZ: hasXYZ, - ), + painter: _ChartPainter(history: history, hasXYZ: hasXYZ), size: const Size(double.infinity, 60), ); } @@ -796,9 +800,15 @@ class _ChartPainter extends CustomPainter { final reading = history[i]; if (hasXYZ) { - final yX = size.height - (reading.x - minVal) / (maxVal - minVal) * size.height; - final yY = size.height - ((reading.y ?? 0) - minVal) / (maxVal - minVal) * size.height; - final yZ = size.height - ((reading.z ?? 0) - minVal) / (maxVal - minVal) * size.height; + final yX = + size.height - + (reading.x - minVal) / (maxVal - minVal) * size.height; + final yY = + size.height - + ((reading.y ?? 0) - minVal) / (maxVal - minVal) * size.height; + final yZ = + size.height - + ((reading.z ?? 0) - minVal) / (maxVal - minVal) * size.height; if (i == 0) { pathX.moveTo(x, yX); @@ -810,7 +820,9 @@ class _ChartPainter extends CustomPainter { pathZ.lineTo(x, yZ); } } else { - final yVal = size.height - (reading.x - minVal) / (maxVal - minVal) * size.height; + final yVal = + size.height - + (reading.x - minVal) / (maxVal - minVal) * size.height; if (i == 0) { pathValue.moveTo(x, yVal); } else { @@ -831,8 +843,9 @@ class _ChartPainter extends CustomPainter { @override bool shouldRepaint(covariant _ChartPainter oldDelegate) { return oldDelegate.history.length != history.length || - (history.isNotEmpty && oldDelegate.history.isNotEmpty && - oldDelegate.history.last != history.last); + (history.isNotEmpty && + oldDelegate.history.isNotEmpty && + oldDelegate.history.last != history.last); } } @@ -874,7 +887,9 @@ class _SensorFusionCard extends StatelessWidget { children: [ Icon( Icons.threed_rotation, - color: enabled ? AppTheme.primaryColor : AppTheme.textSecondary, + color: enabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, ), const SizedBox(width: 12), Expanded( @@ -888,17 +903,13 @@ class _SensorFusionCard extends StatelessWidget { if (!available) Text( 'Not available on this device', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.warningColor, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.warningColor), ), ], ), ), - Switch( - value: enabled, - onChanged: available ? onToggle : null, - ), + Switch(value: enabled, onChanged: available ? onToggle : null), ], ), @@ -909,8 +920,8 @@ class _SensorFusionCard extends StatelessWidget { Text( 'Quaternion', style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), const SizedBox(height: 4), if (latestData != null) ...[ @@ -928,7 +939,7 @@ class _SensorFusionCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( + const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( @@ -940,8 +951,8 @@ class _SensorFusionCard extends StatelessWidget { Text( 'Waiting for data...', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -977,17 +988,28 @@ class _SensorFusionCard extends StatelessWidget { children: [ Text( 'Euler Angles${hasOffset ? ' (corrected)' : ''}', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 8), if (euler != null) ...[ - _EulerRow(label: 'Roll', value: euler.rollDegrees, color: Colors.red), + _EulerRow( + label: 'Roll', + value: euler.rollDegrees, + color: Colors.red, + ), const SizedBox(height: 4), - _EulerRow(label: 'Pitch', value: euler.pitchDegrees, color: Colors.green), + _EulerRow( + label: 'Pitch', + value: euler.pitchDegrees, + color: Colors.green, + ), const SizedBox(height: 4), - _EulerRow(label: 'Yaw', value: euler.yawDegrees, color: Colors.blue), + _EulerRow( + label: 'Yaw', + value: euler.yawDegrees, + color: Colors.blue, + ), ], ], ), @@ -1026,8 +1048,8 @@ class _SensorFusionCard extends StatelessWidget { child: Text( 'Orientation offset applied', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.successColor, - ), + color: AppTheme.successColor, + ), ), ), ], @@ -1058,58 +1080,16 @@ class _QuatChip extends StatelessWidget { Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), ), Text( value.toStringAsFixed(3), style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} - -class _EulerChip extends StatelessWidget { - final String label; - final double value; - final Color color; - - const _EulerChip({ - required this.label, - required this.value, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), - ), - Text( - '${value.toStringAsFixed(1)}°', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), ), ], ), @@ -1136,28 +1116,25 @@ class _EulerRow extends StatelessWidget { Container( width: 8, height: 8, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 8), SizedBox( width: 40, child: Text( label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), Expanded( child: Text( '${value.toStringAsFixed(1)}°', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), ), ), ], @@ -1171,11 +1148,7 @@ class _AxisPainter extends CustomPainter { final double pitch; final double yaw; - _AxisPainter({ - required this.roll, - required this.pitch, - required this.yaw, - }); + _AxisPainter({required this.roll, required this.pitch, required this.yaw}); @override void paint(Canvas canvas, Size size) { @@ -1184,10 +1157,10 @@ class _AxisPainter extends CustomPainter { // Apply rotations to get axis endpoints // We'll use a simplified 3D to 2D projection - + // X-axis (red) - points right initially final xEnd = _rotateAndProject(1, 0, 0, axisLength, center); - // Y-axis (green) - points up initially + // Y-axis (green) - points up initially final yEnd = _rotateAndProject(0, 1, 0, axisLength, center); // Z-axis (blue) - points out of screen initially final zEnd = _rotateAndProject(0, 0, 1, axisLength, center); @@ -1221,10 +1194,22 @@ class _AxisPainter extends CustomPainter { ..color = Colors.white ..style = PaintingStyle.fill; canvas.drawCircle(center, 3, centerPaint); - canvas.drawCircle(center, 3, Paint()..color = Colors.grey..style = PaintingStyle.stroke); + canvas.drawCircle( + center, + 3, + Paint() + ..color = Colors.grey + ..style = PaintingStyle.stroke, + ); } - Offset _rotateAndProject(double x, double y, double z, double scale, Offset center) { + Offset _rotateAndProject( + double x, + double y, + double z, + double scale, + Offset center, + ) { // Apply Euler rotations (ZXY order: yaw, roll, pitch) // Yaw (around Z) final cosYaw = math.cos(yaw); @@ -1265,7 +1250,8 @@ class _AxisPainter extends CustomPainter { final cosRoll = math.cos(roll); final sinRoll = math.sin(roll); - final y2 = y1 * cosRoll - z1 * sinRoll; + // y2 intentionally unused — only z2 feeds the pitch rotation below. + final _ = y1 * cosRoll - z1 * sinRoll; final z2 = y1 * sinRoll + z1 * cosRoll; final cosPitch = math.cos(pitch); @@ -1275,7 +1261,13 @@ class _AxisPainter extends CustomPainter { return z3; } - void _drawAxis(Canvas canvas, Offset start, Offset end, Color color, String label) { + void _drawAxis( + Canvas canvas, + Offset start, + Offset end, + Color color, + String label, + ) { // Draw axis line final paint = Paint() ..color = color @@ -1289,18 +1281,27 @@ class _AxisPainter extends CustomPainter { if (length > 10) { final normalized = direction / length; final perpendicular = Offset(-normalized.dy, normalized.dx); - final arrowSize = 6.0; + const arrowSize = 6.0; final arrowBase = end - normalized * arrowSize; - + final path = Path() ..moveTo(end.dx, end.dy) - ..lineTo(arrowBase.dx + perpendicular.dx * arrowSize * 0.5, - arrowBase.dy + perpendicular.dy * arrowSize * 0.5) - ..lineTo(arrowBase.dx - perpendicular.dx * arrowSize * 0.5, - arrowBase.dy - perpendicular.dy * arrowSize * 0.5) + ..lineTo( + arrowBase.dx + perpendicular.dx * arrowSize * 0.5, + arrowBase.dy + perpendicular.dy * arrowSize * 0.5, + ) + ..lineTo( + arrowBase.dx - perpendicular.dx * arrowSize * 0.5, + arrowBase.dy - perpendicular.dy * arrowSize * 0.5, + ) ..close(); - - canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill); + + canvas.drawPath( + path, + Paint() + ..color = color + ..style = PaintingStyle.fill, + ); } // Draw label @@ -1316,11 +1317,14 @@ class _AxisPainter extends CustomPainter { textDirection: TextDirection.ltr, ); textPainter.layout(); - + final labelOffset = end + (end - start) / (end - start).distance * 8; textPainter.paint( canvas, - Offset(labelOffset.dx - textPainter.width / 2, labelOffset.dy - textPainter.height / 2), + Offset( + labelOffset.dx - textPainter.width / 2, + labelOffset.dy - textPainter.height / 2, + ), ); } diff --git a/zswatch_app/lib/ui/screens/developer/shell_screen.dart b/zswatch_app/lib/ui/screens/developer/shell_screen.dart new file mode 100644 index 0000000..8e2def3 --- /dev/null +++ b/zswatch_app/lib/ui/screens/developer/shell_screen.dart @@ -0,0 +1,1808 @@ +import 'dart:async'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/connection.dart'; +import '../../../data/models/thread_monitor_data.dart'; +import '../../../providers/shell_providers.dart'; +import '../../../providers/watch_service_provider.dart'; + +/// Shell screen with tabs: Terminal, Quick Actions, Remote Control, Live Monitor. +class ShellScreen extends ConsumerStatefulWidget { + const ShellScreen({super.key}); + + @override + ConsumerState createState() => _ShellScreenState(); +} + +class _ShellScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + bool _smpEnabling = false; + + void _setSmpEnabling(bool value) { + if (mounted) setState(() => _smpEnabling = value); + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(_onTabChanged); + _enableSmp(); + } + + @override + void deactivate() { + // Cancel the timer immediately (no state change — safe during teardown). + // Then defer the full state update to after the frame completes. + final monitorNotifier = ref.read(liveMonitorProvider.notifier); + final monitor = ref.read(liveMonitorProvider); + if (monitor.isEnabled) { + monitorNotifier.cancelPolling(); + Future(monitorNotifier.stop); + } + + final watchService = ref.read(watchServiceProvider); + unawaited(watchService.disableSmp()); + super.deactivate(); + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + Future _enableSmp() async { + if (_smpEnabling) return; + _setSmpEnabling(true); + try { + final watchService = ref.read(watchServiceProvider); + await watchService.enableSmp(); + await Future.delayed(const Duration(seconds: 2)); + final hasSmp = await watchService.rediscoverServices(); + if (!hasSmp && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'SMP service not found. If your watch firmware is older, ' + 'you may need to manually enable it: ' + 'on the watch go to Apps → Update → Enable FW Update.', + ), + duration: Duration(seconds: 8), + ), + ); + } + } catch (e) { + debugPrint('[ShellScreen] Failed to enable SMP: $e'); + } finally { + _setSmpEnabling(false); + } + } + + void _onTabChanged() { + // Stop live monitor when leaving the monitor tab (index 3) + if (_tabController.index != 3) { + final monitor = ref.read(liveMonitorProvider); + if (monitor.isEnabled) { + ref.read(liveMonitorProvider.notifier).stop(); + } + } + } + + @override + Widget build(BuildContext context) { + final connection = ref.watch(watchConnectionProvider); + + // Re-enable SMP whenever the watch reconnects while this screen is open. + ref.listen(watchConnectionProvider, (prev, next) { + if (!next.isConnected) { + final monitor = ref.read(liveMonitorProvider); + if (monitor.isEnabled) { + ref.read(liveMonitorProvider.notifier).stop(); + } + return; + } + + if (next.isConnected && prev != null && !prev.isConnected) { + _enableSmp(); + } + }); + + return Scaffold( + appBar: AppBar( + title: const Text('Shell'), + actions: [ + IconButton( + icon: _smpEnabling + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + tooltip: 'Re-enable SMP', + onPressed: _smpEnabling ? null : _enableSmp, + ), + ], + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: const [ + Tab(icon: Icon(Icons.terminal, size: 18), text: 'Terminal'), + Tab(icon: Icon(Icons.flash_on, size: 18), text: 'Quick Actions'), + Tab(icon: Icon(Icons.gamepad, size: 18), text: 'Remote'), + Tab(icon: Icon(Icons.monitor_heart, size: 18), text: 'Monitor'), + ], + ), + ), + body: !connection.isConnected + ? const Center(child: Text('Watch not connected')) + : TabBarView( + controller: _tabController, + children: const [ + _TerminalTab(), + _QuickActionsTab(), + _RemoteControlTab(), + _LiveMonitorTab(), + ], + ), + ); + } +} + +// ============================================================================= +// Terminal Tab +// ============================================================================= + +class _TerminalTab extends ConsumerStatefulWidget { + const _TerminalTab(); + + @override + ConsumerState<_TerminalTab> createState() => _TerminalTabState(); +} + +class _TerminalTabState extends ConsumerState<_TerminalTab> { + final TextEditingController _commandController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + _commandController.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _submit() { + final cmd = _commandController.text; + if (cmd.trim().isEmpty) return; + _commandController.clear(); + ref.read(shellTerminalProvider.notifier).execute(cmd); + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final terminalState = ref.watch(shellTerminalProvider); + + ref.listen(shellTerminalProvider, (prev, next) { + if (prev != null && next.lines.length > prev.lines.length) { + _scrollToBottom(); + } + }); + + return Column( + children: [ + // Terminal output + Expanded( + child: ColoredBox( + color: Colors.black, + child: terminalState.lines.isEmpty + ? Center( + child: Text( + 'Type a command below\ne.g. "battery" or "app list"', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'monospace', + color: AppTheme.textSecondary.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(AppTheme.spacingSm), + itemCount: terminalState.lines.length, + itemBuilder: (context, index) { + final line = terminalState.lines[index]; + return SelectableText( + line.text, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: line.isError + ? AppTheme.errorColor + : line.isCommand + ? AppTheme.primaryColor + : AppTheme.textPrimary, + fontWeight: line.isCommand + ? FontWeight.bold + : FontWeight.normal, + ), + ); + }, + ), + ), + ), + + // Input bar + Container( + color: AppTheme.surfaceColor, + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingSm, + vertical: AppTheme.spacingXs, + ), + child: Row( + children: [ + const Text( + '\$ ', + style: TextStyle( + fontFamily: 'monospace', + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: TextField( + controller: _commandController, + focusNode: _focusNode, + style: const TextStyle(fontFamily: 'monospace', fontSize: 14), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Enter command...', + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), + onSubmitted: (_) => _submit(), + textInputAction: TextInputAction.send, + enabled: !terminalState.isExecuting, + ), + ), + if (terminalState.isExecuting) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + icon: const Icon(Icons.send, size: 20), + onPressed: _submit, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () => + ref.read(shellTerminalProvider.notifier).clear(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Quick Actions Tab +// ============================================================================= + +class _QuickActionsTab extends ConsumerWidget { + const _QuickActionsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + padding: const EdgeInsets.all(AppTheme.spacingMd), + children: [ + const _SectionHeader(title: 'System Info'), + _QuickActionCard( + icon: Icons.battery_full, + title: 'Battery Status', + command: 'battery', + ref: ref, + ), + _QuickActionCard( + icon: Icons.power_settings_new, + title: 'Power Status', + command: 'power status', + ref: ref, + ), + _QuickActionCard( + icon: Icons.speed, + title: 'CPU Frequency', + command: 'cpu freq', + ref: ref, + ), + _QuickActionCard( + icon: Icons.apps, + title: 'App State', + command: 'app state', + ref: ref, + ), + _QuickActionCard( + icon: Icons.list, + title: 'App List', + command: 'app list', + ref: ref, + ), + + const SizedBox(height: AppTheme.spacingMd), + const _SectionHeader(title: 'Hardware'), + _QuickActionCard( + icon: Icons.light_mode, + title: 'Get Brightness', + command: 'display get_brightness', + ref: ref, + ), + _QuickActionCard( + icon: Icons.vibration, + title: 'Vibrate (Click)', + command: 'vibration run_pattern click', + ref: ref, + ), + _QuickActionCard( + icon: Icons.notifications_active, + title: 'Vibrate (Notification)', + command: 'vibration run_pattern notification', + ref: ref, + ), + _QuickActionCard( + icon: Icons.mic, + title: 'Mic Gain', + command: 'mic gain_get', + ref: ref, + ), + _QuickActionCard( + icon: Icons.bluetooth, + title: 'BLE FOTA Status', + command: 'ble_fota status', + ref: ref, + ), + + const SizedBox(height: AppTheme.spacingMd), + const _SectionHeader(title: 'Debug'), + _QuickActionCard( + icon: Icons.bug_report, + title: 'Coredump Summary', + command: 'coredump summary', + ref: ref, + ), + _QuickActionCard( + icon: Icons.record_voice_over, + title: 'Voice Memo Status', + command: 'voice_memo status', + ref: ref, + ), + _QuickActionCard( + icon: Icons.system_update, + title: 'Enter Bootloader', + command: 'boot start', + ref: ref, + isDestructive: true, + ), + _QuickActionCard( + icon: Icons.restore, + title: 'Factory Reset', + command: 'factory_reset', + ref: ref, + isDestructive: true, + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + + const _SectionHeader({required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppTheme.spacingSm), + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: AppTheme.textSecondary), + ), + ); + } +} + +class _QuickActionCard extends StatefulWidget { + final IconData icon; + final String title; + final String command; + final WidgetRef ref; + final bool isDestructive; + + const _QuickActionCard({ + required this.icon, + required this.title, + required this.command, + required this.ref, + this.isDestructive = false, + }); + + @override + State<_QuickActionCard> createState() => _QuickActionCardState(); +} + +class _QuickActionCardState extends State<_QuickActionCard> { + String? _result; + bool _isLoading = false; + bool _isError = false; + + Future _execute() async { + if (widget.isDestructive) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirm'), + content: Text('Execute "${widget.command}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text( + 'Execute', + style: TextStyle(color: AppTheme.errorColor), + ), + ), + ], + ), + ); + if (confirmed != true) return; + } + + setState(() { + _isLoading = true; + _result = null; + _isError = false; + }); + + try { + final shellService = widget.ref.read(shellServiceProvider); + final result = await shellService.execute(widget.command); + if (mounted) { + setState(() { + _isLoading = false; + _result = result.output; + _isError = result.returnCode != 0; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _result = e.toString(); + _isError = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: AppTheme.spacingXs), + child: InkWell( + onTap: _isLoading ? null : _execute, + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + widget.icon, + size: 20, + color: widget.isDestructive + ? AppTheme.errorColor + : AppTheme.primaryColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + widget.command, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + fontFamily: 'monospace', + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ], + ), + ), + if (_isLoading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon( + Icons.play_arrow, + size: 20, + color: AppTheme.textSecondary, + ), + ], + ), + if (_result != null) ...[ + const Divider(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppTheme.spacingSm), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppTheme.radiusSmall), + ), + child: SelectableText( + _result!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: _isError + ? AppTheme.errorColor + : AppTheme.textPrimary, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +// ============================================================================= +// Remote Control Tab +// ============================================================================= + +class _RemoteControlTab extends ConsumerStatefulWidget { + const _RemoteControlTab(); + + @override + ConsumerState<_RemoteControlTab> createState() => _RemoteControlTabState(); +} + +class _RemoteControlTabState extends ConsumerState<_RemoteControlTab> { + List? _appList; + bool _isLoadingApps = false; + String? _status; + bool _touchpadScrollLocked = false; + + // Button key codes: KEY_1=2, KEY_2=3, KEY_3=4, KEY_4=5 (EV_KEY=1) + static const _buttonCodes = {'1': 2, '2': 3, '3': 4, '4': 5}; + + Future _sendButton(String buttonId, String label) async { + setState(() => _status = 'Sending $label...'); + try { + final shellService = ref.read(shellServiceProvider); + final code = _buttonCodes[buttonId]!; + await shellService.execute('input report 1 $code 1 true'); + await shellService.execute('input report 1 $code 0 true'); + if (mounted) setState(() => _status = '$label sent'); + } catch (e) { + if (mounted) setState(() => _status = 'Error: $e'); + } + } + + Future _sendTouchDown(int x, int y) async { + setState(() => _status = 'Down ($x, $y)'); + try { + await ref.read(shellServiceProvider).execute('touchdown $x $y'); + } catch (e) { + if (mounted) setState(() => _status = 'Error: $e'); + } + } + + Future _sendTouchMove(int x, int y) async { + setState(() => _status = 'Move ($x, $y)'); + try { + await ref.read(shellServiceProvider).execute('touchmove $x $y'); + } catch (e) { + if (mounted) setState(() => _status = 'Error: $e'); + } + } + + Future _sendTouchUp() async { + setState(() => _status = 'Touch up'); + try { + await ref.read(shellServiceProvider).execute('touchup'); + } catch (e) { + if (mounted) setState(() => _status = 'Error: $e'); + } + } + + Future _loadApps() async { + setState(() => _isLoadingApps = true); + try { + final shellService = ref.read(shellServiceProvider); + final result = await shellService.execute('app list'); + if (mounted) { + // Parse app names from firmware output. + // Format: " [0] AppName (stopped)" or " [1] My App (visible) [hidden]" + final nameRegex = RegExp( + r'\[\d+\]\s+(.+?)\s+\((?:stopped|visible|hidden|unknown)\)', + ); + final names = []; + for (final line in result.output.split('\n')) { + final match = nameRegex.firstMatch(line); + if (match != null) { + names.add(match.group(1)!); + } + } + setState(() { + _appList = names; + _isLoadingApps = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoadingApps = false; + _status = 'Error loading apps: $e'; + }); + } + } + } + + Future _launchApp(String app) async { + setState(() => _status = 'Launching $app...'); + try { + final shellService = ref.read(shellServiceProvider); + await shellService.execute('app close'); + await shellService.execute('app launch $app'); + if (mounted) setState(() => _status = '$app launched'); + } catch (e) { + if (mounted) setState(() => _status = 'Error: $e'); + } + } + + @override + Widget build(BuildContext context) { + return ListView( + physics: _touchpadScrollLocked + ? const NeverScrollableScrollPhysics() + : null, + padding: const EdgeInsets.all(AppTheme.spacingMd), + children: [ + // Touchpad in center, hardware buttons at 45° corners (like the watch) + const _SectionHeader(title: 'Input'), + Card( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Center( + child: SizedBox( + width: 240 + 52 + 8, // touchpad + button + gap + height: 240 + 52 + 8, + child: Stack( + alignment: Alignment.center, + children: [ + _WatchTouchpad( + onDown: _sendTouchDown, + onMove: _sendTouchMove, + onUp: _sendTouchUp, + onScrollLock: (locked) => + setState(() => _touchpadScrollLocked = locked), + ), + // Top-Left: Next + Positioned( + left: 0, + top: 0, + child: _RemoteButton( + label: 'Next', + icon: Icons.skip_next, + onPressed: () => _sendButton('4', 'Next (Top-Left)'), + ), + ), + // Bottom-Left: Prev + Positioned( + left: 0, + bottom: 0, + child: _RemoteButton( + label: 'Prev', + icon: Icons.skip_previous, + onPressed: () => _sendButton('2', 'Prev (Bot-Left)'), + ), + ), + // Top-Right: Select + Positioned( + right: 0, + top: 0, + child: _RemoteButton( + label: 'Select', + icon: Icons.check_circle_outline, + onPressed: () => _sendButton('1', 'Select (Top-Right)'), + ), + ), + // Bottom-Right: Back + Positioned( + right: 0, + bottom: 0, + child: _RemoteButton( + label: 'Back', + icon: Icons.arrow_back_rounded, + onPressed: () => _sendButton('3', 'Back (Bot-Right)'), + ), + ), + ], + ), + ), + ), + ), + ), + + if (_status != null) ...[ + const SizedBox(height: AppTheme.spacingSm), + Text( + _status!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), + ), + ], + + const SizedBox(height: AppTheme.spacingLg), + // App launcher + const _SectionHeader(title: 'App Launcher'), + Row( + children: [ + if (_appList == null) + Expanded( + child: Center( + child: _isLoadingApps + ? const CircularProgressIndicator() + : ElevatedButton.icon( + onPressed: _loadApps, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Load App List'), + ), + ), + ) + else ...[ + Expanded( + child: ElevatedButton.icon( + onPressed: _loadApps, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Refresh'), + ), + ), + ], + ], + ), + if (_appList != null) ...[ + const SizedBox(height: AppTheme.spacingSm), + ...(_appList!.map( + (app) => Card( + margin: const EdgeInsets.only(bottom: AppTheme.spacingXs), + child: ListTile( + dense: true, + leading: const Icon( + Icons.apps, + size: 20, + color: AppTheme.primaryColor, + ), + title: Text(app), + trailing: IconButton( + icon: const Icon(Icons.launch, size: 18), + onPressed: () => _launchApp(app), + ), + ), + ), + )), + ], + ], + ); + } +} + +class _RemoteButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onPressed; + + const _RemoteButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 52, + height: 52, + child: Material( + color: AppTheme.elevatedSurfaceColor, + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: AppTheme.textSecondary), + Text( + label, + style: const TextStyle( + fontSize: 9, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ============================================================================= +// Watch Touchpad Widget +// ============================================================================= + +class _WatchTouchpad extends StatefulWidget { + final Future Function(int x, int y) onDown; + final Future Function(int x, int y) onMove; + final Future Function() onUp; + final ValueChanged? onScrollLock; + + const _WatchTouchpad({ + required this.onDown, + required this.onMove, + required this.onUp, + this.onScrollLock, + }); + + @override + State<_WatchTouchpad> createState() => _WatchTouchpadState(); +} + +class _WatchTouchpadState extends State<_WatchTouchpad> { + static const double _displaySize = 240.0; + static const int _watchSize = 240; + static const int _moveIntervalMs = 50; + + Offset? _pointerPos; + DateTime _lastMoveSent = DateTime.fromMillisecondsSinceEpoch(0); + + (int, int) _localToWatch(Offset global) { + final box = context.findRenderObject()! as RenderBox; + final local = box.globalToLocal(global); + const center = Offset(_displaySize / 2, _displaySize / 2); + const radius = _displaySize / 2; + final fromCenter = local - center; + final clamped = fromCenter.distance > radius + ? center + fromCenter / fromCenter.distance * radius + : local; + setState(() => _pointerPos = clamped); + final x = (clamped.dx / _displaySize * _watchSize).round().clamp( + 0, + _watchSize - 1, + ); + final y = (clamped.dy / _displaySize * _watchSize).round().clamp( + 0, + _watchSize - 1, + ); + return (x, y); + } + + bool _tracking = false; + + void _handlePointerDown(PointerDownEvent event) { + _tracking = true; + widget.onScrollLock?.call(true); + final (x, y) = _localToWatch(event.position); + widget.onDown(x, y); + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_tracking) return; + final now = DateTime.now(); + if (now.difference(_lastMoveSent).inMilliseconds >= _moveIntervalMs) { + _lastMoveSent = now; + final (x, y) = _localToWatch(event.position); + widget.onMove(x, y); + } + } + + void _handlePointerUp(PointerUpEvent event) { + if (!_tracking) return; + _tracking = false; + widget.onScrollLock?.call(false); + widget.onUp(); + if (mounted) setState(() => _pointerPos = null); + } + + void _handlePointerCancel(PointerCancelEvent event) { + if (!_tracking) return; + _tracking = false; + widget.onScrollLock?.call(false); + widget.onUp(); + if (mounted) setState(() => _pointerPos = null); + } + + @override + Widget build(BuildContext context) { + // Listener gets raw pointer events immediately (no gesture arena delay). + // RawGestureDetector with _EagerGestureRecognizer immediately wins the + // gesture arena, preventing parent ListView/TabBarView from scrolling. + return Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + _EagerGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_EagerGestureRecognizer>( + _EagerGestureRecognizer.new, + (_) {}, + ), + }, + child: SizedBox( + width: _displaySize, + height: _displaySize, + child: CustomPaint( + painter: _TouchpadPainter(pointerPos: _pointerPos), + ), + ), + ), + ); + } +} + +/// Gesture recognizer that immediately claims victory in the arena on pointer +/// down, preventing any parent scrollable (ListView, TabBarView) from +/// intercepting the touch. +class _EagerGestureRecognizer extends OneSequenceGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + startTrackingPointer(event.pointer, event.transform); + resolve(GestureDisposition.accepted); + } + + @override + void handleEvent(PointerEvent event) {} + + @override + String get debugDescription => 'eager'; + + @override + void didStopTrackingLastPointer(int pointer) {} +} + +class _TouchpadPainter extends CustomPainter { + final Offset? pointerPos; + + const _TouchpadPainter({this.pointerPos}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.shortestSide / 2; + + // Background circle + canvas.drawCircle(center, radius, Paint()..color = Colors.white10); + + // Border + canvas.drawCircle( + center, + radius, + Paint() + ..color = Colors.white24 + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + // Crosshair lines + final linePaint = Paint() + ..color = Colors.white12 + ..strokeWidth = 1; + canvas.drawLine( + Offset(center.dx, center.dy - radius * 0.8), + Offset(center.dx, center.dy + radius * 0.8), + linePaint, + ); + canvas.drawLine( + Offset(center.dx - radius * 0.8, center.dy), + Offset(center.dx + radius * 0.8, center.dy), + linePaint, + ); + + // Touch indicator dot + if (pointerPos != null) { + canvas.drawCircle( + pointerPos!, + 8, + Paint()..color = Colors.blueAccent.withValues(alpha: 0.8), + ); + canvas.drawCircle( + pointerPos!, + 8, + Paint() + ..color = Colors.blue + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + } + } + + @override + bool shouldRepaint(_TouchpadPainter old) => old.pointerPos != pointerPos; +} + +// ============================================================================= +// Live Monitor Tab +// ============================================================================= + +class _LiveMonitorTab extends ConsumerWidget { + const _LiveMonitorTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final monitor = ref.watch(liveMonitorProvider); + + return Column( + children: [ + // Toggle bar + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + Icon( + monitor.isEnabled ? Icons.circle : Icons.circle_outlined, + size: 12, + color: monitor.isEnabled + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + monitor.isEnabled + ? 'Monitoring active' + : 'Monitoring paused', + style: Theme.of(context).textTheme.bodyMedium, + ), + if (monitor.lastUpdate != null) + Text( + 'Last update: ${_formatTime(monitor.lastUpdate!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ], + ), + ), + if (monitor.threadHistories.isNotEmpty) + IconButton( + icon: const Icon(Icons.restart_alt, size: 20), + tooltip: 'Reset history', + onPressed: () => + ref.read(liveMonitorProvider.notifier).resetHistory(), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + padding: EdgeInsets.zero, + ), + Switch( + value: monitor.isEnabled, + onChanged: (_) => + ref.read(liveMonitorProvider.notifier).toggle(), + activeThumbColor: AppTheme.primaryColor, + ), + ], + ), + ), + + if (monitor.error != null) + Padding( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Text( + monitor.error!, + style: const TextStyle(color: AppTheme.errorColor, fontSize: 12), + ), + ), + + // Data display + Expanded( + child: !monitor.isEnabled && monitor.lastUpdate == null + ? const Center( + child: Text( + 'Enable monitoring to see live status\ndata from the watch', + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.textSecondary), + ), + ) + : monitor.isEnabled && monitor.lastUpdate == null + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // System info row + _SystemInfoCard(monitor: monitor), + const SizedBox(height: AppTheme.spacingSm), + // Threads (stack bars + state + priority + cycles/s) + if (monitor.threadHistories.isNotEmpty) ...[ + _ThreadsCard(histories: monitor.threadHistories), + const SizedBox(height: AppTheme.spacingSm), + ], + // Cycles/s chart + if (monitor.pollCount > 1 && + monitor.threadHistories.isNotEmpty) + _CyclesChartCard( + histories: monitor.threadHistories, + pollCount: monitor.pollCount, + ), + if (monitor.threadHistories.isEmpty && monitor.isEnabled) + const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Thread data not available — check firmware thread monitor support', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + String _formatTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } +} + +// ============================================================================= +// System Info Card (CPU + Power as labels) +// ============================================================================= + +class _SystemInfoCard extends StatelessWidget { + final LiveMonitorState monitor; + const _SystemInfoCard({required this.monitor}); + + @override + Widget build(BuildContext context) { + final power = monitor.powerInfo; + final cpuFreq = monitor.cpuFreq; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.info_outline, + size: 16, + color: AppTheme.primaryColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Text('System', style: Theme.of(context).textTheme.titleSmall), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + Row( + children: [ + Expanded( + child: _InfoTile( + icon: Icons.speed, + label: 'CPU', + value: cpuFreq ?? '—', + color: cpuFreq == 'fast' + ? AppTheme.warningColor + : AppTheme.successColor, + ), + ), + Expanded( + child: _InfoTile( + icon: Icons.power_settings_new, + label: 'Power', + value: power?.state ?? '—', + color: power?.state == 'Active' + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + ), + Expanded( + child: _InfoTile( + icon: Icons.timer_outlined, + label: 'Sleep in', + value: power != null ? '${power.timeToSleepSec}s' : '—', + color: AppTheme.textSecondary, + ), + ), + Expanded( + child: _InfoTile( + icon: Icons.schedule, + label: 'Uptime', + value: power != null ? _formatUptime(power.uptimeSec) : '—', + color: AppTheme.textSecondary, + ), + ), + ], + ), + if (monitor.schedulerCycles != null) ...[ + const SizedBox(height: AppTheme.spacingXs), + Text( + 'Scheduler: ${monitor.schedulerCycles} cycles since last call', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 10, + ), + ), + ], + ], + ), + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds < 60) return '${seconds}s'; + if (seconds < 3600) return '${seconds ~/ 60}m'; + final h = seconds ~/ 3600; + final m = (seconds % 3600) ~/ 60; + return '${h}h ${m}m'; + } +} + +class _InfoTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const _InfoTile({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(height: 2), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + fontSize: 11, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 9, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Stack Usage Card (horizontal bars with max marker) +// ============================================================================= + +class _ThreadsCard extends StatelessWidget { + final Map histories; + const _ThreadsCard({required this.histories}); + + @override + Widget build(BuildContext context) { + final sorted = histories.values.toList() + ..sort((a, b) { + // Active threads first, removed threads at the bottom. + if (a.removed != b.removed) return a.removed ? 1 : -1; + return b.currentUsagePercent.compareTo(a.currentUsagePercent); + }); + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.memory, + size: 16, + color: AppTheme.primaryColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Threads (${sorted.length})', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: AppTheme.warningColor.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'max', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 9, + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + ...sorted.map((t) => _ThreadEntry(thread: t)), + ], + ), + ), + ); + } +} + +class _ThreadEntry extends StatelessWidget { + final ThreadHistory thread; + const _ThreadEntry({required this.thread}); + + @override + Widget build(BuildContext context) { + final isRemoved = thread.removed; + final dimAlpha = isRemoved ? 0.4 : 1.0; + final currentFrac = thread.stackSize > 0 + ? thread.currentStackUsed / thread.stackSize + : 0.0; + final maxFrac = thread.stackSize > 0 + ? thread.maxStackUsed / thread.stackSize + : 0.0; + final maxPct = thread.stackSize > 0 + ? ((thread.maxStackUsed / thread.stackSize) * 100).round() + : 0; + + Color barColor; + if (isRemoved) { + barColor = AppTheme.textSecondary; + } else if (currentFrac > 0.85) { + barColor = AppTheme.errorColor; + } else if (currentFrac > 0.65) { + barColor = AppTheme.warningColor; + } else { + barColor = AppTheme.primaryColor; + } + + final lastCps = thread.cyclesPerSecHistory.isNotEmpty + ? thread.cyclesPerSecHistory.last + : 0.0; + final cpsText = lastCps > 1e6 + ? '${(lastCps / 1e6).toStringAsFixed(1)}M/s' + : lastCps > 1e3 + ? '${(lastCps / 1e3).toStringAsFixed(1)}K/s' + : '${lastCps.toStringAsFixed(0)}/s'; + + return Opacity( + opacity: dimAlpha, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: name, state, priority, cycles/s + Row( + children: [ + Expanded( + child: Text( + thread.name, + style: const TextStyle( + fontSize: 10, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + thread.state, + style: const TextStyle( + fontSize: 8, + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(width: 6), + Text( + 'P${thread.priority}', + style: const TextStyle( + fontSize: 9, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(width: 6), + Text( + cpsText, + style: TextStyle( + fontSize: 9, + color: AppTheme.primaryColor.withValues(alpha: 0.8), + ), + ), + ], + ), + const SizedBox(height: 3), + // Row 2: stack bar with labels + Row( + children: [ + Expanded( + child: SizedBox( + height: 10, + child: LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + return Stack( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(3), + ), + ), + Container( + width: maxWidth * currentFrac.clamp(0.0, 1.0), + decoration: BoxDecoration( + color: barColor.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + ), + if (maxFrac > currentFrac) + Positioned( + left: (maxWidth * maxFrac.clamp(0.0, 1.0)) - 1, + top: 0, + bottom: 0, + child: Container( + width: 2, + color: AppTheme.warningColor.withValues( + alpha: 0.8, + ), + ), + ), + ], + ); + }, + ), + ), + ), + const SizedBox(width: 6), + Text( + '${thread.currentStackUsed}/${thread.stackSize}', + style: const TextStyle( + fontSize: 9, + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), + ), + if (maxPct > thread.currentUsagePercent) ...[ + const SizedBox(width: 4), + Text( + '(max $maxPct%)', + style: TextStyle( + fontSize: 8, + color: AppTheme.warningColor.withValues(alpha: 0.8), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } +} + +// ============================================================================= +// Cycles/s Line Chart +// ============================================================================= + +class _CyclesChartCard extends StatelessWidget { + final Map histories; + final int pollCount; + const _CyclesChartCard({required this.histories, required this.pollCount}); + + static const int _windowSize = 30; + + static const _chartColors = [ + AppTheme.primaryColor, + AppTheme.warningColor, + AppTheme.errorColor, + AppTheme.successColor, + AppTheme.infoColor, + Color(0xFFE040FB), // purple + Color(0xFFFF6E40), // deep orange + Color(0xFF64FFDA), // teal accent + ]; + + @override + Widget build(BuildContext context) { + // Only show threads that have had non-zero cycles and are not removed. + final active = + histories.entries.where((e) { + return !e.value.removed && + e.value.cyclesPerSecHistory.any((v) => v > 0); + }).toList()..sort((a, b) { + final aMax = a.value.cyclesPerSecHistory.isEmpty + ? 0.0 + : a.value.cyclesPerSecHistory.reduce((a, b) => a > b ? a : b); + final bMax = b.value.cyclesPerSecHistory.isEmpty + ? 0.0 + : b.value.cyclesPerSecHistory.reduce((a, b) => a > b ? a : b); + return bMax.compareTo(aMax); + }); + + if (active.isEmpty) return const SizedBox.shrink(); + + // Limit to top 8 most active threads for readability + final shown = active.take(8).toList(); + + // Rolling window: only show the last _windowSize samples + final xMax = (pollCount - 1).toDouble(); + final xMin = (pollCount - _windowSize).toDouble(); + + final lines = []; + for (int idx = 0; idx < shown.length; idx++) { + final th = shown[idx].value; + final history = th.cyclesPerSecHistory; + final offset = pollCount - history.length; + final spots = []; + for (int i = 0; i < history.length; i++) { + final x = (offset + i).toDouble(); + if (x < xMin) continue; + spots.add(FlSpot(x, history[i])); + } + if (spots.isEmpty) continue; + lines.add( + LineChartBarData( + spots: spots, + isCurved: true, + preventCurveOverShooting: true, + color: _chartColors[idx % _chartColors.length], + barWidth: 1.5, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ); + } + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingSm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.show_chart, + size: 16, + color: AppTheme.primaryColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Text('Cycles/s', style: Theme.of(context).textTheme.titleSmall), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + // Legend + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + for (int idx = 0; idx < shown.length; idx++) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 3, + color: _chartColors[idx % _chartColors.length], + ), + const SizedBox(width: 4), + Text( + shown[idx].key, + style: const TextStyle( + fontSize: 9, + fontFamily: 'monospace', + ), + ), + ], + ), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + SizedBox( + height: 150, + child: LineChart( + duration: Duration.zero, + LineChartData( + minX: xMin, + maxX: xMax, + lineBarsData: lines, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: null, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.white.withValues(alpha: 0.05), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value == meta.min || value == meta.max) { + return const SizedBox.shrink(); + } + String text; + if (value >= 1000000) { + text = '${(value / 1000000).toStringAsFixed(1)}M'; + } else if (value >= 1000) { + text = '${(value / 1000).toStringAsFixed(1)}k'; + } else { + text = value.toInt().toString(); + } + return Text( + text, + style: const TextStyle( + fontSize: 8, + color: AppTheme.textSecondary, + ), + ); + }, + ), + ), + bottomTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppTheme.elevatedSurfaceColor, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final name = shown[spot.barIndex].key; + return LineTooltipItem( + '$name: ${spot.y.toStringAsFixed(0)}', + TextStyle( + fontSize: 10, + color: + _chartColors[spot.barIndex % + _chartColors.length], + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/zswatch_app/lib/ui/screens/firmware/firmware_update_screen.dart b/zswatch_app/lib/ui/screens/firmware/firmware_update_screen.dart index 35cc7bf..d1386e6 100644 --- a/zswatch_app/lib/ui/screens/firmware/firmware_update_screen.dart +++ b/zswatch_app/lib/ui/screens/firmware/firmware_update_screen.dart @@ -1,7 +1,9 @@ -import 'package:file_picker/file_picker.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + +import '../../navigation/app_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -14,6 +16,7 @@ import '../../../providers/filesystem_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/watch_service_provider.dart'; import '../../../services/dfu/firmware_manager.dart'; +import '../../widgets/firmware/filesystem_upload_section.dart'; /// Firmware update screen for DFU operations /// @@ -42,7 +45,7 @@ class _FirmwareUpdateScreenState extends ConsumerState { void initState() { super.initState(); _rotatedMode = ref.read(firmwareManagerProvider).useRotatedFirmware; - + // Reset state when entering the screen to clear any stale state // Use addPostFrameCallback to ensure ref is ready WidgetsBinding.instance.addPostFrameCallback((_) { @@ -50,7 +53,7 @@ class _FirmwareUpdateScreenState extends ConsumerState { ref.read(dfuNotifierProvider.notifier).reset(); } }); - + // Listen to DFU logs ref.read(dfuServiceProvider).logStream.listen((log) { if (mounted) { @@ -74,7 +77,7 @@ class _FirmwareUpdateScreenState extends ConsumerState { /// Enable wakelock if setting is enabled and DFU is in progress void _updateWakelock(bool dfuInProgress) { final keepScreenOn = ref.read(keepScreenOnDuringDfuProvider); - + if (dfuInProgress && keepScreenOn && !_wakelockEnabled) { WakelockPlus.enable(); _wakelockEnabled = true; @@ -101,9 +104,10 @@ class _FirmwareUpdateScreenState extends ConsumerState { final hasSmp = ref.watch(hasSmpServiceProvider); // Manage wakelock based on DFU/upload state - final isDfuInProgress = dfuState.status.isInProgress || - fsUploadState.status.isInProgress || - operationState.isDownloading; + final isDfuInProgress = + dfuState.status.isInProgress || + fsUploadState.status.isInProgress || + operationState.isDownloading; _updateWakelock(isDfuInProgress); // Prevent back navigation during critical DFU phase @@ -145,21 +149,18 @@ class _FirmwareUpdateScreenState extends ConsumerState { // Connection status if (!isConnected) _ConnectionWarningCard( - onReconnect: () => context.go('/scan'), + onReconnect: () => context.go(AppRoutes.scan), ), - // SMP service not available warning - if (isConnected && !hasSmp) - const _SmpWarningCard(), - // Firmware selection sections (shown when idle) if (dfuState.status == DfuStatus.idle && !operationState.isDownloading) ...[ // GitHub releases _ReleasesSection( - boardPrefix: FirmwareManager.boardPrefixFromHardwareVersion( - watch?.hardwareVersion, - ), + boardPrefix: + FirmwareManager.boardPrefixFromHardwareVersion( + watch?.hardwareVersion, + ), onAssetSelected: (release, asset) { ref .read(dfuNotifierProvider.notifier) @@ -171,9 +172,10 @@ class _FirmwareUpdateScreenState extends ConsumerState { // CI Builds (GitHub Actions) _CIBuildsSection( - boardPrefix: FirmwareManager.boardPrefixFromHardwareVersion( - watch?.hardwareVersion, - ), + boardPrefix: + FirmwareManager.boardPrefixFromHardwareVersion( + watch?.hardwareVersion, + ), onOpenInBrowser: (run, artifact) async { final url = ref .read(dfuNotifierProvider.notifier) @@ -198,7 +200,9 @@ class _FirmwareUpdateScreenState extends ConsumerState { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Could not open browser. URL: $url'), + content: Text( + 'Could not open browser. URL: $url', + ), backgroundColor: AppTheme.errorColor, ), ); @@ -220,7 +224,7 @@ class _FirmwareUpdateScreenState extends ConsumerState { const SizedBox(height: AppTheme.spacingMd), // Local file picker - _LocalFileSection( + FilesystemUploadSection( onFileSelected: (path) { ref .read(dfuNotifierProvider.notifier) @@ -256,15 +260,17 @@ class _FirmwareUpdateScreenState extends ConsumerState { onChanged: (value) { final enabled = value ?? false; setState(() => _rotatedMode = enabled); - ref.read(firmwareManagerProvider).useRotatedFirmware = enabled; + ref + .read(firmwareManagerProvider) + .useRotatedFirmware = + enabled; }, ), Expanded( child: Text( 'Use rotated display firmware', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ), ], @@ -297,10 +303,12 @@ class _FirmwareUpdateScreenState extends ConsumerState { hasSmpService: hasSmp, onStartFirmware: () => ref.read(dfuNotifierProvider.notifier).startUpdate(), - onStartFilesystem: () => - ref.read(dfuNotifierProvider.notifier).startFilesystemUpload(), - onStartBoth: () => - ref.read(dfuNotifierProvider.notifier).startBothUpdates(), + onStartFilesystem: () => ref + .read(dfuNotifierProvider.notifier) + .startFilesystemUpload(), + onStartBoth: () => ref + .read(dfuNotifierProvider.notifier) + .startBothUpdates(), onCancel: () => ref.read(dfuNotifierProvider.notifier).cancel(), onReset: () => @@ -389,8 +397,8 @@ class _BatteryWarningCard extends StatelessWidget { Text( 'Low Battery ($level%)', style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.warningColor, - ), + color: AppTheme.warningColor, + ), ), Text( 'Consider charging your watch before updating.', @@ -428,8 +436,8 @@ class _ConnectionWarningCard extends StatelessWidget { Text( 'Watch Not Connected', style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.errorColor, - ), + color: AppTheme.errorColor, + ), ), Text( 'Connect to your watch to update firmware.', @@ -438,106 +446,7 @@ class _ConnectionWarningCard extends StatelessWidget { ], ), ), - TextButton( - onPressed: onReconnect, - child: const Text('Connect'), - ), - ], - ), - ), - ); - } -} - -class _SmpWarningCard extends ConsumerStatefulWidget { - const _SmpWarningCard(); - - @override - ConsumerState<_SmpWarningCard> createState() => _SmpWarningCardState(); -} - -class _SmpWarningCardState extends ConsumerState<_SmpWarningCard> { - bool _isChecking = false; - bool _recheckFailed = false; - - Future _recheck() async { - setState(() { - _isChecking = true; - _recheckFailed = false; - }); - try { - final service = ref.read(watchServiceProvider); - final found = await service.rediscoverServices(); - if (mounted) { - if (found) { - // Force the provider to re-evaluate with updated services - ref.invalidate(hasSmpServiceProvider); - } else { - setState(() => _recheckFailed = true); - } - } - } finally { - if (mounted) setState(() => _isChecking = false); - } - } - - @override - Widget build(BuildContext context) { - return Card( - color: AppTheme.warningColor.withValues(alpha: 0.1), - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.warning_amber, color: AppTheme.warningColor), - const SizedBox(width: AppTheme.spacingSm), - Expanded( - child: Text( - 'Update Mode Not Enabled', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.warningColor, - ), - ), - ), - ], - ), - const SizedBox(height: AppTheme.spacingSm), - Text( - 'The SMP service is not available on this watch. ' - 'To enable firmware updates:\n' - '1. On the watch, go to Apps → Update\n' - '2. Set USB and/or BLE to ON\n' - '3. Tap "Re-check" below', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_recheckFailed) ...[ - const SizedBox(height: AppTheme.spacingSm), - Text( - 'SMP still not detected. Try disconnecting and reconnecting ' - 'after enabling update mode on the watch.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.errorColor, - ), - ), - ], - const SizedBox(height: AppTheme.spacingSm), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: _isChecking ? null : _recheck, - icon: _isChecking - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 18), - label: Text(_isChecking ? 'Checking...' : 'Re-check'), - ), - ), + TextButton(onPressed: onReconnect, child: const Text('Connect')), ], ), ), @@ -549,10 +458,7 @@ class _StatusCard extends ConsumerWidget { final DfuState dfuState; final DownloadProgress downloadProgress; - const _StatusCard({ - required this.dfuState, - required this.downloadProgress, - }); + const _StatusCard({required this.dfuState, required this.downloadProgress}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -574,30 +480,36 @@ class _StatusCard extends ConsumerWidget { String? speedText; String? timeRemainingText; String? subStatusText; + List speedHistory = const []; if (isDownloading) { statusTitle = 'Downloading...'; progress = downloadProgress.progress; - bytesText = '${downloadProgress.formattedBytesReceived} / ${downloadProgress.formattedTotalBytes}'; + bytesText = + '${downloadProgress.formattedBytesReceived} / ${downloadProgress.formattedTotalBytes}'; percentText = '${downloadProgress.progressPercent}%'; } else if (isFsUploading) { statusTitle = 'Uploading Filesystem...'; progress = fsUploadState.progress; - bytesText = '${fsUploadState.formattedBytesTransferred} / ${fsUploadState.formattedTotalBytes}'; + bytesText = + '${fsUploadState.formattedBytesTransferred} / ${fsUploadState.formattedTotalBytes}'; percentText = '${fsUploadState.progressPercent}%'; speedText = 'Speed: ${fsUploadState.formattedSpeed}'; timeRemainingText = 'Remaining: ${fsUploadState.formattedTimeRemaining}'; subStatusText = fsUploadState.imageName; + speedHistory = fsUploadState.speedHistory; } else { statusTitle = dfuState.status.statusText; progress = dfuState.progress; - bytesText = '${dfuState.formattedBytesTransferred} / ${dfuState.formattedTotalBytes}'; + bytesText = + '${dfuState.formattedBytesTransferred} / ${dfuState.formattedTotalBytes}'; percentText = '${dfuState.progressPercent}%'; if (dfuState.status == DfuStatus.uploading) { speedText = 'Speed: ${dfuState.formattedSpeed}'; timeRemainingText = 'Remaining: ${dfuState.formattedTimeRemaining}'; } subStatusText = dfuState.currentImageName; + speedHistory = dfuState.speedHistory; } // Show step indicator for "both" updates @@ -618,8 +530,10 @@ class _StatusCard extends ConsumerWidget { status: isDfuInProgress ? dfuState.status : (isFsUploading - ? DfuStatus.uploading - : (isDownloading ? DfuStatus.preparing : DfuStatus.idle)), + ? DfuStatus.uploading + : (isDownloading + ? DfuStatus.preparing + : DfuStatus.idle)), ), const SizedBox(width: AppTheme.spacingSm), Expanded( @@ -667,12 +581,25 @@ class _StatusCard extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(speedText, style: Theme.of(context).textTheme.bodySmall), - Text(timeRemainingText, style: Theme.of(context).textTheme.bodySmall), + Text( + speedText, + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + timeRemainingText, + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ), + // Speed chart + if (speedHistory.length >= 2) + Padding( + padding: const EdgeInsets.only(top: AppTheme.spacingSm), + child: _SpeedChart(speedHistory: speedHistory), + ), + // Multi-image progress (DFU only) if (isDfuInProgress && dfuState.totalImages > 1) Padding( @@ -689,6 +616,49 @@ class _StatusCard extends ConsumerWidget { } } +class _SpeedChart extends StatelessWidget { + final List speedHistory; + + const _SpeedChart({required this.speedHistory}); + + @override + Widget build(BuildContext context) { + final spots = []; + for (int i = 0; i < speedHistory.length; i++) { + spots.add(FlSpot(i.toDouble(), speedHistory[i] / 1024)); + } + + return SizedBox( + height: 60, + child: LineChart( + LineChartData( + gridData: const FlGridData(show: false), + titlesData: const FlTitlesData(show: false), + borderData: FlBorderData(show: false), + lineTouchData: const LineTouchData(enabled: false), + clipData: const FlClipData.all(), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + preventCurveOverShooting: true, + color: Theme.of(context).colorScheme.primary, + barWidth: 1.5, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + ), + ), + ], + ), + ), + ); + } +} + class _StatusIcon extends StatelessWidget { final DfuStatus status; @@ -788,9 +758,8 @@ class _SelectedFirmwareCard extends StatelessWidget { if (image.version != null) Text( 'Version: ${image.version}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -829,8 +798,8 @@ class _SelectedFirmwareCard extends StatelessWidget { Text( 'Filesystem image included (${filesystemImage!.formattedSize})', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.successColor, - ), + color: AppTheme.successColor, + ), ), ], ), @@ -858,8 +827,8 @@ class _SelectedFirmwareCard extends StatelessWidget { Text( 'Firmware only (no filesystem image)', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -921,9 +890,8 @@ class _ReleasesSection extends ConsumerWidget { const Text('No releases loaded'), Text( 'Tap refresh to fetch from GitHub', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -934,11 +902,14 @@ class _ReleasesSection extends ConsumerWidget { return Column( children: releases .take(5) - .map((release) => _ReleaseCard( - release: release, - boardPrefix: boardPrefix, - onAssetSelected: (asset) => onAssetSelected(release, asset), - )) + .map( + (release) => _ReleaseCard( + release: release, + boardPrefix: boardPrefix, + onAssetSelected: (asset) => + onAssetSelected(release, asset), + ), + ) .toList(), ); }, @@ -957,7 +928,8 @@ class _ReleasesSection extends ConsumerWidget { const SizedBox(height: 8), Text('Failed to load releases: $error'), TextButton( - onPressed: () => ref.read(releasesProvider.notifier).fetch(), + onPressed: () => + ref.read(releasesProvider.notifier).fetch(), child: const Text('Retry'), ), ], @@ -993,8 +965,9 @@ class _ReleaseCard extends StatelessWidget { child: ListTile( leading: Icon( release.isPrerelease ? Icons.science : Icons.new_releases, - color: - release.isPrerelease ? AppTheme.warningColor : AppTheme.successColor, + color: release.isPrerelease + ? AppTheme.warningColor + : AppTheme.successColor, ), title: Text(release.name), subtitle: Text( @@ -1013,7 +986,10 @@ class _ReleaseCard extends StatelessWidget { ); } - void _showAssetSelectionDialog(BuildContext context, List assetsToShow) { + void _showAssetSelectionDialog( + BuildContext context, + List assetsToShow, + ) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1026,9 +1002,9 @@ class _ReleaseCard extends StatelessWidget { children: [ Text( '${release.name} (${release.version})', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingMd), Text( @@ -1038,23 +1014,26 @@ class _ReleaseCard extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: AppTheme.spacingSm), - ...assetsToShow.map((asset) => _AssetTile( - asset: asset, - isCompatible: FirmwareManager.isAssetCompatible( - asset.name, - boardPrefix, - ), - onTap: () { - Navigator.pop(context); - onAssetSelected(asset); - }, - )), + ...assetsToShow.map( + (asset) => _AssetTile( + asset: asset, + isCompatible: FirmwareManager.isAssetCompatible( + asset.name, + boardPrefix, + ), + onTap: () { + Navigator.pop(context); + onAssetSelected(asset); + }, + ), + ), ], ), ), actions: [ // Allow showing all assets if filtering is active - if (boardPrefix != null && assetsToShow.length != release.assets.length) + if (boardPrefix != null && + assetsToShow.length != release.assets.length) TextButton( onPressed: () { Navigator.pop(context); @@ -1116,10 +1095,7 @@ class _CIBuildsSection extends ConsumerStatefulWidget { final void Function(WorkflowRun, WorkflowArtifact) onOpenInBrowser; final String? boardPrefix; - const _CIBuildsSection({ - required this.onOpenInBrowser, - this.boardPrefix, - }); + const _CIBuildsSection({required this.onOpenInBrowser, this.boardPrefix}); @override ConsumerState<_CIBuildsSection> createState() => _CIBuildsSectionState(); @@ -1148,7 +1124,10 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: AppTheme.primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), @@ -1156,15 +1135,16 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { child: Text( 'GitHub Actions', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.primaryColor, - ), + color: AppTheme.primaryColor, + ), ), ), ], ), IconButton( icon: const Icon(Icons.refresh, size: 20), - onPressed: () => ref.read(workflowRunsProvider.notifier).fetch(), + onPressed: () => + ref.read(workflowRunsProvider.notifier).fetch(), tooltip: 'Load CI builds', ), ], @@ -1189,9 +1169,8 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { const Text('No CI builds loaded'), Text( 'Tap refresh to fetch from GitHub Actions', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -1240,9 +1219,7 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { const SizedBox(width: 8), Text( branch, - style: Theme.of(context) - .textTheme - .titleSmall + style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(width: 8), @@ -1253,15 +1230,17 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { ), decoration: BoxDecoration( color: isMainBranch - ? AppTheme.successColor.withValues(alpha: 0.1) - : AppTheme.primaryColor.withValues(alpha: 0.1), + ? AppTheme.successColor.withValues( + alpha: 0.1, + ) + : AppTheme.primaryColor.withValues( + alpha: 0.1, + ), borderRadius: BorderRadius.circular(12), ), child: Text( isMainBranch ? 'Release' : 'Debug', - style: Theme.of(context) - .textTheme - .bodySmall + style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: isMainBranch ? AppTheme.successColor @@ -1273,28 +1252,31 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { const Spacer(), Text( '${branchRuns.length} build${branchRuns.length != 1 ? 's' : ''}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ], ), ), // Runs for this branch - ...branchRuns.map((run) => _WorkflowRunTile( - run: run, - boardPrefix: widget.boardPrefix, - isExpanded: _expandedBranches['${branch}_${run.id}'] ?? false, - onToggle: () { - setState(() { - final key = '${branch}_${run.id}'; - _expandedBranches[key] = !(_expandedBranches[key] ?? false); - }); - }, - onOpenInBrowser: (artifact) { - widget.onOpenInBrowser(run, artifact); - }, - )), + ...branchRuns.map( + (run) => _WorkflowRunTile( + run: run, + boardPrefix: widget.boardPrefix, + isExpanded: + _expandedBranches['${branch}_${run.id}'] ?? false, + onToggle: () { + setState(() { + final key = '${branch}_${run.id}'; + _expandedBranches[key] = + !(_expandedBranches[key] ?? false); + }); + }, + onOpenInBrowser: (artifact) { + widget.onOpenInBrowser(run, artifact); + }, + ), + ), ], ), ); @@ -1321,12 +1303,13 @@ class _CIBuildsSectionState extends ConsumerState<_CIBuildsSection> { Text( error.toString(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), textAlign: TextAlign.center, ), TextButton( - onPressed: () => ref.read(workflowRunsProvider.notifier).fetch(), + onPressed: () => + ref.read(workflowRunsProvider.notifier).fetch(), child: const Text('Retry'), ), ], @@ -1386,9 +1369,8 @@ class _WorkflowRunTile extends StatelessWidget { const SizedBox(width: 8), Text( '${filteredArtifacts.length} artifact${filteredArtifacts.length != 1 ? 's' : ''}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -1397,7 +1379,8 @@ class _WorkflowRunTile extends StatelessWidget { children: [ Text( run.shortSha, - style: Theme.of(context).textTheme.bodySmall?.copyWith( + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( fontFamily: 'monospace', color: AppTheme.textSecondary, ), @@ -1406,9 +1389,8 @@ class _WorkflowRunTile extends StatelessWidget { Expanded( child: Text( run.shortCommitMessage, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), overflow: TextOverflow.ellipsis, ), ), @@ -1459,13 +1441,13 @@ class _WorkflowRunTile extends StatelessWidget { children: [ Text( artifact.name, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontFamily: 'monospace'), ), Text( artifact.formattedSize, - style: Theme.of(context).textTheme.bodySmall?.copyWith( + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( color: AppTheme.textSecondary, fontSize: 10, ), @@ -1495,103 +1477,11 @@ class _WorkflowRunTile extends StatelessWidget { } } -class _LocalFileSection extends StatelessWidget { - final void Function(String) onFileSelected; - - const _LocalFileSection({required this.onFileSelected}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AppTheme.spacingSm), - child: Text( - 'Or Select Downloaded File', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Card( - child: InkWell( - onTap: () => _pickFile(context), - borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingLg), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.folder_open, - color: AppTheme.primaryColor.withValues(alpha: 0.7), - ), - const SizedBox(width: AppTheme.spacingSm), - Text( - 'Select .zip firmware file', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.primaryColor, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: AppTheme.spacingSm), - Text( - 'Select a firmware package .zip, e.g. watchdk@1_nrf5340_cpuapp_debug.zip.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ); - } - - Future _pickFile(BuildContext context) async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['zip'], - allowMultiple: false, - ); - - if (result != null && result.files.isNotEmpty) { - final file = result.files.first; - if (file.path != null) { - onFileSelected(file.path!); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Could not access the selected file'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error picking file: $e'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - } -} - class _ErrorCard extends StatelessWidget { final String error; final VoidCallback onDismiss; - const _ErrorCard({ - required this.error, - required this.onDismiss, - }); + const _ErrorCard({required this.error, required this.onDismiss}); @override Widget build(BuildContext context) { @@ -1606,15 +1496,12 @@ class _ErrorCard extends StatelessWidget { Expanded( child: Text( error, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.errorColor, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.errorColor), ), ), - IconButton( - icon: const Icon(Icons.close), - onPressed: onDismiss, - ), + IconButton(icon: const Icon(Icons.close), onPressed: onDismiss), ], ), ), @@ -1649,12 +1536,14 @@ class _ActionButtons extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final fsUploadState = ref.watch(filesystemUploadStateProvider); final isFsUploading = fsUploadState.status.isInProgress; - final isFsCompleted = fsUploadState.status == FilesystemUploadStatus.completed; + final isFsCompleted = + fsUploadState.status == FilesystemUploadStatus.completed; final isFsFailed = fsUploadState.status == FilesystemUploadStatus.failed; - + // During "both" update, only show completion when firmware DFU finishes (not just FS) final isBothUpdating = operationState.isBothUpdating; - final showFsOnlyComplete = isFsCompleted && !isBothUpdating && dfuState.status == DfuStatus.idle; + final showFsOnlyComplete = + isFsCompleted && !isBothUpdating && dfuState.status == DfuStatus.idle; // Completed state - show reset button if (dfuState.status == DfuStatus.completed || showFsOnlyComplete) { @@ -1669,9 +1558,9 @@ class _ActionButtons extends ConsumerWidget { const SizedBox(height: AppTheme.spacingMd), Text( isFullComplete ? 'Update Complete!' : 'Filesystem Upload Complete!', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppTheme.successColor, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppTheme.successColor), ), const SizedBox(height: AppTheme.spacingLg), FilledButton.icon( @@ -1693,17 +1582,13 @@ class _ActionButtons extends ConsumerWidget { : fsUploadState.errorMessage; return Column( children: [ - const Icon( - Icons.error, - color: AppTheme.errorColor, - size: 64, - ), + const Icon(Icons.error, color: AppTheme.errorColor, size: 64), const SizedBox(height: AppTheme.spacingMd), Text( 'Update Failed', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppTheme.errorColor, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppTheme.errorColor), ), if (errorMessage != null) ...[ const SizedBox(height: AppTheme.spacingSm), @@ -1717,13 +1602,12 @@ class _ActionButtons extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - OutlinedButton( - onPressed: onReset, - child: const Text('Reset'), - ), + OutlinedButton(onPressed: onReset, child: const Text('Reset')), const SizedBox(width: AppTheme.spacingMd), FilledButton( - onPressed: operationState.canStartFirmwareUpdate ? onStartFirmware : null, + onPressed: operationState.canStartFirmwareUpdate + ? onStartFirmware + : null, child: const Text('Retry'), ), ], @@ -1738,17 +1622,17 @@ class _ActionButtons extends ConsumerWidget { onPressed: onCancel, icon: const Icon(Icons.cancel), label: const Text('Cancel Download'), - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.errorColor, - ), + style: OutlinedButton.styleFrom(foregroundColor: AppTheme.errorColor), ); } // In progress - show cancel button - if (dfuState.status.isInProgress || isFsUploading || operationState.isBothUpdating) { + if (dfuState.status.isInProgress || + isFsUploading || + operationState.isBothUpdating) { final isCritical = dfuState.status.isCritical; final canCancel = dfuState.status.canCancel || isFsUploading; - + return Column( children: [ if (isCritical) @@ -1795,7 +1679,9 @@ class _ActionButtons extends ConsumerWidget { // Start Both button (shown when both are available) if (operationState.hasBoth) ...[ FilledButton.icon( - onPressed: operationState.canStartBoth && isConnected && hasSmpService ? onStartBoth : null, + onPressed: operationState.canStartBoth && isConnected + ? onStartBoth + : null, icon: const Icon(Icons.playlist_play), label: const Text('Start Both (FS + FW)'), style: FilledButton.styleFrom( @@ -1806,64 +1692,61 @@ class _ActionButtons extends ConsumerWidget { const SizedBox(height: AppTheme.spacingSm), Text( 'Uploads filesystem first, then firmware', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingMd), const Divider(), const SizedBox(height: AppTheme.spacingSm), Text( 'Or update individually:', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingSm), ], - + // Individual buttons row Row( children: [ // Firmware Update button Expanded( child: OutlinedButton.icon( - onPressed: operationState.canStartFirmwareUpdate && isConnected && hasSmpService + onPressed: operationState.canStartFirmwareUpdate && isConnected ? onStartFirmware : null, icon: const Icon(Icons.system_update, size: 18), label: const Text('FW Update'), - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 44), - ), + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), ), ), const SizedBox(width: AppTheme.spacingSm), // Filesystem Upload button Expanded( child: OutlinedButton.icon( - onPressed: operationState.canStartFilesystemUpload && isConnected && hasSmpService + onPressed: + operationState.canStartFilesystemUpload && isConnected ? onStartFilesystem : null, icon: const Icon(Icons.storage, size: 18), label: const Text('FS Upload'), - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 44), - ), + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), ), ), ], ), - + // Status messages if (!isConnected) Padding( padding: const EdgeInsets.only(top: AppTheme.spacingMd), child: Text( 'Connect to your watch to start', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), if (!operationState.hasFirmware && !operationState.hasFilesystem) @@ -1871,13 +1754,12 @@ class _ActionButtons extends ConsumerWidget { padding: const EdgeInsets.only(top: AppTheme.spacingMd), child: Text( 'Select a firmware package to continue', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), ], ); } } - diff --git a/zswatch_app/lib/ui/screens/health/health_screen.dart b/zswatch_app/lib/ui/screens/health/health_screen.dart index d9590b7..df63124 100644 --- a/zswatch_app/lib/ui/screens/health/health_screen.dart +++ b/zswatch_app/lib/ui/screens/health/health_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../navigation/app_router.dart'; + import '../../../core/theme/app_theme.dart'; import '../../../data/models/health_sample.dart'; import '../../../providers/health_providers.dart'; @@ -11,17 +13,17 @@ import '../../widgets/real_time_chart.dart'; /// Activity state colors const _activityColors = { - ActivityState.unknown: Color(0xFF757575), // Dark Gray - ActivityState.notWorn: Color(0xFF9E9E9E), // Gray - ActivityState.deepSleep: Color(0xFF3F51B5), // Indigo - ActivityState.lightSleep: Color(0xFF7986CB), // Light Indigo - ActivityState.remSleep: Color(0xFF9C27B0), // Purple - ActivityState.still: Color(0xFF2196F3), // Blue - ActivityState.running: Color(0xFFFF5722), // Deep Orange - ActivityState.walking: Color(0xFF4CAF50), // Green - ActivityState.swimming: Color(0xFF00BCD4), // Cyan - ActivityState.cycling: Color(0xFFFFEB3B), // Yellow - ActivityState.exercise: Color(0xFFE91E63), // Pink + ActivityState.unknown: Color(0xFF757575), // Dark Gray + ActivityState.notWorn: Color(0xFF9E9E9E), // Gray + ActivityState.deepSleep: Color(0xFF3F51B5), // Indigo + ActivityState.lightSleep: Color(0xFF7986CB), // Light Indigo + ActivityState.remSleep: Color(0xFF9C27B0), // Purple + ActivityState.still: Color(0xFF2196F3), // Blue + ActivityState.running: Color(0xFFFF5722), // Deep Orange + ActivityState.walking: Color(0xFF4CAF50), // Green + ActivityState.swimming: Color(0xFF00BCD4), // Cyan + ActivityState.cycling: Color(0xFFFFEB3B), // Yellow + ActivityState.exercise: Color(0xFFE91E63), // Pink }; /// Health screen showing step counts and heart rate data @@ -37,7 +39,8 @@ class HealthScreen extends ConsumerStatefulWidget { ConsumerState createState() => _HealthScreenState(); } -class _HealthScreenState extends ConsumerState with SingleTickerProviderStateMixin { +class _HealthScreenState extends ConsumerState + with SingleTickerProviderStateMixin { late TabController _tabController; @override @@ -143,14 +146,13 @@ class _HealthScreenState extends ConsumerState with SingleTickerPr maxHr: hrHistory.todayAggregate?.max.round(), sampleCount: hrHistory.todayReadings.length, isConnected: isConnected, - onTap: () => context.push('/health/heart-rate'), + onTap: () => context.push(AppRoutes.heartRate), ), const SizedBox(height: AppTheme.spacingLg), // Info about data sync - if (!isConnected) - const _ConnectionWarning(), + if (!isConnected) const _ConnectionWarning(), ], ), ), @@ -185,9 +187,9 @@ class _StepsSummaryCard extends StatelessWidget { children: [ Text( rangeLabel, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingSm), if (isLoading) @@ -206,9 +208,9 @@ class _StepsSummaryCard extends StatelessWidget { Text( _formatSteps(totalSteps), style: Theme.of(context).textTheme.displayMedium?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), ), const SizedBox(width: AppTheme.spacingXs), Padding( @@ -216,8 +218,8 @@ class _StepsSummaryCard extends StatelessWidget { child: Text( 'steps', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ), ], @@ -250,13 +252,13 @@ class _ActivityBreakdownCard extends StatelessWidget { @override Widget build(BuildContext context) { final hasData = breakdown.totalDuration > Duration.zero; - + final rangeLabel = switch (range) { StepsHistoryRange.day => "Today's", StepsHistoryRange.week => 'This Week', StepsHistoryRange.month => 'This Month', }; - + return Card( child: Padding( padding: const EdgeInsets.all(AppTheme.spacingMd), @@ -272,14 +274,16 @@ class _ActivityBreakdownCard extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), - if (breakdown.currentState != null && range == StepsHistoryRange.day) + if (breakdown.currentState != null && + range == StepsHistoryRange.day) Container( padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingSm, vertical: AppTheme.spacingXs, ), decoration: BoxDecoration( - color: _activityColors[breakdown.currentState]?.withValues(alpha: 0.2), + color: _activityColors[breakdown.currentState] + ?.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(AppTheme.radiusSmall), ), child: Row( @@ -296,10 +300,11 @@ class _ActivityBreakdownCard extends StatelessWidget { const SizedBox(width: 6), Text( breakdown.currentState!.displayName, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: _activityColors[breakdown.currentState], - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: _activityColors[breakdown.currentState], + fontWeight: FontWeight.w600, + ), ), ], ), @@ -326,7 +331,7 @@ class _ActivityBreakdownCard extends StatelessWidget { ), const SizedBox(height: AppTheme.spacingSm), Text( - range == StepsHistoryRange.day + range == StepsHistoryRange.day ? 'Waiting for activity data...' : 'No activity data for this period', style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -370,13 +375,13 @@ class _ActivityBreakdownCard extends StatelessWidget { List _buildPieSections() { final sections = []; - + for (final state in ActivityState.values) { if (state == ActivityState.unknown) continue; - + final percentage = breakdown.getPercentage(state); if (percentage <= 0) continue; - + sections.add( PieChartSectionData( value: percentage * 100, @@ -386,19 +391,19 @@ class _ActivityBreakdownCard extends StatelessWidget { ), ); } - + return sections; } List _buildLegendItems(BuildContext context) { final items = []; - + for (final state in ActivityState.values) { if (state == ActivityState.unknown) continue; - + final duration = breakdown.durations[state] ?? Duration.zero; final percentage = breakdown.getPercentage(state); - + if (duration > Duration.zero || state == breakdown.currentState) { items.add( Padding( @@ -443,25 +448,25 @@ class _ActivityBreakdownCard extends StatelessWidget { ); } } - + if (items.isEmpty) { items.add( Text( 'No activity recorded', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ); } - + return items; } String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); - + if (hours > 0) { return '${hours}h ${minutes}m'; } @@ -540,10 +545,7 @@ class _StepsChartCard extends StatelessWidget { StepsHistoryRange.week => _formatWeekday(aggregate.periodStart), StepsHistoryRange.month => _formatDayOfMonth(aggregate.periodStart), }; - return StepsBarData( - label: label, - steps: aggregate.total.round(), - ); + return StepsBarData(label: label, steps: aggregate.total.round()); }).toList(); } @@ -622,15 +624,15 @@ class _HeartRateCard extends StatelessWidget { Text( 'No heart rate data today', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), const SizedBox(height: AppTheme.spacingSm), Text( 'Tap to start live monitoring', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.primaryColor, - ), + color: AppTheme.primaryColor, + ), ), ], ), @@ -666,8 +668,8 @@ class _HeartRateCard extends StatelessWidget { Text( '$sampleCount readings today', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ], @@ -683,11 +685,7 @@ class _HrStatItem extends StatelessWidget { final int? value; final String unit; - const _HrStatItem({ - required this.label, - this.value, - required this.unit, - }); + const _HrStatItem({required this.label, this.value, required this.unit}); @override Widget build(BuildContext context) { @@ -695,9 +693,9 @@ class _HrStatItem extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 4), Row( @@ -707,17 +705,17 @@ class _HrStatItem extends StatelessWidget { Text( value?.toString() ?? '--', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppTheme.errorColor, - fontWeight: FontWeight.bold, - ), + color: AppTheme.errorColor, + fontWeight: FontWeight.bold, + ), ), Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( unit, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), ), ), ], @@ -737,16 +735,11 @@ class _ConnectionWarning extends StatelessWidget { decoration: BoxDecoration( color: AppTheme.warningColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - border: Border.all( - color: AppTheme.warningColor.withValues(alpha: 0.3), - ), + border: Border.all(color: AppTheme.warningColor.withValues(alpha: 0.3)), ), child: Row( children: [ - const Icon( - Icons.bluetooth_disabled, - color: AppTheme.warningColor, - ), + const Icon(Icons.bluetooth_disabled, color: AppTheme.warningColor), const SizedBox(width: AppTheme.spacingSm), Expanded( child: Column( @@ -755,15 +748,15 @@ class _ConnectionWarning extends StatelessWidget { Text( 'Watch not connected', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.warningColor, - fontWeight: FontWeight.w500, - ), + color: AppTheme.warningColor, + fontWeight: FontWeight.w500, + ), ), Text( 'Connect your watch to sync health data', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), diff --git a/zswatch_app/lib/ui/screens/health/heart_rate_screen.dart b/zswatch_app/lib/ui/screens/health/heart_rate_screen.dart index 69300c8..bd41b97 100644 --- a/zswatch_app/lib/ui/screens/health/heart_rate_screen.dart +++ b/zswatch_app/lib/ui/screens/health/heart_rate_screen.dart @@ -78,9 +78,12 @@ class _HeartRateScreenState extends ConsumerState { style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), - if (hrState.recentReadings.isNotEmpty && + if (hrState.recentReadings.isNotEmpty && hrState.lastUpdate != null && - DateTime.now().difference(hrState.lastUpdate!).inSeconds < 10) ...[ + DateTime.now() + .difference(hrState.lastUpdate!) + .inSeconds < + 10) ...[ Container( width: 8, height: 8, @@ -92,17 +95,14 @@ class _HeartRateScreenState extends ConsumerState { const SizedBox(width: 6), Text( 'Live', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.errorColor, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: AppTheme.errorColor), ), ], ], ), const SizedBox(height: AppTheme.spacingMd), - Expanded( - child: _buildChart(hrState), - ), + Expanded(child: _buildChart(hrState)), ], ), ), @@ -123,8 +123,7 @@ class _HeartRateScreenState extends ConsumerState { const SizedBox(height: AppTheme.spacingMd), // Info message when not connected - if (!isConnected) - _ConnectionWarning(), + if (!isConnected) _ConnectionWarning(), ], ), ), @@ -145,17 +144,17 @@ class _HeartRateScreenState extends ConsumerState { const SizedBox(height: AppTheme.spacingMd), Text( 'No heart rate data', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingSm), Text( 'Heart rate data will appear when\nthe watch sends activity updates', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ], ), @@ -164,11 +163,17 @@ class _HeartRateScreenState extends ConsumerState { // Convert readings to chart data final now = DateTime.now(); - final spots = hrState.recentReadings.map((reading) { - final secondsAgo = now.difference(reading.timestamp).inSeconds; - final x = (_timeWindowSeconds - secondsAgo).toDouble().clamp(0.0, _timeWindowSeconds.toDouble()); - return FlSpot(x.toDouble(), reading.bpm.toDouble()); - }).where((spot) => spot.x >= 0).toList(); + final spots = hrState.recentReadings + .map((reading) { + final secondsAgo = now.difference(reading.timestamp).inSeconds; + final x = (_timeWindowSeconds - secondsAgo).toDouble().clamp( + 0.0, + _timeWindowSeconds.toDouble(), + ); + return FlSpot(x.toDouble(), reading.bpm.toDouble()); + }) + .where((spot) => spot.x >= 0) + .toList(); // Sort by x value spots.sort((a, b) => a.x.compareTo(b.x)); @@ -189,10 +194,7 @@ class _CurrentBpmCard extends StatelessWidget { final int? currentBpm; final bool hasData; - const _CurrentBpmCard({ - this.currentBpm, - required this.hasData, - }); + const _CurrentBpmCard({this.currentBpm, required this.hasData}); @override Widget build(BuildContext context) { @@ -207,7 +209,10 @@ class _CurrentBpmCard extends StatelessWidget { children: [ // Animated heart icon TweenAnimationBuilder( - tween: Tween(begin: 1.0, end: hasData && currentBpm != null ? 1.15 : 1.0), + tween: Tween( + begin: 1.0, + end: hasData && currentBpm != null ? 1.15 : 1.0, + ), duration: const Duration(milliseconds: 300), builder: (context, scale, child) { return Transform.scale( @@ -229,12 +234,12 @@ class _CurrentBpmCard extends StatelessWidget { Text( currentBpm?.toString() ?? '--', style: Theme.of(context).textTheme.displayLarge?.copyWith( - color: hasData && currentBpm != null - ? AppTheme.errorColor - : AppTheme.textSecondary, - fontWeight: FontWeight.bold, - fontSize: 56, - ), + color: hasData && currentBpm != null + ? AppTheme.errorColor + : AppTheme.textSecondary, + fontWeight: FontWeight.bold, + fontSize: 56, + ), ), ], ), @@ -243,9 +248,9 @@ class _CurrentBpmCard extends StatelessWidget { padding: const EdgeInsets.only(top: 16), child: Text( 'BPM', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppTheme.textSecondary), ), ), ], @@ -283,15 +288,15 @@ class _StatisticsCard extends StatelessWidget { Text( 'Session Statistics', style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), const Spacer(), Text( '$sampleCount readings', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), @@ -333,11 +338,7 @@ class _StatItem extends StatelessWidget { final int? value; final Color color; - const _StatItem({ - required this.label, - this.value, - required this.color, - }); + const _StatItem({required this.label, this.value, required this.color}); @override Widget build(BuildContext context) { @@ -345,23 +346,23 @@ class _StatItem extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 4), Text( value?.toString() ?? '--', style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), + color: color, + fontWeight: FontWeight.bold, + ), ), Text( 'BPM', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), ), ], ); @@ -380,16 +381,13 @@ class _ConnectionWarning extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( - Icons.bluetooth_disabled, - color: AppTheme.warningColor, - ), + const Icon(Icons.bluetooth_disabled, color: AppTheme.warningColor), const SizedBox(width: AppTheme.spacingSm), Text( 'Connect your watch to see heart rate', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.warningColor, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.warningColor), ), ], ), diff --git a/zswatch_app/lib/ui/screens/notifications/notification_settings_screen.dart b/zswatch_app/lib/ui/screens/notifications/notification_settings_screen.dart index 2f1e755..965f8a9 100644 --- a/zswatch_app/lib/ui/screens/notifications/notification_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/notifications/notification_settings_screen.dart @@ -19,12 +19,13 @@ class NotificationSettingsScreen extends ConsumerStatefulWidget { const NotificationSettingsScreen({super.key}); @override - ConsumerState createState() => _NotificationSettingsScreenState(); + ConsumerState createState() => + _NotificationSettingsScreenState(); } -class _NotificationSettingsScreenState extends ConsumerState +class _NotificationSettingsScreenState + extends ConsumerState with WidgetsBindingObserver { - List? _apps; bool _loadingApps = false; @@ -53,9 +54,11 @@ class _NotificationSettingsScreenState extends ConsumerState _loadApps() async { if (_loadingApps) return; setState(() => _loadingApps = true); - + try { - final apps = await ref.read(notificationForwardingProvider.notifier).getNotificationApps(); + final apps = await ref + .read(notificationForwardingProvider.notifier) + .getNotificationApps(); if (mounted) { setState(() { _apps = apps; @@ -72,7 +75,7 @@ class _NotificationSettingsScreenState extends ConsumerState _buildDefaultIcon(), + errorBuilder: (_, _, _) => _buildDefaultIcon(), ), ); } catch (_) { @@ -521,12 +511,7 @@ class _AppFilterTile extends StatelessWidget { color: AppTheme.surfaceColor, borderRadius: BorderRadius.circular(8), ), - child: const Icon( - Icons.android, - color: AppTheme.textSecondary, - size: 24, - ), + child: const Icon(Icons.android, color: AppTheme.textSecondary, size: 24), ); } } - diff --git a/zswatch_app/lib/ui/screens/onboarding/permission_onboarding_screen.dart b/zswatch_app/lib/ui/screens/onboarding/permission_onboarding_screen.dart index b64ba49..890c93c 100644 --- a/zswatch_app/lib/ui/screens/onboarding/permission_onboarding_screen.dart +++ b/zswatch_app/lib/ui/screens/onboarding/permission_onboarding_screen.dart @@ -71,7 +71,7 @@ class _PermissionOnboardingScreenState ], // Critical Permissions Section - _SectionHeader( + const _SectionHeader( title: 'Required Permissions', icon: Icons.warning_amber_rounded, color: AppTheme.errorColor, @@ -92,7 +92,7 @@ class _PermissionOnboardingScreenState const SizedBox(height: AppTheme.spacingLg), // Recommended Permissions Section - _SectionHeader( + const _SectionHeader( title: 'Recommended Permissions', icon: Icons.recommend_outlined, color: AppTheme.warningColor, @@ -131,7 +131,7 @@ class _PermissionOnboardingScreenState const SizedBox(height: AppTheme.spacingLg), // Optional Permissions Section - _SectionHeader( + const _SectionHeader( title: 'Optional Permissions', icon: Icons.add_circle_outline, color: AppTheme.textSecondary, diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart new file mode 100644 index 0000000..5a3978d --- /dev/null +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -0,0 +1,2427 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:record/record.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../providers/ai_providers.dart'; +import '../../../providers/settings_providers.dart'; +import '../../../providers/voice_memo_providers.dart'; +import '../../../services/ai/ai_debug_info.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; +import '../../../services/ai/llm_service.dart'; +import '../../../services/ai/model_benchmark_service.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; +import '../../widgets/ai_debug_widgets.dart'; + +// --------------------------------------------------------------------------- +// Benchmark provider (screen-scoped singleton) +// --------------------------------------------------------------------------- + +final _benchmarkServiceProvider = Provider.autoDispose(( + ref, +) { + final service = ModelBenchmarkService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +final _benchmarkStateProvider = StreamProvider.autoDispose(( + ref, +) { + return ref.watch(_benchmarkServiceProvider).stateStream; +}); + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +/// Unified settings page for both Transcription and AI Processing models. +/// +/// Replaces the separate Voice Memos / AI Processing sections that were +/// previously inline in the main Settings screen. +class AiModelsSettingsScreen extends ConsumerWidget { + const AiModelsSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Voice Memo AI')), + body: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + // ---- Transcription section ---- + const _SectionHeader( + title: 'Transcription Model', + subtitle: 'Speech-to-text engine used for voice memos', + ), + const _TranscriptionModelSelector(), + const _RetranscribeButton(), + + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- AI Processing section ---- + const _SectionHeader( + title: 'AI Processing Model', + subtitle: 'Local LLM for summarisation & classification', + ), + const _AiTogglesTile(), + const _AiModelSelector(), + const _ImportModelTile(), + + if (ref.watch(localAiEnabledProvider)) ...[ + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Calendar / Reminders section ---- + const _SectionHeader( + title: 'Calendar Integration', + subtitle: + 'When a voice memo mentions a meeting, deadline, or ' + 'reminder, the AI can create it directly in your calendar. ' + 'Grant access below to enable this.', + ), + const _CalendarPermissionTile(), + if (Platform.isIOS) const _RemindersPermissionTile(), + if (Platform.isAndroid) const _CalendarPickerTile(), + ], + + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Benchmark section ---- + const _SectionHeader( + title: 'Model Benchmark', + subtitle: 'Test model performance on your device', + ), + const _BenchmarkSection(), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Common helpers +// --------------------------------------------------------------------------- + +class _SectionHeader extends StatelessWidget { + final String title; + final String subtitle; + + const _SectionHeader({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ); + } +} + +String _formatBytes(int bytes) { + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + if (bytes >= gb) return '${(bytes / gb).toStringAsFixed(2)} GB'; + if (bytes >= mb) return '${(bytes / mb).toStringAsFixed(0)} MB'; + if (bytes >= kb) return '${(bytes / kb).toStringAsFixed(0)} KB'; + return '$bytes B'; +} + +// --------------------------------------------------------------------------- +// Transcription model selector (dropdown + download/delete) +// --------------------------------------------------------------------------- + +class _TranscriptionModelSelector extends ConsumerStatefulWidget { + const _TranscriptionModelSelector(); + + @override + ConsumerState<_TranscriptionModelSelector> createState() => + _TranscriptionModelSelectorState(); +} + +class _TranscriptionModelSelectorState + extends ConsumerState<_TranscriptionModelSelector> { + bool _isDownloading = false; + double _downloadProgress = 0; + + Future _downloadModel(TranscriptionEngineType type) async { + final info = TranscriptionModelCatalog.info(type); + final engine = createTranscriptionEngine(type); + StreamSubscription? sub; + + try { + setState(() { + _isDownloading = true; + _downloadProgress = 0; + }); + + sub = engine.stateStream.listen((state) { + if (!mounted) return; + if (state.status == TranscriptionEngineStatus.downloading) { + setState(() => _downloadProgress = state.downloadProgress); + } + }); + + await engine.initialize(); + + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Downloaded ${info.name}'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); + } + } finally { + await sub?.cancel(); + if (mounted) setState(() => _isDownloading = false); + engine.dispose(); + _invalidateTranscription(); + } + } + + Future _deleteModel(TranscriptionEngineType type) async { + final info = TranscriptionModelCatalog.info(type); + final shouldDelete = + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: Text('Delete ${info.name} from local storage?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + final engine = createTranscriptionEngine(type); + try { + await engine.deleteModel(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Deleted ${info.name}'))); + } + } finally { + engine.dispose(); + _invalidateTranscription(); + } + } + + void _invalidateTranscription() { + for (final type in TranscriptionEngineType.values) { + ref.invalidate(transcriptionModelStatusProvider(type)); + } + ref.invalidate(transcriptionConfiguredProvider); + ref.invalidate(transcriptionEngineProvider); + ref.invalidate(transcriptionEngineStateProvider); + } + + @override + Widget build(BuildContext context) { + final selectedType = ref.watch(transcriptionEngineTypeProvider); + + return Column( + children: [ + // Dropdown selector + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: DropdownButtonFormField( + initialValue: selectedType, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Select model', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: () { + const all = TranscriptionModelCatalog.all; + // Rank within each language group (models already ordered + // best→worst, so first occurrence per language = rank 1). + final ranked = {}; + final langCounters = {}; + for (final m in all) { + final lang = m.language; + final rank = (langCounters[lang] ?? 0) + 1; + langCounters[lang] = rank; + ranked[m.type] = rank; + } + return all.map((info) { + final modelRank = ranked[info.type]; + final Color rankColor; + switch (modelRank) { + case 1: + rankColor = const Color(0xFFFFD700); + case 2: + rankColor = const Color(0xFFC0C0C0); + case 3: + rankColor = const Color(0xFFCD7F32); + default: + rankColor = AppTheme.textSecondary; + } + return DropdownMenuItem( + value: info.type, + child: Row( + children: [ + if (modelRank != null) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: rankColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '#$modelRank', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: rankColor, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Text(info.name, overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + }).toList(); + }(), + onChanged: _isDownloading + ? null + : (value) { + if (value == null) return; + ref + .read(transcriptionEngineTypeProvider.notifier) + .setType(value); + _invalidateTranscription(); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 6, + AppTheme.spacingMd, + 0, + ), + child: Text( + TranscriptionModelCatalog.info(selectedType).description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ), + + // Selected model details card + _TranscriptionModelCard( + type: selectedType, + isDownloading: _isDownloading, + downloadProgress: _downloadProgress, + onDownload: () => _downloadModel(selectedType), + onDelete: () => _deleteModel(selectedType), + ), + ], + ); + } +} + +class _TranscriptionModelCard extends ConsumerWidget { + final TranscriptionEngineType type; + final bool isDownloading; + final double downloadProgress; + final VoidCallback onDownload; + final VoidCallback onDelete; + + const _TranscriptionModelCard({ + required this.type, + required this.isDownloading, + required this.downloadProgress, + required this.onDownload, + required this.onDelete, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final info = TranscriptionModelCatalog.info(type); + final statusAsync = ref.watch(transcriptionModelStatusProvider(type)); + + return statusAsync.when( + data: (status) { + return Container( + margin: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: status.downloaded + ? AppTheme.successColor.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all( + color: status.downloaded + ? AppTheme.successColor.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status row + Row( + children: [ + Icon( + status.downloaded + ? Icons.check_circle + : Icons.cloud_download_outlined, + size: 18, + color: status.downloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + status.downloaded ? 'Downloaded' : 'Not downloaded', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: status.downloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Info rows + _DetailRow( + label: 'Language', + value: info.language == 'auto' + ? 'Auto-detect' + : info.language.toUpperCase(), + ), + _DetailRow( + label: 'Size', + value: _formatBytes(info.expectedSizeBytes), + ), + if (status.localSizeBytes != null) + _DetailRow( + label: 'Local', + value: _formatBytes(status.localSizeBytes!), + ), + _DetailRow( + label: 'RAM needed', + value: _ramEstimate(info.expectedSizeBytes), + ), + + // Download progress + if (isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: downloadProgress > 0 ? downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + downloadProgress > 0 + ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' + : 'Downloading...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + + const SizedBox(height: 8), + + // Action buttons + Row( + children: [ + if (!status.downloaded) + _CompactButton( + icon: isDownloading ? null : Icons.download, + label: isDownloading ? 'Downloading...' : 'Download', + onPressed: isDownloading ? null : onDownload, + showSpinner: isDownloading, + ) + else + _CompactButton( + icon: Icons.delete_outline, + label: 'Delete', + onPressed: isDownloading ? null : onDelete, + ), + const SizedBox(width: 8), + _CompactButton( + icon: Icons.open_in_new, + label: 'Source', + onPressed: () => launchUrl( + Uri.parse(info.sourceUrl), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Error: $e', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ); + } + + static String _ramEstimate(int modelSizeBytes) { + final mb = modelSizeBytes / (1024 * 1024); + if (mb < 100) return '~200 MB'; + if (mb < 200) return '~500 MB'; + if (mb < 300) return '~500 MB'; + return '~1 GB'; + } +} + +// --------------------------------------------------------------------------- +// Re-transcribe button +// --------------------------------------------------------------------------- + +class _RetranscribeButton extends ConsumerWidget { + const _RetranscribeButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final actionsState = ref.watch(voiceMemoActionsProvider); + final isBusy = actionsState.isLoading; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 4, + AppTheme.spacingMd, + 0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + textStyle: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 16), + label: Text( + isBusy + ? 'Re-transcribing...' + : 'Re-transcribe all with selected model', + ), + onPressed: isBusy + ? null + : () async { + final confirmed = + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Re-transcribe all memos?'), + content: const Text( + 'This will overwrite existing transcriptions ' + 'using the currently selected model.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Re-transcribe'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + final count = await ref + .read(voiceMemoActionsProvider.notifier) + .retranscribeAll(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count == 0 + ? 'No downloaded memos to re-transcribe' + : 'Started re-transcribing $count memos', + ), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Re-transcription failed: $e')), + ); + } + } + }, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// AI Processing toggles +// --------------------------------------------------------------------------- + +class _AiTogglesTile extends ConsumerWidget { + const _AiTogglesTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAiEnabled = ref.watch(localAiEnabledProvider); + final autoProcess = ref.watch(autoProcessVoiceNotesProvider); + final autoCreate = ref.watch(autoCreateActionsProvider); + final correctionEnabled = ref.watch(aiCorrectionEnabledProvider); + final bothEnabled = localAiEnabled && autoProcess; + + return Column( + children: [ + SwitchListTile( + secondary: Icon( + Icons.auto_awesome, + color: localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('Enable Local AI'), + subtitle: const Text('Process voice notes with on-device LLM'), + value: localAiEnabled, + onChanged: (value) { + ref.read(localAiEnabledProvider.notifier).setEnabled(value); + }, + ), + Opacity( + opacity: localAiEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.autorenew, + color: autoProcess && localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('Auto-process after transcription'), + subtitle: Text( + localAiEnabled + ? 'Automatically run AI after each transcription' + : 'Enable Local AI first', + ), + value: autoProcess, + onChanged: localAiEnabled + ? (value) { + ref + .read(autoProcessVoiceNotesProvider.notifier) + .setEnabled(value); + } + : null, + ), + ), + Opacity( + opacity: bothEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.event_available, + color: autoCreate && bothEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('Auto-create calendar events'), + subtitle: Text( + bothEnabled + ? 'Create events automatically (watch have undo window)' + : 'Enable Local AI and auto-process first', + ), + value: autoCreate, + onChanged: bothEnabled + ? (value) { + ref + .read(autoCreateActionsProvider.notifier) + .setEnabled(value); + } + : null, + ), + ), + Opacity( + opacity: localAiEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.spellcheck, + color: correctionEnabled && localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('AI transcript correction'), + subtitle: Text( + localAiEnabled + ? 'Fix transcription errors before classification' + : 'Enable Local AI first', + ), + value: correctionEnabled, + onChanged: localAiEnabled + ? (value) { + ref + .read(aiCorrectionEnabledProvider.notifier) + .setEnabled(value); + } + : null, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// AI Model selector (dropdown + status + download/delete/import) +// --------------------------------------------------------------------------- + +class _AiModelSelector extends ConsumerStatefulWidget { + const _AiModelSelector(); + + @override + ConsumerState<_AiModelSelector> createState() => _AiModelSelectorState(); +} + +class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { + void _refreshProviders() { + ref.invalidate(llmAvailableModelsProvider); + ref.invalidate(selectedLlmModelInfoProvider); + ref.invalidate(llmModelDownloadedProvider); + ref.invalidate(llmModelSizeProvider); + ref.invalidate(llmServiceStateProvider); + } + + Future _downloadModel() async { + final llm = ref.read(llmServiceProvider); + try { + await llm.downloadModel(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Model downloaded'))); + } + _refreshProviders(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); + } + } + } + + Future _deleteModel() async { + final shouldDelete = + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: const Text( + 'Delete the selected model from local storage?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + try { + await ref.read(llmServiceProvider).deleteModel(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Model deleted'))); + } + _refreshProviders(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Delete failed: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final selectedModelId = ref.watch(selectedAiModelIdProvider); + final availableAsync = ref.watch(llmAvailableModelsProvider); + final selectedAsync = ref.watch(selectedLlmModelInfoProvider); + final downloadedAsync = ref.watch(llmModelDownloadedProvider); + final sizeAsync = ref.watch(llmModelSizeProvider); + final serviceAsync = ref.watch(llmServiceStateProvider); + + return selectedAsync.when( + data: (selectedModel) { + return downloadedAsync.when( + data: (isDownloaded) { + final localSize = sizeAsync.whenOrNull( + data: (s) => s != null ? _formatBytes(s) : null, + ); + final isDownloading = + serviceAsync.whenOrNull( + data: (s) => s.status == LlmServiceStatus.downloading, + ) ?? + false; + final downloadProgress = + serviceAsync.whenOrNull(data: (s) => s.downloadProgress) ?? 0.0; + + return Column( + children: [ + // Dropdown + availableAsync.when( + data: (models) => Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: DropdownButtonFormField( + initialValue: models.any((m) => m.id == selectedModelId) + ? selectedModelId + : models.isNotEmpty + ? models.first.id + : null, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Select model', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: () { + // Assign ranks only to catalog models that + // have a benchmarkScore (already sorted best→worst). + final ranked = {}; + var rank = 1; + for (final m in models) { + if (m.benchmarkScore != null) { + ranked[m.id] = rank++; + } + } + return models.map((m) { + final modelRank = ranked[m.id]; + final Color rankColor; + switch (modelRank) { + case 1: + rankColor = const Color(0xFFFFD700); + case 2: + rankColor = const Color(0xFFC0C0C0); + case 3: + rankColor = const Color(0xFFCD7F32); + default: + rankColor = AppTheme.textSecondary; + } + return DropdownMenuItem( + value: m.id, + child: Row( + children: [ + if (modelRank != null) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: rankColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '#$modelRank', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: rankColor, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Text( + m.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(); + }(), + onChanged: isDownloading + ? null + : (value) { + if (value == null) return; + ref + .read(selectedAiModelIdProvider.notifier) + .setModelId(value); + _refreshProviders(); + }, + ), + ), + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Error: $e', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ), + + // Status card + Container( + margin: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isDownloaded + ? Icons.check_circle + : Icons.cloud_download_outlined, + size: 18, + color: isDownloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + isDownloaded ? 'Downloaded' : 'Not downloaded', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: isDownloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + if (selectedModel.expectedSizeBytes != null) + _DetailRow( + label: 'Size', + value: _formatBytes(selectedModel.expectedSizeBytes!), + ), + if (localSize != null) + _DetailRow(label: 'Local', value: localSize), + _DetailRow( + label: 'Source', + value: selectedModel.userProvided + ? 'Imported' + : 'Catalog', + ), + + // Memory fit indicator + _ModelMemoryFitBanner(model: selectedModel), + + // Download progress + if (isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: downloadProgress > 0 ? downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + downloadProgress > 0 + ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' + : 'Starting download...', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + + const SizedBox(height: 8), + + // Action buttons + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (isDownloaded) + _CompactButton( + icon: Icons.delete_outline, + label: 'Delete', + onPressed: isDownloading ? null : _deleteModel, + ) + else if (selectedModel.isDownloadable) + _CompactButton( + icon: isDownloading ? null : Icons.download, + label: isDownloading + ? 'Downloading...' + : 'Download', + onPressed: isDownloading ? null : _downloadModel, + showSpinner: isDownloading, + ), + ], + ), + ], + ), + ), + + // Process all button + _ProcessAllButton(), + ], + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Error: $e', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Error: $e', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Import custom model tile +// --------------------------------------------------------------------------- + +/// Standalone tile that lets the user sideload a local .gguf file. +/// Kept separate from the catalog model selector so it's clear it's a +/// one-time "bring your own model" action, not tied to the selected model. +class _ImportModelTile extends ConsumerWidget { + const _ImportModelTile(); + + Future _importModel(BuildContext context, WidgetRef ref) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a GGUF model file', + ); + final path = result?.files.single.path; + if (path == null) return; + + if (!path.toLowerCase().endsWith('.gguf')) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only .gguf model files can be imported'), + ), + ); + } + return; + } + + final llm = ref.read(llmServiceProvider); + final imported = await llm.importModel(path); + ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); + ref.invalidate(llmAvailableModelsProvider); + ref.invalidate(selectedLlmModelInfoProvider); + ref.invalidate(llmModelDownloadedProvider); + ref.invalidate(llmModelSizeProvider); + ref.invalidate(llmServiceStateProvider); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Imported ${imported.filename}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Import failed: $e'))); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(Icons.upload_file_outlined), + title: const Text('Import custom model'), + subtitle: const Text('Use a local .gguf file from your device'), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: () => _importModel(context, ref), + ); + } +} + +class _ProcessAllButton extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAiEnabled = ref.watch(localAiEnabledProvider); + final aiActionsState = ref.watch(aiActionsProvider); + final isBusy = aiActionsState.isLoading; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 4, + AppTheme.spacingMd, + 0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + textStyle: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 16), + label: Text(isBusy ? 'Processing...' : 'Process all unprocessed'), + onPressed: isBusy || !localAiEnabled + ? null + : () async { + final confirmed = + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Process all unprocessed?'), + content: const Text( + 'All voice memos not yet AI-processed will be processed now.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Process'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + await ref + .read(aiActionsProvider.notifier) + .processAllUnprocessed(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Started processing unprocessed memos'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Processing failed: $e')), + ); + } + } + }, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Benchmark section (debug-sheet-style live progress) +// --------------------------------------------------------------------------- + +class _BenchmarkSection extends ConsumerStatefulWidget { + const _BenchmarkSection(); + + @override + ConsumerState<_BenchmarkSection> createState() => _BenchmarkSectionState(); +} + +class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { + late final TextEditingController _aiInputController; + AudioRecorder? _audioRecorder; + bool _isRecording = false; + Duration _recordingDuration = Duration.zero; + Timer? _recordingTimer; + String? _lastRecordingPath; + + @override + void initState() { + super.initState(); + _aiInputController = TextEditingController( + text: 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.', + ); + } + + @override + void dispose() { + _aiInputController.dispose(); + _recordingTimer?.cancel(); + _audioRecorder?.dispose(); + // Clean up temp recording file + if (_lastRecordingPath != null) { + final f = File(_lastRecordingPath!); + if (f.existsSync()) f.deleteSync(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final benchState = ref.watch(_benchmarkStateProvider); + final isRunning = benchState.whenOrNull(data: (s) => s.isRunning) ?? false; + final runningType = benchState.whenOrNull(data: (s) => s.runningTestType); + final hasTranscript = _aiInputController.text.trim().isNotEmpty; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _BenchmarkPill( + icon: _isRecording ? Icons.fiber_manual_record : Icons.mic, + label: _isRecording ? 'Recording' : 'Phone input', + color: _isRecording + ? AppTheme.errorColor + : AppTheme.primaryColor, + ), + _BenchmarkPill( + icon: hasTranscript ? Icons.check_circle : Icons.edit_note, + label: hasTranscript ? 'Transcript ready' : 'Type or record', + color: hasTranscript + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + ], + ), + const SizedBox(height: 12), + _AiBenchmarkInputEditor( + controller: _aiInputController, + isRecording: _isRecording, + recordingDuration: _recordingDuration, + onReset: () { + _aiInputController.text = + 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; + }, + onRecordToggle: isRunning + ? null + : () => _toggleRecording(context), + ), + const SizedBox(height: 14), + _BenchmarkHintCard( + message: _isRecording + ? 'Recording in progress. Tap the microphone again to stop and transcribe.' + : 'Record on the phone or paste text, then run the AI benchmark using the same prompt flow as voice memos.', + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: runningType == 'ai' + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ) + : const Icon(Icons.psychology, size: 18), + label: Text(runningType == 'ai' ? 'Running…' : 'Test AI'), + onPressed: isRunning || _isRecording + ? null + : () => _runAiBenchmark(context), + ), + ), + const SizedBox(height: 12), + benchState.when( + data: (state) { + final progress = state.current; + if (progress == null || !progress.isComplete) { + return const SizedBox.shrink(); + } + return _LastResultTile(progress: progress); + }, + loading: () => const SizedBox.shrink(), + error: (e, _) => + _BenchmarkHintCard(message: 'Error: $e', isError: true), + ), + ], + ), + ), + ); + } + + // ---- Recording ---- + + Future _toggleRecording(BuildContext context) async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(context); + } + } + + Future _startRecording(BuildContext context) async { + // Request microphone permission + final status = await Permission.microphone.request(); + if (!status.isGranted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Microphone permission is required to record audio.'), + ), + ); + } + return; + } + + _audioRecorder = AudioRecorder(); + + // Record as WAV for best Whisper compatibility + final tempDir = await getTemporaryDirectory(); + _lastRecordingPath = + '${tempDir.path}/benchmark_recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await _audioRecorder!.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 16000, + numChannels: 1, + ), + path: _lastRecordingPath!, + ); + + setState(() { + _isRecording = true; + _recordingDuration = Duration.zero; + }); + + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + setState(() { + _recordingDuration += const Duration(seconds: 1); + }); + }); + } + + Future _stopRecording() async { + _recordingTimer?.cancel(); + _recordingTimer = null; + + final path = await _audioRecorder?.stop(); + await _audioRecorder?.dispose(); + _audioRecorder = null; + + setState(() { + _isRecording = false; + }); + + if (path == null || !File(path).existsSync()) { + debugPrint('[Benchmark] Recording failed – no file at $path'); + return; + } + + _lastRecordingPath = path; + + // Transcribe the recording and fill the text field + final selectedType = ref.read(transcriptionEngineTypeProvider); + final engine = createTranscriptionEngine(selectedType); + try { + final available = await engine.isAvailable(); + if (!available) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Transcription model not downloaded — download it in settings first.', + ), + ), + ); + } + return; + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Transcribing recording…')), + ); + } + + final transcript = await engine.transcribe(path); + if (transcript.isNotEmpty && mounted) { + _aiInputController.text = transcript; + ScaffoldMessenger.of(context).clearSnackBars(); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No speech detected in recording.')), + ); + } + } catch (e) { + debugPrint('[Benchmark] Transcription error: $e'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Transcription failed: $e'))); + } + } finally { + engine.dispose(); + } + } + + void _runAiBenchmark(BuildContext context) { + final benchmarkInput = _aiInputController.text.trim(); + if (benchmarkInput.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Benchmark input cannot be empty.')), + ); + return; + } + + _showBenchmarkSheet(context); + final llm = ref.read(llmServiceProvider); + unawaited( + ref + .read(_benchmarkServiceProvider) + .benchmarkAiModel( + llm, + testInput: benchmarkInput, + correctTranscription: ref.read(aiCorrectionEnabledProvider), + ), + ); + } + + void _showBenchmarkSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.elevatedSurfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => + _BenchmarkDebugSheet(scrollController: scrollController), + ), + ); + } +} + +class _AiBenchmarkInputEditor extends StatelessWidget { + final TextEditingController controller; + final bool isRecording; + final Duration recordingDuration; + final VoidCallback onReset; + final VoidCallback? onRecordToggle; + + const _AiBenchmarkInputEditor({ + required this.controller, + required this.isRecording, + required this.recordingDuration, + required this.onReset, + required this.onRecordToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final minutes = recordingDuration.inMinutes.remainder(60); + final seconds = recordingDuration.inSeconds.remainder(60); + final durationText = + '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'AI benchmark input', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (isRecording) ...[ + Text( + durationText, + style: theme.textTheme.labelMedium?.copyWith( + color: AppTheme.errorColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 4), + ], + IconButton( + onPressed: onRecordToggle, + icon: Icon( + isRecording ? Icons.stop_circle : Icons.mic, + color: isRecording ? AppTheme.errorColor : null, + size: 22, + ), + tooltip: isRecording ? 'Stop recording' : 'Record audio', + style: IconButton.styleFrom( + backgroundColor: isRecording + ? AppTheme.errorColor.withValues(alpha: 0.15) + : Colors.white.withValues(alpha: 0.04), + ), + ), + const SizedBox(width: 4), + TextButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text('Reset'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: controller, + minLines: 4, + maxLines: 7, + decoration: InputDecoration( + labelText: 'Test input text', + alignLabelWithHint: true, + border: const OutlineInputBorder(), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.02), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + ), + ], + ); + } +} + +/// Compact tile shown in the benchmark section after a completed run. +class _LastResultTile extends StatelessWidget { + final AiDebugInfo progress; + const _LastResultTile({required this.progress}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isError = progress.isError; + final icon = switch (progress.testType) { + 'transcription' => Icons.mic, + _ => Icons.psychology, + }; + final statusColor = isError ? AppTheme.errorColor : AppTheme.successColor; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: statusColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Latest run', + style: theme.textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + progress.modelName, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + if (progress.elapsed > Duration.zero) + Text( + isError + ? 'Failed' + : '${(progress.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s' + '${progress.tokensPerSecond != null ? ' • ${progress.tokensPerSecond!.toStringAsFixed(1)} t/s' : ''}', + style: theme.textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _BenchmarkPill extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + + const _BenchmarkPill({ + required this.icon, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.22)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _BenchmarkHintCard extends StatelessWidget { + final String message; + final bool isError; + + const _BenchmarkHintCard({required this.message, this.isError = false}); + + @override + Widget build(BuildContext context) { + final color = isError ? AppTheme.errorColor : AppTheme.textSecondary; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: isError ? 0.08 : 0.05), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: color.withValues(alpha: 0.14)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.info_outline, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} + +/// Bottom sheet showing live benchmark progress — uses shared debug widgets +/// from [ai_debug_widgets.dart] for visual parity with the voice-memo debug +/// sheet. +class _BenchmarkDebugSheet extends ConsumerWidget { + final ScrollController scrollController; + + const _BenchmarkDebugSheet({required this.scrollController}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final benchState = ref.watch(_benchmarkStateProvider); + final progress = benchState.whenOrNull(data: (s) => s.current); + final isRunning = benchState.whenOrNull(data: (s) => s.isRunning) ?? false; + + return Column( + children: [ + aiDebugHandleBar(), + aiDebugSheetHeader( + context, + title: 'Benchmark Debug', + showSpinner: progress != null && !progress.isComplete, + onStop: isRunning + ? () => ref.read(_benchmarkServiceProvider).abort() + : null, + onClose: () => Navigator.of(context).pop(), + ), + const Divider(), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: _buildBody(context, progress), + ), + ), + ], + ); + } + + List _buildBody(BuildContext context, AiDebugInfo? progress) { + if (progress == null) { + return [aiDebugNote(context, 'Waiting for benchmark to start…')]; + } + + final isAiType = progress.testType == 'ai'; + + if (!progress.isComplete) { + // ---- Live / in-progress view ---- + final phaseText = switch (progress.phase) { + 'loading' => 'Loading model…', + 'transcribing' => 'Transcribing audio…', + 'correcting' => 'Correcting transcription…', + 'classifying' => 'Classifying & extracting…', + 'running' => isAiType ? 'Generating…' : 'Transcribing…', + _ => 'Processing…', + }; + return [ + aiLivePhaseHeader( + context, + modelName: progress.modelName, + phaseText: phaseText, + tokens: progress.tokens, + tokensPerSecond: progress.tokensPerSecond, + elapsed: progress.elapsed, + ), + if (progress.partialOutput.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: progress.phase == 'transcribing' + ? 'Transcription Status' + : 'LLM Output (live)', + content: progress.partialOutput, + icon: progress.phase == 'transcribing' ? Icons.mic : Icons.code, + mono: progress.phase != 'transcribing', + showCopyButton: true, + ), + ], + // Memory & inference info + if (aiMemoryInfoBlock(context, progress) != null) ...[ + const SizedBox(height: 12), + aiMemoryInfoBlock(context, progress)!, + ], + ]; + } + + // ---- Completed view ---- + return [ + aiCompletedHeader( + context, + modelName: progress.modelName, + isError: progress.isError, + tokens: progress.tokens, + tokensPerSecond: progress.tokensPerSecond, + elapsed: progress.elapsed, + ), + // Correction result + if (progress.correctedTranscription != null && + progress.correctedTranscription!.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: + 'Corrected Transcription' + '${progress.correctionTokensPerSecond != null ? ' (${progress.correctionTokens} tokens, ${progress.correctionTokensPerSecond!.toStringAsFixed(1)} t/s, ${(progress.correctionElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s)' : ''}', + content: progress.correctedTranscription!, + icon: Icons.spellcheck, + showCopyButton: true, + ), + ], + if (isAiType) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Prompt / Flow', + content: aiFormatPromptFlow( + strategy: progress.promptStrategy, + retryEnabled: progress.retryEnabled, + attempts: progress.attempts, + ), + icon: Icons.tune, + showCopyButton: true, + ), + ], + if (isAiType && + aiHasChronoDetails( + extractedIntent: progress.extractedIntent, + extractedTitle: progress.extractedTitle, + datetimeExpressionOriginal: progress.datetimeExpressionOriginal, + datetimeExpressionEnglish: progress.datetimeExpressionEnglish, + resolvedDateTime: progress.resolvedDateTime, + resolverMethod: progress.resolverMethod, + extractedActions: progress.extractedActions, + )) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Chrono Extraction / Resolution', + content: aiFormatChronoDetails( + extractedIntent: progress.extractedIntent, + extractedTitle: progress.extractedTitle, + datetimeExpressionOriginal: progress.datetimeExpressionOriginal, + datetimeExpressionEnglish: progress.datetimeExpressionEnglish, + resolvedDateTime: progress.resolvedDateTime, + resolverMethod: progress.resolverMethod, + extractedActions: progress.extractedActions, + ), + icon: Icons.schedule, + showCopyButton: true, + ), + ], + // Show parsed summary + if (progress.partialOutput.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Parsed Result', + content: progress.partialOutput, + icon: Icons.check_circle_outline, + showCopyButton: true, + ), + ], + if (progress.parsedJson != null && progress.parsedJson!.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Parsed JSON', + content: aiFormatJson(progress.parsedJson!), + icon: Icons.data_object, + mono: true, + showCopyButton: true, + ), + ], + // Show full raw LLM output (preserved after completion) + if (progress.rawOutput != null && + progress.rawOutput!.isNotEmpty && + progress.rawOutput != progress.partialOutput) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Raw LLM Output', + content: progress.rawOutput!, + icon: Icons.code, + mono: true, + showCopyButton: true, + ), + ], + if (progress.isError && progress.error != null) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Error', + content: progress.error!, + icon: Icons.error_outline, + showCopyButton: true, + ), + ], + // Memory & inference info + if (aiMemoryInfoBlock(context, progress) != null) ...[ + const SizedBox(height: 12), + aiMemoryInfoBlock(context, progress)!, + ], + ]; + } +} + +// --------------------------------------------------------------------------- +// Shared small widgets +// --------------------------------------------------------------------------- + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ); + } +} + +class _CompactButton extends StatelessWidget { + final IconData? icon; + final String label; + final VoidCallback? onPressed; + final bool showSpinner; + + const _CompactButton({ + this.icon, + required this.label, + this.onPressed, + this.showSpinner = false, + }); + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onPressed, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + icon: showSpinner + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : icon != null + ? Icon(icon, size: 16) + : const SizedBox.shrink(), + label: Text(label), + ); + } +} + +// --------------------------------------------------------------------------- +// Memory fit banner for the selected AI model +// --------------------------------------------------------------------------- + +class _ModelMemoryFitBanner extends ConsumerWidget { + const _ModelMemoryFitBanner({required this.model}); + + final LlmModelInfo model; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fitAsync = ref.watch(llmModelFitProvider); + + return fitAsync.when( + data: (fit) { + final IconData icon; + final Color color; + final String label; + + switch (fit.fit) { + case ModelMemoryFit.comfortable: + icon = Icons.check_circle_outline; + color = AppTheme.successColor; + label = fit.summary; + case ModelMemoryFit.reduced: + icon = Icons.warning_amber_rounded; + color = AppTheme.warningColor; + label = fit.summary; + case ModelMemoryFit.cpuFallback: + icon = Icons.memory; + color = AppTheme.errorColor; + label = fit.summary; + } + + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + +// --------------------------------------------------------------------------- +// Calendar Integration +// --------------------------------------------------------------------------- + +/// Shows calendar permission status and a button to grant it. +class _CalendarPermissionTile extends ConsumerStatefulWidget { + const _CalendarPermissionTile(); + + @override + ConsumerState<_CalendarPermissionTile> createState() => + _CalendarPermissionTileState(); +} + +class _CalendarPermissionTileState + extends ConsumerState<_CalendarPermissionTile> + with WidgetsBindingObserver { + bool _granted = false; + bool _checking = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _checkPermission(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkPermission(); + } + } + + Future _checkPermission() async { + final status = await Permission.calendarFullAccess.status; + if (mounted) { + setState(() { + _granted = status.isGranted; + _checking = false; + }); + } + } + + Future _requestPermission() async { + final status = await Permission.calendarFullAccess.request(); + if (status.isPermanentlyDenied && mounted) { + await openAppSettings(); + } + await _checkPermission(); + if (_granted) { + // Refresh calendar list now that permission is granted + ref.invalidate(writableCalendarsProvider); + } + } + + @override + Widget build(BuildContext context) { + if (_checking) { + return const ListTile( + leading: Icon(Icons.hourglass_empty, color: AppTheme.textSecondary), + title: Text('Calendar Permission'), + subtitle: Text('Checking...'), + ); + } + + return ListTile( + leading: Icon( + _granted ? Icons.check_circle : Icons.calendar_month, + color: _granted ? AppTheme.successColor : AppTheme.warningColor, + ), + title: const Text('Calendar Permission'), + subtitle: Text( + _granted + ? 'Granted — AI can create calendar events and reminders' + : 'Required for creating events from voice memos', + ), + trailing: _granted + ? null + : FilledButton( + onPressed: _requestPermission, + child: const Text('Grant'), + ), + ); + } +} + +/// Shows iOS Reminders permission status (separate from calendar on iOS). +class _RemindersPermissionTile extends ConsumerStatefulWidget { + const _RemindersPermissionTile(); + + @override + ConsumerState<_RemindersPermissionTile> createState() => + _RemindersPermissionTileState(); +} + +class _RemindersPermissionTileState + extends ConsumerState<_RemindersPermissionTile> + with WidgetsBindingObserver { + bool _granted = false; + bool _checking = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _checkPermission(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkPermission(); + } + } + + Future _checkPermission() async { + final status = await Permission.reminders.status; + if (mounted) { + setState(() { + _granted = status.isGranted; + _checking = false; + }); + } + } + + Future _requestPermission() async { + final status = await Permission.reminders.request(); + if (status.isPermanentlyDenied && mounted) { + await openAppSettings(); + } + await _checkPermission(); + } + + @override + Widget build(BuildContext context) { + if (_checking) { + return const ListTile( + leading: Icon(Icons.hourglass_empty, color: AppTheme.textSecondary), + title: Text('Reminders Permission'), + subtitle: Text('Checking...'), + ); + } + + return ListTile( + leading: Icon( + _granted ? Icons.check_circle : Icons.checklist, + color: _granted ? AppTheme.successColor : AppTheme.warningColor, + ), + title: const Text('Reminders Permission'), + subtitle: Text( + _granted + ? 'Granted — AI can create reminders' + : 'Required for creating reminders from voice memos', + ), + trailing: _granted + ? null + : FilledButton( + onPressed: _requestPermission, + child: const Text('Grant'), + ), + ); + } +} + +/// Shows the selected calendar and allows picking a different one. +/// Only visible when calendar permission is granted (Android only). +class _CalendarPickerTile extends ConsumerWidget { + const _CalendarPickerTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calendarsAsync = ref.watch(writableCalendarsProvider); + final selectedCalendarId = ref.watch( + selectedProductivityCalendarIdProvider, + ); + + return calendarsAsync.when( + data: (calendars) { + if (calendars.isEmpty) { + return const ListTile( + leading: Icon(Icons.calendar_today, color: AppTheme.textSecondary), + title: Text('Default Calendar'), + subtitle: Text( + 'No writable calendars found. Grant calendar permission above.', + ), + ); + } + + final selectedCalendar = calendars + .where((c) => c.id == selectedCalendarId) + .cast() + .firstWhere((c) => c != null, orElse: () => calendars.first); + + final cal = selectedCalendar ?? calendars.first; + + return ListTile( + leading: Icon( + cal.looksLocal ? Icons.event_busy : Icons.calendar_today, + color: cal.looksLocal + ? AppTheme.warningColor + : AppTheme.primaryColor, + ), + title: const Text('Default Calendar'), + subtitle: Text( + cal.looksLocal + ? '${cal.label}\nLocal calendars may not sync to cloud.' + : cal.label, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showPicker(context, ref, calendars), + ); + }, + loading: () => const ListTile( + leading: Icon(Icons.calendar_today, color: AppTheme.textSecondary), + title: Text('Default Calendar'), + subtitle: Text('Loading calendars...'), + ), + error: (error, _) => ListTile( + leading: const Icon(Icons.calendar_today, color: AppTheme.warningColor), + title: const Text('Default Calendar'), + subtitle: Text('Error: $error'), + trailing: IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(writableCalendarsProvider), + ), + ), + ); + } + + Future _showPicker( + BuildContext context, + WidgetRef ref, + List calendars, + ) async { + final selectedId = ref.read(selectedProductivityCalendarIdProvider); + final picked = await showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text('Choose Calendar'), + subtitle: Text('Used for events and reminders created by AI.'), + ), + for (final calendar in calendars) + ListTile( + leading: Icon( + calendar.id == selectedId + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: calendar.id == selectedId + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: Text(calendar.label), + subtitle: calendar.looksLocal + ? const Text('Local — may not sync to cloud') + : null, + onTap: () => Navigator.of(context).pop(calendar.id), + ), + ], + ), + ); + }, + ); + + if (picked != null) { + ref + .read(selectedProductivityCalendarIdProvider.notifier) + .setCalendarId(picked); + } + } +} diff --git a/zswatch_app/lib/ui/screens/settings/settings_screen.dart b/zswatch_app/lib/ui/screens/settings/settings_screen.dart index 85a5184..347c5ce 100644 --- a/zswatch_app/lib/ui/screens/settings/settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/settings_screen.dart @@ -9,9 +9,12 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../providers/ai_providers.dart'; import '../../../providers/demo_mode_provider.dart'; import '../../../providers/permission_providers.dart'; import '../../../providers/settings_providers.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; +import '../../navigation/app_router.dart'; import '../onboarding/permission_onboarding_screen.dart'; /// Settings screen for app configuration @@ -23,21 +26,21 @@ class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); // TODO: Update these URLs to point to the correct repositories - static const String _appGithubUrl = 'https://github.com/ZSWatch/ZSWatch-App'; // <-- Change to app repo - static const String _firmwareGithubUrl = 'https://github.com/ZSWatch/ZSWatch'; // <-- Change to firmware repo + static const String _appGithubUrl = + 'https://github.com/ZSWatch/ZSWatch-App'; // <-- Change to app repo + static const String _firmwareGithubUrl = + 'https://github.com/ZSWatch/ZSWatch'; // <-- Change to firmware repo static const String _appVersion = '1.0.0'; static const String _buildNumber = '1'; @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - ), + appBar: AppBar(title: const Text('Settings')), body: ListView( children: [ // Connection Settings - _SectionHeader(title: 'Connection'), + const _SectionHeader(title: 'Connection'), _SettingsTile( leading: Icon( ref.watch(backgroundConnectionEnabledProvider) @@ -64,17 +67,21 @@ class SettingsScreen extends ConsumerWidget { // Show warning that feature won't work properly ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Notification permission is required for the persistent connection indicator'), + content: Text( + 'Notification permission is required for the persistent connection indicator', + ), duration: Duration(seconds: 3), ), ); } } } - ref.read(backgroundConnectionEnabledProvider.notifier).setEnabled(value); + ref + .read(backgroundConnectionEnabledProvider.notifier) + .setEnabled(value); // If disabling while connected, the service will keep running // until the watch is disconnected - if (!value) { + if (!value && context.mounted) { _showPersistentConnectionDisabledDialog(context); } }, @@ -84,37 +91,54 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), // Permissions Section (consolidated) - _SectionHeader(title: 'Permissions'), + const _SectionHeader(title: 'Permissions'), _PermissionsSummaryTile(), const Divider(height: 32), // Firmware Update Settings - _SectionHeader(title: 'Firmware Update'), + const _SectionHeader(title: 'Firmware Update'), _SettingsTile( - leading: const Icon(Icons.screen_lock_portrait, color: AppTheme.textSecondary), + leading: const Icon( + Icons.screen_lock_portrait, + color: AppTheme.textSecondary, + ), title: 'Keep Screen On During DFU', subtitle: 'Prevent screen timeout during firmware updates', trailing: Switch( value: ref.watch(keepScreenOnDuringDfuProvider), onChanged: (value) { - ref.read(keepScreenOnDuringDfuProvider.notifier).setEnabled(value); + ref + .read(keepScreenOnDuringDfuProvider.notifier) + .setEnabled(value); }, ), ), const Divider(height: 32), + // Voice Memo AI (sub-page) + const _SectionHeader(title: 'Voice Memo AI'), + _AiTranscriptionNavTile(), + + const Divider(height: 32), + // About Section - _SectionHeader(title: 'About'), + const _SectionHeader(title: 'About'), _SettingsTile( - leading: const Icon(Icons.info_outline, color: AppTheme.textSecondary), + leading: const Icon( + Icons.info_outline, + color: AppTheme.textSecondary, + ), title: 'App Version', subtitle: '$_appVersion ($_buildNumber)', onTap: () => _showVersionDialog(context), ), _SettingsTile( - leading: const Icon(Icons.phone_android, color: AppTheme.textSecondary), + leading: const Icon( + Icons.phone_android, + color: AppTheme.textSecondary, + ), title: 'Companion App Source', subtitle: 'View app source code on GitHub', trailing: const Icon(Icons.open_in_new, size: 20), @@ -132,7 +156,10 @@ class SettingsScreen extends ConsumerWidget { onTap: () => _launchUrl(_firmwareGithubUrl), ), _SettingsTile( - leading: const Icon(Icons.description_outlined, color: AppTheme.textSecondary), + leading: const Icon( + Icons.description_outlined, + color: AppTheme.textSecondary, + ), title: 'Licenses', subtitle: 'Open source licenses', trailing: const Icon(Icons.chevron_right), @@ -142,7 +169,7 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), // Demo Mode (for app store reviewers without hardware) - _SectionHeader(title: 'Developer'), + const _SectionHeader(title: 'Developer'), _SettingsTile( leading: Icon( Icons.science, @@ -157,11 +184,21 @@ class SettingsScreen extends ConsumerWidget { onChanged: (value) { ref.read(demoModeProvider.notifier).state = value; if (value) { - context.go('/'); + context.go(AppRoutes.home); } }, ), ), + _SettingsTile( + leading: const Icon( + Icons.dns_outlined, + color: AppTheme.textSecondary, + ), + title: 'Coredump Server', + subtitle: ref.watch(coredumpServerUrlProvider), + trailing: const Icon(Icons.edit, size: 20), + onTap: () => _showCoredumpServerUrlDialog(context, ref), + ), const SizedBox(height: 32), @@ -179,15 +216,15 @@ class SettingsScreen extends ConsumerWidget { Text( 'ZSWatch Companion', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), const SizedBox(height: 4), Text( 'Companion app for the open-source ZSWatch smartwatch', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), textAlign: TextAlign.center, ), ], @@ -205,13 +242,13 @@ class SettingsScreen extends ConsumerWidget { context: context, builder: (context) => AlertDialog( title: const Text('App Version'), - content: Column( + content: const Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _InfoRow(label: 'Version', value: _appVersion), _InfoRow(label: 'Build', value: _buildNumber), - const _InfoRow(label: 'Platform', value: 'Flutter'), + _InfoRow(label: 'Platform', value: 'Flutter'), ], ), actions: [ @@ -247,6 +284,50 @@ class SettingsScreen extends ConsumerWidget { } } + void _showCoredumpServerUrlDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController( + text: ref.read(coredumpServerUrlProvider), + ); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Coredump Server URL'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: CoredumpServerUrlNotifier.defaultUrl, + labelText: 'Server URL', + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + actions: [ + TextButton( + onPressed: () { + ref.read(coredumpServerUrlProvider.notifier).reset(); + Navigator.pop(ctx); + }, + child: const Text('Reset'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isNotEmpty) { + ref.read(coredumpServerUrlProvider.notifier).setUrl(url); + } + Navigator.pop(ctx); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showPersistentConnectionDisabledDialog(BuildContext context) { showDialog( context: context, @@ -286,9 +367,9 @@ class _SectionHeader extends StatelessWidget { child: Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), ), ); } @@ -316,9 +397,9 @@ class _SettingsTile extends StatelessWidget { title: Text(title), subtitle: Text( subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), trailing: trailing, onTap: onTap, @@ -341,22 +422,42 @@ class _InfoRow extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - Text( - value, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), + Text(value, style: Theme.of(context).textTheme.bodyMedium), ], ), ); } } +/// Compact summary tile that navigates to the Voice Memo AI sub-page. +class _AiTranscriptionNavTile extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final transcriptionType = ref.watch(transcriptionEngineTypeProvider); + final transcriptionInfo = TranscriptionModelCatalog.info(transcriptionType); + final localAiEnabled = ref.watch(localAiEnabledProvider); + final aiModelName = ref + .watch(selectedLlmModelInfoProvider) + .whenOrNull(data: (m) => m.displayName); + + return _SettingsTile( + leading: const Icon(Icons.mic, color: AppTheme.primaryColor), + title: 'Voice Memo AI', + subtitle: + 'Transcription: ${transcriptionInfo.name} · ' + 'AI: ${localAiEnabled ? (aiModelName ?? 'Loading...') : 'Off'}', + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(AppRoutes.aiModels), + ); + } +} + /// Consolidated permissions summary tile -/// +/// /// Shows an overview of permission status and allows users to manage all permissions class _PermissionsSummaryTile extends ConsumerWidget { @override @@ -364,15 +465,15 @@ class _PermissionsSummaryTile extends ConsumerWidget { final permState = ref.watch(permissionNotifierProvider); final status = permState.status; final missingCount = status.missingPermissions.length; - + // Calculate overall status final allGranted = status.hasAllPermissions; final hasCritical = status.hasCriticalPermissions; - + Color statusColor; IconData statusIcon; String statusText; - + if (allGranted) { statusColor = AppTheme.successColor; statusIcon = Icons.check_circle; @@ -380,7 +481,8 @@ class _PermissionsSummaryTile extends ConsumerWidget { } else if (hasCritical) { statusColor = AppTheme.warningColor; statusIcon = Icons.warning_amber; - statusText = '$missingCount optional permission${missingCount > 1 ? 's' : ''} missing'; + statusText = + '$missingCount optional permission${missingCount > 1 ? 's' : ''} missing'; } else { statusColor = AppTheme.errorColor; statusIcon = Icons.error; @@ -396,7 +498,7 @@ class _PermissionsSummaryTile extends ConsumerWidget { trailing: const Icon(Icons.chevron_right), onTap: () => _openPermissionsScreen(context), ), - + // Show quick status for each permission if (!allGranted) ...[ Padding( @@ -409,7 +511,7 @@ class _PermissionsSummaryTile extends ConsumerWidget { label: 'Bluetooth', isGranted: status.bluetoothGranted, ), - + // Notifications (Android only) if (Platform.isAndroid) _QuickPermissionRow( @@ -417,7 +519,7 @@ class _PermissionsSummaryTile extends ConsumerWidget { label: 'Notifications', isGranted: status.isNotificationGranted, ), - + // Battery Optimization (Android only) if (Platform.isAndroid) _QuickPermissionRow( @@ -425,14 +527,14 @@ class _PermissionsSummaryTile extends ConsumerWidget { label: 'Battery Optimization', isGranted: status.batteryOptimizationDisabled, ), - + // Location _QuickPermissionRow( icon: Icons.location_on, label: 'Location', isGranted: status.isLocationGranted, ), - + // Notification Listener (Android only) if (Platform.isAndroid) _QuickPermissionRow( @@ -451,9 +553,8 @@ class _PermissionsSummaryTile extends ConsumerWidget { void _openPermissionsScreen(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => const PermissionOnboardingScreen( - isInitialOnboarding: false, - ), + builder: (context) => + const PermissionOnboardingScreen(isInitialOnboarding: false), ), ); } @@ -474,7 +575,7 @@ class _QuickPermissionRow extends StatelessWidget { @override Widget build(BuildContext context) { final color = isGranted ? AppTheme.successColor : AppTheme.warningColor; - + return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -484,9 +585,9 @@ class _QuickPermissionRow extends StatelessWidget { Expanded( child: Text( label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), Icon( @@ -498,4 +599,4 @@ class _QuickPermissionRow extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/zswatch_app/lib/ui/screens/start/start_page_screen.dart b/zswatch_app/lib/ui/screens/start/start_page_screen.dart index 9bcabd9..e361980 100644 --- a/zswatch_app/lib/ui/screens/start/start_page_screen.dart +++ b/zswatch_app/lib/ui/screens/start/start_page_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import '../../navigation/app_router.dart'; + import '../../../core/theme/app_theme.dart'; import '../../../data/database/app_database.dart'; import '../../../data/models/connection_state.dart'; @@ -12,7 +14,6 @@ import '../../../providers/auto_reconnect_provider.dart'; import '../../../providers/ble_providers.dart'; import '../../../providers/watch_providers.dart' as db; import '../../../providers/watch_service_provider.dart'; -import '../../widgets/connection_status_pill.dart'; import '../../widgets/watch_config_dialog.dart'; /// Start page showing stored watches and option to add new watch (FR-067 to FR-070) @@ -48,7 +49,7 @@ class _StartPageScreenState extends ConsumerState { // Only start once per widget instance if (_autoReconnectStarted) return; _autoReconnectStarted = true; - + final enabled = await ref.read(autoReconnectEnabledProvider.future); if (enabled && mounted) { final notifier = ref.read(autoReconnectNotifierProvider.notifier); @@ -82,7 +83,7 @@ class _StartPageScreenState extends ConsumerState { ), ); // Navigate to dashboard on successful connection (FR-074) - context.go('/'); + context.go(AppRoutes.home); } } catch (e) { if (mounted) { @@ -106,42 +107,7 @@ class _StartPageScreenState extends ConsumerState { void _navigateToScan() { // Cancel auto-reconnect when user wants to add new watch (FR-073) ref.read(autoReconnectNotifierProvider.notifier).cancel(); - context.push('/scan'); - } - - Future _deleteWatch(WatchEntity watch) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove Watch'), - content: Text( - 'Remove "${watch.customName ?? watch.name}" from saved watches?\n\n' - 'You can pair it again later.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor), - child: const Text('Remove'), - ), - ], - ), - ); - - if (confirmed == true) { - await ref.read(db.watchNotifierProvider.notifier).deleteWatch(watch.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${watch.customName ?? watch.name} removed'), - ), - ); - } - } + context.push(AppRoutes.scan); } /// Open the watch config dialog for renaming or forgetting a watch (T116) @@ -150,7 +116,9 @@ class _StartPageScreenState extends ConsumerState { context: context, watch: watch, onRename: (watchId, customName) async { - await ref.read(db.watchNotifierProvider.notifier).renameWatch(watchId, customName); + await ref + .read(db.watchNotifierProvider.notifier) + .renameWatch(watchId, customName); if (mounted) { final newName = customName ?? watch.name; ScaffoldMessenger.of(context).showSnackBar( @@ -168,10 +136,10 @@ class _StartPageScreenState extends ConsumerState { if (currentDeviceId == watchId) { await watchService.disconnect(); } - + // Forget the watch (removes from DB and unbonds BLE) await ref.read(db.watchNotifierProvider.notifier).forgetWatch(watchId); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -195,21 +163,18 @@ class _StartPageScreenState extends ConsumerState { // Navigate to dashboard when connected (FR-074) ref.listen(connectionStateProvider, (previous, next) { if (next == WatchConnectionState.connected && mounted) { - context.go('/'); + context.go(AppRoutes.home); } }); return Scaffold( appBar: AppBar( centerTitle: true, - title: SvgPicture.asset( - 'assets/images/ZSWatch_Text.svg', - height: 24, - ), + title: SvgPicture.asset('assets/images/ZSWatch_Text.svg', height: 24), actions: [ IconButton( icon: const Icon(Icons.settings), - onPressed: () => context.push('/settings'), + onPressed: () => context.push(AppRoutes.settings), tooltip: 'Settings', ), ], @@ -258,9 +223,9 @@ class _StartPageScreenState extends ConsumerState { Text( 'Add your ZSWatch to get started.\n' 'Make sure your watch is turned on and nearby.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), textAlign: TextAlign.center, ), ], @@ -276,7 +241,7 @@ class _StartPageScreenState extends ConsumerState { // Primary watch first if (a.isPrimary && !b.isPrimary) return -1; if (!a.isPrimary && b.isPrimary) return 1; - + // Then by last connected time (most recent first) if (a.lastConnectedAt == null && b.lastConnectedAt == null) return 0; if (a.lastConnectedAt == null) return 1; @@ -322,9 +287,9 @@ class _StartPageScreenState extends ConsumerState { const SizedBox(height: AppTheme.spacingSm), Text( error, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), textAlign: TextAlign.center, ), ], @@ -351,38 +316,38 @@ class _WatchListTile extends StatelessWidget { @override Widget build(BuildContext context) { final displayName = watch.customName ?? watch.name; - + return ListTile( - leading: _buildLeadingIcon(), - title: Row( - children: [ - Expanded( - child: Text( - displayName, - style: const TextStyle(fontWeight: FontWeight.w500), - ), + leading: _buildLeadingIcon(), + title: Row( + children: [ + Expanded( + child: Text( + displayName, + style: const TextStyle(fontWeight: FontWeight.w500), ), - if (watch.isPrimary) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'Primary', - style: TextStyle( - color: AppTheme.primaryColor, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + ), + if (watch.isPrimary) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Primary', + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 10, + fontWeight: FontWeight.w600, ), ), - ], - ), - subtitle: _buildSubtitle(), - trailing: _buildTrailing(), - onTap: isConnecting ? null : onTap, + ), + ], + ), + subtitle: _buildSubtitle(), + trailing: _buildTrailing(), + onTap: isConnecting ? null : onTap, ); } @@ -394,7 +359,7 @@ class _WatchListTile extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2), ); } - + return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -408,10 +373,7 @@ class _WatchListTile extends StatelessWidget { onPressed: onConfig, tooltip: 'Watch Settings', ), - const Icon( - Icons.chevron_right, - color: AppTheme.textSecondary, - ), + const Icon(Icons.chevron_right, color: AppTheme.textSecondary), ], ); } @@ -451,7 +413,7 @@ class _WatchListTile extends StatelessWidget { Widget _buildBatteryIndicator() { final level = watch.batteryLevel ?? 0; final color = AppTheme.getBatteryColor(level); - + return Container( padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), decoration: BoxDecoration( @@ -472,35 +434,32 @@ class _WatchListTile extends StatelessWidget { Widget _buildSubtitle() { final parts = []; - + // Show firmware version if available if (watch.firmwareVersion != null) { parts.add('v${watch.firmwareVersion}'); } - + // Show last connected time if (watch.lastConnectedAt != null) { parts.add(_formatLastConnected(watch.lastConnectedAt!)); } - + // Show ID as fallback if (parts.isEmpty) { parts.add(watch.id); } - + return Text( parts.join(' • '), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), ); } String _formatLastConnected(DateTime lastConnected) { final now = DateTime.now(); final diff = now.difference(lastConnected); - + if (diff.inMinutes < 1) { return 'Just now'; } else if (diff.inHours < 1) { diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart new file mode 100644 index 0000000..0443f6b --- /dev/null +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -0,0 +1,2481 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/extracted_action.dart'; +import '../../../data/models/voice_memo.dart'; +import '../../../providers/ai_providers.dart'; +import '../../../providers/settings_providers.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; +import '../../widgets/ai_debug_widgets.dart'; +import '../../../providers/voice_memo_providers.dart'; +import '../../../providers/watch_service_provider.dart'; +import '../../../services/ai/ai_debug_info.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; +import '../../navigation/app_router.dart'; +import '../../widgets/voice_memos/memo_list_item.dart'; +import '../../widgets/voice_memos/sync_progress_bar.dart'; + +/// Transcript-first timeline view for synced voice notes. +class VoiceMemosScreen extends ConsumerStatefulWidget { + const VoiceMemosScreen({super.key}); + + @override + ConsumerState createState() => _VoiceMemosScreenState(); +} + +class _VoiceMemosScreenState extends ConsumerState { + late final TextEditingController _searchController; + String _query = ''; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController() + ..addListener(() { + if (!mounted) { + return; + } + setState(() => _query = _searchController.text.trim().toLowerCase()); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoSync(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _autoSync() { + final isConnected = ref.read(isWatchConnectedProvider); + if (isConnected) { + ref.read(voiceMemoActionsProvider.notifier).sync(); + } + } + + void _openMemo(VoiceMemo memo) { + context.push(AppRoutes.voiceMemoDetail(memo.id), extra: memo); + } + + @override + Widget build(BuildContext context) { + final memosAsync = ref.watch(voiceMemoListProvider); + final syncStateAsync = ref.watch(voiceMemoSyncStateProvider); + final transcriptionConfiguredAsync = ref.watch( + transcriptionConfiguredProvider, + ); + final isConnected = ref.watch(isWatchConnectedProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Voice Notes'), + actions: [ + if (isConnected) + IconButton( + icon: const Icon(Icons.sync), + tooltip: 'Sync from watch', + onPressed: () => + ref.read(voiceMemoActionsProvider.notifier).sync(), + ), + ], + ), + body: Column( + children: [ + syncStateAsync.when( + data: (syncState) => VoiceMemoSyncProgressBar(state: syncState), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + if (!isConnected) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: Colors.orange.withValues(alpha: 0.15), + child: Row( + children: [ + const Icon( + Icons.bluetooth_disabled, + size: 16, + color: Colors.orange, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + 'Connect to your watch to sync new notes', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.orange), + ), + ), + ], + ), + ), + transcriptionConfiguredAsync.when( + data: (configured) { + if (configured) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: AppTheme.spacingSm), + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: AppTheme.warningColor.withValues(alpha: 0.15), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.settings_suggest, + size: 16, + color: AppTheme.warningColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Transcription model not configured', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.warningColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Choose and download a model in Settings > Voice Memos.', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.warningColor), + ), + ], + ), + ), + TextButton( + onPressed: () => context.push(AppRoutes.settings), + child: const Text('Setup'), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + 0, + ), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search voice notes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _query.isEmpty + ? null + : IconButton( + onPressed: _searchController.clear, + icon: const Icon(Icons.close), + ), + ), + ), + ), + Expanded( + child: memosAsync.when( + data: (memos) { + final filteredMemos = _filterMemos(memos, _query); + + return RefreshIndicator( + onRefresh: () => + ref.read(voiceMemoActionsProvider.notifier).sync(), + child: filteredMemos.isEmpty + ? _EmptyState(hasQuery: _query.isNotEmpty) + : _VoiceMemoTimeline( + memos: filteredMemos, + onOpenMemo: _openMemo, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Error loading notes: $error', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ), + ), + ], + ), + ); + } +} + +class VoiceMemoDetailScreen extends ConsumerStatefulWidget { + final int memoId; + final VoiceMemo? initialMemo; + + const VoiceMemoDetailScreen({ + super.key, + required this.memoId, + this.initialMemo, + }); + + @override + ConsumerState createState() => + _VoiceMemoDetailScreenState(); +} + +class _VoiceMemoDetailScreenState extends ConsumerState { + late final TextEditingController _transcriptController; + bool _isEditing = false; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _transcriptController = TextEditingController( + text: widget.initialMemo?.transcription ?? '', + ); + } + + @override + void dispose() { + _transcriptController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final memoAsync = ref.watch(voiceMemoByIdProvider(widget.memoId)); + + return Scaffold( + appBar: AppBar(title: const Text('Voice Note')), + body: memoAsync.when( + data: (memo) { + final effectiveMemo = memo ?? widget.initialMemo; + if (effectiveMemo == null) { + return const _MissingNoteState(); + } + + final currentTranscript = effectiveMemo.transcription ?? ''; + if (!_isEditing && _transcriptController.text != currentTranscript) { + _transcriptController.text = currentTranscript; + } + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), + child: LayoutBuilder( + builder: (context, constraints) { + final showSideBySide = constraints.maxWidth >= 430; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TopSummarySection( + memo: effectiveMemo, + sideBySide: showSideBySide, + ), + const SizedBox(height: 12), + _AISummarySection(memo: effectiveMemo), + const SizedBox(height: 12), + _ExtractedActionsSection(memo: effectiveMemo), + const SizedBox(height: 12), + _SectionCard( + title: 'Transcript', + trailing: IconButton( + tooltip: _isEditing + ? 'Cancel editing' + : 'Edit transcript', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 32, + height: 32, + ), + onPressed: () { + setState(() { + _isEditing = !_isEditing; + if (!_isEditing) { + _transcriptController.text = currentTranscript; + } + }); + }, + icon: Icon( + _isEditing + ? Icons.close_rounded + : Icons.edit_outlined, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isEditing) ...[ + TextField( + controller: _transcriptController, + minLines: 6, + maxLines: null, + decoration: const InputDecoration( + hintText: 'Edit transcript text...', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: _isSaving + ? null + : () { + setState(() { + _isEditing = false; + _transcriptController.text = + currentTranscript; + }); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: AppTheme.spacingSm), + FilledButton( + style: _compactFilledButtonStyle(), + onPressed: _isSaving + ? null + : () => _saveTranscript(effectiveMemo), + child: Text(_isSaving ? 'Saving...' : 'Save'), + ), + ], + ), + ] else ...[ + SelectableText( + currentTranscript.trim().isEmpty + ? 'Transcription will appear here after sync and transcription finish.' + : currentTranscript, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + height: 1.45, + color: currentTranscript.trim().isEmpty + ? AppTheme.textSecondary + : AppTheme.textPrimary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: currentTranscript.trim().isEmpty + ? null + : () async { + await Clipboard.setData( + ClipboardData( + text: currentTranscript, + ), + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Transcript copied to clipboard', + ), + ), + ); + }, + icon: const Icon(Icons.copy_all_outlined), + label: const Text('Copy text'), + ), + _TranscribeButton( + memo: effectiveMemo, + expand: false, + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Actions', + child: Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => _deleteMemo(effectiveMemo), + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ), + if (!hasLocalAudio(effectiveMemo)) + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: ref.watch(isWatchConnectedProvider) + ? () => ref + .read(voiceMemoActionsProvider.notifier) + .sync() + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + ], + ), + ), + ], + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Unable to load voice note: $error', + style: const TextStyle(color: AppTheme.errorColor), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + Future _saveTranscript(VoiceMemo memo) async { + setState(() => _isSaving = true); + try { + await ref + .read(voiceMemoRepositoryProvider) + .updateTranscription( + filename: memo.filename, + transcription: _transcriptController.text.trim(), + ); + if (!mounted) { + return; + } + setState(() => _isEditing = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Transcript updated'))); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + Future _deleteMemo(VoiceMemo memo) async { + final shouldDelete = await confirmDeleteMemo(context, memo); + if (shouldDelete != true || !mounted) { + return; + } + + await ref.read(voiceMemoActionsProvider.notifier).delete(memo.filename); + if (!mounted) { + return; + } + context.pop(); + } +} + +class _AISummarySection extends ConsumerWidget { + final VoiceMemo memo; + + const _AISummarySection({required this.memo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final aiEnabled = ref.watch(localAiEnabledProvider); + final modelDownloadedAsync = ref.watch(llmModelDownloadedProvider); + + if (!aiEnabled) { + return const SizedBox.shrink(); + } + + return modelDownloadedAsync.when( + data: (modelDownloaded) { + if (!modelDownloaded) { + return const SizedBox.shrink(); + } + + final hasSummary = memo.summary != null && memo.summary!.isNotEmpty; + final hasCategory = memo.aiCategory != null; + final isProcessing = memo.isAiProcessing; + final hasFailed = + memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; + + if (!hasSummary && !hasCategory && !isProcessing && !hasFailed) { + return _SectionCard( + title: 'AI Analysis', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Get AI-powered insights including summary, category, and extracted actions.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: memo.transcription?.trim().isEmpty == true + ? null + : () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.auto_awesome), + label: const Text('Process with AI'), + ), + if (memo.transcription?.trim().isEmpty == true) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Transcribe the audio first before AI processing.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + } + + return _SectionCard( + title: 'AI Summary', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isProcessing) + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _showAiDebugDialog(context, ref), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Processing with AI...', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + const Spacer(), + const Icon( + Icons.bug_report_outlined, + size: 16, + color: AppTheme.textSecondary, + ), + ], + ), + ), + ) + else if (hasFailed) + Text( + 'AI processing failed. Please try again.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.errorColor), + ) + else ...[ + if (hasCategory) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryBadge(category: memo.aiCategory!), + ), + if (hasSummary) + SelectableText( + memo.summary!, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(height: 1.45), + ), + ], + if (!isProcessing && (hasSummary || hasCategory)) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.refresh), + label: const Text('Re-process'), + ), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => _showAiDebugDialog(context, ref), + icon: const Icon(Icons.bug_report_outlined), + label: const Text('Debug'), + ), + ], + ), + ), + if (hasSummary && memo.aiModel != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Model: ${memo.aiModel}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } + + void _showAiDebugDialog(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.elevatedSurfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => + _AiDebugSheet(memo: memo, scrollController: scrollController), + ), + ); + } +} + +class _AiDebugSheet extends ConsumerWidget { + final VoiceMemo memo; + final ScrollController scrollController; + + const _AiDebugSheet({required this.memo, required this.scrollController}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final streamValue = ref.watch(aiProcessingDebugInfoProvider).valueOrNull; + // Show live stream data only when it's for THIS memo + final liveInfo = + (streamValue != null && streamValue.filename == memo.filename) + ? streamValue + : null; + // For completed results, look up per-file cache + final storedInfo = ref + .read(voiceNoteAiPipelineProvider) + .getDebugInfoForFile(memo.filename); + // Prefer live (in-progress or just-completed) over stored + final debugInfo = liveInfo ?? storedInfo; + final theme = Theme.of(context); + + return Column( + children: [ + // Handle bar + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.bug_report_outlined, size: 20), + const SizedBox(width: 8), + Text('AI Debug Info', style: theme.textTheme.titleMedium), + const Spacer(), + if (debugInfo != null && !debugInfo.isComplete) + const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + if (debugInfo == null) ...[ + _debugNote( + context, + 'No debug data available for the latest run. ' + 'Re-process this memo to see debug info.', + ), + const SizedBox(height: 16), + _debugInfoFromMemo(context), + ] else if (!debugInfo.isComplete) ...[ + // --- Live / in-progress view --- + _livePhaseHeader(context, debugInfo), + const SizedBox(height: 12), + if (debugInfo.transcriptionResult != null) ...[ + _debugBlock( + context, + title: 'Original Transcription', + content: debugInfo.transcriptionResult!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + // Only show the partial-response block once tokens are flowing + if (debugInfo.phase != 'loading') + _debugBlock( + context, + title: '${_phaseLabel(debugInfo.phase)} (live)', + content: debugInfo.partialOutput.isEmpty + ? '...' + : debugInfo.partialOutput, + icon: debugInfo.phase == 'correcting' + ? Icons.auto_fix_high + : Icons.code, + mono: debugInfo.phase == 'classifying', + ), + ] else ...[ + // --- Completed view --- + _metricsRow(context, debugInfo), + const SizedBox(height: 16), + if (debugInfo.transcriptionResult != null && + debugInfo.correctedTranscription != null && + debugInfo.correctedTranscription != + debugInfo.transcriptionResult) ...[ + _transcriptionDiffBlock( + context, + original: debugInfo.transcriptionResult!, + corrected: debugInfo.correctedTranscription!, + ), + const SizedBox(height: 12), + ] else if (debugInfo.transcriptionResult != null) ...[ + _debugBlock( + context, + title: 'Transcription', + content: debugInfo.transcriptionResult!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + if (debugInfo.rawOutput != null) ...[ + _debugBlock( + context, + title: 'Raw LLM Response', + content: debugInfo.rawOutput!, + icon: Icons.code, + mono: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.parsedJson != null) ...[ + _debugBlock( + context, + title: 'Parsed JSON', + content: aiFormatJson(debugInfo.parsedJson!), + icon: Icons.data_object, + mono: true, + ), + const SizedBox(height: 12), + ], + if (aiHasChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: + debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: + debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + extractedActions: debugInfo.extractedActions, + )) ...[ + aiDebugBlock( + context, + title: 'Chrono Extraction / Resolution', + content: aiFormatChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: + debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: + debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + extractedActions: debugInfo.extractedActions, + ), + icon: Icons.schedule, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], + _resultRow(context, debugInfo), + ], + ], + ), + ), + ], + ); + } + + String _phaseLabel(String? phase) { + switch (phase) { + case 'correcting': + return 'Correction Output'; + case 'classifying': + return 'Classify Output'; + default: + return 'Output'; + } + } + + Widget _livePhaseHeader(BuildContext context, AiDebugInfo info) { + final theme = Theme.of(context); + final phaseText = switch (info.phase) { + 'loading' => 'Loading model...', + 'correcting' => 'Correcting transcription...', + 'classifying' => 'Classifying & summarizing...', + _ => 'Processing...', + }; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + Text( + phaseText, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _metricChip(context, 'Tokens', '${info.tokens}', Icons.token), + ], + ), + if (aiMemoryInfoBlock(context, info) != null) ...[ + const SizedBox(height: 8), + aiMemoryInfoBlock(context, info)!, + ], + ], + ), + ); + } + + Widget _debugNote(BuildContext context, String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + Icons.info_outline, + size: 16, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); + } + + Widget _debugInfoFromMemo(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (memo.aiModel != null) _kvRow(context, 'Model', memo.aiModel!), + if (memo.summary != null) _kvRow(context, 'Summary', memo.summary!), + if (memo.aiCategory != null) + _kvRow(context, 'Category', memo.aiCategory!.name), + if (memo.transcription != null) ...[ + const SizedBox(height: 12), + Text('Transcription', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + SelectableText(memo.transcription!, style: theme.textTheme.bodySmall), + ], + ], + ); + } + + Widget _metricsRow(BuildContext context, AiDebugInfo info) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (info.correctionElapsed > Duration.zero) + _metricChip( + context, + 'Correction', + '${info.correctionElapsed.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.correctionTokensPerSecond != null) + _metricChip( + context, + 'Correction tok/s', + info.correctionTokensPerSecond!.toStringAsFixed(1), + Icons.speed, + ), + if (info.correctionTokens > 0) + _metricChip( + context, + 'Correction tokens', + '${info.correctionTokens}', + Icons.token, + ), + if (info.elapsed > Duration.zero) + _metricChip( + context, + 'Classify', + '${info.elapsed.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.tokensPerSecond != null) + _metricChip( + context, + 'Classify tok/s', + info.tokensPerSecond!.toStringAsFixed(1), + Icons.speed, + ), + if (info.tokens > 0) + _metricChip( + context, + 'Classify tokens', + '${info.tokens}', + Icons.token, + ), + ], + ), + if (aiMemoryInfoBlock(context, info) != null) ...[ + const SizedBox(height: 8), + aiMemoryInfoBlock(context, info)!, + ], + ], + ), + ); + } + + Widget _metricChip( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ); + } + + Widget _debugBlock( + BuildContext context, { + required String title, + required String content, + required IconData icon, + bool mono = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + title, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied $title'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), + ), + ], + ); + } + + /// Build a diff view showing words removed (from original) in red and words + /// added (in corrected) in green. Uses a simple longest-common-subsequence + /// approach on whitespace-split word arrays. + Widget _transcriptionDiffBlock( + BuildContext context, { + required String original, + required String corrected, + }) { + final origWords = original.split(RegExp(r'\s+')); + final corrWords = corrected.split(RegExp(r'\s+')); + final spans = _computeWordDiffSpans(origWords, corrWords, context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.compare_arrows, + size: 16, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 6), + Text( + 'Transcription Diff', + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), + ), + const Spacer(), + _diffLegendChip(context, 'removed', const Color(0x40EF5350)), + const SizedBox(width: 6), + _diffLegendChip(context, 'added', const Color(0x4066BB6A)), + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: Text.rich( + TextSpan(children: spans), + style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), + ), + ), + ], + ); + } + + Widget _diffLegendChip(BuildContext context, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), + ), + ); + } + + /// Compute word-level diff spans using LCS (longest common subsequence). + List _computeWordDiffSpans( + List origWords, + List corrWords, + BuildContext context, + ) { + final n = origWords.length; + final m = corrWords.length; + + // Build LCS table + final dp = List.generate(n + 1, (_) => List.filled(m + 1, 0)); + for (var i = 1; i <= n; i++) { + for (var j = 1; j <= m; j++) { + if (origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = dp[i - 1][j] > dp[i][j - 1] ? dp[i - 1][j] : dp[i][j - 1]; + } + } + } + + // Backtrack to produce diff operations + final ops = <_DiffOp>[]; + var i = n; + var j = m; + while (i > 0 || j > 0) { + if (i > 0 && + j > 0 && + origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + ops.add(_DiffOp.equal(corrWords[j - 1])); + i--; + j--; + } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) { + ops.add(_DiffOp.insert(corrWords[j - 1])); + j--; + } else { + ops.add(_DiffOp.delete(origWords[i - 1])); + i--; + } + } + ops.reversed; // reversed is lazy, need toList + final orderedOps = ops.reversed.toList(); + + // Convert to TextSpans + final spans = []; + for (final op in orderedOps) { + if (spans.isNotEmpty) { + spans.add(const TextSpan(text: ' ')); + } + switch (op.type) { + case _DiffType.equal: + spans.add(TextSpan(text: op.word)); + break; + case _DiffType.delete: + spans.add( + TextSpan( + text: op.word, + style: const TextStyle( + backgroundColor: Color(0x40EF5350), + decoration: TextDecoration.lineThrough, + decorationColor: Color(0xFFEF5350), + ), + ), + ); + break; + case _DiffType.insert: + spans.add( + TextSpan( + text: op.word, + style: const TextStyle( + backgroundColor: Color(0x4066BB6A), + fontWeight: FontWeight.w600, + ), + ), + ); + break; + } + } + return spans; + } + + Widget _resultRow(BuildContext context, AiDebugInfo info) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.check_circle_outline, + size: 16, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 6), + Text( + 'Parsed Result', + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + const SizedBox(height: 8), + if (info.category != null) _kvRow(context, 'Category', info.category!), + if (info.summary != null) _kvRow(context, 'Summary', info.summary!), + _kvRow(context, 'Actions', '${info.actionCount}'), + if (info.timestamp != null) + _kvRow( + context, + 'Processed', + DateFormat('HH:mm:ss.SSS').format(info.timestamp!), + ), + ], + ); + } + + Widget _kvRow(BuildContext context, String key, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + key, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} + +class _ExtractedActionsSection extends ConsumerStatefulWidget { + final VoiceMemo memo; + + const _ExtractedActionsSection({required this.memo}); + + @override + ConsumerState<_ExtractedActionsSection> createState() => + _ExtractedActionsSectionState(); +} + +class _ExtractedActionsSectionState + extends ConsumerState<_ExtractedActionsSection> { + bool _isExpanded = true; + + @override + Widget build(BuildContext context) { + final aiEnabled = ref.watch(localAiEnabledProvider); + + if (!aiEnabled) { + return const SizedBox.shrink(); + } + + final actionsAsync = ref.watch( + extractedActionsForMemoProvider(widget.memo.id), + ); + + return actionsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (actions) { + if (actions.isEmpty) { + return const SizedBox.shrink(); + } + + return _SectionCard( + title: 'Extracted Actions', + trailing: IconButton( + tooltip: _isExpanded ? 'Collapse' : 'Expand', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor(width: 32, height: 32), + onPressed: () => setState(() => _isExpanded = !_isExpanded), + icon: Icon(_isExpanded ? Icons.expand_less : Icons.expand_more), + ), + child: _isExpanded + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + _ActionItem(action: actions[i]), + ], + ], + ) + : Text( + '${actions.length} action${actions.length == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + }, + ); + } +} + +class _ActionItem extends ConsumerStatefulWidget { + final ExtractedAction action; + + const _ActionItem({required this.action}); + + @override + ConsumerState<_ActionItem> createState() => _ActionItemState(); +} + +class _ActionItemState extends ConsumerState<_ActionItem> { + bool _isCreating = false; + bool _isDismissing = false; + bool _isOpening = false; + + ExtractedAction get action => widget.action; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _actionTypeColor(action.actionType).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Icon( + _actionTypeIcon(action.actionType), + size: 16, + color: _actionTypeColor(action.actionType), + ), + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + action.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: AppTheme.spacingSm), + _ActionStatusBadge(action: action), + ], + ), + if (_timingLabel(action) case final timingLabel?) ...[ + const SizedBox(height: 4), + Text( + timingLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + if (action.notes != null && action.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + action.notes!.trim(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + if (action.location != null) ...[ + const SizedBox(height: 4), + Text( + action.location!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (!action.created && !action.dismissed) + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: _isCreating ? null : _createAction, + icon: _isCreating + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_task_outlined), + label: Text(_isCreating ? 'Creating\u2026' : 'Create'), + ), + if (!action.created && !action.dismissed) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isDismissing ? null : _dismissAction, + icon: _isDismissing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + if (action.created && action.platformTargetId != null) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isOpening ? null : _openCreatedAction, + icon: _isOpening + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.open_in_new_rounded), + label: const Text('Open'), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Future _createAction() async { + setState(() => _isCreating = true); + try { + final selectedCalendarId = ref.read( + selectedProductivityCalendarIdProvider, + ); + final draft = ActionCreationDraft.fromAction(action).copyWith( + platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, + ); + + final message = await ref + .read(extractedActionOperationsProvider) + .createAction(action: action, draft: draft); + + if (!mounted) return; + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _dismissAction() async { + setState(() => _isDismissing = true); + try { + await ref + .read(extractedActionOperationsProvider) + .dismissAction(action.id); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to dismiss action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isDismissing = false); + } + } + } + + Future _openCreatedAction() async { + setState(() => _isOpening = true); + try { + await ref + .read(extractedActionCreationServiceProvider) + .openCreatedAction(action); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to open created action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isOpening = false); + } + } + } + + IconData _actionTypeIcon(ExtractedActionType type) { + return switch (type) { + ExtractedActionType.task => Icons.check_box_outlined, + ExtractedActionType.reminder => Icons.alarm, + ExtractedActionType.calendarEvent => Icons.calendar_today, + }; + } + + Color _actionTypeColor(ExtractedActionType type) { + return switch (type) { + ExtractedActionType.task => AppTheme.primaryColor, + ExtractedActionType.reminder => AppTheme.warningColor, + ExtractedActionType.calendarEvent => AppTheme.infoColor, + }; + } + + String? _timingLabel(ExtractedAction action) { + final dateFormat = DateFormat.yMMMd(); + final dateTimeFormat = DateFormat.yMMMd().add_jm(); + + if (action.startTime != null) { + final start = action.startTime!.toLocal(); + if (action.endTime != null) { + final end = action.endTime!.toLocal(); + return 'When: ${dateTimeFormat.format(start)} \u2192 ${dateTimeFormat.format(end)}'; + } + return 'When: ${dateTimeFormat.format(start)}'; + } + + if (action.dueDate != null) { + return 'Due: ${dateFormat.format(action.dueDate!.toLocal())}'; + } + + return null; + } +} + +class _ActionStatusBadge extends StatelessWidget { + final ExtractedAction action; + + const _ActionStatusBadge({required this.action}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch ((action.created, action.dismissed)) { + (true, _) => ('Created', AppTheme.successColor), + (_, true) => ('Dismissed', AppTheme.textSecondary), + _ => ('Pending', AppTheme.warningColor), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _CategoryBadge extends StatelessWidget { + final VoiceNoteCategory category; + + const _CategoryBadge({required this.category}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: voiceNoteCategoryColor(category).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + voiceNoteCategoryIcon(category), + size: 16, + color: voiceNoteCategoryColor(category), + ), + const SizedBox(width: 6), + Text( + voiceNoteCategoryLabel(category), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: voiceNoteCategoryColor(category), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final bool hasQuery; + + const _EmptyState({this.hasQuery = false}); + + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.55, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasQuery ? Icons.search_off_rounded : Icons.mic_none_rounded, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: AppTheme.spacingMd), + Text( + hasQuery ? 'No matching notes' : 'No voice notes yet', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + hasQuery + ? 'Try a different search term or clear the filter.' + : 'Press record on the watch to create your first note.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _VoiceMemoTimeline extends ConsumerWidget { + final List memos; + final ValueChanged onOpenMemo; + + const _VoiceMemoTimeline({required this.memos, required this.onOpenMemo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sections = _groupMemosByDay(memos); + + return ListView( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingLg, + ), + children: [ + for (final section in sections) ...[ + Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingSm, + bottom: AppTheme.spacingSm, + ), + child: Text( + section.label, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + for (final memo in section.memos) + VoiceNoteCard(memo: memo, onOpen: () => onOpenMemo(memo)), + ], + ], + ); + } +} + +class _MissingNoteState extends StatelessWidget { + const _MissingNoteState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.description_outlined, size: 56), + const SizedBox(height: AppTheme.spacingMd), + Text( + 'Voice note not found', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } +} + +class _TopSummarySection extends StatelessWidget { + final VoiceMemo memo; + final bool sideBySide; + + const _TopSummarySection({required this.memo, required this.sideBySide}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + final compactWidth = constraints.maxWidth < 380; + final rightColumnWidth = compactWidth ? 128.0 : 164.0; + final audioWidget = hasLocalAudio(memo) + ? _AudioPlayerCard(memo: memo, compact: true, alignRight: true) + : _SyncPromptCard(memo: memo, compact: true, alignRight: true); + + if (!sideBySide && constraints.maxWidth < 320) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VoiceNoteHeaderContent(memo: memo), + const SizedBox(height: 10), + audioWidget, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _VoiceNoteHeaderContent(memo: memo)), + const SizedBox(width: 10), + SizedBox(width: rightColumnWidth, child: audioWidget), + ], + ); + }, + ), + ), + ); + } +} + +class _VoiceNoteHeaderContent extends StatelessWidget { + final VoiceMemo memo; + + const _VoiceNoteHeaderContent({required this.memo}); + + @override + Widget build(BuildContext context) { + final local = memo.timestampUtc.toLocal(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('MMMM d · HH:mm').format(local), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '${memo.formattedDuration} · ${memo.formattedSize}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + VoiceMemoMetaChip( + icon: syncStatusIcon(memo.syncStatus), + label: syncStatusLabel(memo), + color: syncStatusColor(memo.syncStatus), + ), + if (memo.syncedFromWatch) + const VoiceMemoMetaChip( + icon: Icons.smartphone_outlined, + label: 'Synced', + color: AppTheme.primaryColor, + ), + if (!memo.deletedOnWatch) + const VoiceMemoMetaChip( + icon: Icons.watch_outlined, + label: 'Still on watch', + color: AppTheme.warningColor, + ), + ], + ), + ], + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final Widget child; + final Widget? trailing; + + const _SectionCard({required this.title, required this.child, this.trailing}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} + +class _AudioPlayerCard extends ConsumerStatefulWidget { + final VoiceMemo memo; + final bool compact; + final bool alignRight; + + const _AudioPlayerCard({ + required this.memo, + this.compact = false, + this.alignRight = false, + }); + + @override + ConsumerState<_AudioPlayerCard> createState() => _AudioPlayerCardState(); +} + +class _AudioPlayerCardState extends ConsumerState<_AudioPlayerCard> { + AudioPlayer? _player; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + bool _isPlaying = false; + String? _error; + + @override + void initState() { + super.initState(); + _initPlayer(); + } + + Future _initPlayer() async { + final path = widget.memo.convertedFilePath ?? widget.memo.localFilePath; + if (path == null || !File(path).existsSync()) { + setState(() => _error = 'Audio file not found'); + return; + } + + try { + _player = AudioPlayer(); + final duration = await _player!.setFilePath(path); + if (duration != null && mounted) { + setState(() => _duration = duration); + } + + _player!.positionStream.listen((position) { + if (mounted) { + setState(() => _position = position); + } + }); + + _player!.playerStateStream.listen((state) { + if (!mounted) { + return; + } + setState(() => _isPlaying = state.playing); + if (state.processingState == ProcessingState.completed) { + _player!.seek(Duration.zero); + _player!.pause(); + } + }); + } catch (error) { + if (mounted) { + setState(() => _error = 'Failed to load audio: $error'); + } + } + } + + @override + void dispose() { + _player?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_error != null) { + return Text(_error!, style: const TextStyle(color: AppTheme.errorColor)); + } + + return Column( + crossAxisAlignment: widget.alignRight + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: widget.alignRight + ? MainAxisAlignment.end + : MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + constraints: BoxConstraints.tightFor( + width: widget.compact ? 30 : 36, + height: widget.compact ? 30 : 36, + ), + onPressed: () { + final next = _position - const Duration(seconds: 10); + _player?.seek(next < Duration.zero ? Duration.zero : next); + }, + icon: const Icon(Icons.replay_10_rounded), + ), + SizedBox(width: widget.compact ? 4 : 8), + IconButton.filled( + style: IconButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: Size( + widget.compact ? 34 : 40, + widget.compact ? 34 : 40, + ), + padding: EdgeInsets.zero, + ), + iconSize: widget.compact ? 24 : 28, + onPressed: () { + if (_isPlaying) { + _player?.pause(); + } else { + _player?.play(); + } + }, + icon: Icon( + _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + ), + ), + SizedBox(width: widget.compact ? 4 : 8), + IconButton( + visualDensity: VisualDensity.compact, + constraints: BoxConstraints.tightFor( + width: widget.compact ? 30 : 36, + height: widget.compact ? 30 : 36, + ), + onPressed: () { + final next = _position + const Duration(seconds: 10); + _player?.seek(next > _duration ? _duration : next); + }, + icon: const Icon(Icons.forward_10_rounded), + ), + ], + ), + SizedBox(height: widget.compact ? 4 : AppTheme.spacingSm), + Row( + children: [ + Text( + _formatDuration(_position), + style: Theme.of(context).textTheme.bodySmall, + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: widget.compact ? 2 : null, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: widget.compact ? 5 : 8, + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compact ? 10 : 16, + ), + ), + child: Slider( + padding: widget.compact ? EdgeInsets.zero : null, + value: _duration.inMilliseconds == 0 + ? 0 + : _position.inMilliseconds + .clamp(0, _duration.inMilliseconds) + .toDouble(), + max: _duration.inMilliseconds == 0 + ? 1 + : _duration.inMilliseconds.toDouble(), + onChanged: (value) => + _player?.seek(Duration(milliseconds: value.toInt())), + ), + ), + ), + Text( + _formatDuration(_duration), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ); + } +} + +class _TranscribeButton extends ConsumerWidget { + final VoiceMemo memo; + final bool expand; + + const _TranscribeButton({required this.memo, this.expand = true}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final engineStateAsync = ref.watch(transcriptionEngineStateProvider); + final configuredAsync = ref.watch(transcriptionConfiguredProvider); + + return configuredAsync.when( + data: (configured) { + if (!configured) { + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.settings, size: 18), + label: const Text('Set up transcription model'), + onPressed: () => context.push(AppRoutes.settings), + ), + ); + } + + return engineStateAsync.when( + data: (engineState) { + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; + final buttonLabel = memo.transcription == null + ? 'Transcribe' + : 'Re-transcribe'; + + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.transcribe, size: 18), + label: Text( + memo.transcription == null ? 'Transcribe' : 'Re-transcribe', + ), + onPressed: () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => engineStateAsync.when( + data: (engineState) { + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; + + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text( + isTranscribing + ? 'Transcribing...' + : (memo.transcription == null + ? 'Transcribe' + : 'Re-transcribe'), + ), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.transcribe, size: 18), + label: Text( + memo.transcription == null ? 'Transcribe' : 'Re-transcribe', + ), + onPressed: () => + ref.read(voiceMemoActionsProvider.notifier).retranscribe(memo), + ), + ), + ), + ); + } +} + +class _SyncPromptCard extends ConsumerWidget { + final VoiceMemo memo; + final bool compact; + final bool alignRight; + + const _SyncPromptCard({ + required this.memo, + this.compact = false, + this.alignRight = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isConnected = ref.watch(isWatchConnectedProvider); + final syncStateAsync = ref.watch(voiceMemoSyncStateProvider); + + return Column( + crossAxisAlignment: alignRight + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.all(compact ? 6 : AppTheme.spacingSm), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Row( + children: [ + const Icon(Icons.cloud_download_outlined, color: Colors.orange), + SizedBox(width: compact ? 6 : AppTheme.spacingSm), + Expanded( + child: Text( + 'This note is still on the watch. Sync it to enable playback and transcription.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: compact ? 11 : null, + ), + ), + ), + ], + ), + ), + SizedBox(height: compact ? 8 : AppTheme.spacingMd), + syncStateAsync.when( + data: (state) { + if (!state.isSyncing) { + return const SizedBox.shrink(); + } + + return Padding( + padding: EdgeInsets.only( + bottom: compact ? 8 : AppTheme.spacingMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LinearProgressIndicator(), + const SizedBox(height: AppTheme.spacingXs), + Text( + 'Syncing in progress...', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + SizedBox( + width: compact && alignRight ? null : double.infinity, + child: FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: isConnected + ? () => ref.read(voiceMemoActionsProvider.notifier).sync() + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + ), + if (!isConnected) ...[ + SizedBox(height: compact ? 6 : AppTheme.spacingSm), + Text( + 'Connect to your watch to sync this note.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: compact ? 11 : null, + ), + ), + ], + ], + ); + } +} + +class _ButtonBox extends StatelessWidget { + final bool expand; + final Widget child; + + const _ButtonBox({required this.expand, required this.child}); + + @override + Widget build(BuildContext context) { + if (expand) { + return SizedBox(width: double.infinity, child: child); + } + + return Align(alignment: Alignment.centerLeft, child: child); + } +} + +class _VoiceMemoTimelineSection { + final String label; + final List memos; + + const _VoiceMemoTimelineSection({required this.label, required this.memos}); +} + +List _filterMemos(List memos, String query) { + if (query.isEmpty) { + return memos; + } + + return memos.where((memo) => _matchesQuery(memo, query)).toList(); +} + +List<_VoiceMemoTimelineSection> _groupMemosByDay(List memos) { + final grouped = >{}; + + for (final memo in memos) { + final local = memo.timestampUtc.toLocal(); + final key = DateTime( + local.year, + local.month, + local.day, + ).millisecondsSinceEpoch.toString(); + grouped.putIfAbsent(key, () => []).add(memo); + } + + final keys = grouped.keys.toList() + ..sort((a, b) => int.parse(b).compareTo(int.parse(a))); + + return keys.map((key) { + final firstMemo = grouped[key]!.first; + return _VoiceMemoTimelineSection( + label: dayGroupLabel(firstMemo.timestampUtc.toLocal()), + memos: grouped[key]!, + ); + }).toList(); +} + +bool _matchesQuery(VoiceMemo memo, String query) { + final local = memo.timestampUtc.toLocal(); + final haystack = [ + memo.filename, + memo.transcription ?? '', + memo.summary ?? '', + dayGroupLabel(local), + timelineTimestampLabel(local), + DateFormat.yMMMMd().format(local), + DateFormat('MMMM d yyyy').format(local), + ].join(' ').toLowerCase(); + + return haystack.contains(query); +} + +String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; +} + +ButtonStyle _compactOutlinedButtonStyle() { + return OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ); +} + +ButtonStyle _compactFilledButtonStyle() { + return FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ); +} + +// --------------------------------------------------------------------------- +// Word-level diff helpers +// --------------------------------------------------------------------------- + +enum _DiffType { equal, delete, insert } + +class _DiffOp { + final _DiffType type; + final String word; + + const _DiffOp._(this.type, this.word); + factory _DiffOp.equal(String word) => _DiffOp._(_DiffType.equal, word); + factory _DiffOp.delete(String word) => _DiffOp._(_DiffType.delete, word); + factory _DiffOp.insert(String word) => _DiffOp._(_DiffType.insert, word); +} diff --git a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart new file mode 100644 index 0000000..5ad5b4e --- /dev/null +++ b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart @@ -0,0 +1,612 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../services/ai/ai_debug_info.dart'; +import '../../services/ai/llm_service.dart'; + +/// Try to pretty-print a JSON string. Returns the original string unchanged +/// when it isn't valid JSON. +String aiFormatJson(String raw) { + try { + final decoded = jsonDecode(raw); + return const JsonEncoder.withIndent(' ').convert(decoded); + } catch (_) { + return raw; + } +} + +// --------------------------------------------------------------------------- +// Shared UI primitives for AI / benchmark debug bottom sheets. +// +// Used by both: +// • _BenchmarkDebugSheet (settings → AI models page) +// • _AiDebugSheet (voice memos page) +// --------------------------------------------------------------------------- + +/// Drag handle bar shown at the top of a modal bottom sheet. +Widget aiDebugHandleBar() { + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +/// Header row: bug icon · title · optional spinner · optional Stop · Close. +Widget aiDebugSheetHeader( + BuildContext context, { + required String title, + bool showSpinner = false, + VoidCallback? onStop, + required VoidCallback onClose, +}) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.bug_report_outlined, size: 20), + const SizedBox(width: 8), + Text(title, style: theme.textTheme.titleMedium), + const Spacer(), + if (showSpinner) + const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + ), + if (onStop != null) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.errorColor, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + icon: const Icon(Icons.stop, size: 18), + label: const Text('Stop'), + onPressed: onStop, + ), + IconButton(icon: const Icon(Icons.close), onPressed: onClose), + ], + ), + ); +} + +/// Informational note / empty-state box. +Widget aiDebugNote(BuildContext context, String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); +} + +/// Content block with a title row, optional copy button, and body text. +Widget aiDebugBlock( + BuildContext context, { + required String title, + required String content, + required IconData icon, + bool mono = false, + bool showCopyButton = false, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + title, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), + ), + if (showCopyButton) ...[ + const Spacer(), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied $title'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), + ), + ], + ); +} + +String aiFormatPromptFlow({ + required String? strategy, + required bool retryEnabled, + required int attempts, +}) { + return 'Strategy: ${strategy ?? 'unknown'}\n' + 'Retry invalid output: ${retryEnabled ? 'enabled' : 'disabled'}\n' + 'Attempts used: $attempts'; +} + +bool aiHasChronoDetails({ + String? extractedIntent, + String? extractedTitle, + String? datetimeExpressionOriginal, + String? datetimeExpressionEnglish, + String? resolvedDateTime, + String? resolverMethod, + List extractedActions = const [], +}) { + if (extractedActions.isNotEmpty) return true; + return (extractedIntent?.isNotEmpty ?? false) || + (extractedTitle?.isNotEmpty ?? false) || + (datetimeExpressionOriginal?.isNotEmpty ?? false) || + (datetimeExpressionEnglish?.isNotEmpty ?? false) || + (resolvedDateTime?.isNotEmpty ?? false) || + (resolverMethod?.isNotEmpty ?? false); +} + +String aiFormatChronoDetails({ + String? extractedIntent, + String? extractedTitle, + String? datetimeExpressionOriginal, + String? datetimeExpressionEnglish, + String? resolvedDateTime, + String? resolverMethod, + List extractedActions = const [], +}) { + String show(String? value) => + (value != null && value.trim().isNotEmpty) ? value.trim() : 'null'; + + // When multiple actions are available, show all of them. + if (extractedActions.length > 1) { + final buf = StringBuffer(); + for (var i = 0; i < extractedActions.length; i++) { + final a = extractedActions[i]; + if (i > 0) buf.writeln(); + buf.writeln('--- Action ${i + 1} ---'); + buf.writeln('Intent: ${show(a.intent)}'); + buf.writeln('Title: ${show(a.title)}'); + buf.writeln( + 'Original time phrase: ${show(a.datetimeExpressionOriginal)}', + ); + buf.writeln('English time phrase: ${show(a.datetimeExpressionEnglish)}'); + buf.writeln('Resolved datetime: ${show(a.resolvedDateTime)}'); + buf.write('Resolver: ${show(a.resolverMethod)}'); + } + return buf.toString(); + } + + // Single action — use the direct fields (or the single extractedAction). + final a = extractedActions.isNotEmpty ? extractedActions.first : null; + return 'Intent: ${show(a?.intent ?? extractedIntent)}\n' + 'Title: ${show(a?.title ?? extractedTitle)}\n' + 'Original time phrase: ${show(a?.datetimeExpressionOriginal ?? datetimeExpressionOriginal)}\n' + 'English time phrase: ${show(a?.datetimeExpressionEnglish ?? datetimeExpressionEnglish)}\n' + 'Resolved datetime: ${show(a?.resolvedDateTime ?? resolvedDateTime)}\n' + 'Resolver: ${show(a?.resolverMethod ?? resolverMethod)}'; +} + +/// Small label + value chip used inside metric rows. +Widget aiMetricChip( + BuildContext context, + String label, + String value, + IconData icon, +) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ); +} + +/// Build the standard metric-chip [Wrap] from optional token / speed / time +/// values. Returns an empty list when nothing should be shown. +List aiMetricChips( + BuildContext context, { + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + return [ + if (tokens != null && tokens > 0) + aiMetricChip(context, 'Tokens', '$tokens', Icons.token), + if (tokensPerSecond != null && tokensPerSecond > 0) + aiMetricChip( + context, + 'Speed', + '${tokensPerSecond.toStringAsFixed(1)} t/s', + Icons.speed, + ), + if (elapsed != null && elapsed > Duration.zero) + aiMetricChip( + context, + 'Time', + '${(elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s', + Icons.timer_outlined, + ), + ]; +} + +/// Live phase header: model name, animated spinner + phase label, metric chips. +Widget aiLivePhaseHeader( + BuildContext context, { + required String modelName, + required String phaseText, + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + final theme = Theme.of(context); + final chips = aiMetricChips( + context, + tokens: tokens, + tokensPerSecond: tokensPerSecond, + elapsed: elapsed, + ); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + Text( + phaseText, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: chips), + ], + ], + ), + ); +} + +/// Completed status header (benchmark-style: success / error colouring). +Widget aiCompletedHeader( + BuildContext context, { + required String modelName, + required bool isError, + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + final theme = Theme.of(context); + final statusColor = isError ? AppTheme.errorColor : AppTheme.successColor; + final chips = aiMetricChips( + context, + tokens: tokens, + tokensPerSecond: tokensPerSecond, + elapsed: elapsed, + ); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isError ? Icons.error_outline : Icons.check_circle_outline, + size: 18, + color: statusColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + isError ? 'Failed' : 'Complete', + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: chips), + ], + ], + ), + ); +} + +/// Completed metrics banner for the voice-memo debug sheet. +/// Shows per-phase timing & throughput in a single box. +Widget aiCompletedMetricsHeader( + BuildContext context, { + required String modelName, + List extraChips = const [], +}) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (extraChips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: extraChips), + ], + ], + ), + ); +} + +/// Memory & inference parameter info block. +/// +/// Returns `null` when no memory data is available so callers can skip it with +/// a simple null check: +/// ```dart +/// final memBlock = aiMemoryInfoBlock(context, info); +/// if (memBlock != null) ...[const SizedBox(height: 12), memBlock], +/// ``` +Widget? aiMemoryInfoBlock(BuildContext context, AiDebugInfo info) { + if (info.availableMemoryMB == null) return null; + + final theme = Theme.of(context); + final isLowMemory = (info.memoryHeadroomMB ?? 999) < 100; + final requestedContextSize = + info.requestedContextSize ?? info.inferenceContextSize; + final actualContextSize = info.inferenceContextSize; + final availableMemoryMB = info.availableMemoryMB; + final headroomMB = info.memoryHeadroomMB; + final showSeparateHeadroom = + headroomMB != null && + availableMemoryMB != null && + headroomMB != availableMemoryMB; + final isFullPrompt = requestedContextSize != null && actualContextSize != null + ? actualContextSize >= requestedContextSize + : false; + final isEmergencyCompact = actualContextSize != null + ? LlmService.usesEmergencyCompactPrompt(actualContextSize) + : false; + + final String promptLabel; + final String promptExplanation; + final Color statusColor; + + if (isEmergencyCompact) { + promptLabel = 'Emergency compact'; + promptExplanation = + 'Very low free RAM forced the smallest prompt so the model could still run.'; + statusColor = Colors.orange; + } else if (isFullPrompt) { + promptLabel = 'Full prompt'; + promptExplanation = 'The full prompt fit in memory for this run.'; + statusColor = AppTheme.textSecondary; + } else { + promptLabel = 'Shorter prompt'; + promptExplanation = + 'Free RAM was tight, so the app shortened the prompt for this run.'; + statusColor = AppTheme.warningColor; + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: (isLowMemory || !isFullPrompt) + ? Colors.orange.withValues(alpha: 0.08) + : AppTheme.textSecondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: (isLowMemory || !isFullPrompt) + ? Border.all(color: Colors.orange.withValues(alpha: 0.3)) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.memory, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + 'Memory & Inference', + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + if (isLowMemory || !isFullPrompt) ...[ + const SizedBox(width: 6), + const Icon( + Icons.warning_amber_rounded, + size: 13, + color: Colors.orange, + ), + ], + ], + ), + const SizedBox(height: 6), + Text( + promptExplanation, + style: theme.textTheme.bodySmall?.copyWith(color: statusColor), + ), + const SizedBox(height: 6), + Text( + showSeparateHeadroom + ? 'RAM values are measured after the model is loaded. Headroom is the free memory left for the prompt and KV cache.' + : 'Free RAM is measured after the model is loaded, so headroom would be the same number and is hidden here.', + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + aiMetricChip(context, 'Prompt', promptLabel, Icons.short_text), + if (availableMemoryMB != null) + aiMetricChip( + context, + 'Free RAM after load', + '${availableMemoryMB}MB', + Icons.memory, + ), + if (info.deviceMemoryMB != null) + aiMetricChip( + context, + 'Total RAM', + '${info.deviceMemoryMB}MB', + Icons.phone_android, + ), + if (info.modelSizeMB != null) + aiMetricChip( + context, + 'Model', + '${info.modelSizeMB}MB', + Icons.smart_toy_outlined, + ), + if (showSeparateHeadroom) + aiMetricChip( + context, + 'Headroom', + '${headroomMB}MB', + Icons.expand, + ), + if (actualContextSize != null && requestedContextSize != null) + aiMetricChip( + context, + 'Prompt window', + '$actualContextSize of $requestedContextSize', + Icons.tune, + ), + if (info.inferenceMaxTokensCap != null) + aiMetricChip( + context, + 'Max tokens cap', + '${info.inferenceMaxTokensCap}', + Icons.compress, + ), + ], + ), + ], + ), + ); +} diff --git a/zswatch_app/lib/ui/widgets/battery_ring.dart b/zswatch_app/lib/ui/widgets/battery_ring.dart index da80278..3e47e1e 100644 --- a/zswatch_app/lib/ui/widgets/battery_ring.dart +++ b/zswatch_app/lib/ui/widgets/battery_ring.dart @@ -98,11 +98,7 @@ class BatteryRing extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (isCharging) - Icon( - Icons.bolt_rounded, - size: size * 0.2, - color: color, - ), + Icon(Icons.bolt_rounded, size: size * 0.2, color: color), Text( '$level%', style: TextStyle( @@ -226,4 +222,3 @@ class BatteryIndicator extends StatelessWidget { return Icons.battery_alert_rounded; } } - diff --git a/zswatch_app/lib/ui/widgets/common/async_value_widget.dart b/zswatch_app/lib/ui/widgets/common/async_value_widget.dart new file mode 100644 index 0000000..dc87373 --- /dev/null +++ b/zswatch_app/lib/ui/widgets/common/async_value_widget.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/app_theme.dart'; + +/// Standard loading/error/data wrapper for [AsyncValue]. +/// +/// Usage: +/// ```dart +/// AsyncValueWidget>( +/// value: ref.watch(watchListProvider), +/// data: (watches) => WatchList(watches: watches), +/// ) +/// ``` +/// +/// Error display follows the app contract: +/// - [errorBuilder] overrides the default snackbar-style error card. +/// - The default error card is non-blocking (inline, not full-screen). +class AsyncValueWidget extends StatelessWidget { + final AsyncValue value; + final Widget Function(T data) data; + final Widget Function(Object error, StackTrace? st)? errorBuilder; + final Widget? loading; + + const AsyncValueWidget({ + super.key, + required this.value, + required this.data, + this.errorBuilder, + this.loading, + }); + + @override + Widget build(BuildContext context) { + return value.when( + data: data, + loading: () => + loading ?? const Center(child: CircularProgressIndicator()), + error: (e, st) => + errorBuilder?.call(e, st) ?? _DefaultErrorWidget(error: e), + ); + } +} + +/// Inline error card for transient / non-blocking errors. +class _DefaultErrorWidget extends StatelessWidget { + final Object error; + + const _DefaultErrorWidget({required this.error}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: AppTheme.errorColor, size: 40), + const SizedBox(height: AppTheme.spacingSm), + Text( + error.toString(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.errorColor), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/zswatch_app/lib/ui/widgets/common/battery_indicator.dart b/zswatch_app/lib/ui/widgets/common/battery_indicator.dart new file mode 100644 index 0000000..30ab5e2 --- /dev/null +++ b/zswatch_app/lib/ui/widgets/common/battery_indicator.dart @@ -0,0 +1,7 @@ +/// Re-export of battery indicator widgets. +/// +/// Use [BatteryRing] for circular ring display or [BatteryIndicator] +/// for a compact icon+percentage row. +library; + +export '../battery_ring.dart' show BatteryRing, BatteryIndicator; diff --git a/zswatch_app/lib/ui/widgets/common/connection_status_card.dart b/zswatch_app/lib/ui/widgets/common/connection_status_card.dart new file mode 100644 index 0000000..fc2594b --- /dev/null +++ b/zswatch_app/lib/ui/widgets/common/connection_status_card.dart @@ -0,0 +1,8 @@ +/// Re-export of connection status widgets. +/// +/// Use [ConnectionStatusPill] for a pill-shaped status indicator or +/// [ConnectionStatusDot] for a minimal dot indicator. +library; + +export '../connection_status_pill.dart' + show ConnectionStatusPill, ConnectionStatusDot; diff --git a/zswatch_app/lib/ui/widgets/connection_status_pill.dart b/zswatch_app/lib/ui/widgets/connection_status_pill.dart index 6abd39c..00d5e24 100644 --- a/zswatch_app/lib/ui/widgets/connection_status_pill.dart +++ b/zswatch_app/lib/ui/widgets/connection_status_pill.dart @@ -51,10 +51,7 @@ class ConnectionStatusPill extends ConsumerWidget { decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), - border: Border.all( - color: color.withValues(alpha: 0.3), - width: 1, - ), + border: Border.all(color: color.withValues(alpha: 0.3), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -82,11 +79,7 @@ class ConnectionStatusPill extends ConsumerWidget { } Widget _buildStatusIcon(IconData icon, Color color) { - return Icon( - icon, - size: compact ? 14 : 16, - color: color, - ); + return Icon(icon, size: compact ? 14 : 16, color: color); } (Color, String, IconData) _getStateInfo(Connection? connection) { @@ -130,11 +123,7 @@ class ConnectionStatusPill extends ConsumerWidget { Icons.bluetooth_searching_rounded, ); case WatchConnectionState.syncing: - return ( - AppTheme.connecting, - 'Syncing...', - Icons.sync_rounded, - ); + return (AppTheme.connecting, 'Syncing...', Icons.sync_rounded); case WatchConnectionState.reconnecting: return ( AppTheme.connecting, @@ -142,11 +131,7 @@ class ConnectionStatusPill extends ConsumerWidget { Icons.bluetooth_searching_rounded, ); case WatchConnectionState.scanning: - return ( - AppTheme.infoColor, - 'Scanning...', - Icons.search_rounded, - ); + return (AppTheme.infoColor, 'Scanning...', Icons.search_rounded); case WatchConnectionState.disconnecting: return ( AppTheme.disconnected, @@ -154,11 +139,7 @@ class ConnectionStatusPill extends ConsumerWidget { Icons.bluetooth_disabled_rounded, ); case WatchConnectionState.error: - return ( - AppTheme.errorColor, - 'Error', - Icons.error_outline_rounded, - ); + return (AppTheme.errorColor, 'Error', Icons.error_outline_rounded); case WatchConnectionState.disconnected: return ( AppTheme.disconnected, @@ -173,10 +154,7 @@ class ConnectionStatusPill extends ConsumerWidget { class ConnectionStatusDot extends ConsumerWidget { final double size; - const ConnectionStatusDot({ - super.key, - this.size = 8, - }); + const ConnectionStatusDot({super.key, this.size = 8}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/zswatch_app/lib/ui/widgets/developer/music_debug_section.dart b/zswatch_app/lib/ui/widgets/developer/music_debug_section.dart index fd5ad1f..70c2691 100644 --- a/zswatch_app/lib/ui/widgets/developer/music_debug_section.dart +++ b/zswatch_app/lib/ui/widgets/developer/music_debug_section.dart @@ -112,10 +112,7 @@ class _MusicDebugSectionState extends ConsumerState { }); try { - await watchService.sendMusicState( - state: 'stop', - positionSeconds: 0, - ); + await watchService.sendMusicState(state: 'stop', positionSeconds: 0); _showSnackBar('Stopped'); } catch (e) { _showSnackBar('Failed to send: $e', isError: true); @@ -146,7 +143,7 @@ class _MusicDebugSectionState extends ConsumerState { children: [ Row( children: [ - Icon( + const Icon( Icons.music_note, color: AppTheme.primaryColor, size: 20, @@ -187,9 +184,9 @@ class _MusicDebugSectionState extends ConsumerState { // Sample tracks section Text( 'Sample Tracks', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: AppTheme.spacingSm), @@ -210,9 +207,9 @@ class _MusicDebugSectionState extends ConsumerState { const SizedBox(height: AppTheme.spacingSm), Text( 'Connect to watch to send music info', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.warningColor, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.warningColor), ), ], ], @@ -264,7 +261,9 @@ class _TrackTile extends StatelessWidget { return ListTile( dense: true, visualDensity: VisualDensity.compact, - contentPadding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingSm), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingSm, + ), leading: CircleAvatar( radius: 16, backgroundColor: isSelected diff --git a/zswatch_app/lib/ui/widgets/developer/notification_debug_section.dart b/zswatch_app/lib/ui/widgets/developer/notification_debug_section.dart index 5ba1e7f..c2c5537 100644 --- a/zswatch_app/lib/ui/widgets/developer/notification_debug_section.dart +++ b/zswatch_app/lib/ui/widgets/developer/notification_debug_section.dart @@ -19,14 +19,19 @@ class NotificationDebugSection extends ConsumerStatefulWidget { const NotificationDebugSection({super.key}); @override - ConsumerState createState() => _NotificationDebugSectionState(); + ConsumerState createState() => + _NotificationDebugSectionState(); } -class _NotificationDebugSectionState extends ConsumerState { +class _NotificationDebugSectionState + extends ConsumerState { final _titleController = TextEditingController(text: 'Test Notification'); - final _bodyController = TextEditingController(text: 'This is a debug notification from the companion app.'); + final _bodyController = TextEditingController( + text: 'This is a debug notification from the companion app.', + ); String _selectedApp = 'Messages'; - int _notificationId = 1000; // Start from 1000 to avoid conflicts with real notifications + int _notificationId = + 1000; // Start from 1000 to avoid conflicts with real notifications int? _nativeNotificationId; String? _nativeNotificationTag; bool _isSending = false; @@ -100,7 +105,10 @@ class _NotificationDebugSectionState extends ConsumerState _sendNativeAndroidNotification() async { if (_isPostingNative) return; if (!Platform.isAndroid) { - _showSnackBar('Native notification testing is only available on Android', isError: true); + _showSnackBar( + 'Native notification testing is only available on Android', + isError: true, + ); return; } @@ -110,8 +118,12 @@ class _NotificationDebugSectionState extends ConsumerState( - value: _selectedApp, + initialValue: _selectedApp, decoration: const InputDecoration( - labelText: 'App Source', + labelText: 'Source', border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: AppTheme.spacingSm, - vertical: AppTheme.spacingSm, + vertical: 11, ), ), items: _appOptions.map((app) { - return DropdownMenuItem( - value: app, - child: Text(app), - ); + return DropdownMenuItem(value: app, child: Text(app)); }).toList(), onChanged: (value) { if (value != null) { @@ -201,96 +240,104 @@ class _NotificationDebugSectionState extends ConsumerState _pickFile(context), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingLg), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open, + color: AppTheme.primaryColor.withValues(alpha: 0.7), + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Select .zip firmware file', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.primaryColor, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + 'Select a firmware package .zip, e.g. watchdk@1_nrf5340_cpuapp_debug.zip.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ); + } + + Future _pickFile(BuildContext context) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['zip'], + allowMultiple: false, + ); + + if (result != null && result.files.isNotEmpty) { + final file = result.files.first; + if (file.path != null) { + onFileSelected(file.path!); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not access the selected file'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error picking file: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } +} diff --git a/zswatch_app/lib/ui/widgets/progress_card.dart b/zswatch_app/lib/ui/widgets/progress_card.dart index 0a343c2..13458c4 100644 --- a/zswatch_app/lib/ui/widgets/progress_card.dart +++ b/zswatch_app/lib/ui/widgets/progress_card.dart @@ -74,8 +74,8 @@ class ProgressCard extends StatelessWidget { final progressColor = error ? AppTheme.errorColor : complete - ? AppTheme.successColor - : color ?? AppTheme.primaryColor; + ? AppTheme.successColor + : color ?? AppTheme.primaryColor; return Card( child: Padding( @@ -161,14 +161,11 @@ class ProgressCard extends StatelessWidget { Text( '${(progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + ), ), if (infoText != null && !error) - Text( - infoText!, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(infoText!, style: Theme.of(context).textTheme.bodySmall), ], ), @@ -224,19 +221,13 @@ class ProgressCard extends StatelessWidget { return const SizedBox( width: 24, height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - ), + child: CircularProgressIndicator(strokeWidth: 2.5), ); } else { icon = Icons.download_rounded; } - return Icon( - icon, - color: color, - size: 24, - ); + return Icon(icon, color: color, size: 24); } } @@ -258,10 +249,7 @@ class InlineProgress extends StatelessWidget { return Row( children: [ if (label != null) ...[ - Text( - label!, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(label!, style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: AppTheme.spacingSm), ], Expanded( @@ -285,4 +273,3 @@ class InlineProgress extends StatelessWidget { ); } } - diff --git a/zswatch_app/lib/ui/widgets/real_time_chart.dart b/zswatch_app/lib/ui/widgets/real_time_chart.dart index aa740f3..94c22e9 100644 --- a/zswatch_app/lib/ui/widgets/real_time_chart.dart +++ b/zswatch_app/lib/ui/widgets/real_time_chart.dart @@ -74,13 +74,16 @@ class RealTimeChart extends StatelessWidget { gridData: FlGridData( show: showGrid, drawVerticalLine: true, - horizontalInterval: _calculateYInterval(calculatedMinY, calculatedMaxY), + horizontalInterval: _calculateYInterval( + calculatedMinY, + calculatedMaxY, + ), verticalInterval: timeWindowSeconds / 6, - getDrawingHorizontalLine: (value) => FlLine( + getDrawingHorizontalLine: (value) => const FlLine( color: AppTheme.elevatedSurfaceColor, strokeWidth: 1, ), - getDrawingVerticalLine: (value) => FlLine( + getDrawingVerticalLine: (value) => const FlLine( color: AppTheme.elevatedSurfaceColor, strokeWidth: 1, ), @@ -296,7 +299,7 @@ class StepsBarChart extends StatelessWidget { show: showGrid, drawVerticalLine: false, horizontalInterval: calculatedMaxY / 5, - getDrawingHorizontalLine: (value) => FlLine( + getDrawingHorizontalLine: (value) => const FlLine( color: AppTheme.elevatedSurfaceColor, strokeWidth: 1, ), @@ -387,7 +390,8 @@ class StepsBarChart extends StatelessWidget { dashArray: [8, 4], label: HorizontalLineLabel( show: true, - labelResolver: (line) => 'Goal: ${_formatStepCount(stepGoal!)}', + labelResolver: (line) => + 'Goal: ${_formatStepCount(stepGoal!)}', style: const TextStyle( fontSize: 10, color: AppTheme.warningColor, @@ -467,8 +471,5 @@ class StepsBarData { final String label; final int steps; - const StepsBarData({ - required this.label, - required this.steps, - }); + const StepsBarData({required this.label, required this.steps}); } diff --git a/zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart b/zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart new file mode 100644 index 0000000..a10fcc7 --- /dev/null +++ b/zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart @@ -0,0 +1,333 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/voice_memo.dart'; +import '../../../providers/voice_memo_providers.dart'; + +/// A dismissible card for a single voice memo in the timeline list. +class VoiceNoteCard extends ConsumerWidget { + final VoiceMemo memo; + final VoidCallback onOpen; + + const VoiceNoteCard({super.key, required this.memo, required this.onOpen}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final previewText = memoPreviewText(memo); + final canPlay = hasLocalAudio(memo); + final isProcessing = memo.isAiProcessing; + + return Dismissible( + key: ValueKey('voice-note-${memo.id}'), + direction: DismissDirection.endToStart, + background: Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.errorColor, + borderRadius: BorderRadius.circular(AppTheme.radiusLarge), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: AppTheme.spacingLg), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (_) => confirmDeleteMemo(context, memo), + onDismissed: (_) { + ref.read(voiceMemoActionsProvider.notifier).delete(memo.filename); + }, + child: Card( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (memo.aiCategory != null) + Padding( + padding: const EdgeInsets.only( + right: AppTheme.spacingSm, + ), + child: Icon( + voiceNoteCategoryIcon(memo.aiCategory!), + size: 20, + color: voiceNoteCategoryColor(memo.aiCategory!), + ), + ), + Expanded( + child: Text( + timelineTimestampLabel(memo.timestampUtc.toLocal()), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppTheme.textSecondary, + ), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + previewText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.35, + color: + memo.summary != null || + memo.transcription?.trim().isNotEmpty == true + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (memo.aiCategory != null) + VoiceMemoMetaChip( + icon: voiceNoteCategoryIcon(memo.aiCategory!), + label: voiceNoteCategoryLabel(memo.aiCategory!), + color: voiceNoteCategoryColor(memo.aiCategory!), + ), + if (isProcessing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular( + AppTheme.radiusXLarge, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + const SizedBox(width: 4), + Text( + 'Processing', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 10.5, + ), + ), + ], + ), + ), + VoiceMemoMetaChip( + icon: syncStatusIcon(memo.syncStatus), + label: syncStatusLabel(memo), + color: syncStatusColor(memo.syncStatus), + ), + if (memo.syncedFromWatch) + const VoiceMemoMetaChip( + icon: Icons.smartphone_outlined, + label: 'Phone', + color: AppTheme.primaryColor, + ), + if (!memo.deletedOnWatch) + const VoiceMemoMetaChip( + icon: Icons.watch_outlined, + label: 'On watch', + color: AppTheme.warningColor, + ), + ], + ), + const SizedBox(height: AppTheme.spacingMd), + Row( + children: [ + Text( + memo.formattedDuration, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + memo.formattedSize, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + Icon( + canPlay + ? Icons.play_circle_fill_rounded + : Icons.cloud_download_outlined, + color: canPlay + ? AppTheme.primaryColor + : AppTheme.warningColor, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Small icon+label chip used throughout the voice memo UI. +class VoiceMemoMetaChip extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + + const VoiceMemoMetaChip({ + super.key, + required this.icon, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 3), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10.5, + ), + ), + ], + ), + ); + } +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +String memoPreviewText(VoiceMemo memo) { + final aiSummary = memo.summary?.trim(); + if (aiSummary != null && aiSummary.isNotEmpty) return aiSummary; + + final transcript = memo.transcription?.trim(); + if (transcript != null && transcript.isNotEmpty) { + return transcript.split('\n').first; + } + + if (memo.syncedFromWatch) return 'Audio synced. Transcription pending.'; + return 'On watch only. Sync to download and transcribe this note.'; +} + +bool hasLocalAudio(VoiceMemo memo) { + final path = memo.convertedFilePath ?? memo.localFilePath; + return path != null && File(path).existsSync(); +} + +String timelineTimestampLabel(DateTime dateTime) => + '${dayGroupLabel(dateTime)} · ${DateFormat.Hm().format(dateTime)}'; + +String dayGroupLabel(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final target = DateTime(dateTime.year, dateTime.month, dateTime.day); + final difference = today.difference(target).inDays; + if (difference == 0) return 'Today'; + if (difference == 1) return 'Yesterday'; + return DateFormat.MMMMEEEEd().format(dateTime); +} + +String syncStatusLabel(VoiceMemo memo) { + if (memo.transcription?.trim().isNotEmpty == true) return 'Ready'; + if (memo.syncedFromWatch) return 'Synced'; + return 'On watch'; +} + +IconData syncStatusIcon(VoiceMemoSyncStatus status) => switch (status) { + VoiceMemoSyncStatus.onWatchOnly => Icons.watch_outlined, + VoiceMemoSyncStatus.downloading => Icons.downloading_rounded, + VoiceMemoSyncStatus.synced => Icons.check_circle_outline, + VoiceMemoSyncStatus.downloadFailed => Icons.error_outline, + VoiceMemoSyncStatus.transcribed => Icons.text_snippet_outlined, +}; + +Color syncStatusColor(VoiceMemoSyncStatus status) => switch (status) { + VoiceMemoSyncStatus.onWatchOnly => AppTheme.warningColor, + VoiceMemoSyncStatus.downloading => AppTheme.primaryColor, + VoiceMemoSyncStatus.synced => AppTheme.successColor, + VoiceMemoSyncStatus.downloadFailed => AppTheme.errorColor, + VoiceMemoSyncStatus.transcribed => AppTheme.primaryColor, +}; + +IconData voiceNoteCategoryIcon(VoiceNoteCategory category) => + switch (category) { + VoiceNoteCategory.idea => Icons.lightbulb_outline, + VoiceNoteCategory.task => Icons.check_box_outlined, + VoiceNoteCategory.reminder => Icons.alarm, + VoiceNoteCategory.meeting => Icons.people_outline, + VoiceNoteCategory.note => Icons.note_outlined, + }; + +Color voiceNoteCategoryColor(VoiceNoteCategory category) => switch (category) { + VoiceNoteCategory.idea => const Color(0xFFFFA726), + VoiceNoteCategory.task => AppTheme.primaryColor, + VoiceNoteCategory.reminder => AppTheme.warningColor, + VoiceNoteCategory.meeting => const Color(0xFF26A69A), + VoiceNoteCategory.note => AppTheme.textSecondary, +}; + +String voiceNoteCategoryLabel(VoiceNoteCategory category) => switch (category) { + VoiceNoteCategory.idea => 'Idea', + VoiceNoteCategory.task => 'Task', + VoiceNoteCategory.reminder => 'Reminder', + VoiceNoteCategory.meeting => 'Meeting', + VoiceNoteCategory.note => 'Note', +}; + +Future confirmDeleteMemo(BuildContext context, VoiceMemo memo) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete recording?'), + content: Text( + 'Transcript and audio will be removed.\n\n' + '${memo.formattedDuration} · ${timelineTimestampLabel(memo.timestampUtc.toLocal())}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor), + child: const Text('Delete'), + ), + ], + ), + ); +} diff --git a/zswatch_app/lib/ui/widgets/voice_memos/sync_progress_bar.dart b/zswatch_app/lib/ui/widgets/voice_memos/sync_progress_bar.dart new file mode 100644 index 0000000..53ec6f2 --- /dev/null +++ b/zswatch_app/lib/ui/widgets/voice_memos/sync_progress_bar.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../services/voice_memo/voice_memo_sync_service.dart'; + +/// Displays sync progress at the top of the Voice Notes screen. +class VoiceMemoSyncProgressBar extends StatelessWidget { + final VoiceMemoSyncState state; + + const VoiceMemoSyncProgressBar({super.key, required this.state}); + + @override + Widget build(BuildContext context) { + if (!state.isSyncing) { + return const SizedBox.shrink(); + } + + final phaseText = switch (state.phase) { + VoiceMemoSyncPhase.fetchingList => 'Fetching recording list...', + VoiceMemoSyncPhase.downloading => + 'Downloading ${state.currentFilename ?? ''}...', + VoiceMemoSyncPhase.verifying => 'Verifying download...', + VoiceMemoSyncPhase.deleting => 'Cleaning up watch storage...', + _ => 'Syncing...', + }; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: AppTheme.primaryColor.withValues(alpha: 0.1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + phaseText, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (state.totalToSync > 0) + Text( + '${state.completedCount}/${state.totalToSync}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (state.phase == VoiceMemoSyncPhase.downloading) + Padding( + padding: const EdgeInsets.only(top: AppTheme.spacingXs), + child: LinearProgressIndicator( + value: state.downloadProgress, + backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2), + ), + ), + ], + ), + ); + } +} diff --git a/zswatch_app/lib/ui/widgets/watch_config_dialog.dart b/zswatch_app/lib/ui/widgets/watch_config_dialog.dart index 9823413..68d7987 100644 --- a/zswatch_app/lib/ui/widgets/watch_config_dialog.dart +++ b/zswatch_app/lib/ui/widgets/watch_config_dialog.dart @@ -5,7 +5,7 @@ import '../../core/theme/app_theme.dart'; import '../../data/database/app_database.dart'; /// Dialog for watch management (rename, forget) - T117 -/// +/// /// Shows: /// - Editable watch name text field /// - "Save" button to save custom name @@ -84,7 +84,7 @@ class _WatchConfigDialogState extends State { // If new name equals the original advertised name, clear customName final customName = newName == widget.watch.name ? null : newName; await widget.onRename(widget.watch.id, customName); - + if (mounted) { Navigator.of(context).pop(false); // false = not forgotten } @@ -137,7 +137,7 @@ class _WatchConfigDialogState extends State { try { await widget.onForget(widget.watch.id); - + if (mounted) { Navigator.of(context).pop(true); // true = was forgotten } @@ -172,7 +172,7 @@ class _WatchConfigDialogState extends State { // Watch info header _buildWatchInfo(), const SizedBox(height: AppTheme.spacingLg), - + // Name text field TextField( controller: _nameController, @@ -196,7 +196,7 @@ class _WatchConfigDialogState extends State { textCapitalization: TextCapitalization.words, ), const SizedBox(height: AppTheme.spacingLg), - + // Forget watch button SizedBox( width: double.infinity, @@ -264,23 +264,23 @@ class _WatchConfigDialogState extends State { children: [ Text( _displayName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), if (widget.watch.customName != null) Text( 'Original: ${widget.watch.name}', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), if (widget.watch.firmwareVersion != null) Text( 'v${widget.watch.firmwareVersion}', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + color: AppTheme.textSecondary, + ), ), ], ), diff --git a/zswatch_app/linux/flutter/generated_plugin_registrant.cc b/zswatch_app/linux/flutter/generated_plugin_registrant.cc index a35cce6..54d51d9 100644 --- a/zswatch_app/linux/flutter/generated_plugin_registrant.cc +++ b/zswatch_app/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/zswatch_app/linux/flutter/generated_plugins.cmake b/zswatch_app/linux/flutter/generated_plugins.cmake index 2aa89bb..eaa6754 100644 --- a/zswatch_app/linux/flutter/generated_plugins.cmake +++ b/zswatch_app/linux/flutter/generated_plugins.cmake @@ -4,11 +4,14 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + record_linux sqlite3_flutter_libs url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama + whisper_ggml_plus ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift index 590b7d0..f1a19c4 100644 --- a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,24 +5,32 @@ import FlutterMacOS import Foundation +import audio_session +import ffmpeg_kit_flutter_new_min import file_picker import flutter_blue_plus_darwin import flutter_secure_storage_macos import geolocator_apple +import just_audio import package_info_plus import path_provider_foundation +import record_macos import shared_preferences_foundation import sqlite3_flutter_libs import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/zswatch_app/macos/Podfile.lock b/zswatch_app/macos/Podfile.lock new file mode 100644 index 0000000..8355d71 --- /dev/null +++ b/zswatch_app/macos/Podfile.lock @@ -0,0 +1,108 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): + - sqlite3/common + - sqlite3/math (3.50.4): + - sqlite3/common + - sqlite3/perf-threadsafe (3.50.4): + - sqlite3/common + - sqlite3/rtree (3.50.4): + - sqlite3/common + - sqlite3/session (3.50.4): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.50.4) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session + - url_launcher_macos (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + +SPEC REPOS: + trunk: + - sqlite3 + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + flutter_blue_plus_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + geolocator_apple: + :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/zswatch_app/macos/Runner.xcodeproj/project.pbxproj b/zswatch_app/macos/Runner.xcodeproj/project.pbxproj index 79af6d2..ec42db1 100644 --- a/zswatch_app/macos/Runner.xcodeproj/project.pbxproj +++ b/zswatch_app/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 73605F5E48BC31DCFDF3187B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */; }; + A74ABE6B0CFF0B4CE71D63DC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* zswatch_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "zswatch_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* zswatch_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = zswatch_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,14 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 4F7B90951BC1BBBAACCD6558 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 828F3D83EE1B8802F4257328 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A53B852986222779194C1FD4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 73605F5E48BC31DCFDF3187B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A74ABE6B0CFF0B4CE71D63DC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + DAA0D77AEB775359935A1CD0 /* Pods */, ); sourceTree = ""; }; @@ -175,10 +188,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */, + B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + DAA0D77AEB775359935A1CD0 /* Pods */ = { + isa = PBXGroup; + children = ( + A53B852986222779194C1FD4 /* Pods-Runner.debug.xcconfig */, + 828F3D83EE1B8802F4257328 /* Pods-Runner.release.xcconfig */, + 4F7B90951BC1BBBAACCD6558 /* Pods-Runner.profile.xcconfig */, + 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */, + 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */, + 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + DFEBB83803065A91D337FCE3 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + FE274AFC8F797F6075A838DF /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 3CD6BB3E961BBBBDB6D2442B /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 3CD6BB3E961BBBBDB6D2442B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + DFEBB83803065A91D337FCE3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FE274AFC8F797F6075A838DF /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index db03b7b..1f02206 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" bluez: dependency: transitive description: @@ -153,6 +161,21 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + chrono_ai_flow: + dependency: "direct main" + description: + path: "../packages/chrono_ai_flow" + relative: true + source: path + version: "0.1.0" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" cli_util: dependency: transitive description: @@ -169,6 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -241,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" dbus: dependency: transitive description: @@ -266,7 +305,7 @@ packages: source: hosted version: "2.28.0" equatable: - dependency: "direct main" + dependency: transitive description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" @@ -282,13 +321,29 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" + ffmpeg_kit_flutter_new_min: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_new_min + sha256: "15640bf8f177c5c3169a07490635ec0e2fe3816aeb31813eaa747ef0fbd271a6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" file: dependency: transitive description: @@ -321,6 +376,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.69.2" + fllama: + dependency: "direct main" + description: + path: "../third_party/fllama" + relative: true + source: path + version: "0.0.1" flutter: dependency: "direct main" description: flutter @@ -464,8 +526,16 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "2d399f823b8849663744d2a9ddcce01c49268fb4170d0442a655bf6a2f47be22" + url: "https://pub.dev" + source: hosted + version: "3.1.0" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" @@ -552,6 +622,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: "direct main" description: @@ -592,6 +678,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jinja: + dependency: transitive + description: + name: jinja + sha256: "67485c43c8551688669a81b4e01fe94f6126578ba8c194908d00f254f23f9b8b" + url: "https://pub.dev" + source: hosted + version: "0.6.5" js: dependency: transitive description: @@ -604,10 +698,34 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -695,6 +813,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.6" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -903,6 +1037,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: @@ -1100,6 +1298,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1116,6 +1322,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + textwrap: + dependency: transitive + description: + name: textwrap + sha256: "7e79503c220a9c772d370075e0d4117204546ed4c6479ab1c9ee4d4c27add606" + url: "https://pub.dev" + source: hosted + version: "2.2.0" timing: dependency: transitive description: @@ -1140,6 +1354,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -1300,6 +1522,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + whisper_ggml_plus: + dependency: "direct main" + description: + name: whisper_ggml_plus + sha256: "67ffeae30a4e9a5f3b54651fb0d6a27c7de23ea6855aa54118bb894e2b32e205" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + whisper_ggml_plus_ffmpeg: + dependency: "direct main" + description: + name: whisper_ggml_plus_ffmpeg + sha256: "29224753c76822b09b8ef69391ae218f8df5b8db82ec32b6d43173df01782383" + url: "https://pub.dev" + source: hosted + version: "1.0.0" win32: dependency: transitive description: diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 3295463..5a32655 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -1,7 +1,7 @@ name: zswatch_app -description: "ZSWatch Companion App - BLE communication, firmware updates, health data, and developer tools for ZSWatch smartwatch." +description: "Connect your Open Source ZSWatch smartwatch to set it up, update firmware, track health data and more." publish_to: 'none' -version: 1.0.0+2 +version: 1.1.0+1 environment: sdk: ^3.10.1 @@ -49,19 +49,41 @@ dependencies: # Navigation go_router: ^14.6.2 + # Immutable models + freezed_annotation: ^3.1.0 + # Utilities collection: ^1.19.1 intl: ^0.19.0 - equatable: ^2.0.7 uuid: ^4.5.1 rxdart: ^0.28.0 + ffi: ^2.1.3 + chrono_dart: ^2.0.2 url_launcher: ^6.3.1 file_picker: ^8.1.6 wakelock_plus: ^1.2.8 + # Audio Playback (voice memo Ogg/Opus) + just_audio: ^0.9.42 + + # Audio Recording (debug/benchmark recording on phone) + record: ^6.2.0 + + # Speech-to-Text (offline Whisper inference) + whisper_ggml_plus: ^1.3.5 + whisper_ggml_plus_ffmpeg: ^1.0.0 + ffmpeg_kit_flutter_new_min: ^3.1.0 + # Location (for GPS requests from watch) geolocator: ^13.0.2 + # Local LLM inference (llama.cpp via fllama) + # Git submodule (ZSWatch/fllama fork) with iOS ggml header-map fixes. + fllama: + path: ../third_party/fllama + chrono_ai_flow: + path: ../packages/chrono_ai_flow + dev_dependencies: flutter_test: sdk: flutter @@ -71,6 +93,7 @@ dev_dependencies: # Code Generation build_runner: ^2.4.14 + freezed: ^3.1.0 drift_dev: ^2.22.1 riverpod_generator: ^2.6.3 diff --git a/zswatch_app/test/widget_test.dart b/zswatch_app/test/widget_test.dart index 1e53810..8be3633 100644 --- a/zswatch_app/test/widget_test.dart +++ b/zswatch_app/test/widget_test.dart @@ -8,11 +8,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:zswatch_app/core/theme/app_theme.dart'; void main() { - testWidgets('Theme smoke test - dark theme loads correctly', - (WidgetTester tester) async { + testWidgets('Theme smoke test - dark theme loads correctly', ( + WidgetTester tester, + ) async { // Test that the theme configuration works final theme = AppTheme.darkTheme; - + expect(theme.brightness, Brightness.dark); expect(theme.colorScheme.primary, AppTheme.primaryColor); expect(theme.scaffoldBackgroundColor, AppTheme.backgroundColor); diff --git a/zswatch_app/test/zsw_opus_parser_test.dart b/zswatch_app/test/zsw_opus_parser_test.dart new file mode 100644 index 0000000..eacb0a8 --- /dev/null +++ b/zswatch_app/test/zsw_opus_parser_test.dart @@ -0,0 +1,478 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:zswatch_app/services/voice_memo/zsw_opus_parser.dart'; +import 'package:zswatch_app/services/voice_memo/ogg_opus_writer.dart'; +import 'package:zswatch_app/data/models/voice_memo.dart'; + +/// Build a valid .zsw_opus binary blob for testing. +/// +/// Returns raw bytes with a 32-byte header + [frameCount] frames, +/// each containing [frameLenBytes] bytes of dummy Opus data. +Uint8List buildZswOpusFile({ + String magic = 'ZSWO', + int version = 1, + int sampleRate = 16000, + int frameSize = 160, + int bitrate = 32000, + int timestamp = 1700000000, + int? totalFrames, + int? durationMs, + int frameCount = 10, + int frameLenBytes = 20, +}) { + totalFrames ??= frameCount; + durationMs ??= (frameCount * frameSize * 1000) ~/ sampleRate; + + // Header: 32 bytes + final bodySize = frameCount * (2 + frameLenBytes); + final buf = ByteData(32 + bodySize); + + // Magic (4 bytes) + for (var i = 0; i < 4; i++) { + buf.setUint8(i, magic.length > i ? magic.codeUnitAt(i) : 0); + } + buf.setUint16(4, version, Endian.little); + buf.setUint16(6, sampleRate, Endian.little); + buf.setUint16(8, frameSize, Endian.little); + buf.setUint16(10, 0, Endian.little); // reserved + buf.setUint32(12, bitrate, Endian.little); + buf.setUint32(16, timestamp, Endian.little); + buf.setUint32(20, totalFrames, Endian.little); + buf.setUint32(24, durationMs, Endian.little); + buf.setUint32(28, 0, Endian.little); // reserved + + // Frames + var offset = 32; + for (var f = 0; f < frameCount; f++) { + buf.setUint16(offset, frameLenBytes, Endian.little); + offset += 2; + for (var b = 0; b < frameLenBytes; b++) { + buf.setUint8(offset + b, (f + b) & 0xFF); + } + offset += frameLenBytes; + } + + return buf.buffer.asUint8List(); +} + +void main() { + group('ZswOpusParser', () { + test('parses valid file', () { + final data = buildZswOpusFile(); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.magic, 'ZSWO'); + expect(result.header.version, 1); + expect(result.header.sampleRate, 16000); + expect(result.header.frameSize, 160); + expect(result.header.bitrate, 32000); + expect(result.header.timestamp, 1700000000); + expect(result.header.totalFrames, 10); + expect(result.header.isDirtyStop, false); + expect(result.frames.length, 10); + expect(result.isValid, true); + }); + + test('returns null for data smaller than header', () { + final data = Uint8List(16); // Too small + expect(ZswOpusParser.parse(data), isNull); + expect(ZswOpusParser.parseHeader(data), isNull); + }); + + test('returns null for wrong magic', () { + final data = buildZswOpusFile(magic: 'NOPE'); + expect(ZswOpusParser.parse(data), isNull); + }); + + test('parses header-only (no frames) as invalid', () { + // Build just the 32-byte header with no frames + final data = buildZswOpusFile(frameCount: 0); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.isValid, false); // No frames → isValid = false + expect(result.frames, isEmpty); + }); + + test('detects dirty stop (0xFFFFFFFF in totalFrames)', () { + final data = buildZswOpusFile(totalFrames: 0xFFFFFFFF); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.isDirtyStop, true); + }); + + test('detects dirty stop (0xFFFFFFFF in durationMs)', () { + final data = buildZswOpusFile(durationMs: 0xFFFFFFFF); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.isDirtyStop, true); + }); + + test('handles truncated frame (dirty stop mid-write)', () { + final fullData = buildZswOpusFile(frameCount: 5, frameLenBytes: 30); + // Chop off last 10 bytes → last frame is truncated + final truncated = Uint8List.sublistView( + fullData, + 0, + fullData.length - 10, + ); + final result = ZswOpusParser.parse(truncated); + + expect(result, isNotNull); + // Should have 4 complete frames (5th is truncated) + expect(result!.frames.length, 4); + }); + + test('handles zero-length frame as end marker', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + // Insert a zero-length frame after the 2nd frame + // offset of 3rd frame = 32 + 2*(2+10) = 56 + final modified = Uint8List.fromList(data); + modified[56] = 0; + modified[57] = 0; + + final result = ZswOpusParser.parse(modified); + expect(result, isNotNull); + expect(result!.frames.length, 2); // Stops at zero-length marker + }); + + test('computed duration matches expected value', () { + final data = buildZswOpusFile( + frameCount: 100, + frameSize: 160, + sampleRate: 16000, + ); + final result = ZswOpusParser.parse(data); + + // 100 frames * 160 samples / 16000 Hz = 1.0 seconds = 1000 ms + expect(result!.computedDurationMs, 1000); + }); + + test('validate returns true for valid file', () { + final data = buildZswOpusFile(); + expect(ZswOpusParser.validate(data), true); + }); + + test('validate returns false for wrong magic', () { + final data = buildZswOpusFile(magic: 'XXXX'); + expect(ZswOpusParser.validate(data), false); + }); + + test('validate returns false for empty data', () { + expect(ZswOpusParser.validate(Uint8List(0)), false); + }); + + test('validateDownload checks size match', () { + final data = buildZswOpusFile(); + expect( + ZswOpusParser.validateDownload(data, expectedSizeBytes: data.length), + true, + ); + expect( + ZswOpusParser.validateDownload( + data, + expectedSizeBytes: data.length + 1, + ), + false, + ); + }); + + test('parseHeader returns header without parsing frames', () { + final data = buildZswOpusFile(timestamp: 1234567890, bitrate: 24000); + final header = ZswOpusParser.parseHeader(data); + + expect(header, isNotNull); + expect(header!.timestamp, 1234567890); + expect(header.bitrate, 24000); + }); + + test('frame data content is correct', () { + final data = buildZswOpusFile(frameCount: 2, frameLenBytes: 5); + final result = ZswOpusParser.parse(data); + + expect(result!.frames.length, 2); + // First frame: bytes are (0+0)&0xFF, (0+1)&0xFF, ... + expect(result.frames[0].data.length, 5); + expect(result.frames[0].data[0], 0); + expect(result.frames[0].data[1], 1); + // Second frame: bytes are (1+0)&0xFF, (1+1)&0xFF, ... + expect(result.frames[1].data[0], 1); + expect(result.frames[1].data[1], 2); + }); + + test('frame fileOffset values are sequential', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + final result = ZswOpusParser.parse(data); + + expect(result!.frames[0].fileOffset, 32); // Right after header + expect(result.frames[1].fileOffset, 32 + 12); // 2 + 10 + expect(result.frames[2].fileOffset, 32 + 24); // 2*(2+10) + }); + }); + + group('VoiceMemo model', () { + test('syncStatus returns onWatchOnly when not synced', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.onWatchOnly); + }); + + test('syncStatus returns synced when downloaded', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + syncedFromWatch: true, + localFilePath: '/path/to/file', + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.synced); + }); + + test('syncStatus returns transcribed when transcription exists', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + syncedFromWatch: true, + localFilePath: '/path/to/file', + transcription: 'Hello world', + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.transcribed); + }); + + test('formattedDuration formats correctly', () { + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 65000, + sizeBytes: 0, + ).formattedDuration, + '1:05', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 3000, + sizeBytes: 0, + ).formattedDuration, + '0:03', + ); + }); + + test('formattedSize formats correctly', () { + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 512, + ).formattedSize, + '512 B', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 2048, + ).formattedSize, + '2.0 KB', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 2 * 1024 * 1024, + ).formattedSize, + '2.0 MB', + ); + }); + + test('copyWith creates a correct copy', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + ); + + final updated = memo.copyWith( + transcription: 'Hello', + syncedFromWatch: true, + ); + + expect(updated.id, 1); + expect(updated.filename, 'test.zsw_opus'); + expect(updated.transcription, 'Hello'); + expect(updated.syncedFromWatch, true); + expect(updated.durationMs, 5000); // Unchanged + }); + + test('Equatable equality works', () { + final a = VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + final b = VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + final c = VoiceMemo( + id: 2, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // OggOpusWriter tests + // ═══════════════════════════════════════════════════════════ + + group('OggOpusWriter', () { + test('convert produces valid Ogg stream starting with OggS', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Must start with "OggS" capture pattern + expect(ogg[0], 0x4F); // O + expect(ogg[1], 0x67); // g + expect(ogg[2], 0x67); // g + expect(ogg[3], 0x53); // S + }); + + test('convert output contains OpusHead and OpusTags', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + final str = String.fromCharCodes(ogg); + + expect(str.contains('OpusHead'), isTrue); + expect(str.contains('OpusTags'), isTrue); + }); + + test('convert produces at least 3 Ogg pages (head, tags, audio)', () { + final data = buildZswOpusFile(frameCount: 10, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Count OggS markers + int pageCount = 0; + for (int i = 0; i <= ogg.length - 4; i++) { + if (ogg[i] == 0x4F && + ogg[i + 1] == 0x67 && + ogg[i + 2] == 0x67 && + ogg[i + 3] == 0x53) { + pageCount++; + } + } + expect(pageCount, greaterThanOrEqualTo(3)); + }); + + test('first page has BOS flag set', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Byte 5 is the header type flag; BOS = 0x02 + expect(ogg[5] & 0x02, equals(0x02)); + }); + + test('last page has EOS flag set', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Find the last OggS marker + int lastPageOffset = -1; + for (int i = ogg.length - 4; i >= 0; i--) { + if (ogg[i] == 0x4F && + ogg[i + 1] == 0x67 && + ogg[i + 2] == 0x67 && + ogg[i + 3] == 0x53) { + lastPageOffset = i; + break; + } + } + expect(lastPageOffset, greaterThan(0)); + // Byte 5 is header type; EOS = 0x04 + expect(ogg[lastPageOffset + 5] & 0x04, equals(0x04)); + }); + + test('OpusHead contains correct channel count and sample rate', () { + final data = buildZswOpusFile( + frameCount: 3, + frameLenBytes: 10, + sampleRate: 16000, + ); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Find "OpusHead" in the output + final str = String.fromCharCodes(ogg); + final headIdx = str.indexOf('OpusHead'); + expect(headIdx, greaterThan(0)); + + // OpusHead layout: magic(8) version(1) channels(1) preskip(2) samplerate(4) + expect(ogg[headIdx + 8], 1); // version + expect(ogg[headIdx + 9], 1); // 1 channel (mono) + + // Input sample rate at offset 12 (LE) + final bd = ByteData.sublistView(ogg, headIdx + 12, headIdx + 16); + expect(bd.getUint32(0, Endian.little), 16000); + }); + + test('convert output grows with more frames', () { + final small = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final large = buildZswOpusFile(frameCount: 500, frameLenBytes: 20); + final oggSmall = OggOpusWriter.convert(ZswOpusParser.parse(small)!); + final oggLarge = OggOpusWriter.convert(ZswOpusParser.parse(large)!); + + expect(oggLarge.length, greaterThan(oggSmall.length)); + }); + + test('convert handles single frame', () { + final data = buildZswOpusFile(frameCount: 1, frameLenBytes: 40); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Should still produce valid output with OggS pages + expect(ogg.length, greaterThan(0)); + expect(ogg[0], 0x4F); // O + }); + }); +} diff --git a/zswatch_app/windows/flutter/generated_plugin_registrant.cc b/zswatch_app/windows/flutter/generated_plugin_registrant.cc index 766f9d7..457c9ec 100644 --- a/zswatch_app/windows/flutter/generated_plugin_registrant.cc +++ b/zswatch_app/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/zswatch_app/windows/flutter/generated_plugins.cmake b/zswatch_app/windows/flutter/generated_plugins.cmake index 7c9e425..80b4026 100644 --- a/zswatch_app/windows/flutter/generated_plugins.cmake +++ b/zswatch_app/windows/flutter/generated_plugins.cmake @@ -6,11 +6,14 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows geolocator_windows permission_handler_windows + record_windows sqlite3_flutter_libs url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama + whisper_ggml_plus ) set(PLUGIN_BUNDLED_LIBRARIES)