diff --git a/ai_testbench/.gitignore b/ai_testbench/.gitignore index 29235f2..eb874d5 100644 --- a/ai_testbench/.gitignore +++ b/ai_testbench/.gitignore @@ -56,3 +56,4 @@ native_libs/ # MLCEngine virtualenv .mlc_venv/ +benchmark_results/ diff --git a/ai_testbench/README.md b/ai_testbench/README.md index c52c234..5b7734e 100644 --- a/ai_testbench/README.md +++ b/ai_testbench/README.md @@ -84,12 +84,19 @@ flutter build linux --release ./build/linux/x64/release/bundle/ai_testbench --headless-correction --model-dir models/ --output correction.json ``` +**Timer/alarm benchmark** (uses extended 5-intent prompt with timer/alarm cases): +```bash +./build/linux/x64/release/bundle/ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf +``` + ### CLI Options | Flag | Description | |------|-------------| | `--headless` | Run structured extraction benchmark (all models) | | `--headless-time` | Run time extraction benchmark | +| `--headless-timer` | Run timer/alarm benchmark (5-intent prompt, timer/alarm cases only) | +| `--prompt-timer` | Use the 5-intent prompt (with `--headless` to run all cases for regression testing) | | `--headless-correction` | Run correction benchmark | | `--model ` | Filter to a specific model filename | | `--model-dir ` | Path to directory containing `.gguf` files (default: `models/`) | diff --git a/ai_testbench/lib/benchmark_main.dart b/ai_testbench/lib/benchmark_main.dart index ba49133..a54b248 100644 --- a/ai_testbench/lib/benchmark_main.dart +++ b/ai_testbench/lib/benchmark_main.dart @@ -269,6 +269,8 @@ Map _serializeModelResult(BenchmarkModelResult result) { 'titleLanguageDetail': caseResult.titleLanguageDetail, 'timeResolutionCorrect': caseResult.timeResolutionCorrect, 'timeResolutionDetail': caseResult.timeResolutionDetail, + 'durationMatch': caseResult.durationMatch, + 'durationDetail': caseResult.durationDetail, 'intent': caseResult.intent, 'title': caseResult.title, 'datetimeOriginal': caseResult.datetimeOriginal, diff --git a/ai_testbench/lib/main.dart b/ai_testbench/lib/main.dart index 4845f98..1160f91 100644 --- a/ai_testbench/lib/main.dart +++ b/ai_testbench/lib/main.dart @@ -4,11 +4,18 @@ import 'package:flutter/material.dart'; import 'benchmark_main.dart' as model_bench; import 'correction_main.dart'; +import 'router_benchmark_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 router pre-classifier benchmark + if (args.contains('--headless-router')) { + await runRouterBenchmark(args); + exit(exitCode); + } + // Headless mode: run time extraction tests from CLI if (args.contains('--headless-time')) { await runHeadlessTimeExtraction(args); diff --git a/ai_testbench/lib/router_benchmark_main.dart b/ai_testbench/lib/router_benchmark_main.dart new file mode 100644 index 0000000..c953ded --- /dev/null +++ b/ai_testbench/lib/router_benchmark_main.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/material.dart'; + +import 'services/llm_service.dart'; + +/// Benchmarks the two-stage router approach: +/// 1. Router prompt classifies input as timer_alarm / voice_memo / mixed +/// 2. Routes to dedicated timer/alarm prompt OR original 3-intent prompt +/// +/// Measures: router accuracy, router latency, total pipeline latency. +Future runRouterBenchmark(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + final modelFilter = _readArg(args, '--model'); + final modelDir = _readArg(args, '--model-dir') ?? 'models'; + + final modelPaths = Directory(modelDir) + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .where( + (p) => modelFilter == null || p.toLowerCase().contains(modelFilter.toLowerCase())) + .toList() + ..sort(); + + if (modelPaths.isEmpty) { + stdout.writeln('[RouterBench] No .gguf models found'); + exitCode = 1; + return; + } + + final modelPath = modelPaths.first; + stdout.writeln('[RouterBench] Using model: ${modelPath.split('/').last}'); + + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 4096 + ..nThreads = Platform.numberOfProcessors + ..maxTokens = 32 // Router output is tiny: {"route":"timer_alarm"} + ..temperature = 0.1 + ..topP = 1.0 + ..presencePenalty = 2.0 + ..enableThinking = false; + + final parser = const ChronoLlmParser(); + final referenceTime = DateTime(2026, 3, 11, 10, 15); + + // Test cases with expected route + final cases = <_RouterTestCase>[ + // Timer cases → timer_alarm + _RouterTestCase('Set a timer for 8 minutes', 'timer_alarm'), + _RouterTestCase('Timer for 5 minutes for pasta', 'timer_alarm'), + _RouterTestCase('Set a 30 second timer', 'timer_alarm'), + _RouterTestCase('Set a timer for one and a half hours', 'timer_alarm'), + _RouterTestCase('Sätt en timer på 10 minuter', 'timer_alarm'), + _RouterTestCase('Timer på 5 minuter för äggen', 'timer_alarm'), + _RouterTestCase('Stell einen Timer auf 15 Minuten', 'timer_alarm'), + _RouterTestCase('In 10 minutes', 'timer_alarm'), + _RouterTestCase('30 minutes', 'timer_alarm'), + + // Alarm cases → timer_alarm + _RouterTestCase('Set an alarm for 7:30 AM', 'timer_alarm'), + _RouterTestCase('Alarm at 6 AM, wake up', 'timer_alarm'), + _RouterTestCase('Wake me up tomorrow at 5:30', 'timer_alarm'), + _RouterTestCase('Ställ ett alarm klockan 7', 'timer_alarm'), + _RouterTestCase('Wecker auf 7 Uhr stellen', 'timer_alarm'), + _RouterTestCase('7 AM', 'timer_alarm'), + + // Reminder cases → voice_memo (NOT timer/alarm despite having time) + _RouterTestCase('Remind me in 30 minutes to check the oven', 'voice_memo'), + _RouterTestCase('Remind me at 3 PM to call the dentist', 'voice_memo'), + _RouterTestCase( + 'Påminn mig om 10 minuter att stänga av ugnen', 'voice_memo'), + _RouterTestCase( + 'Påminn mig klockan 15 att ringa tandläkaren', 'voice_memo'), + + // Event/note cases → voice_memo + _RouterTestCase('Meeting with John next Tuesday at 2 pm', 'voice_memo'), + _RouterTestCase('Buy milk and bread', 'voice_memo'), + _RouterTestCase('Köp mjölk och bröd på vägen hem', 'voice_memo'), + _RouterTestCase( + 'Tandläkare den 15 mars klockan halv 10', 'voice_memo'), + _RouterTestCase( + 'Fika med Anna imorgon klockan 10 och sen lämna in paketet', + 'voice_memo'), + + // Mixed cases + _RouterTestCase( + 'Set a timer for 10 minutes and an alarm for 7 AM tomorrow', 'timer_alarm'), + _RouterTestCase('Set an alarm for 6:30 and buy milk', 'mixed'), + _RouterTestCase( + 'Sätt en timer på 5 minuter och påminn mig klockan 3 att ringa tandläkaren', + 'mixed'), + ]; + + stdout.writeln('[RouterBench] Running ${cases.length} router cases...\n'); + + // ── Stage 1: Router prompt benchmark ── + int routerCorrect = 0; + final routerTimes = []; + + for (final tc in cases) { + final prompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.routerTemplate, + transcript: tc.transcript, + now: referenceTime, + ); + + final result = await llm.generate(prompt).timeout( + const Duration(seconds: 30), + onTimeout: () => const InferenceResult( + output: '{"route":"timeout"}', elapsed: Duration(seconds: 30)), + ); + + routerTimes.add(result.elapsed); + final route = _parseRoute(parser.sanitizeModelOutput(result.output)); + final correct = route == tc.expectedRoute; + if (correct) routerCorrect++; + + final status = correct ? 'OK' : 'FAIL'; + stdout.writeln( + ' $status route=$route (expected=${tc.expectedRoute}) ' + '${result.elapsed.inMilliseconds}ms "${tc.transcript}"'); + } + + final avgRouterMs = + routerTimes.fold(0, (s, d) => s + d.inMilliseconds) ~/ + routerTimes.length; + stdout.writeln( + '\n[RouterBench] Router accuracy: $routerCorrect/${cases.length}'); + stdout.writeln('[RouterBench] Router avg latency: ${avgRouterMs}ms'); + + // ── Stage 2: Full two-stage pipeline on timer/alarm cases ── + stdout.writeln('\n[RouterBench] Running full two-stage pipeline on timer/alarm cases...\n'); + + // Reconfigure for extraction (more tokens needed) + llm.maxTokens = 384; + + final timerCases = cases + .where((c) => c.expectedRoute == 'timer_alarm') + .toList(); + + int extractionCorrect = 0; + final totalTimes = []; + + for (final tc in timerCases) { + final sw = Stopwatch()..start(); + + // Stage 1: Router + llm.maxTokens = 32; + final routerPrompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.routerTemplate, + transcript: tc.transcript, + now: referenceTime, + ); + final routerResult = await llm.generate(routerPrompt).timeout( + const Duration(seconds: 30), + onTimeout: () => const InferenceResult( + output: '{"route":"timeout"}', elapsed: Duration(seconds: 30)), + ); + final routerMs = routerResult.elapsed.inMilliseconds; + + // Stage 2: Timer/alarm extraction + llm.maxTokens = 384; + final extractPrompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.timerAlarmTemplate, + transcript: tc.transcript, + now: referenceTime, + ); + final extractResult = await llm.generate(extractPrompt).timeout( + const Duration(seconds: 60), + onTimeout: () => const InferenceResult( + output: '[]', elapsed: Duration(seconds: 60)), + ); + final extractMs = extractResult.elapsed.inMilliseconds; + + sw.stop(); + totalTimes.add(sw.elapsed); + + final parseResult = parser.parse(extractResult.output); + final first = parseResult.extractions.isNotEmpty + ? parseResult.extractions.first + : null; + final intentOk = first != null && + (first.intent == 'timer' || first.intent == 'alarm'); + if (intentOk) extractionCorrect++; + + stdout.writeln( + ' ${intentOk ? "OK" : "FAIL"} intent=${first?.intent ?? "null"} ' + 'dur=${first?.durationSeconds ?? "null"} ' + 'router=${routerMs}ms extract=${extractMs}ms ' + 'total=${sw.elapsedMilliseconds}ms "${tc.transcript}"'); + } + + final avgTotalMs = + totalTimes.fold(0, (s, d) => s + d.inMilliseconds) ~/ + totalTimes.length; + stdout.writeln( + '\n[RouterBench] Extraction accuracy: $extractionCorrect/${timerCases.length}'); + stdout.writeln('[RouterBench] Avg total (router+extract): ${avgTotalMs}ms'); + stdout.writeln('[RouterBench] Avg router only: ${avgRouterMs}ms'); + stdout.writeln( + '[RouterBench] Avg extract only: ${avgTotalMs - avgRouterMs}ms'); + + // ── Stage 3: Compare with single-pass original prompt on voice_memo cases ── + stdout.writeln('\n[RouterBench] Comparing single-pass original prompt latency...\n'); + + final voiceMemoCases = cases + .where((c) => c.expectedRoute == 'voice_memo') + .take(5) + .toList(); + + llm.maxTokens = 384; + final singlePassTimes = []; + + for (final tc in voiceMemoCases) { + final prompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.compactTemplate, + transcript: tc.transcript, + now: referenceTime, + ); + final result = await llm.generate(prompt).timeout( + const Duration(seconds: 90), + onTimeout: () => const InferenceResult( + output: 'timeout', elapsed: Duration(seconds: 90)), + ); + singlePassTimes.add(result.elapsed); + stdout.writeln( + ' single-pass: ${result.elapsed.inMilliseconds}ms "${tc.transcript}"'); + } + + final avgSingleMs = + singlePassTimes.fold(0, (s, d) => s + d.inMilliseconds) ~/ + singlePassTimes.length; + stdout.writeln('\n[RouterBench] Avg single-pass (original prompt): ${avgSingleMs}ms'); + stdout.writeln('[RouterBench] Avg two-stage (router+extract): ${avgTotalMs}ms'); + stdout.writeln( + '[RouterBench] Overhead: ${avgTotalMs - avgSingleMs}ms (${((avgTotalMs - avgSingleMs) / avgSingleMs * 100).toStringAsFixed(0)}%)'); + + llm.dispose(); + stdout.writeln('\n[RouterBench] Done.'); +} + +String _parseRoute(String raw) { + // Try to parse JSON + try { + final start = raw.indexOf('{'); + if (start == -1) return _guessRoute(raw); + final end = raw.lastIndexOf('}'); + if (end == -1) return _guessRoute(raw); + final json = jsonDecode(raw.substring(start, end + 1)) as Map; + final route = (json['route'] as String?)?.trim().toLowerCase() ?? ''; + if (route == 'timer_alarm' || route == 'voice_memo' || route == 'mixed') { + return route; + } + return _guessRoute(raw); + } catch (_) { + return _guessRoute(raw); + } +} + +String _guessRoute(String raw) { + final lower = raw.toLowerCase(); + if (lower.contains('timer_alarm')) return 'timer_alarm'; + if (lower.contains('voice_memo')) return 'voice_memo'; + if (lower.contains('mixed')) return 'mixed'; + return 'unknown'; +} + +String? _readArg(List args, String name) { + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == name) return args[i + 1]; + } + return null; +} + +class _RouterTestCase { + final String transcript; + final String expectedRoute; + const _RouterTestCase(this.transcript, this.expectedRoute); +} diff --git a/ai_testbench/lib/services/model_benchmark_service.dart b/ai_testbench/lib/services/model_benchmark_service.dart index 0848620..77698a1 100644 --- a/ai_testbench/lib/services/model_benchmark_service.dart +++ b/ai_testbench/lib/services/model_benchmark_service.dart @@ -16,12 +16,20 @@ class ExpectedItem { final DateTime? expectedDateTime; final int toleranceMinutes; + /// For timer intent: expected duration in seconds. + final int? expectedDurationSeconds; + + /// Tolerance for duration matching (default ±5 seconds). + final int durationToleranceSeconds; + const ExpectedItem({ required this.expectedIntent, required this.expectTime, this.titleLanguageKeywords = const [], this.expectedDateTime, this.toleranceMinutes = 5, + this.expectedDurationSeconds, + this.durationToleranceSeconds = 5, }); } @@ -47,6 +55,8 @@ class BenchmarkCase { List titleLanguageKeywords = const [], DateTime? expectedDateTime, int toleranceMinutes = 5, + int? expectedDurationSeconds, + int durationToleranceSeconds = 5, }) : expectedItems = [ ExpectedItem( expectedIntent: expectedIntent, @@ -54,6 +64,8 @@ class BenchmarkCase { titleLanguageKeywords: titleLanguageKeywords, expectedDateTime: expectedDateTime, toleranceMinutes: toleranceMinutes, + expectedDurationSeconds: expectedDurationSeconds, + durationToleranceSeconds: durationToleranceSeconds, ), ]; @@ -101,6 +113,12 @@ class BenchmarkCaseResult { /// Per-item validation details for multi-item cases. final List itemFailures; + /// Whether the extracted duration matches expected (for timer intents). + final bool durationMatch; + + /// Detail about duration match failure. + final String? durationDetail; + const BenchmarkCaseResult({ required this.caseName, required this.validJson, @@ -110,6 +128,8 @@ class BenchmarkCaseResult { this.titleLanguageDetail, this.timeResolutionCorrect = true, this.timeResolutionDetail, + this.durationMatch = true, + this.durationDetail, required this.intent, this.title, this.datetimeOriginal, @@ -130,6 +150,7 @@ class BenchmarkCaseResult { timePresenceMatch && titleLanguageMatch && timeResolutionCorrect && + durationMatch && countMatch && itemFailures.isEmpty; } @@ -190,6 +211,9 @@ class ModelBenchmarkService { static const Duration perCaseTimeout = Duration(seconds: 90); static const ChronoLlmParser _parser = ChronoLlmParser(); + /// Prompt template to use for extraction. + String promptTemplate = ChronoPromptTemplate.defaultTemplate; + /// Fixed reference time for deterministic tests. /// Wednesday March 11, 2026, 10:15 AM. static final DateTime referenceTime = DateTime(2026, 3, 11, 10, 15); @@ -797,8 +821,262 @@ class ModelBenchmarkService { ), ], ), + + // ── Timer cases ────────────────────────────────────────────────── + + BenchmarkCase.single( + name: 'en_timer_simple', + transcript: 'Set a timer for 8 minutes', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 480, + ), + BenchmarkCase.single( + name: 'en_timer_labeled', + transcript: 'Timer for 5 minutes for pasta', + expectedIntent: 'timer', + expectTime: false, + titleLanguageKeywords: ['pasta'], + expectedDurationSeconds: 300, + ), + BenchmarkCase.single( + name: 'en_timer_seconds', + transcript: 'Set a 30 second timer', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 30, + ), + BenchmarkCase.single( + name: 'en_timer_hour', + transcript: 'Set a timer for one and a half hours', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 5400, + ), + BenchmarkCase.single( + name: 'sv_timer_simple', + transcript: 'Sätt en timer på 10 minuter', + expectedIntent: 'timer', + expectTime: false, + titleLanguageKeywords: [], + expectedDurationSeconds: 600, + ), + BenchmarkCase.single( + name: 'sv_timer_labeled', + transcript: 'Timer på 5 minuter för äggen', + expectedIntent: 'timer', + expectTime: false, + titleLanguageKeywords: ['äggen'], + expectedDurationSeconds: 300, + ), + BenchmarkCase.single( + name: 'de_timer_simple', + transcript: 'Stell einen Timer auf 15 Minuten', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 900, + ), + BenchmarkCase.single( + name: 'en_timer_vs_reminder', + transcript: 'Remind me in 30 minutes to check the oven', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['check', 'oven'], + ), + BenchmarkCase.single( + name: 'en_timer_bare_duration', + transcript: 'In 10 minutes', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 600, + ), + BenchmarkCase.single( + name: 'en_timer_not_reminder', + transcript: '30 minutes', + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 1800, + ), + BenchmarkCase.single( + name: 'sv_timer_vs_reminder', + transcript: 'Påminn mig om 10 minuter att stänga av ugnen', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['stänga', 'ugnen'], + ), + + // ── Alarm cases ────────────────────────────────────────────────── + + BenchmarkCase.single( + name: 'en_alarm_morning', + transcript: 'Set an alarm for 7:30 AM', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 11, 7, 30), + ), + BenchmarkCase.single( + name: 'en_alarm_labeled', + transcript: 'Alarm at 6 AM, wake up', + expectedIntent: 'alarm', + expectTime: true, + titleLanguageKeywords: ['wake'], + expectedDateTime: DateTime(2026, 3, 12, 6, 0), + ), + BenchmarkCase.single( + name: 'en_alarm_weekdays', + transcript: 'Set an alarm for 7 AM every weekday', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 0), + ), + BenchmarkCase.single( + name: 'en_alarm_tomorrow', + transcript: 'Wake me up tomorrow at 5:30', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 5, 30), + ), + BenchmarkCase.single( + name: 'sv_alarm_simple', + transcript: 'Ställ ett alarm klockan 7', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 0), + ), + BenchmarkCase.single( + name: 'sv_alarm_labeled', + transcript: 'Alarm klockan halv 8, dags att gå', + expectedIntent: 'alarm', + expectTime: true, + titleLanguageKeywords: ['dags', 'gå'], + expectedDateTime: DateTime(2026, 3, 11, 7, 30), + ), + BenchmarkCase.single( + name: 'de_alarm_simple', + transcript: 'Wecker auf 7 Uhr stellen', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 0), + ), + BenchmarkCase.single( + name: 'en_alarm_vs_reminder', + transcript: 'Remind me at 3 PM to call the dentist', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['call', 'dentist'], + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + BenchmarkCase.single( + name: 'en_alarm_no_action', + transcript: '7 AM', + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 0), + ), + BenchmarkCase.single( + name: 'sv_alarm_vs_reminder', + transcript: 'Påminn mig klockan 15 att ringa tandläkaren', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ringa', 'tandläkaren'], + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + + // ── Multi-item cases with timers/alarms ────────────────────────── + + BenchmarkCase( + name: 'en_multi_timer_and_alarm', + transcript: + 'Set a timer for 10 minutes and an alarm for 7 AM tomorrow', + expectedItems: [ + ExpectedItem( + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 600, + ), + ExpectedItem( + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_alarm_and_note', + transcript: 'Set an alarm for 6:30 and buy milk', + expectedItems: [ + ExpectedItem( + expectedIntent: 'alarm', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 6, 30), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['milk'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_timer_and_reminder', + transcript: + 'Sätt en timer på 5 minuter och påminn mig klockan 3 att ringa tandläkaren', + expectedItems: [ + ExpectedItem( + expectedIntent: 'timer', + expectTime: false, + expectedDurationSeconds: 300, + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ringa', 'tandläkaren'], + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + ], + ), + + // ── No-speech / garbage input cases ───────────────────────────────── + // These should produce an empty array []. The LLM should not + // hallucinate actions from noise or silence markers. + + BenchmarkCase( + name: 'nospeech_blank_audio_marker', + transcript: '[BLANK_AUDIO]', + expectedItems: [], + ), + BenchmarkCase( + name: 'nospeech_silence_marker', + transcript: '[silence]', + expectedItems: [], + ), + BenchmarkCase( + name: 'nospeech_music_marker', + transcript: '(music)', + expectedItems: [], + ), + BenchmarkCase( + name: 'nospeech_just_punctuation', + transcript: '... . ,', + expectedItems: [], + ), + BenchmarkCase( + name: 'nospeech_gibberish_short', + transcript: 'hmm uhh', + expectedItems: [], + ), + BenchmarkCase( + name: 'nospeech_whisper_noise_artifact', + transcript: '♪ ♪ ♪', + expectedItems: [], + ), ]; + /// Timer/alarm test cases only (for --headless-timer mode). + static List get timerAlarmCases => benchmarkCases + .where((c) => c.name.contains('timer') || c.name.contains('alarm')) + .toList(); + Future> runForModels( List modelPaths, { void Function(BenchmarkProgress progress)? onProgress, @@ -844,7 +1122,7 @@ class ModelBenchmarkService { // Use the shared ChronoPromptTemplate from chrono_ai_flow final prompt = ChronoPromptTemplate.render( - ChronoPromptTemplate.defaultTemplate, + promptTemplate, transcript: testCase.transcript, now: referenceTime, ); @@ -857,7 +1135,11 @@ class ModelBenchmarkService { // Parse using the shared ChronoLlmParser final parseResult = _parser.parse(result.output); final extractions = parseResult.extractions; - final validJson = extractions.isNotEmpty; + // An empty array [] is valid JSON when we expect 0 items + // (no-speech / garbage input). + final validJson = extractions.isNotEmpty || + (testCase.expectedCount == 0 && + _containsEmptyJsonArray(result.output)); final extractedCount = extractions.length; final expectedCount = testCase.expectedCount; @@ -869,8 +1151,10 @@ class ModelBenchmarkService { var allTimePresenceMatch = true; var allTitleLangMatch = true; var allTimeResMatch = true; + var allDurationMatch = true; String? allTitleLangDetail; String? allTimeResDetail; + String? allDurationDetail; final itemFailures = []; final checkCount = @@ -917,6 +1201,17 @@ class ModelBenchmarkService { } allTimeResDetail = (allTimeResDetail ?? '') + 'item[$i]: ${timeRes.detail}; '; + + // Duration validation for timer intents + final durRes = _checkDurationForItem( + ext.durationSeconds, exp); + if (!durRes.passed) { + allDurationMatch = false; + itemFailures.add( + 'item[$i] duration: ${durRes.detail}'); + } + allDurationDetail = (allDurationDetail ?? '') + + 'item[$i]: ${durRes.detail}; '; } // If count mismatch, mark missing items as failures @@ -943,6 +1238,8 @@ class ModelBenchmarkService { titleLanguageDetail: allTitleLangDetail, timeResolutionCorrect: allTimeResMatch, timeResolutionDetail: allTimeResDetail, + durationMatch: allDurationMatch, + durationDetail: allDurationDetail, intent: first?.intent ?? '', title: first?.title, datetimeOriginal: first?.datetimeExpressionOriginal, @@ -1043,12 +1340,20 @@ class ModelBenchmarkService { // ── Helpers ───────────────────────────────────────────────────────────── + /// Check if the raw output contains an empty JSON array `[]`. + bool _containsEmptyJsonArray(String raw) { + final cleaned = raw.replaceAll(RegExp(r'\s+'), ''); + return cleaned.contains('[]'); + } + 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; + // Allow countdown <-> timer fuzzy match + if ({g, e}.containsAll({'countdown', 'timer'})) return true; return false; } @@ -1083,6 +1388,31 @@ class ModelBenchmarkService { return _CheckResult(passed: passed, detail: detail); } + _CheckResult _checkDurationForItem(int? gotDuration, ExpectedItem item) { + if (item.expectedDurationSeconds == null) { + return const _CheckResult(passed: true, detail: 'no duration check'); + } + if (gotDuration == null) { + return const _CheckResult( + passed: false, + detail: 'no duration_seconds in output', + ); + } + final diff = (gotDuration - item.expectedDurationSeconds!).abs(); + if (diff > item.durationToleranceSeconds) { + return _CheckResult( + passed: false, + detail: + 'got ${gotDuration}s, expected ${item.expectedDurationSeconds}s ' + '(diff ${diff}s, tolerance ${item.durationToleranceSeconds}s)', + ); + } + return _CheckResult( + passed: true, + detail: '${gotDuration}s OK', + ); + } + _CheckResult _checkTimeResolution( String? timeExpr, BenchmarkCase testCase, diff --git a/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart index ab6ea1f..cfd5875 100644 --- a/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart @@ -8,7 +8,8 @@ class CorrectionPromptTemplate { static const String promptPlaceholderTranscript = '{{transcript}}'; - static const String defaultTemplate = ''' + 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. @@ -37,10 +38,7 @@ Input: "$promptPlaceholderTranscript" Output:'''; /// Render the correction prompt with the given [transcript]. - static String render( - String template, { - required String transcript, - }) { + static String render(String template, {required String transcript}) { return template.replaceAll(promptPlaceholderTranscript, transcript); } diff --git a/packages/chrono_ai_flow/lib/src/models.dart b/packages/chrono_ai_flow/lib/src/models.dart index 536db56..0cc5f62 100644 --- a/packages/chrono_ai_flow/lib/src/models.dart +++ b/packages/chrono_ai_flow/lib/src/models.dart @@ -4,11 +4,15 @@ class ChronoLlmExtraction { final String? datetimeExpressionOriginal; final String? datetimeExpressionEnglish; + /// Duration in seconds for timer intents. Null for all other intents. + final int? durationSeconds; + const ChronoLlmExtraction({ required this.intent, required this.title, this.datetimeExpressionOriginal, this.datetimeExpressionEnglish, + this.durationSeconds, }); } diff --git a/packages/chrono_ai_flow/lib/src/parser.dart b/packages/chrono_ai_flow/lib/src/parser.dart index b199a78..4353a3b 100644 --- a/packages/chrono_ai_flow/lib/src/parser.dart +++ b/packages/chrono_ai_flow/lib/src/parser.dart @@ -64,18 +64,43 @@ class ChronoLlmParser { 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(); + final datetimeOriginal = (parsed['datetime_expression_original'] as String?) + ?.trim(); + final datetimeEnglish = (parsed['datetime_expression_english'] as String?) + ?.trim(); + + // Extract duration_seconds for timer intents. + final rawDuration = parsed['duration_seconds']; + int? durationSeconds; + if (rawDuration is int) { + durationSeconds = rawDuration; + } else if (rawDuration is double) { + durationSeconds = rawDuration.round(); + } else if (rawDuration is String) { + durationSeconds = int.tryParse(rawDuration); + } + + // Reject incomplete timer/alarm extractions — a timer needs a duration + // and an alarm needs at least one datetime expression. + if (intent == 'timer' && durationSeconds == null) { + return null; + } + if (intent == 'alarm' && + (datetimeOriginal == null || datetimeOriginal.isEmpty) && + (datetimeEnglish == null || datetimeEnglish.isEmpty)) { + return null; + } return ChronoLlmExtraction( intent: intent, title: title, - datetimeExpressionOriginal: - (datetimeOriginal?.isNotEmpty ?? false) ? datetimeOriginal : null, - datetimeExpressionEnglish: - (datetimeEnglish?.isNotEmpty ?? false) ? datetimeEnglish : null, + datetimeExpressionOriginal: (datetimeOriginal?.isNotEmpty ?? false) + ? datetimeOriginal + : null, + datetimeExpressionEnglish: (datetimeEnglish?.isNotEmpty ?? false) + ? datetimeEnglish + : null, + durationSeconds: durationSeconds, ); } @@ -171,6 +196,12 @@ class ChronoLlmParser { case 'task': case 'todo': return 'reminder'; + case 'timer': + case 'countdown': + return 'timer'; + case 'alarm': + case 'wake': + return 'alarm'; 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 index cbdaa39..ed2ebb5 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -19,6 +19,8 @@ The memo may be in ANY language. Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. +If the memo is noise, gibberish, a silence marker, or contains no meaningful words, return an empty array: [] + Your tasks per item: 1. Detect intent: "reminder", "event", or "note". 2. Extract the time/date phrase exactly as it appears in the memo. @@ -122,6 +124,9 @@ 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}] +Memo: "[NO SPEECH DETECTED]" +[] + Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact Timezone: UTC$promptPlaceholderTimezoneOffset @@ -144,6 +149,8 @@ The memo may be in ANY language. Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. +If the memo is noise, gibberish, a silence marker, or contains no meaningful words, return an empty array: [] + Your tasks per item: 1. Detect intent: "reminder", "event", or "note". 2. Extract the time/date phrase exactly as it appears in the memo. @@ -203,6 +210,9 @@ Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möt 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: "[NO SPEECH DETECTED]" +[] + Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact Timezone: UTC$promptPlaceholderTimezoneOffset @@ -210,6 +220,99 @@ Voice memo: $promptPlaceholderTranscript +/no_think +JSON:'''; + + /// Lightweight pre-classifier prompt. Determines if the input is a + /// timer/alarm or a general voice memo. Output is a single JSON object + /// with a "route" field. Designed to be as small as possible for speed. + static const String routerTemplate = + ''' +Classify this voice memo. The memo may be in ANY language. + +Output ONLY a JSON object. No other text. + +Rules: +- "none" = NOT a real voice memo. Noise, gibberish, silence markers, music symbols, filler sounds, or no meaningful words. +- "timer_alarm" = setting a timer, countdown, alarm, or wake-up. No task or action — just ring after a duration or at a time. +- "voice_memo" = anything with meaningful content: reminders, events, notes, ideas, tasks. +- IMPORTANT: if the memo mentions an ACTION to perform at a time ("remind me to X", "call Y at 3 PM"), that is "voice_memo", NOT "timer_alarm". +- If the memo contains BOTH a timer/alarm AND other items, output "mixed". + +{"route": "none" | "timer_alarm" | "voice_memo" | "mixed"} + +Examples: + +Memo: "<|nospeech|>" +{"route":"none"} + +Memo: ", , ," +{"route":"none"} + +Memo: "uh eh um" +{"route":"none"} + +Memo: "Set a timer for 5 minutes" +{"route":"timer_alarm"} + +Memo: "Remind me to call the dentist at 3 pm" +{"route":"voice_memo"} + +Memo: "Buy milk" +{"route":"voice_memo"} + +Memo: "$promptPlaceholderTranscript" + +/no_think +JSON:'''; + + /// Dedicated timer/alarm extraction prompt. Used after the router + /// classifies input as "timer_alarm". Smaller and more focused than + /// the full 5-intent prompt — no event/reminder/note rules to confuse the model. + static const String timerAlarmTemplate = + ''' +Extract timer or alarm details from this voice memo. The memo may be in ANY language. + +Return a JSON array. Output MUST start with '[' and end with ']'. No text before or after. + +If the memo is noise, gibberish, a silence marker, or contains no meaningful words, return an empty array: [] + +Rules: +- "timer" = countdown for a duration. Extract duration as integer seconds in "duration_seconds". Set datetime fields to null. +- "alarm" = ring at a specific clock time. Extract time into datetime fields. Set duration_seconds to null. +- Keep title in the original language. If no label, set title to empty string. +- Copy the original time phrase exactly into datetime_expression_original. +- Translate to English in datetime_expression_english. Convert 24h to 12h format. +- For timers: convert all durations to total seconds (e.g. "1.5 hours" = 5400). + +Output schema: +[{"intent": "timer" | "alarm", "title": "label", "datetime_expression_original": "..." | null, "datetime_expression_english": "..." | null, "duration_seconds": 480 | null}] + +Examples: + +Memo: "Set a timer for 8 minutes" +[{"intent":"timer","title":"","datetime_expression_original":null,"datetime_expression_english":null,"duration_seconds":480}] + +Memo: "Timer for 5 minutes for pasta" +[{"intent":"timer","title":"pasta","datetime_expression_original":null,"datetime_expression_english":null,"duration_seconds":300}] + +Memo: "Set an alarm for 7:30 AM" +[{"intent":"alarm","title":"","datetime_expression_original":"7:30 AM","datetime_expression_english":"7:30 AM","duration_seconds":null}] + +Memo: "Sätt en timer på 10 minuter" +[{"intent":"timer","title":"","datetime_expression_original":null,"datetime_expression_english":null,"duration_seconds":600}] + +Memo: "Ställ ett alarm klockan 7" +[{"intent":"alarm","title":"","datetime_expression_original":"klockan 7","datetime_expression_english":"at 7 AM","duration_seconds":null}] + +Memo: "Wecker auf 7 Uhr stellen" +[{"intent":"alarm","title":"","datetime_expression_original":"7 Uhr","datetime_expression_english":"at 7 AM","duration_seconds":null}] + +Memo: "Set a timer for one and a half hours" +[{"intent":"timer","title":"","datetime_expression_original":null,"datetime_expression_english":null,"duration_seconds":5400}] + +Memo: "$promptPlaceholderTranscript" + /no_think JSON:'''; diff --git a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart index af6c4c5..78a015c 100644 --- a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart +++ b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart @@ -53,10 +53,7 @@ class TimeExpressionResolver { resolved.minute, resolved.second, ); - return ResolvedTime( - dateTime: resolved, - method: 'chrono+adjusted', - ); + return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); } if (resolved.isBefore(ref)) { @@ -86,13 +83,17 @@ class TimeExpressionResolver { // Chrono gave this week's occurrence — push to next week. resolved = resolved.add(const Duration(days: 7)); return ResolvedTime( - dateTime: resolved, method: 'chrono+adjusted'); + 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'); + dateTime: resolved, + method: 'chrono+adjusted', + ); } } } diff --git a/specs/ai-tasks/alarms-and-timers.md b/specs/ai-tasks/alarms-and-timers.md new file mode 100644 index 0000000..db5c239 --- /dev/null +++ b/specs/ai-tasks/alarms-and-timers.md @@ -0,0 +1,576 @@ +# ZSWatch Alarms & Timers — Spec + +**Status**: Draft +**Date**: 2026-03-27 +**Depends on**: `voice-memo-time-extraction.md` (chrono_ai_flow pipeline), `ai-enhanced-voice-notes.md` (AI pipeline) + +--- + +## 1. Scope + +Voice or text input → existing LLM pipeline parses intent → confirm on watch → create alarm/timer on phone. No complex recurring logic, no UI alarm manager. Fast path only. + +### Multilingual Support + +The existing `chrono_ai_flow` prompt and AI testbench already handle **Swedish, German, English, and French** input. The prompt explicitly states "the memo may be in ANY language" and extracts `datetime_expression_original` (original language) + `datetime_expression_english` (translated to English for chrono_dart parsing). + +This must carry over to timers and alarms: +- **"Sätt en timer på 10 minuter"** → timer, 600s +- **"Ställ ett alarm klockan 7"** → alarm, 07:00 +- **"Stell einen Timer auf 15 Minuten"** → timer, 900s +- **"Wecker auf 7 Uhr stellen"** → alarm, 07:00 + +The LLM translates duration/time expressions to English internally. The `duration_seconds` field is always an integer (language-agnostic). For alarms, `datetime_expression_english` feeds into `TimeExpressionResolver` as it does today for reminders. + +The AI testbench already has Swedish/German test cases for reminders and events — the new timer/alarm cases (Section 6.2) follow the same multilingual coverage pattern. + +## 2. Supported Intents + +| Intent | Description | Example | +|--------|-------------|--------| +| **Timer** | Bare countdown — no attached task/action. Just a duration. | "Set a timer for 8 minutes", "Timer 5 min for pasta" | +| **Alarm** | Ring at a specific clock time — no attached task/action. | "Set an alarm for 7:30 AM", "Wake me at 6" | + +Everything else (e.g. "remind me when I get home") is out of scope for this feature. + +### Key Distinction: Timer/Alarm vs Reminder + +The defining difference is whether the utterance carries an **action to perform**: + +- **"Remind me in 30 minutes to check the oven"** → `reminder`. There's an action ("check the oven") tied to a time. +- **"Set a timer for 30 minutes"** → `timer`. No action — just a countdown that rings. +- **"Set an alarm for 7 AM"** → `alarm`. No action — just ring at that time. +- **"Remind me at 3 PM to call the dentist"** → `reminder`. Action = "call the dentist". +- **"Wake me up at 6"** → `alarm`. "Wake me" is not a task, it's what the alarm does by definition. +- **"In 10 minutes"** → `timer`. No action stated, bare duration. + +Rule of thumb: if you can answer "do *what*?" — it's a reminder. If the answer is just "ring" — it's a timer or alarm. + +### Relationship to Existing Intents + +The current `chrono_ai_flow` prompt (`ChronoPromptTemplate`) classifies into three intents: + +- `"reminder"` — personal task WITH a specific time +- `"event"` — meeting/appointment/social plan WITH a time +- `"note"` — no time/date mentioned + +Timers and alarms are **new intent types** that extend the existing set to five. They differ from reminders structurally: +- Reminders have a **title** (the action to do) and a **datetime** (when to do it). +- Timers have an optional **label** and a **duration** (seconds). No datetime. +- Alarms have an optional **label** and a **clock time**. No real title/action. + +See [Section 6](#6-ai-testbench-extension) for how to validate this in the AI testbench before committing to the prompt change. + +--- + +## 3. LLM Contract + +### Current State + +The existing `chrono_ai_flow` prompt extracts from free-form text/voice: +```json +[{ + "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 +}] +``` + +The `TimeExpressionResolver` then resolves `datetime_expression_english` to an absolute `DateTime` via `chrono_dart` + regex fallbacks. + +### What Needs to Change + +**Option A — Extend existing prompt with new intents**: + +Add `"timer"` and `"alarm"` to the intent classification rules in `ChronoPromptTemplate`. Timer intents would use a `duration_seconds` field instead of datetime expressions. This keeps the pipeline single-pass. + +New schema (per item in the array): +```json +{ + "intent": "reminder" | "event" | "note" | "timer" | "alarm", + "title": "short label in original language", + "datetime_expression_original": "..." | null, + "datetime_expression_english": "..." | null, + "duration_seconds": 480 | null +} +``` + +New prompt rules to add: +``` +- "timer" = a bare countdown with NO task/action attached. The user just wants something to ring + after a duration. "Set a timer for 8 minutes", "timer 5 min for pasta", "countdown 5 minutes", + "in 10 minutes" (no action stated). + Extract duration as integer seconds in "duration_seconds". Set both datetime fields to null. + IMPORTANT: if the utterance contains an action to perform ("remind me in 30 minutes to check + the oven"), that is a "reminder", NOT a timer. +- "alarm" = ring at a specific clock time with NO task/action attached. The user wants to be woken + or alerted at a time. "Set an alarm for 7:30 AM", "wake me up at 6", "alarm at 22:00". + Extract the time into datetime fields as usual. Set duration_seconds to null. + IMPORTANT: if the utterance contains an action to perform ("remind me at 3 PM to call the + dentist"), that is a "reminder", NOT an alarm. +``` + +**Option B — Two-stage routing (pre-classifier + existing prompt)**: + +A lightweight pre-classifier prompt runs first to detect if the input is a timer/alarm vs a voice memo. Then routes to the correct prompt: +- Timer/Alarm → new dedicated prompt (simpler, faster, higher accuracy) +- Everything else → existing `ChronoPromptTemplate` + +**Recommendation**: Start with Option A (single prompt), validate in the AI testbench, fallback to Option B only if accuracy suffers. The existing prompt already handles multi-intent extraction and the LLM models handle 5 intent categories about as well as 3. + +### Timer Schema (when `intent == "timer"`) + +| Field | Type | Example | +|-------|------|---------| +| `intent` | `"timer"` | `"timer"` | +| `duration_seconds` | `int` | `480` (8 minutes) | +| `title` | `string` | `"pasta"` | +| `datetime_expression_original` | `null` | — | +| `datetime_expression_english` | `null` | — | + +### Alarm Schema (when `intent == "alarm"`) + +| Field | Type | Example | +|-------|------|---------| +| `intent` | `"alarm"` | `"alarm"` | +| `title` | `string` | `"wake up"` | +| `datetime_expression_original` | `string` | `"klockan 7:30"` | +| `datetime_expression_english` | `string` | `"at 7:30 AM"` | +| `duration_seconds` | `null` | — | + +Repeat days are **not extracted by the LLM**. All alarms are **one-shot** in V1 — no repeat logic. + +### Error Handling + +If the LLM returns an unrecognized intent or malformed JSON, the existing `ChronoLlmParser.parse()` fallback applies — the parser already sanitizes output and handles graceful failures. + +--- + +## 4. Existing Codebase — What We Have + +### 4.1 Firmware: Timer App (already exists) + +`app/src/applications/timer/` has a full timer app with: +- `timer_app_timer_t` struct supporting both `TYPE_ALARM` and `TYPE_TIMER` +- Up to `TIMER_UI_MAX_TIMERS` (10) active timers +- Core alarm API: `zsw_alarm_add_timer(hour, min, sec, callback, user_data)` and `zsw_alarm_add(rtc_time, callback, user_data)` +- Persistence via Zephyr settings subsystem +- Alarm triggered popup: `zsw_popup_show("Timer", buf, NULL, 10, false)` + `zsw_vibration_run_pattern(ZSW_VIBRATION_PATTERN_ALARM)` +- zbus integration: listens to `periodic_event_1s_chan` for countdown updates + +**Implication**: The watch firmware already knows how to run timers and alarms natively. The companion app just needs to *create* them via BLE. + +### 4.2 Firmware: BLE Protocol + +`gadgetbridge_api.txt` defines `t:"alarm"` with `d:[{h, m, rep, on}]` — but this is **not yet parsed** in `ble_gadgetbridge.c`. The firmware only handles: `notify`, `notify-`, `weather`, `musicinfo`, `musicstate`, `http`, `gps`, `log`, `voice_memo`, `smp`, `reset`, `ver`, `coredump_erase`. + +**Needs**: Add `parse_alarm()` handler in `ble_gadgetbridge.c` that calls `zsw_alarm_add()` or `zsw_alarm_add_timer()`. + +### 4.3 Firmware: Voice Memo Popup (reusable pattern) + +`app/src/ui/overlay/zsw_voice_memo_popup.c` shows: +- Type-aware overlay (reminder=purple/bell, event=blue/list, task=orange/check) +- Title + datetime display +- Red "Undo" button → sends `ble_gadgetbridge_send_voice_memo_undo(filename)` +- 20-second auto-dismiss +- Async show via `k_work_submit()` (required from zbus context) +- Input capture via `zsw_ui_controller_set_notification_mode()` + +**This is the popup we repurpose** for alarm/timer confirm (with modifications per Section 5.3). + +### 4.4 Companion App: Protocol Layer + +`GadgetbridgeProtocol.setAlarms(List)` is **already implemented** in `gadgetbridge_protocol.dart`: +```dart +Future setAlarms(List alarms) async { + final alarmList = alarms.map((a) => { + 'h': a.hour, 'm': a.minute, 'rep': a.repeatDaysMask, 'on': a.enabled ? 1 : 0, + }).toList(); + await _sendGb({'t': 'alarm', 'd': alarmList}); +} +``` + +`WatchAlarm` model exists in `protocol_service.dart`: +```dart +class WatchAlarm { + final int hour; + final int minute; + final int repeatDaysMask; // binary mask: bit 0=Mon, ..., bit 6=Sun; 127=every day + final bool enabled; +} +``` + +**Implication**: The companion app can already *send* alarms to the watch. The missing piece is: +1. Firmware parsing the `t:"alarm"` message +2. A timer message type (not in Gadgetbridge protocol — needs new `t:"timer"` or extend `t:"alarm"`) +3. Integration with the AI pipeline + +### 4.5 Companion App: AI Pipeline + +The existing flow is: +``` +Voice recording on watch → MCUmgr FS sync → Whisper STT → (optional) correction LLM → +classification LLM (chrono_ai_flow) → parse JSON → resolve time → create calendar/reminder +via ExtractedActionCreationService (MethodChannel to native Android/iOS APIs) +``` + +`ExtractedActionCreationService` uses `MethodChannel('dev.zswatch.app/productivity')` to create calendar events and reminders on Android via native APIs. + +### 4.6 Companion App: Platform Bridges + +Android: native Kotlin code behind `MethodChannel` for calendar/reminder creation, notification listener, media control. + +iOS: uses ANCS/AMS natively on the watch for notifications/media (app is no-op for those). + +--- + +## 5. Design + +### 5.1 End-to-End Flow + +``` +User speaks on watch + ↓ +Voice recording → MCUmgr sync → Phone app receives audio + ↓ +Whisper STT → transcript text + ↓ +(Optional) Correction LLM pass + ↓ +Classification LLM (extended chrono_ai_flow prompt) + ↓ +Parse JSON → detect intent: "timer" | "alarm" | "reminder" | "event" | "note" + ↓ +If timer/alarm: + ↓ + Resolve time (for alarm: chrono_dart → absolute DateTime → extract hour:minute) + Resolve duration (for timer: duration_seconds from LLM output) + ↓ + Send confirmation to watch via BLE: + voice_memo command with action="confirm_alarm" or "confirm_timer" + ↓ +Watch shows confirm popup (repurposed voice memo popup) + ↓ (timeout auto-confirms OR user confirms) +Phone receives confirmation → sets alarm/timer + ↓ (user taps undo within window) +Phone receives undo → cancels alarm/timer +``` + +### 5.2 Platform Strategy for Setting Alarms/Timers + +**V1: Set on Phone** — the user may not always wear the watch. Phone alarms are the reliable baseline. + +| | Android | iOS | +|---|---------|-----| +| **Alarm** | System intent → native Clock app | `alarm` package — rings through killed app | +| **Timer** | System intent → native Clock app | Local notification — sufficient for timers | + +**Why this split**: Android system intents create real alarms that survive app uninstall and use the user's own alarm sound. iOS exposes no such API. The `alarm` package on iOS is the closest behavioral equivalent — it uses a background audio trick to ring even when the app is dead. Local notifications are good enough for timers since missing a timer by a few seconds is acceptable; missing an alarm is not. + +**New dependencies needed**: +- `android_intent_plus` (or direct MethodChannel) for Android system alarm/timer intents +- `alarm` package for iOS alarm functionality +- `flutter_local_notifications` for iOS timers + +**V2 (future): Also set on Watch** + +The firmware already has `zsw_alarm_add()` / `zsw_alarm_add_timer()` and a full timer app. In V2, set alarms on *both* phone and watch — phone rings with sound, watch vibrates. Belt and suspenders. Requires: +- Add `parse_alarm()` / `parse_timer()` handlers in `ble_gadgetbridge.c` +- Add `BLE_COMM_DATA_TYPE_ALARM` / `BLE_COMM_DATA_TYPE_TIMER` to `ble_comm.h` +- Possibly a watch-side confirm popup (`zsw_alarm_confirm_popup.c`) + +### 5.3 Watch Confirm Popup + +Reuse the existing voice memo popup pattern (`zsw_voice_memo_popup.c`) on the watch to show what's about to be set. + +**Important design reversal**: The existing voice memo popup is undo-after-set (the note is already saved). For alarms/timers, we use **confirm-before-set** — the phone only fires the alarm/timer intent after the timeout expires or user confirms. + +``` +AI pipeline resolves timer/alarm + ↓ +Phone sends confirm popup data to watch via BLE + ↓ +Watch shows confirm popup (type icon + label + time/duration) + ↓ (timeout auto-confirms = inaction confirms) +Phone sets alarm/timer on the PHONE (Android intent / iOS alarm package) + ↓ +Explicit dismiss on watch → phone cancels, nothing is set +``` + +- **Timeout auto-confirms** → alarm/timer is created on the phone +- **Explicit dismiss** (press button / tap cancel) → phone receives cancel, nothing happens +- After set: brief "Set ✓" feedback on watch (same as voice memo "Saved" pattern) + +#### Popup Content + +| Intent | Watch Display | +|--------|---------------| +| Timer 8min "pasta" | ⏱ **Timer** · pasta — 8:00 | +| Timer 30min (no label) | ⏱ **Timer** · 30:00 | +| Alarm 07:30 one-shot | ⏰ **Alarm** · 07:30 | + +All alarms are one-shot in V1 — no repeat day display needed. + +#### Cancellation (V1 — phone alarms) + +- **Android alarms/timers**: No cancellation API for system intents. The undo window fires *before* the intent is sent — this is why confirm-before-set is critical on Android. +- **iOS alarm** (`alarm` package): Can be cancelled by alarm ID — a short post-set undo window would work, but we don't need it since we confirm-before-set. +- **iOS timer** (local notification): Can be cancelled by notification ID. + +**Design implication**: All platform differences are handled by confirming before the intent fires. No post-set undo needed in V1. + +### 5.4 Error States + +| Situation | Watch Feedback | App Feedback | +|-----------|---------------|--------------| +| LLM returns unknown intent | Short buzz, watch shows ❓ | Snackbar: "Didn't catch that" | +| LLM returns malformed JSON | Same as above | Same as above | +| Timer duration unparseable | Short buzz | Snackbar: "Couldn't parse timer duration" | +| Platform permission missing | Short buzz | Deep link to app permission settings | +| Alarm time already passed today | Silent — app schedules for tomorrow | Snackbar: "Set for tomorrow" | + +--- + +## 6. AI Testbench Extension + +### 6.1 Goal + +Before writing any production code, extend the AI testbench to answer: + +1. **Can the current `ChronoPromptTemplate` handle timer/alarm intents?** — Run the existing prompt with timer/alarm transcripts and see what happens. +2. **Does adding `"timer"` and `"alarm"` intents degrade accuracy for existing cases?** — Run the full benchmark suite with the extended prompt. +3. **Do we need a separate pre-classifier prompt (Option B)?** — Compare accuracy and latency. + +### 6.2 New Test Cases to Add + +Add these to `model_benchmark_service.dart` alongside the existing 46+ cases: + +#### Timer Cases + +| Case ID | Transcript | Expected Intent | Expected Duration (s) | Expected Title Keywords | +|---------|-----------|----------------|----------------------|------------------------| +| `en_timer_simple` | "Set a timer for 8 minutes" | timer | 480 | [] | +| `en_timer_labeled` | "Timer for 5 minutes for pasta" | timer | 300 | ["pasta"] | +| `en_timer_seconds` | "Set a 30 second timer" | timer | 30 | [] | +| `en_timer_hour` | "Set a timer for one and a half hours" | timer | 5400 | [] | +| `sv_timer_simple` | "Sätt en timer på 10 minuter" | timer | 600 | [] | +| `sv_timer_labeled` | "Timer på 5 minuter för äggen" | timer | 300 | ["äggen"] | +| `de_timer_simple` | "Stell einen Timer auf 15 Minuten" | timer | 900 | [] | +| `en_timer_vs_reminder` | "Remind me in 30 minutes to check the oven" | reminder | — | ["check", "oven"] | +| `en_timer_bare_duration` | "In 10 minutes" | timer | 600 | [] | +| `en_timer_not_reminder` | "30 minutes" | timer | 1800 | [] | +| `sv_timer_vs_reminder` | "Påminn mig om 10 minuter att stänga av ugnen" | reminder | — | ["stänga", "ugnen"] | + +#### Alarm Cases + +| Case ID | Transcript | Expected Intent | Expected Time | Expected Title Keywords | +|---------|-----------|----------------|---------------|------------------------| +| `en_alarm_morning` | "Set an alarm for 7:30 AM" | alarm | 07:30 | [] | +| `en_alarm_labeled` | "Alarm at 6 AM, wake up" | alarm | 06:00 | ["wake up"] | +| `en_alarm_weekdays` | "Set an alarm for 7 AM every weekday" | alarm | 07:00 | [] | + +**Note**: `en_alarm_weekdays` should still be classified as `alarm` even though repeat is out of scope — the LLM just needs to get the intent and time right. The app ignores the repeat part and creates a one-shot alarm. + +| `en_alarm_tomorrow` | "Wake me up tomorrow at 5:30" | alarm | 05:30 | ["wake"] | +| `sv_alarm_simple` | "Ställ ett alarm klockan 7" | alarm | 07:00 | [] | +| `sv_alarm_labeled` | "Alarm klockan halv 8, dags att gå" | alarm | 07:30 | ["dags", "gå"] | +| `de_alarm_simple` | "Wecker auf 7 Uhr stellen" | alarm | 07:00 | [] | +| `en_alarm_vs_reminder` | "Remind me at 3 PM to call the dentist" | reminder | 15:00 | ["call", "dentist"] | +| `en_alarm_no_action` | "7 AM" | alarm | 07:00 | [] | +| `sv_alarm_vs_reminder` | "Påminn mig klockan 15 att ringa tandläkaren" | reminder | 15:00 | ["ringa", "tandläkaren"] | + +#### Multi-Item Cases with Timers/Alarms + +| Case ID | Transcript | Expected Items | +|---------|-----------|---------------| +| `en_multi_timer_and_alarm` | "Set a timer for 10 minutes and an alarm for 7 AM tomorrow" | [timer 600s, alarm 07:00] | +| `en_multi_alarm_and_note` | "Set an alarm for 6:30 and buy milk" | [alarm 06:30, note] | +| `sv_multi_timer_and_reminder` | "Sätt en timer på 5 minuter och påminn mig klockan 3 att ringa tandläkaren" | [timer 300s, reminder 15:00] | + +### 6.3 Benchmark Extensions Needed + +#### A. Extend `BenchmarkCaseResult` + +Add: +```dart +final bool durationMatch; // For timer: extracted duration matches expected +final String? durationDetail; // Why duration match failed +``` + +#### B. Extend `ExpectedItem` + +Add: +```dart +final int? expectedDurationSeconds; // For timer intent: expected duration in seconds +final int durationToleranceSeconds; // ±5s default +``` + +#### C. Extend `BenchmarkCase` evaluation logic + +For timer intents: +- Verify `intent == "timer"` +- Verify `duration_seconds` present and within tolerance of expected +- Verify `datetime_expression_*` fields are null +- Verify title keywords + +For alarm intents: +- Verify `intent == "alarm"` +- Verify time expression resolves to expected hour:minute +- Verify `duration_seconds` is null + +#### D. Extend `ChronoLlmExtraction` model + +In `packages/chrono_ai_flow/lib/src/models.dart`: +```dart +class ChronoLlmExtraction { + final String intent; // now includes "timer" | "alarm" + final String title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final int? durationSeconds; // NEW: for timer intent +} +``` + +#### E. Extend parser + +In `packages/chrono_ai_flow/lib/src/parser.dart`, extract `duration_seconds` from JSON and populate the new field. + +### 6.4 Prompt Experiments to Run + +**Experiment 1 — Baseline**: Run existing prompt (3 intents) with new timer/alarm transcripts. Measure how the LLM classifies them (probably as "reminder" or "note"). + +**Experiment 2 — Extended prompt (5 intents)**: Add timer/alarm intent rules to the prompt. Run the full suite (existing 46+ cases + new timer/alarm cases). Compare pass rates. + +**Experiment 3 — Regression check**: Run *only* the existing 46 cases with the extended prompt. Ensure no regression in reminder/event/note classification. + +**Experiment 4 — Pre-classifier (Option B)**: If Experiment 2 shows poor accuracy, implement a lightweight pre-classifier: +``` +Input: "set a timer for 5 minutes" +Output: {"route": "timer_alarm"} or {"route": "voice_memo"} +``` +Then route to the appropriate full prompt. Measure added latency. + +### 6.5 New Headless Mode + +Add `--headless-timer` flag to `ai_testbench/lib/main.dart`: +```bash +./ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf +``` + +This runs only the timer/alarm test cases for fast iteration on the prompt. + +### 6.6 Implementation Order + +1. Extend `ChronoLlmExtraction` model with `durationSeconds` field +2. Extend `ChronoLlmParser` to extract `duration_seconds` +3. Add timer/alarm test cases to `model_benchmark_service.dart` +4. Run Experiment 1 (baseline — no prompt change) +5. Create extended prompt variant in `ChronoPromptTemplate` +6. Run Experiments 2 + 3 (extended prompt + regression) +7. Decide: single prompt vs. pre-classifier based on results +8. Add `--headless-timer` mode for CI + +### 6.7 AI Debug Screen in App — Manual Testing + +The companion app's **AI Models settings screen** (`ai_models_settings_screen.dart`) already has a "Model Benchmark" section with: +- A text input field ("Test input text") where you type or paste a transcript +- A microphone button to record and transcribe on-phone +- A "Test AI" button that runs the full AI pipeline (correction → classification → time resolution) and shows results in a debug bottom sheet + +**Extend this screen** to also test the timer/alarm flow end-to-end on the phone, without needing the watch: + +1. After the AI pipeline runs and detects a `timer` or `alarm` intent, show the result in the debug sheet (same as today for reminders/events/notes) +2. Add a **"Fire Timer/Alarm"** button in the debug sheet result that actually creates the phone alarm/timer via the platform API (Android intent / iOS alarm package) — so you can verify the full chain works +3. Display the resolved timer duration or alarm time prominently in the result card + +This is the **last integration step before touching watch firmware**. The full flow you can test from this screen: +``` +Type/record input → STT → correction → classification (timer/alarm detected) + → resolve duration/time → tap "Fire" → phone alarm/timer is set +``` + +This lets us iterate on the prompt, test multilingual inputs ("Sätt en timer på 10 minuter"), and verify platform integration — all without a watch connected. + +--- + +## 7. Implementation Plan + +### Phase 1: AI Testbench Validation (do first) + +- [ ] Extend `chrono_ai_flow` models + parser for `durationSeconds` +- [ ] Add timer/alarm benchmark cases to AI testbench +- [ ] Run baseline experiment (existing prompt) +- [ ] Create extended prompt variant +- [ ] Run accuracy experiments, decide on prompt strategy +- [ ] Add `--headless-timer` mode + +### Phase 1b: AI Debug Screen in App (test without watch) + +- [ ] Extend AI debug screen result sheet to show timer/alarm intent details (duration, resolved time) +- [ ] Add "Fire Timer" / "Fire Alarm" button in debug result sheet → calls platform API directly +- [ ] Verify full E2E on Android: text input → AI → fire system alarm/timer intent +- [ ] Verify full E2E on iOS: text input → AI → fire alarm package / local notification +- [ ] Test multilingual inputs from the debug screen (Swedish, German, etc.) + +**This phase completes before any watch firmware changes.** The phone-side feature must work standalone. + +### Phase 2: Companion App — Phone-Side Alarms/Timers + +- [ ] Update `chrono_ai_flow` prompt (based on testbench results) +- [ ] Update `ChronoLlmExtraction` model with `durationSeconds` +- [ ] Add timer/alarm routing in voice memo AI pipeline +- [ ] **Android**: Add MethodChannel for system alarm/timer intents (extend existing `dev.zswatch.app/productivity` channel or create new `dev.zswatch.app/alarms`) +- [ ] **Android**: Handle `SCHEDULE_EXACT_ALARM` permission (Android 12+) +- [ ] **iOS**: Add `alarm` package for alarm functionality +- [ ] **iOS**: Add `flutter_local_notifications` for timer countdown notifications +- [ ] **iOS**: Handle local notification permission request (contextual, on first use) +- [ ] Add confirmation protocol: send confirm popup data to watch, wait for confirm/cancel response +- [ ] Handle watch confirm/cancel in `WatchService.incomingMessages` stream → fire platform intent + +### Phase 3: Watch-Side Confirm Popup + +- [ ] Create `zsw_alarm_confirm_popup.c` overlay (or extend voice memo popup with timer/alarm mode) +- [ ] Add BLE message type for confirm popup data (phone → watch) +- [ ] Add BLE message type for confirm/cancel response (watch → phone) +- [ ] Add zbus channel for alarm/timer confirmation events + +### Phase 4: Watch-Side Alarms (V2 — future) + +- [ ] Add `parse_alarm()` to `ble_gadgetbridge.c` — parse `t:"alarm"` and call `zsw_alarm_add()` +- [ ] Add `parse_timer()` to `ble_gadgetbridge.c` — parse `t:"timer"` and call `zsw_alarm_add_timer()` +- [ ] Add `BLE_COMM_DATA_TYPE_ALARM` and `BLE_COMM_DATA_TYPE_TIMER` to `ble_comm.h` +- [ ] Implement dual-set: phone alarm + watch alarm (belt and suspenders) +- [ ] Add `sendTimer(int durationSeconds, String label)` to `WatchService` + +--- + +## 8. Permissions Required + +| Platform | Permission | When | +|----------|-----------|------| +| Android | `SCHEDULE_EXACT_ALARM` | Required for system timer intent on Android 12+ | +| Android | None for alarm intent | System alarm intent doesn't need permissions | +| iOS | Local notification | Request on first timer use, contextual prompt | +| iOS | None for `alarm` package | Uses audio session trick, no special permission | + +--- + +## 9. Out of Scope + +- Alarm management UI on the phone (list, edit, delete) — lives in the native Clock app on Android; iOS alarm package has no list UI +- Repeating timers +- Repeating alarms / alarms on specific days (all alarms are one-shot in V1) +- Condition-based reminders ("when I get home") +- Snooze behavior — handled natively by phone platform +- Complex recurrence rules (every 2nd Tuesday) +- Watch-side alarms/timers (V2) + +--- + +## 10. Open Questions + +1. **Confirm popup on watch**: New overlay file or extend `zsw_voice_memo_popup.c` with a timer/alarm mode? The voice memo popup already has type-aware icons and auto-dismiss — extending it may be cleanest. +2. **iOS `alarm` package reliability**: The background audio trick is clever but can be killed by iOS in some circumstances. Need to test on real devices. If unreliable, fall back to local notifications for everything on iOS. +3. **Android AlarmManager vs system intent**: System intent opens the Clock app and the user sees the alarm. `AlarmManager` + `BroadcastReceiver` runs silently. Which UX do we want? System intent is simpler but the user might dismiss it. Need to decide. +4. **Watch BLE round-trip latency for confirm**: How fast does the confirm/cancel message travel? If > 1-2 seconds, the UX feels sluggish. May need to optimize or use a shorter timeout. diff --git a/zswatch_app/android/app/src/main/AndroidManifest.xml b/zswatch_app/android/app/src/main/AndroidManifest.xml index 8441e88..5e4bfa8 100644 --- a/zswatch_app/android/app/src/main/AndroidManifest.xml +++ b/zswatch_app/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,9 @@ + + + @@ -108,5 +111,12 @@ + + + + + + + diff --git a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt index 142476b..cd494ce 100644 --- a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt +++ b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt @@ -549,6 +549,50 @@ class MainActivity : FlutterActivity() { private fun handleCreateAction(call: io.flutter.plugin.common.MethodCall, result: MethodChannel.Result) { Log.d(TAG, "handleCreateAction called with args=${call.arguments}") + val actionType = call.argument("actionType") ?: "task" + val title = call.argument("title")?.trim().orEmpty() + val notes = call.argument("notes")?.trim()?.takeIf { it.isNotEmpty() } + val location = call.argument("location")?.trim()?.takeIf { it.isNotEmpty() } + val scheduledAtMillis = call.argument("scheduledAtMillis")?.toLong() + val endAtMillis = call.argument("endAtMillis")?.toLong() + val reminderMinutes = call.argument("reminderMinutes")?.toInt() + val durationSeconds = call.argument("durationSeconds")?.toInt() + val skipUi = call.argument("skipUi") ?: true + val requestedCalendarId = call.argument("calendarId")?.toLong() + + // Timer and alarm use system intents — no calendar permission needed. + if (actionType == "timer") { + try { + val response = createTimerViaIntent( + durationSeconds = durationSeconds ?: 0, + label = title, + skipUi = skipUi + ) + Log.d(TAG, "createAction (timer) succeeded with response=$response") + result.success(response) + } catch (e: Exception) { + Log.e(TAG, "createAction (timer) failed", e) + result.error("CREATE_ACTION_FAILED", e.localizedMessage, null) + } + return + } + + if (actionType == "alarm") { + try { + val response = createAlarmViaIntent( + triggerAtMillis = scheduledAtMillis, + label = title, + skipUi = skipUi + ) + Log.d(TAG, "createAction (alarm) succeeded with response=$response") + result.success(response) + } catch (e: Exception) { + Log.e(TAG, "createAction (alarm) failed", e) + result.error("CREATE_ACTION_FAILED", e.localizedMessage, null) + } + return + } + if (!hasCalendarPermission()) { Log.w(TAG, "Calendar permission missing when attempting to create productivity action") result.error( @@ -559,15 +603,6 @@ class MainActivity : FlutterActivity() { return } - val actionType = call.argument("actionType") ?: "task" - val title = call.argument("title")?.trim().orEmpty() - val notes = call.argument("notes")?.trim()?.takeIf { it.isNotEmpty() } - val location = call.argument("location")?.trim()?.takeIf { it.isNotEmpty() } - val scheduledAtMillis = call.argument("scheduledAtMillis")?.toLong() - val endAtMillis = call.argument("endAtMillis")?.toLong() - val reminderMinutes = call.argument("reminderMinutes")?.toInt() - val requestedCalendarId = call.argument("calendarId")?.toLong() - if (title.isEmpty()) { result.error("INVALID_ARGUMENT", "title is required", null) return @@ -638,6 +673,50 @@ class MainActivity : FlutterActivity() { } } + private fun createTimerViaIntent(durationSeconds: Int, label: String, skipUi: Boolean = true): Map { + val intent = android.content.Intent(android.provider.AlarmClock.ACTION_SET_TIMER).apply { + putExtra(android.provider.AlarmClock.EXTRA_LENGTH, durationSeconds) + if (label.isNotEmpty()) { + putExtra(android.provider.AlarmClock.EXTRA_MESSAGE, label) + } + putExtra(android.provider.AlarmClock.EXTRA_SKIP_UI, skipUi) + } + try { + startActivity(intent) + } catch (e: android.content.ActivityNotFoundException) { + throw IllegalStateException("No app found that can handle timers. Please install a clock app.") + } + return mapOf( + "platformId" to null, + "targetType" to "timer", + "syncDisabled" to false, + ) + } + + private fun createAlarmViaIntent(triggerAtMillis: Long?, label: String, skipUi: Boolean = true): Map { + val intent = android.content.Intent(android.provider.AlarmClock.ACTION_SET_ALARM).apply { + if (triggerAtMillis != null) { + val cal = java.util.Calendar.getInstance().apply { timeInMillis = triggerAtMillis } + putExtra(android.provider.AlarmClock.EXTRA_HOUR, cal.get(java.util.Calendar.HOUR_OF_DAY)) + putExtra(android.provider.AlarmClock.EXTRA_MINUTES, cal.get(java.util.Calendar.MINUTE)) + } + if (label.isNotEmpty()) { + putExtra(android.provider.AlarmClock.EXTRA_MESSAGE, label) + } + putExtra(android.provider.AlarmClock.EXTRA_SKIP_UI, skipUi) + } + try { + startActivity(intent) + } catch (e: android.content.ActivityNotFoundException) { + throw IllegalStateException("No app found that can handle alarms. Please install a clock app.") + } + return mapOf( + "platformId" to null, + "targetType" to "alarm", + "syncDisabled" to false, + ) + } + private fun hasCalendarPermission(): Boolean { return ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED } diff --git a/zswatch_app/ios/Runner/AppDelegate.swift b/zswatch_app/ios/Runner/AppDelegate.swift index b528259..a130840 100644 --- a/zswatch_app/ios/Runner/AppDelegate.swift +++ b/zswatch_app/ios/Runner/AppDelegate.swift @@ -62,6 +62,17 @@ import UIKit let scheduledAtMillis = (args["scheduledAtMillis"] as? NSNumber)?.doubleValue let endAtMillis = (args["endAtMillis"] as? NSNumber)?.doubleValue let reminderMinutes = (args["reminderMinutes"] as? NSNumber)?.intValue + let durationSeconds = (args["durationSeconds"] as? NSNumber)?.intValue + + // Timer and alarm use Clock app URLs on iOS — no calendar permission needed. + if actionType == "timer" { + createTimerViaClockApp(durationSeconds: durationSeconds ?? 0, label: title, result: result) + return + } + if actionType == "alarm" { + createAlarmViaClockApp(scheduledAtMillis: scheduledAtMillis, label: title, result: result) + return + } guard !title.isEmpty else { result(FlutterError(code: "INVALID_ARGUMENT", message: "title is required", details: nil)) @@ -92,6 +103,46 @@ import UIKit } } + private func createTimerViaClockApp(durationSeconds: Int, label: String, result: @escaping FlutterResult) { + // iOS doesn't have a public API for setting timers programmatically. + // Open the Clock app's timer tab as the best available option. + if let url = URL(string: "clock-timer://") { + UIApplication.shared.open(url, options: [:]) { success in + result([ + "platformId": nil, + "targetType": "timer", + "syncDisabled": false, + ] as [String: Any?]) + } + } else { + result([ + "platformId": nil, + "targetType": "timer", + "syncDisabled": false, + ] as [String: Any?]) + } + } + + private func createAlarmViaClockApp(scheduledAtMillis: Double?, label: String, result: @escaping FlutterResult) { + // iOS doesn't have a public API for setting alarms programmatically. + // Open the Clock app's alarm tab as the best available option. + if let url = URL(string: "clock-alarm://") { + UIApplication.shared.open(url, options: [:]) { success in + result([ + "platformId": nil, + "targetType": "alarm", + "syncDisabled": false, + ] as [String: Any?]) + } + } else { + result([ + "platformId": nil, + "targetType": "alarm", + "syncDisabled": false, + ] as [String: Any?]) + } + } + private func createCalendarEvent( title: String, notes: String?, diff --git a/zswatch_app/lib/data/database/app_database.dart b/zswatch_app/lib/data/database/app_database.dart index 1840d5b..9c62a2d 100644 --- a/zswatch_app/lib/data/database/app_database.dart +++ b/zswatch_app/lib/data/database/app_database.dart @@ -41,7 +41,7 @@ class AppDatabase extends _$AppDatabase { /// Database schema version @override - int get schemaVersion => 3; + int get schemaVersion => 5; @override MigrationStrategy get migration { @@ -58,6 +58,21 @@ class AppDatabase extends _$AppDatabase { // v2 → v3: added CrashReports table. await m.createTable(crashReports); } + if (from >= 2 && from < 4) { + // v3 → v4: added duration_seconds column to extracted_actions. + // Guard: only run when the table already existed (v2+). Fresh v1→v4 + // upgrades create the table with this column via createTable above. + await m.addColumn(extractedActions, extractedActions.durationSeconds); + } + if (from >= 2 && from < 5) { + // v4 → v5: added archived column to voice_memos. + // Guard: only run when the table already existed (v2+). + await m.addColumn(voiceMemos, voiceMemos.archived); + } + }, + beforeOpen: (details) async { + // Reset memos stuck in intermediate processing states from a crash. + await resetStuckProcessingMemos(); }, ); } @@ -508,6 +523,19 @@ class AppDatabase extends _$AppDatabase { .write(VoiceMemosCompanion(processingStatus: Value(status))); } + /// Reset any memos stuck in intermediate processing states back to 'failed' + /// so they can be retried. This handles crash recovery on app startup. + Future resetStuckProcessingMemos() { + return (update(voiceMemos)..where( + (v) => + v.summary.isNull() & + (v.processingStatus.equals('summarizing') | + v.processingStatus.equals('categorizing') | + v.processingStatus.equals('extractingActions')), + )) + .write(const VoiceMemosCompanion(processingStatus: Value('failed'))); + } + /// Mark task created for a voice memo Future updateVoiceMemoTaskCreated(String filename) { return (update(voiceMemos)..where((v) => v.filename.equals(filename))) @@ -520,6 +548,15 @@ class AppDatabase extends _$AppDatabase { .write(const VoiceMemosCompanion(calendarEventCreated: Value(true))); } + /// Set the archived flag for a voice memo + Future updateVoiceMemoArchived({ + required String filename, + required bool archived, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion(archived: Value(archived))); + } + /// Delete a voice memo by filename Future deleteVoiceMemo(String filename) { return (delete(voiceMemos)..where((v) => v.filename.equals(filename))).go(); @@ -584,6 +621,31 @@ class AppDatabase extends _$AppDatabase { )..where((a) => a.created.equals(false) & a.dismissed.equals(false))).get(); } + /// Delete a single extracted action by id + Future deleteExtractedAction(int actionId) { + return (delete(extractedActions)..where((a) => a.id.equals(actionId))).go(); + } + + /// Watch all alarm and timer extracted actions (reactive stream) + Stream> watchAlarmTimerActions() { + return (select(extractedActions) + ..where( + (a) => a.actionType.equals('alarm') | a.actionType.equals('timer'), + ) + ..orderBy([ + (a) => OrderingTerm.asc(a.created), + (a) => OrderingTerm.desc(a.id), + ])) + .watch(); + } + + /// Watch all extracted actions (for building memo→actionTypes map) + Stream> watchAllExtractedActions() { + return (select( + extractedActions, + )..orderBy([(a) => OrderingTerm.desc(a.id)])).watch(); + } + // ==================== Crash Report Operations ==================== /// Insert a crash report diff --git a/zswatch_app/lib/data/database/app_database.g.dart b/zswatch_app/lib/data/database/app_database.g.dart index 531e2f7..2622c11 100644 --- a/zswatch_app/lib/data/database/app_database.g.dart +++ b/zswatch_app/lib/data/database/app_database.g.dart @@ -2704,6 +2704,21 @@ class $VoiceMemosTable extends VoiceMemos type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _archivedMeta = const VerificationMeta( + 'archived', + ); + @override + late final GeneratedColumn archived = GeneratedColumn( + 'archived', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("archived" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -2726,6 +2741,7 @@ class $VoiceMemosTable extends VoiceMemos taskCreated, calendarEventCreated, actionReviewState, + archived, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2903,6 +2919,12 @@ class $VoiceMemosTable extends VoiceMemos ), ); } + if (data.containsKey('archived')) { + context.handle( + _archivedMeta, + archived.isAcceptableOrUnknown(data['archived']!, _archivedMeta), + ); + } return context; } @@ -2992,6 +3014,10 @@ class $VoiceMemosTable extends VoiceMemos DriftSqlType.string, data['${effectivePrefix}action_review_state'], ), + archived: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}archived'], + )!, ); } @@ -3062,6 +3088,9 @@ class VoiceMemoEntity extends DataClass implements Insertable { /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' final String? actionReviewState; + + /// Whether this memo has been archived by the user + final bool archived; const VoiceMemoEntity({ required this.id, required this.filename, @@ -3083,6 +3112,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { required this.taskCreated, required this.calendarEventCreated, this.actionReviewState, + required this.archived, }); @override Map toColumns(bool nullToAbsent) { @@ -3129,6 +3159,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { if (!nullToAbsent || actionReviewState != null) { map['action_review_state'] = Variable(actionReviewState); } + map['archived'] = Variable(archived); return map; } @@ -3176,6 +3207,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { actionReviewState: actionReviewState == null && nullToAbsent ? const Value.absent() : Value(actionReviewState), + archived: Value(archived), ); } @@ -3211,6 +3243,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { actionReviewState: serializer.fromJson( json['actionReviewState'], ), + archived: serializer.fromJson(json['archived']), ); } @override @@ -3237,6 +3270,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { 'taskCreated': serializer.toJson(taskCreated), 'calendarEventCreated': serializer.toJson(calendarEventCreated), 'actionReviewState': serializer.toJson(actionReviewState), + 'archived': serializer.toJson(archived), }; } @@ -3261,6 +3295,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { bool? taskCreated, bool? calendarEventCreated, Value actionReviewState = const Value.absent(), + bool? archived, }) => VoiceMemoEntity( id: id ?? this.id, filename: filename ?? this.filename, @@ -3296,6 +3331,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { actionReviewState: actionReviewState.present ? actionReviewState.value : this.actionReviewState, + archived: archived ?? this.archived, ); VoiceMemoEntity copyWithCompanion(VoiceMemosCompanion data) { return VoiceMemoEntity( @@ -3347,6 +3383,7 @@ class VoiceMemoEntity extends DataClass implements Insertable { actionReviewState: data.actionReviewState.present ? data.actionReviewState.value : this.actionReviewState, + archived: data.archived.present ? data.archived.value : this.archived, ); } @@ -3372,13 +3409,14 @@ class VoiceMemoEntity extends DataClass implements Insertable { ..write('aiProcessedAt: $aiProcessedAt, ') ..write('taskCreated: $taskCreated, ') ..write('calendarEventCreated: $calendarEventCreated, ') - ..write('actionReviewState: $actionReviewState') + ..write('actionReviewState: $actionReviewState, ') + ..write('archived: $archived') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, filename, timestampUtc, @@ -3399,7 +3437,8 @@ class VoiceMemoEntity extends DataClass implements Insertable { taskCreated, calendarEventCreated, actionReviewState, - ); + archived, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -3423,7 +3462,8 @@ class VoiceMemoEntity extends DataClass implements Insertable { other.aiProcessedAt == this.aiProcessedAt && other.taskCreated == this.taskCreated && other.calendarEventCreated == this.calendarEventCreated && - other.actionReviewState == this.actionReviewState); + other.actionReviewState == this.actionReviewState && + other.archived == this.archived); } class VoiceMemosCompanion extends UpdateCompanion { @@ -3447,6 +3487,7 @@ class VoiceMemosCompanion extends UpdateCompanion { final Value taskCreated; final Value calendarEventCreated; final Value actionReviewState; + final Value archived; const VoiceMemosCompanion({ this.id = const Value.absent(), this.filename = const Value.absent(), @@ -3468,6 +3509,7 @@ class VoiceMemosCompanion extends UpdateCompanion { this.taskCreated = const Value.absent(), this.calendarEventCreated = const Value.absent(), this.actionReviewState = const Value.absent(), + this.archived = const Value.absent(), }); VoiceMemosCompanion.insert({ this.id = const Value.absent(), @@ -3490,6 +3532,7 @@ class VoiceMemosCompanion extends UpdateCompanion { this.taskCreated = const Value.absent(), this.calendarEventCreated = const Value.absent(), this.actionReviewState = const Value.absent(), + this.archived = const Value.absent(), }) : filename = Value(filename), timestampUtc = Value(timestampUtc), durationMs = Value(durationMs), @@ -3515,6 +3558,7 @@ class VoiceMemosCompanion extends UpdateCompanion { Expression? taskCreated, Expression? calendarEventCreated, Expression? actionReviewState, + Expression? archived, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -3538,6 +3582,7 @@ class VoiceMemosCompanion extends UpdateCompanion { if (calendarEventCreated != null) 'calendar_event_created': calendarEventCreated, if (actionReviewState != null) 'action_review_state': actionReviewState, + if (archived != null) 'archived': archived, }); } @@ -3562,6 +3607,7 @@ class VoiceMemosCompanion extends UpdateCompanion { Value? taskCreated, Value? calendarEventCreated, Value? actionReviewState, + Value? archived, }) { return VoiceMemosCompanion( id: id ?? this.id, @@ -3584,6 +3630,7 @@ class VoiceMemosCompanion extends UpdateCompanion { taskCreated: taskCreated ?? this.taskCreated, calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, actionReviewState: actionReviewState ?? this.actionReviewState, + archived: archived ?? this.archived, ); } @@ -3652,6 +3699,9 @@ class VoiceMemosCompanion extends UpdateCompanion { if (actionReviewState.present) { map['action_review_state'] = Variable(actionReviewState.value); } + if (archived.present) { + map['archived'] = Variable(archived.value); + } return map; } @@ -3677,7 +3727,8 @@ class VoiceMemosCompanion extends UpdateCompanion { ..write('aiProcessedAt: $aiProcessedAt, ') ..write('taskCreated: $taskCreated, ') ..write('calendarEventCreated: $calendarEventCreated, ') - ..write('actionReviewState: $actionReviewState') + ..write('actionReviewState: $actionReviewState, ') + ..write('archived: $archived') ..write(')')) .toString(); } @@ -3836,6 +3887,17 @@ class $ExtractedActionsTable extends ExtractedActions type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _durationSecondsMeta = const VerificationMeta( + 'durationSeconds', + ); + @override + late final GeneratedColumn durationSeconds = GeneratedColumn( + 'duration_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -3862,6 +3924,7 @@ class $ExtractedActionsTable extends ExtractedActions created, dismissed, platformTargetId, + durationSeconds, createdAt, ]; @override @@ -3963,6 +4026,15 @@ class $ExtractedActionsTable extends ExtractedActions ), ); } + if (data.containsKey('duration_seconds')) { + context.handle( + _durationSecondsMeta, + durationSeconds.isAcceptableOrUnknown( + data['duration_seconds']!, + _durationSecondsMeta, + ), + ); + } if (data.containsKey('created_at')) { context.handle( _createdAtMeta, @@ -4030,6 +4102,10 @@ class $ExtractedActionsTable extends ExtractedActions DriftSqlType.string, data['${effectivePrefix}platform_target_id'], ), + durationSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_seconds'], + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}created_at'], @@ -4084,6 +4160,9 @@ class ExtractedActionEntity extends DataClass /// Platform-specific ID after creation (e.g. calendar event ID) final String? platformTargetId; + /// Duration in seconds (for timer actions) + final int? durationSeconds; + /// When this action was created in the OS final DateTime? createdAt; const ExtractedActionEntity({ @@ -4100,6 +4179,7 @@ class ExtractedActionEntity extends DataClass required this.created, required this.dismissed, this.platformTargetId, + this.durationSeconds, this.createdAt, }); @override @@ -4132,6 +4212,9 @@ class ExtractedActionEntity extends DataClass if (!nullToAbsent || platformTargetId != null) { map['platform_target_id'] = Variable(platformTargetId); } + if (!nullToAbsent || durationSeconds != null) { + map['duration_seconds'] = Variable(durationSeconds); + } if (!nullToAbsent || createdAt != null) { map['created_at'] = Variable(createdAt); } @@ -4167,6 +4250,9 @@ class ExtractedActionEntity extends DataClass platformTargetId: platformTargetId == null && nullToAbsent ? const Value.absent() : Value(platformTargetId), + durationSeconds: durationSeconds == null && nullToAbsent + ? const Value.absent() + : Value(durationSeconds), createdAt: createdAt == null && nullToAbsent ? const Value.absent() : Value(createdAt), @@ -4192,6 +4278,7 @@ class ExtractedActionEntity extends DataClass created: serializer.fromJson(json['created']), dismissed: serializer.fromJson(json['dismissed']), platformTargetId: serializer.fromJson(json['platformTargetId']), + durationSeconds: serializer.fromJson(json['durationSeconds']), createdAt: serializer.fromJson(json['createdAt']), ); } @@ -4212,6 +4299,7 @@ class ExtractedActionEntity extends DataClass 'created': serializer.toJson(created), 'dismissed': serializer.toJson(dismissed), 'platformTargetId': serializer.toJson(platformTargetId), + 'durationSeconds': serializer.toJson(durationSeconds), 'createdAt': serializer.toJson(createdAt), }; } @@ -4230,6 +4318,7 @@ class ExtractedActionEntity extends DataClass bool? created, bool? dismissed, Value platformTargetId = const Value.absent(), + Value durationSeconds = const Value.absent(), Value createdAt = const Value.absent(), }) => ExtractedActionEntity( id: id ?? this.id, @@ -4249,6 +4338,9 @@ class ExtractedActionEntity extends DataClass platformTargetId: platformTargetId.present ? platformTargetId.value : this.platformTargetId, + durationSeconds: durationSeconds.present + ? durationSeconds.value + : this.durationSeconds, createdAt: createdAt.present ? createdAt.value : this.createdAt, ); ExtractedActionEntity copyWithCompanion(ExtractedActionsCompanion data) { @@ -4272,6 +4364,9 @@ class ExtractedActionEntity extends DataClass platformTargetId: data.platformTargetId.present ? data.platformTargetId.value : this.platformTargetId, + durationSeconds: data.durationSeconds.present + ? data.durationSeconds.value + : this.durationSeconds, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -4292,6 +4387,7 @@ class ExtractedActionEntity extends DataClass ..write('created: $created, ') ..write('dismissed: $dismissed, ') ..write('platformTargetId: $platformTargetId, ') + ..write('durationSeconds: $durationSeconds, ') ..write('createdAt: $createdAt') ..write(')')) .toString(); @@ -4312,6 +4408,7 @@ class ExtractedActionEntity extends DataClass created, dismissed, platformTargetId, + durationSeconds, createdAt, ); @override @@ -4331,6 +4428,7 @@ class ExtractedActionEntity extends DataClass other.created == this.created && other.dismissed == this.dismissed && other.platformTargetId == this.platformTargetId && + other.durationSeconds == this.durationSeconds && other.createdAt == this.createdAt); } @@ -4348,6 +4446,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { final Value created; final Value dismissed; final Value platformTargetId; + final Value durationSeconds; final Value createdAt; const ExtractedActionsCompanion({ this.id = const Value.absent(), @@ -4363,6 +4462,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { this.created = const Value.absent(), this.dismissed = const Value.absent(), this.platformTargetId = const Value.absent(), + this.durationSeconds = const Value.absent(), this.createdAt = const Value.absent(), }); ExtractedActionsCompanion.insert({ @@ -4379,6 +4479,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { this.created = const Value.absent(), this.dismissed = const Value.absent(), this.platformTargetId = const Value.absent(), + this.durationSeconds = const Value.absent(), this.createdAt = const Value.absent(), }) : memoId = Value(memoId), actionType = Value(actionType), @@ -4397,6 +4498,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { Expression? created, Expression? dismissed, Expression? platformTargetId, + Expression? durationSeconds, Expression? createdAt, }) { return RawValuesInsertable({ @@ -4413,6 +4515,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { if (created != null) 'created': created, if (dismissed != null) 'dismissed': dismissed, if (platformTargetId != null) 'platform_target_id': platformTargetId, + if (durationSeconds != null) 'duration_seconds': durationSeconds, if (createdAt != null) 'created_at': createdAt, }); } @@ -4431,6 +4534,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { Value? created, Value? dismissed, Value? platformTargetId, + Value? durationSeconds, Value? createdAt, }) { return ExtractedActionsCompanion( @@ -4447,6 +4551,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { created: created ?? this.created, dismissed: dismissed ?? this.dismissed, platformTargetId: platformTargetId ?? this.platformTargetId, + durationSeconds: durationSeconds ?? this.durationSeconds, createdAt: createdAt ?? this.createdAt, ); } @@ -4493,6 +4598,9 @@ class ExtractedActionsCompanion extends UpdateCompanion { if (platformTargetId.present) { map['platform_target_id'] = Variable(platformTargetId.value); } + if (durationSeconds.present) { + map['duration_seconds'] = Variable(durationSeconds.value); + } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } @@ -4515,6 +4623,7 @@ class ExtractedActionsCompanion extends UpdateCompanion { ..write('created: $created, ') ..write('dismissed: $dismissed, ') ..write('platformTargetId: $platformTargetId, ') + ..write('durationSeconds: $durationSeconds, ') ..write('createdAt: $createdAt') ..write(')')) .toString(); @@ -7513,6 +7622,7 @@ typedef $$VoiceMemosTableCreateCompanionBuilder = Value taskCreated, Value calendarEventCreated, Value actionReviewState, + Value archived, }); typedef $$VoiceMemosTableUpdateCompanionBuilder = VoiceMemosCompanion Function({ @@ -7536,6 +7646,7 @@ typedef $$VoiceMemosTableUpdateCompanionBuilder = Value taskCreated, Value calendarEventCreated, Value actionReviewState, + Value archived, }); class $$VoiceMemosTableFilterComposer @@ -7646,6 +7757,11 @@ class $$VoiceMemosTableFilterComposer column: $table.actionReviewState, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get archived => $composableBuilder( + column: $table.archived, + builder: (column) => ColumnFilters(column), + ); } class $$VoiceMemosTableOrderingComposer @@ -7756,6 +7872,11 @@ class $$VoiceMemosTableOrderingComposer column: $table.actionReviewState, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get archived => $composableBuilder( + column: $table.archived, + builder: (column) => ColumnOrderings(column), + ); } class $$VoiceMemosTableAnnotationComposer @@ -7854,6 +7975,9 @@ class $$VoiceMemosTableAnnotationComposer column: $table.actionReviewState, builder: (column) => column, ); + + GeneratedColumn get archived => + $composableBuilder(column: $table.archived, builder: (column) => column); } class $$VoiceMemosTableTableManager @@ -7907,6 +8031,7 @@ class $$VoiceMemosTableTableManager Value taskCreated = const Value.absent(), Value calendarEventCreated = const Value.absent(), Value actionReviewState = const Value.absent(), + Value archived = const Value.absent(), }) => VoiceMemosCompanion( id: id, filename: filename, @@ -7928,6 +8053,7 @@ class $$VoiceMemosTableTableManager taskCreated: taskCreated, calendarEventCreated: calendarEventCreated, actionReviewState: actionReviewState, + archived: archived, ), createCompanionCallback: ({ @@ -7951,6 +8077,7 @@ class $$VoiceMemosTableTableManager Value taskCreated = const Value.absent(), Value calendarEventCreated = const Value.absent(), Value actionReviewState = const Value.absent(), + Value archived = const Value.absent(), }) => VoiceMemosCompanion.insert( id: id, filename: filename, @@ -7972,6 +8099,7 @@ class $$VoiceMemosTableTableManager taskCreated: taskCreated, calendarEventCreated: calendarEventCreated, actionReviewState: actionReviewState, + archived: archived, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -8013,6 +8141,7 @@ typedef $$ExtractedActionsTableCreateCompanionBuilder = Value created, Value dismissed, Value platformTargetId, + Value durationSeconds, Value createdAt, }); typedef $$ExtractedActionsTableUpdateCompanionBuilder = @@ -8030,6 +8159,7 @@ typedef $$ExtractedActionsTableUpdateCompanionBuilder = Value created, Value dismissed, Value platformTargetId, + Value durationSeconds, Value createdAt, }); @@ -8107,6 +8237,11 @@ class $$ExtractedActionsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnFilters(column), @@ -8187,6 +8322,11 @@ class $$ExtractedActionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnOrderings(column), @@ -8247,6 +8387,11 @@ class $$ExtractedActionsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => column, + ); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); } @@ -8301,6 +8446,7 @@ class $$ExtractedActionsTableTableManager Value created = const Value.absent(), Value dismissed = const Value.absent(), Value platformTargetId = const Value.absent(), + Value durationSeconds = const Value.absent(), Value createdAt = const Value.absent(), }) => ExtractedActionsCompanion( id: id, @@ -8316,6 +8462,7 @@ class $$ExtractedActionsTableTableManager created: created, dismissed: dismissed, platformTargetId: platformTargetId, + durationSeconds: durationSeconds, createdAt: createdAt, ), createCompanionCallback: @@ -8333,6 +8480,7 @@ class $$ExtractedActionsTableTableManager Value created = const Value.absent(), Value dismissed = const Value.absent(), Value platformTargetId = const Value.absent(), + Value durationSeconds = const Value.absent(), Value createdAt = const Value.absent(), }) => ExtractedActionsCompanion.insert( id: id, @@ -8348,6 +8496,7 @@ class $$ExtractedActionsTableTableManager created: created, dismissed: dismissed, platformTargetId: platformTargetId, + durationSeconds: durationSeconds, createdAt: createdAt, ), withReferenceMapper: (p0) => p0 diff --git a/zswatch_app/lib/data/database/tables/extracted_actions_table.dart b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart index ea95553..91ab200 100644 --- a/zswatch_app/lib/data/database/tables/extracted_actions_table.dart +++ b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart @@ -47,6 +47,10 @@ class ExtractedActions extends Table { TextColumn get platformTargetId => text().nullable().named('platform_target_id')(); + /// Duration in seconds (for timer actions) + IntColumn get durationSeconds => + integer().nullable().named('duration_seconds')(); + /// 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/voice_memos_table.dart b/zswatch_app/lib/data/database/tables/voice_memos_table.dart index 670fb5a..b6c514b 100644 --- a/zswatch_app/lib/data/database/tables/voice_memos_table.dart +++ b/zswatch_app/lib/data/database/tables/voice_memos_table.dart @@ -80,4 +80,7 @@ class VoiceMemos extends Table { /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' TextColumn get actionReviewState => text().nullable().named('action_review_state')(); + + /// Whether this memo has been archived by the user + BoolColumn get archived => boolean().withDefault(const Constant(false))(); } diff --git a/zswatch_app/lib/data/models/extracted_action.dart b/zswatch_app/lib/data/models/extracted_action.dart index d306309..deab500 100644 --- a/zswatch_app/lib/data/models/extracted_action.dart +++ b/zswatch_app/lib/data/models/extracted_action.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'extracted_action.freezed.dart'; /// Type of extracted action from AI processing -enum ExtractedActionType { task, calendarEvent, reminder } +enum ExtractedActionType { task, calendarEvent, reminder, timer, alarm } /// Domain model for an AI-extracted action from a voice memo @freezed @@ -21,6 +21,7 @@ abstract class ExtractedAction with _$ExtractedAction { DateTime? dueDate, String? location, int? reminderMinutes, + int? durationSeconds, @Default(false) bool created, @Default(false) bool dismissed, String? platformTargetId, @@ -36,6 +37,10 @@ abstract class ExtractedAction with _$ExtractedAction { return ExtractedActionType.calendarEvent; case 'reminder': return ExtractedActionType.reminder; + case 'timer': + return ExtractedActionType.timer; + case 'alarm': + return ExtractedActionType.alarm; default: return ExtractedActionType.task; } @@ -50,6 +55,10 @@ abstract class ExtractedAction with _$ExtractedAction { return 'calendar_event'; case ExtractedActionType.reminder: return 'reminder'; + case ExtractedActionType.timer: + return 'timer'; + case ExtractedActionType.alarm: + return 'alarm'; } } } diff --git a/zswatch_app/lib/data/models/extracted_action.freezed.dart b/zswatch_app/lib/data/models/extracted_action.freezed.dart index b8e1288..f1ae226 100644 --- a/zswatch_app/lib/data/models/extracted_action.freezed.dart +++ b/zswatch_app/lib/data/models/extracted_action.freezed.dart @@ -14,7 +14,7 @@ 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; + 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; int? get durationSeconds; 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) @@ -25,16 +25,16 @@ $ExtractedActionCopyWith get copyWith => _$ExtractedActionCopyW @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)); + 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.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(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); +int get hashCode => Object.hash(runtimeType,id,memoId,actionType,title,notes,startTime,endTime,dueDate,location,reminderMinutes,durationSeconds,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)'; + return 'ExtractedAction(id: $id, memoId: $memoId, actionType: $actionType, title: $title, notes: $notes, startTime: $startTime, endTime: $endTime, dueDate: $dueDate, location: $location, reminderMinutes: $reminderMinutes, durationSeconds: $durationSeconds, created: $created, dismissed: $dismissed, platformTargetId: $platformTargetId, createdAt: $createdAt)'; } @@ -45,7 +45,7 @@ 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 + int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, int? durationSeconds, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt }); @@ -62,7 +62,7 @@ class _$ExtractedActionCopyWithImpl<$Res> /// 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,}) { +@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? durationSeconds = 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 @@ -74,6 +74,7 @@ as DateTime?,endTime: freezed == endTime ? _self.endTime : endTime // ignore: ca 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?,durationSeconds: freezed == durationSeconds ? _self.durationSeconds : durationSeconds // 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 @@ -163,10 +164,10 @@ return $default(_that);case _: /// } /// ``` -@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; +@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, int? durationSeconds, 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 $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.durationSeconds,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: return orElse(); } @@ -184,10 +185,10 @@ return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_ /// } /// ``` -@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; +@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, int? durationSeconds, 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 _: +return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.durationSeconds,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: throw StateError('Unexpected subclass'); } @@ -204,10 +205,10 @@ return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_ /// } /// ``` -@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; +@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, int? durationSeconds, 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 $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_that.startTime,_that.endTime,_that.dueDate,_that.location,_that.reminderMinutes,_that.durationSeconds,_that.created,_that.dismissed,_that.platformTargetId,_that.createdAt);case _: return null; } @@ -219,7 +220,7 @@ return $default(_that.id,_that.memoId,_that.actionType,_that.title,_that.notes,_ 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._(); + 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.durationSeconds, this.created = false, this.dismissed = false, this.platformTargetId, this.createdAt}): super._(); @override final int id; @@ -232,6 +233,7 @@ class _ExtractedAction extends ExtractedAction { @override final DateTime? dueDate; @override final String? location; @override final int? reminderMinutes; +@override final int? durationSeconds; @override@JsonKey() final bool created; @override@JsonKey() final bool dismissed; @override final String? platformTargetId; @@ -247,16 +249,16 @@ _$ExtractedActionCopyWith<_ExtractedAction> get copyWith => __$ExtractedActionCo @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)); + 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.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(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); +int get hashCode => Object.hash(runtimeType,id,memoId,actionType,title,notes,startTime,endTime,dueDate,location,reminderMinutes,durationSeconds,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)'; + return 'ExtractedAction(id: $id, memoId: $memoId, actionType: $actionType, title: $title, notes: $notes, startTime: $startTime, endTime: $endTime, dueDate: $dueDate, location: $location, reminderMinutes: $reminderMinutes, durationSeconds: $durationSeconds, created: $created, dismissed: $dismissed, platformTargetId: $platformTargetId, createdAt: $createdAt)'; } @@ -267,7 +269,7 @@ abstract mixin class _$ExtractedActionCopyWith<$Res> implements $ExtractedAction 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 + int id, int memoId, ExtractedActionType actionType, String title, String? notes, DateTime? startTime, DateTime? endTime, DateTime? dueDate, String? location, int? reminderMinutes, int? durationSeconds, bool created, bool dismissed, String? platformTargetId, DateTime? createdAt }); @@ -284,7 +286,7 @@ class __$ExtractedActionCopyWithImpl<$Res> /// 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,}) { +@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? durationSeconds = 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 @@ -296,6 +298,7 @@ as DateTime?,endTime: freezed == endTime ? _self.endTime : endTime // ignore: ca 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?,durationSeconds: freezed == durationSeconds ? _self.durationSeconds : durationSeconds // 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 diff --git a/zswatch_app/lib/data/models/voice_memo.dart b/zswatch_app/lib/data/models/voice_memo.dart index a7ee4ec..b7e4dd9 100644 --- a/zswatch_app/lib/data/models/voice_memo.dart +++ b/zswatch_app/lib/data/models/voice_memo.dart @@ -72,6 +72,7 @@ abstract class VoiceMemo with _$VoiceMemo { @Default(false) bool taskCreated, @Default(false) bool calendarEventCreated, String? actionReviewState, + @Default(false) bool archived, }) = _VoiceMemo; /// Computed sync status based on field values diff --git a/zswatch_app/lib/data/models/voice_memo.freezed.dart b/zswatch_app/lib/data/models/voice_memo.freezed.dart index 8e74add..46b1f0b 100644 --- a/zswatch_app/lib/data/models/voice_memo.freezed.dart +++ b/zswatch_app/lib/data/models/voice_memo.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; 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; + String? get summary; String? get category; String? get processingStatus; String? get aiModel; DateTime? get aiProcessedAt; bool get taskCreated; bool get calendarEventCreated; String? get actionReviewState; bool get archived; /// Create a copy of VoiceMemo /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +26,16 @@ $VoiceMemoCopyWith get copyWith => _$VoiceMemoCopyWithImpl @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)); + 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)&&(identical(other.archived, archived) || other.archived == archived)); } @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]); +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,archived]); @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)'; + 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, archived: $archived)'; } @@ -46,7 +46,7 @@ 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 + 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, bool archived }); @@ -63,7 +63,7 @@ class _$VoiceMemoCopyWithImpl<$Res> /// 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,}) { +@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,Object? archived = null,}) { 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 @@ -85,7 +85,8 @@ as String?,aiProcessedAt: freezed == aiProcessedAt ? _self.aiProcessedAt : aiPro 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?, +as String?,archived: null == archived ? _self.archived : archived // ignore: cast_nullable_to_non_nullable +as bool, )); } @@ -170,10 +171,10 @@ return $default(_that);case _: /// } /// ``` -@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; +@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, bool archived)? $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 $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,_that.archived);case _: return orElse(); } @@ -191,10 +192,10 @@ return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_tha /// } /// ``` -@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; +@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, bool archived) $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 _: +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,_that.archived);case _: throw StateError('Unexpected subclass'); } @@ -211,10 +212,10 @@ return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_tha /// } /// ``` -@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; +@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, bool archived)? $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 $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,_that.archived);case _: return null; } @@ -226,7 +227,7 @@ return $default(_that.id,_that.filename,_that.timestampUtc,_that.durationMs,_tha 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._(); + 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, this.archived = false}): super._(); @override final int id; @@ -250,6 +251,7 @@ class _VoiceMemo extends VoiceMemo { @override@JsonKey() final bool taskCreated; @override@JsonKey() final bool calendarEventCreated; @override final String? actionReviewState; +@override@JsonKey() final bool archived; /// Create a copy of VoiceMemo /// with the given fields replaced by the non-null parameter values. @@ -261,16 +263,16 @@ _$VoiceMemoCopyWith<_VoiceMemo> get copyWith => __$VoiceMemoCopyWithImpl<_VoiceM @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)); + 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)&&(identical(other.archived, archived) || other.archived == archived)); } @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]); +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,archived]); @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)'; + 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, archived: $archived)'; } @@ -281,7 +283,7 @@ abstract mixin class _$VoiceMemoCopyWith<$Res> implements $VoiceMemoCopyWith<$Re 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 + 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, bool archived }); @@ -298,7 +300,7 @@ class __$VoiceMemoCopyWithImpl<$Res> /// 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,}) { +@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,Object? archived = null,}) { 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 @@ -320,7 +322,8 @@ as String?,aiProcessedAt: freezed == aiProcessedAt ? _self.aiProcessedAt : aiPro 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?, +as String?,archived: null == archived ? _self.archived : archived // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/zswatch_app/lib/data/repositories/extracted_action_repository.dart b/zswatch_app/lib/data/repositories/extracted_action_repository.dart index f38ab1c..7de3439 100644 --- a/zswatch_app/lib/data/repositories/extracted_action_repository.dart +++ b/zswatch_app/lib/data/repositories/extracted_action_repository.dart @@ -46,6 +46,7 @@ class ExtractedActionRepository DateTime? dueDate, String? location, int? reminderMinutes, + int? durationSeconds, }) async { final id = await _db.insertExtractedAction( ExtractedActionsCompanion( @@ -58,6 +59,7 @@ class ExtractedActionRepository dueDate: Value(dueDate), location: Value(location), reminderMinutes: Value(reminderMinutes), + durationSeconds: Value(durationSeconds), ), ); debugPrint( @@ -82,11 +84,34 @@ class ExtractedActionRepository await _db.dismissExtractedAction(actionId); } + /// Delete a single extracted action by id + Future deleteAction(int actionId) async { + await _db.deleteExtractedAction(actionId); + } + /// Delete all actions for a memo Future deleteActionsForMemo(int memoId) async { await _db.deleteActionsForMemo(memoId); } + /// Watch all alarm and timer actions (reactive stream, for iOS page) + Stream> watchAlarmTimerActions() { + return _db.watchAlarmTimerActions().map( + (entities) => entities.map(_entityToModel).toList(), + ); + } + + /// Watch a map of memoId → set of action types (for list filtering) + Stream>> watchMemoActionTypesMap() { + return _db.watchAllExtractedActions().map((entities) { + final map = >{}; + for (final e in entities) { + map.putIfAbsent(e.memoId, () => {}).add(e.actionType); + } + return map; + }); + } + // ==================== Private Helpers ==================== @override @@ -105,6 +130,7 @@ class ExtractedActionRepository dueDate: entity.dueDate, location: entity.location, reminderMinutes: entity.reminderMinutes, + durationSeconds: entity.durationSeconds, created: entity.created, dismissed: entity.dismissed, platformTargetId: entity.platformTargetId, diff --git a/zswatch_app/lib/data/repositories/voice_memo_repository.dart b/zswatch_app/lib/data/repositories/voice_memo_repository.dart index 36191f4..d491a06 100644 --- a/zswatch_app/lib/data/repositories/voice_memo_repository.dart +++ b/zswatch_app/lib/data/repositories/voice_memo_repository.dart @@ -164,12 +164,23 @@ class VoiceMemoRepository extends BaseRepository { ); } + /// Reset memos stuck in intermediate processing states back to 'failed'. + Future resetStuckProcessingMemos() => _db.resetStuckProcessingMemos(); + /// Get memos that are transcribed but not yet AI-processed Future> getUnprocessedMemos() async { final entities = await _db.getUnprocessedVoiceMemos(); return entities.map(_entityToModel).toList(); } + /// Toggle the archived state of a voice memo + Future setArchived({ + required String filename, + required bool archived, + }) async { + await _db.updateVoiceMemoArchived(filename: filename, archived: archived); + } + /// 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 @@ -239,6 +250,7 @@ class VoiceMemoRepository extends BaseRepository { taskCreated: entity.taskCreated, calendarEventCreated: entity.calendarEventCreated, actionReviewState: entity.actionReviewState, + archived: entity.archived, ); } } diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index 1dc066e..60059fe 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -120,6 +120,10 @@ class ExtractedActionOperations { Future dismissAction(int actionId) { return _actionRepository.dismiss(actionId); } + + Future deleteAction(int actionId) { + return _actionRepository.deleteAction(actionId); + } } final extractedActionOperationsProvider = Provider(( @@ -229,3 +233,15 @@ final extractedActionsForMemoProvider = final repo = ref.watch(extractedActionRepositoryProvider); return repo.watchActionsForMemo(memoId); }); + +/// Watch all alarm and timer extracted actions (for iOS Alarms & Timers page) +final alarmTimerActionsProvider = StreamProvider>((ref) { + final repo = ref.watch(extractedActionRepositoryProvider); + return repo.watchAlarmTimerActions(); +}); + +/// Map of memoId → set of action type strings (for list filtering) +final memoActionTypesMapProvider = StreamProvider>>((ref) { + final repo = ref.watch(extractedActionRepositoryProvider); + return repo.watchMemoActionTypesMap(); +}); diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index b1e178d..e63cecc 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -247,15 +247,20 @@ Future _autoCreateActionsForMemo({ 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) { + // Notes (tasks without time) are app-internal only — never auto-create. + // Tasks/reminders require a scheduled time — skip if missing. + final isNote = + action.actionType == ExtractedActionType.task && + action.startTime == null && + action.dueDate == null; + final requiresDate = action.actionType == ExtractedActionType.reminder; + if (isNote || + (requiresDate && + action.startTime == null && + action.dueDate == null)) { debugPrint( '[VoiceMemoProviders] Skipping auto-create for action ${action.id} ' - '(${action.actionType}) — no scheduled time', + '(${action.actionType}) — note or no scheduled time', ); continue; } @@ -380,6 +385,11 @@ class VoiceMemoActionsNotifier extends StateNotifier { } } + /// Toggle archive state for a voice memo + Future setArchived(String filename, {required bool archived}) async { + await _repository.setArchived(filename: filename, archived: archived); + } + /// Delete a voice memo locally Future delete(String filename) async { state = VoiceMemoActionState.loading( diff --git a/zswatch_app/lib/services/ai/ai_debug_info.dart b/zswatch_app/lib/services/ai/ai_debug_info.dart index 010362e..d8eddf1 100644 --- a/zswatch_app/lib/services/ai/ai_debug_info.dart +++ b/zswatch_app/lib/services/ai/ai_debug_info.dart @@ -120,6 +120,9 @@ class ActionChronoDebug { final String? resolvedDateTime; final String? resolverMethod; + /// Duration in seconds for timer intents. + final int? durationSeconds; + const ActionChronoDebug({ this.intent, this.title, @@ -127,5 +130,6 @@ class ActionChronoDebug { this.datetimeExpressionEnglish, this.resolvedDateTime, this.resolverMethod, + this.durationSeconds, }); } diff --git a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart index d1fd1a3..36bf8da 100644 --- a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart +++ b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart @@ -67,6 +67,11 @@ class ActionCreationDraft { final DateTime? endAt; final String? location; final int? reminderMinutes; + final int? durationSeconds; + + /// When true, the system clock app creates the timer/alarm silently + /// without opening its UI. Defaults to true for background use. + final bool skipUi; final int? platformCalendarId; const ActionCreationDraft({ @@ -77,6 +82,8 @@ class ActionCreationDraft { this.endAt, this.location, this.reminderMinutes, + this.durationSeconds, + this.skipUi = true, this.platformCalendarId, }); @@ -98,6 +105,7 @@ class ActionCreationDraft { reminderMinutes: action.reminderMinutes ?? (action.actionType == ExtractedActionType.reminder ? 0 : null), + durationSeconds: action.durationSeconds, platformCalendarId: null, ); } @@ -110,6 +118,8 @@ class ActionCreationDraft { DateTime? endAt, String? location, int? reminderMinutes, + int? durationSeconds, + bool? skipUi, int? platformCalendarId, }) { return ActionCreationDraft( @@ -120,6 +130,8 @@ class ActionCreationDraft { endAt: endAt ?? this.endAt, location: location ?? this.location, reminderMinutes: reminderMinutes ?? this.reminderMinutes, + durationSeconds: durationSeconds ?? this.durationSeconds, + skipUi: skipUi ?? this.skipUi, platformCalendarId: platformCalendarId ?? this.platformCalendarId, ); } @@ -133,6 +145,8 @@ class ActionCreationDraft { 'endAtMillis': endAt?.millisecondsSinceEpoch, 'location': location, 'reminderMinutes': reminderMinutes, + 'durationSeconds': durationSeconds, + 'skipUi': skipUi, 'calendarId': platformCalendarId, }; } @@ -170,6 +184,10 @@ class CreatedPlatformAction { return 'Reminder created$calendarSuffix'; case 'calendar_reminder': return 'Calendar reminder created$calendarSuffix'; + case 'timer': + return 'Timer started'; + case 'alarm': + return 'Alarm set'; default: return 'Action created$calendarSuffix'; } @@ -299,10 +317,18 @@ class ExtractedActionCreationService { return; } + // Timer/alarm are created via system intents — there's no calendar entry + // to open afterwards, so skip them. + if (action.actionType == ExtractedActionType.timer || + action.actionType == ExtractedActionType.alarm) { + return; + } + final targetType = switch (action.actionType) { ExtractedActionType.calendarEvent => 'calendar_event', ExtractedActionType.task || ExtractedActionType.reminder => 'calendar_reminder', + ExtractedActionType.timer || ExtractedActionType.alarm => 'alarm', }; await _openCreatedCalendarEntryIfSupported( @@ -363,6 +389,13 @@ class ExtractedActionCreationService { ); } + // Timer/alarm use system intents (Android) or URL schemes (iOS) — + // no calendar or notification permission needed on either platform. + if (actionType == ExtractedActionType.timer || + actionType == ExtractedActionType.alarm) { + return; + } + final permission = _permissionForActionType(actionType); final failureMessage = _failureMessageForActionType(actionType); @@ -370,16 +403,18 @@ class ExtractedActionCreationService { } Permission _permissionForActionType(ExtractedActionType actionType) { - if (Platform.isAndroid) { - return Permission.calendarFullAccess; - } - switch (actionType) { + case ExtractedActionType.timer: + case ExtractedActionType.alarm: + // Timer/alarm use system intents — no calendar permissions needed. + return Permission.notification; case ExtractedActionType.calendarEvent: return Permission.calendarFullAccess; case ExtractedActionType.task: case ExtractedActionType.reminder: - return Permission.reminders; + return Platform.isAndroid + ? Permission.calendarFullAccess + : Permission.reminders; } } @@ -395,6 +430,8 @@ class ExtractedActionCreationService { 'Calendar access is required to create events.', ExtractedActionType.task || ExtractedActionType.reminder => 'Reminders access is required to create reminders.', + ExtractedActionType.timer || ExtractedActionType.alarm => + 'Notification permission is required for timers and alarms.', }; } diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 060f4c1..fe82705 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -124,13 +124,16 @@ class LlmServiceState { /// One extracted action from the LLM output. class ExtractedActionResult { - final String type; // "task", "calendar_event", "reminder" + final String type; // "task", "calendar_event", "reminder", "timer", "alarm" final String title; final String? notes; final String? dueDate; final String? startTime; final String? location; + /// Duration in seconds for timer intents. + final int? durationSeconds; + const ExtractedActionResult({ required this.type, required this.title, @@ -138,6 +141,7 @@ class ExtractedActionResult { this.dueDate, this.startTime, this.location, + this.durationSeconds, }); } @@ -964,26 +968,86 @@ class LlmService { // "Callback invoked after it has been deleted" crash. await Future.delayed(const Duration(milliseconds: 500)); - // --- Step 2: Build the extraction prompt --- + // --- Step 2: Route via lightweight pre-classifier --- final promptTemplate = classifyPromptOverride?.trim(); - final prompt = (promptTemplate != null && promptTemplate.isNotEmpty) - ? _renderClassifyPromptTemplate( - promptTemplate, - transcript: effectiveTranscript, - ) - : _buildClassifyPrompt( - effectiveTranscript, - effectiveCtx: effectiveCtx, - ); + String? routeResult; + + if (promptTemplate == null || promptTemplate.isEmpty) { + // Run the router prompt to decide timer_alarm vs voice_memo + final routerPrompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.routerTemplate, + transcript: effectiveTranscript, + ); + final routerGen = await _generate( + routerPrompt, + overrideMaxTokens: 32, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('routing', partial, tokens), + ); + routeResult = _parseRouterOutput(_sanitizeModelOutput(routerGen.text)); + debugPrint( + '[LlmService] Router: route=$routeResult ' + '(${routerGen.metrics.wallTime.inMilliseconds}ms)', + ); + + // Brief pause between inference calls + await Future.delayed(const Duration(milliseconds: 500)); + + // Router detected no meaningful speech — return empty result. + if (routeResult == 'none') { + debugPrint( + '[LlmService] Router returned "none" — no speech detected', + ); + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.ready), + ); + return TranscriptResult( + summary: '', + category: 'note', + actions: [], + originalTranscription: transcript, + correctedTranscription: correctedTranscription, + correctionMetrics: correctionMetrics, + actionChronoDetails: [], + ); + } + } + + // --- Step 3: Build the extraction prompt based on route --- + final String prompt; + final String promptStrategy; + + if (promptTemplate != null && promptTemplate.isNotEmpty) { + prompt = _renderClassifyPromptTemplate( + promptTemplate, + transcript: effectiveTranscript, + ); + promptStrategy = promptStrategyOverride ?? 'custom-template'; + } else if (routeResult == 'timer_alarm' || routeResult == 'mixed') { + // For "mixed" inputs (e.g., "Set a timer for 5 min and buy milk"), + // use the timer/alarm template to avoid losing the timer/alarm intent. + // The non-timer items won't be extracted but the primary action is preserved. + prompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.timerAlarmTemplate, + transcript: effectiveTranscript, + ); + promptStrategy = 'router→$routeResult'; + } else { + prompt = _buildClassifyPrompt( + effectiveTranscript, + effectiveCtx: effectiveCtx, + ); + promptStrategy = usesFullPrompt(effectiveCtx) + ? 'router→voice_memo/full' + : usesEmergencyCompactPrompt(effectiveCtx) + ? 'router→voice_memo/emergency-compact' + : 'router→voice_memo/compact'; + } + 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)', + promptStrategy: promptStrategy, phase: 'classifying', onProgress: onProgress, ); @@ -1053,12 +1117,36 @@ class LlmService { } String _buildClassifyPrompt(String transcript, {int? effectiveCtx}) { - final template = ChronoPromptTemplate.templateForContextSize( - effectiveCtx ?? nCtx, - ); + // Use the standard 3-intent template (reminder/event/note). + // Timer/alarm cases are handled by the two-stage router in processTranscript. + const template = ChronoPromptTemplate.compactTemplate; return _renderClassifyPromptTemplate(template, transcript: transcript); } + /// Parse the router prompt output into a route string. + String _parseRouterOutput(String raw) { + try { + final start = raw.indexOf('{'); + if (start == -1) return 'voice_memo'; + final end = raw.lastIndexOf('}'); + if (end == -1) return 'voice_memo'; + final decoded = + jsonDecode(raw.substring(start, end + 1)) as Map; + final route = (decoded['route'] as String?)?.trim().toLowerCase() ?? ''; + if (const { + 'timer_alarm', + 'voice_memo', + 'mixed', + 'none', + }.contains(route)) { + return route; + } + return 'voice_memo'; + } catch (_) { + return 'voice_memo'; + } + } + String _renderClassifyPromptTemplate( String template, { required String transcript, @@ -1429,6 +1517,35 @@ JSON: } } + static String _friendlySummary(ChronoLlmExtraction first, String raw) { + if (first.intent == 'timer' && first.durationSeconds != null) { + final d = first.durationSeconds!; + final h = d ~/ 3600; + final m = (d % 3600) ~/ 60; + final s = d % 60; + final parts = [ + if (h > 0) '${h}h', + if (m > 0) '${m}m', + if (s > 0 || (h == 0 && m == 0)) '${s}s', + ]; + final dur = parts.join(' '); + return first.title.isNotEmpty + ? 'Timer $dur — ${first.title}' + : 'Timer $dur'; + } + if (first.intent == 'alarm') { + final expr = + first.datetimeExpressionEnglish ?? first.datetimeExpressionOriginal; + if (expr != null) { + return first.title.isNotEmpty + ? 'Alarm $expr — ${first.title}' + : 'Alarm $expr'; + } + return first.title.isNotEmpty ? 'Alarm — ${first.title}' : 'Alarm'; + } + return first.title.isNotEmpty ? first.title : raw.trim(); + } + TranscriptResult _buildTranscriptResultFromChronoExtractions( List extractions, String raw, @@ -1461,6 +1578,25 @@ JSON: continue; } + if (extraction.intent == 'timer') { + // Timer: duration-based, no datetime resolution needed + actions.add( + ExtractedActionResult( + type: 'timer', + title: title, + durationSeconds: extraction.durationSeconds, + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + durationSeconds: extraction.durationSeconds, + ), + ); + continue; + } + final englishExpression = extraction.datetimeExpressionEnglish; final resolved = englishExpression == null ? null @@ -1469,6 +1605,28 @@ JSON: firstResolvedDateTime ??= resolved?.dateTime.toIso8601String(); firstResolverMethod ??= resolved?.method; + if (extraction.intent == 'alarm') { + // Alarm: clock-time based, resolve time + actions.add( + ExtractedActionResult( + type: 'alarm', + title: title, + startTime: resolved?.dateTime.toIso8601String(), + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + resolvedDateTime: resolved?.dateTime.toIso8601String(), + resolverMethod: resolved?.method, + ), + ); + continue; + } + actions.add( ExtractedActionResult( type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', @@ -1495,10 +1653,12 @@ JSON: } final first = extractions.first; - final summary = first.title.isNotEmpty ? first.title : raw.trim(); + final summary = _friendlySummary(first, raw); final category = switch (first.intent) { 'event' => 'meeting', 'reminder' => 'reminder', + 'timer' => 'timer', + 'alarm' => 'alarm', _ => 'note', }; diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index 37d83b1..6be1131 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -63,9 +63,9 @@ class VoiceNoteAiPipeline { required String filename, required String transcript, }) async { - if (transcript.trim().isEmpty) { + if (transcript.trim().isEmpty || isNoSpeechTranscript(transcript)) { debugPrint( - '[VoiceNoteAiPipeline] Skipping empty transcript for $filename', + '[VoiceNoteAiPipeline] Skipping empty/no-speech transcript for $filename', ); return false; } @@ -152,6 +152,19 @@ class VoiceNoteAiPipeline { '${result.actions.length} actions', ); + // Router or LLM returned nothing useful — treat as no speech. + if (result.summary.trim().isEmpty && result.actions.isEmpty) { + debugPrint( + '[VoiceNoteAiPipeline] No meaningful content for $filename, ' + 'marking as failed', + ); + await _memoRepository.updateProcessingStatus( + filename: filename, + status: 'failed', + ); + return false; + } + // If the LLM corrected the transcription, update the transcript as well if (result.correctedTranscription != null && result.correctedTranscription!.isNotEmpty) { @@ -184,18 +197,26 @@ class VoiceNoteAiPipeline { dueDate: _tryParseDate(action.dueDate), startTime: _tryParseDate(action.startTime), location: action.location, + durationSeconds: action.durationSeconds, ); } - // Notify watch with round-trip confirmation toast - final firstAction = result.actions.isNotEmpty - ? result.actions.first - : null; - final actionDatetime = firstAction?.startTime ?? firstAction?.dueDate; + // Notify watch with round-trip confirmation toast. + // Skip sending notes (tasks without time) — they're app-internal only. + // Find the first actionable item (not a plain note) across all actions. + ExtractedActionResult? actionableAction; + for (final a in result.actions) { + if (!(a.type == 'task' && a.startTime == null && a.dueDate == null)) { + actionableAction = a; + break; + } + } + final actionDatetime = + actionableAction?.startTime ?? actionableAction?.dueDate; onProcessingComplete?.call( filename, result.summary, - firstAction?.type, + actionableAction?.type, actionDatetime, ); @@ -254,6 +275,12 @@ class VoiceNoteAiPipeline { /// Process all transcribed but unprocessed memos Future processAllUnprocessed() async { + // Reset any memos stuck in intermediate states from a previous crash. + final reset = await _memoRepository.resetStuckProcessingMemos(); + if (reset > 0) { + debugPrint('[VoiceNoteAiPipeline] Reset $reset stuck processing memo(s)'); + } + final unprocessed = await _memoRepository.getUnprocessedMemos(); if (unprocessed.isEmpty) { debugPrint('[VoiceNoteAiPipeline] No unprocessed memos'); @@ -288,11 +315,48 @@ class VoiceNoteAiPipeline { return ExtractedActionType.calendarEvent; case 'reminder': return ExtractedActionType.reminder; + case 'timer': + return ExtractedActionType.timer; + case 'alarm': + return ExtractedActionType.alarm; default: return ExtractedActionType.task; } } + /// Detects common Whisper no-speech / hallucination patterns so we can skip + /// LLM inference entirely. + @visibleForTesting + static bool isNoSpeechTranscript(String transcript) { + final t = transcript.trim(); + + final lower = t.toLowerCase(); + + // Whisper explicit no-speech markers + const noSpeechMarkers = [ + '[blank_audio]', + '[no speech]', + '', + '(no speech)', + '[silence]', + '<|nospeech|>', + '[music]', + '(music)', + ]; + for (final marker in noSpeechMarkers) { + if (lower.contains(marker)) return true; + } + + // Pure punctuation / music symbols / whitespace + final stripped = t.replaceAll( + RegExp(r'[\s\p{P}\p{S}]+', unicode: true), + '', + ); + if (stripped.isEmpty) return true; + + return false; + } + DateTime? _tryParseDate(String? value) { if (value == null || value.trim().isEmpty) return null; try { diff --git a/zswatch_app/lib/ui/navigation/app_router.dart b/zswatch_app/lib/ui/navigation/app_router.dart index cffb0a3..3f50f97 100644 --- a/zswatch_app/lib/ui/navigation/app_router.dart +++ b/zswatch_app/lib/ui/navigation/app_router.dart @@ -26,6 +26,7 @@ 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/alarms_timers_screen.dart'; import '../screens/voice_memos/voice_memos_screen.dart'; /// Route names for the app @@ -60,8 +61,9 @@ abstract final class AppRoutes { // Crash report static const String crashReport = '/crash-report'; - // Voice routes (placeholder) + // Voice routes static const String voiceMemos = '/voice-memos'; + static const String alarmsTimers = '/voice-memos/alarms-timers'; static String voiceMemoDetail(int id) => '$voiceMemos/$id'; } @@ -199,6 +201,11 @@ class AppRouter { name: 'voice-memos', builder: (context, state) => const VoiceMemosScreen(), routes: [ + GoRoute( + path: 'alarms-timers', + name: 'alarms-timers', + builder: (context, state) => const AlarmsTimersScreen(), + ), GoRoute( path: ':memoId', name: 'voice-memo-detail', 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 index 5a3978d..f04ad24 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -14,6 +14,7 @@ import '../../../providers/ai_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../services/ai/ai_debug_info.dart'; +import '../../../data/models/extracted_action.dart'; import '../../../services/ai/extracted_action_creation_service.dart'; import '../../../services/ai/llm_service.dart'; import '../../../services/ai/model_benchmark_service.dart'; @@ -105,6 +106,19 @@ class AiModelsSettingsScreen extends ConsumerWidget { subtitle: 'Test model performance on your device', ), const _BenchmarkSection(), + + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Timer / Alarm test section ---- + const _SectionHeader( + title: 'Timer & Alarm Test', + subtitle: + 'Test creating timers and alarms via the system clock app ' + 'without connecting a watch.', + ), + const _TimerAlarmTestSection(), ], ), ); @@ -1663,6 +1677,96 @@ class _AiBenchmarkInputEditor extends StatelessWidget { } /// Compact tile shown in the benchmark section after a completed run. +class _TimerAlarmTestSection extends ConsumerStatefulWidget { + const _TimerAlarmTestSection(); + + @override + ConsumerState<_TimerAlarmTestSection> createState() => + _TimerAlarmTestSectionState(); +} + +class _TimerAlarmTestSectionState + extends ConsumerState<_TimerAlarmTestSection> { + bool _busy = false; + + Future _testTimer() async { + setState(() => _busy = true); + try { + const service = ExtractedActionCreationService(); + const draft = ActionCreationDraft( + actionType: ExtractedActionType.timer, + title: 'Test timer', + durationSeconds: 30, + skipUi: false, + ); + final created = await service.createDraft(draft); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(created.successMessage))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Timer failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _testAlarm() async { + setState(() => _busy = true); + try { + const service = ExtractedActionCreationService(); + final alarmTime = DateTime.now().add(const Duration(minutes: 5)); + final draft = ActionCreationDraft( + actionType: ExtractedActionType.alarm, + title: 'Test alarm', + scheduledAt: alarmTime, + skipUi: false, + ); + final created = await service.createDraft(draft); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(created.successMessage))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Alarm failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _busy ? null : _testTimer, + icon: const Icon(Icons.timer, size: 18), + label: const Text('Timer 30s'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonalIcon( + onPressed: _busy ? null : _testAlarm, + icon: const Icon(Icons.alarm_add, size: 18), + label: const Text('Alarm +5min'), + ), + ), + ], + ), + ); + } +} + class _LastResultTile extends StatelessWidget { final AiDebugInfo progress; const _LastResultTile({required this.progress}); diff --git a/zswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dart new file mode 100644 index 0000000..38594b3 --- /dev/null +++ b/zswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dart @@ -0,0 +1,471 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/extracted_action.dart'; +import '../../../providers/ai_providers.dart'; +import '../../../providers/settings_providers.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; +import '../../navigation/app_router.dart'; + +/// Dedicated screen for managing alarm and timer extracted actions. +/// +/// On iOS these can't be handed off to the OS automatically, so users +/// need an explicit place to Set, Dismiss, or Delete them. +/// On Android the screen is still useful for reviewing what was created. +class AlarmsTimersScreen extends ConsumerWidget { + const AlarmsTimersScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final actionsAsync = ref.watch(alarmTimerActionsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Alarms & Timers')), + body: actionsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Error loading actions: $error', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + data: (actions) { + if (actions.isEmpty) { + return const _EmptyState(); + } + + final pending = actions + .where((a) => !a.created && !a.dismissed) + .toList(); + final completed = actions + .where((a) => a.created || a.dismissed) + .toList(); + + return ListView( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 0, + AppTheme.spacingMd, + AppTheme.spacingLg, + ), + children: [ + if (Platform.isIOS) + Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingSm, + bottom: AppTheme.spacingMd, + ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.elevatedSurfaceColor.withValues( + alpha: 0.88, + ), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + ), + ), + child: Text.rich( + const TextSpan( + children: [ + TextSpan( + text: 'Tip: ', + style: TextStyle( + fontWeight: FontWeight.w800, + color: AppTheme.textPrimary, + ), + ), + TextSpan( + text: + 'Pending items support Set, Dismiss, and Delete. ' + 'Delete removes the extracted action entirely.', + style: TextStyle(color: AppTheme.textSecondary), + ), + ], + ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(height: 1.5), + ), + ), + ), + + // Pending section + if (pending.isNotEmpty) ...[ + const _SectionHeader(label: 'Pending from notes'), + for (final action in pending) + _AlarmTimerCard(key: ValueKey(action.id), action: action), + ], + + // Completed section + if (completed.isNotEmpty) ...[ + const _SectionHeader(label: 'Completed'), + for (final action in completed) + Opacity( + opacity: 0.78, + child: _AlarmTimerCard( + key: ValueKey(action.id), + action: action, + ), + ), + ], + + // Manual creation FABs + const SizedBox(height: 18), + ], + ); + }, + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String label; + const _SectionHeader({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 10), + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + ); + } +} + +class _AlarmTimerCard extends ConsumerStatefulWidget { + final ExtractedAction action; + const _AlarmTimerCard({super.key, required this.action}); + + @override + ConsumerState<_AlarmTimerCard> createState() => _AlarmTimerCardState(); +} + +class _AlarmTimerCardState extends ConsumerState<_AlarmTimerCard> { + bool _isCreating = false; + bool _isDismissing = false; + bool _isDeleting = false; + bool _isOpening = false; + + ExtractedAction get action => widget.action; + + @override + Widget build(BuildContext context) { + final isPending = !action.created && !action.dismissed; + + return Card( + margin: const EdgeInsets.only(bottom: 10), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top row: time + details + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Time display + SizedBox( + width: 58, + child: Text( + _timeDisplay(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ), + const SizedBox(width: 10), + // Title + description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + action.title.isNotEmpty + ? action.title + : (action.actionType == ExtractedActionType.alarm + ? 'Alarm' + : 'Timer'), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (action.notes != null && + action.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + action.notes!.trim(), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + ], + // Source note link + const SizedBox(height: 6), + GestureDetector( + onTap: () => context.push( + AppRoutes.voiceMemoDetail(action.memoId), + ), + child: Text( + 'Open source note', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: AppTheme.infoColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ], + ), + + // Action buttons + const SizedBox(height: 12), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (isPending) ...[ + FilledButton( + style: _compactFilledStyle(), + onPressed: _isCreating ? null : _createAction, + child: Text(_isCreating ? 'Setting...' : 'Set'), + ), + OutlinedButton( + style: _compactWarnStyle(), + onPressed: _isDismissing ? null : _dismissAction, + child: Text(_isDismissing ? 'Dismissing...' : 'Dismiss'), + ), + ], + if (action.created && action.platformTargetId != null) + OutlinedButton( + style: _compactOutlinedStyle(), + onPressed: _isOpening ? null : _openCreatedAction, + child: const Text('Open'), + ), + // Delete is always available + OutlinedButton( + style: _compactDangerStyle(), + onPressed: _isDeleting ? null : _deleteAction, + child: Text(_isDeleting ? 'Deleting...' : 'Delete'), + ), + ], + ), + ], + ), + ), + ); + } + + String _timeDisplay() { + if (action.actionType == ExtractedActionType.timer) { + final d = action.durationSeconds ?? 0; + final h = d ~/ 3600; + final m = (d % 3600) ~/ 60; + final s = d % 60; + if (h > 0) return '${h}h${m}m'; + if (m > 0 && s > 0) return '${m}m${s}s'; + if (m > 0) return '${m}m'; + return '${s}s'; + } + // Alarm — show time + final time = action.startTime; + if (time != null) { + return DateFormat.Hm().format(time.toLocal()); + } + return '--:--'; + } + + 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 set: $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: $error'))); + } finally { + if (mounted) setState(() => _isDismissing = false); + } + } + + Future _deleteAction() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete action?'), + content: const Text( + 'This will permanently remove the extracted action.', + ), + 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'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + setState(() => _isDeleting = true); + try { + await ref.read(extractedActionOperationsProvider).deleteAction(action.id); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); + } finally { + if (mounted) setState(() => _isDeleting = 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: $error'))); + } finally { + if (mounted) setState(() => _isOpening = false); + } + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.alarm_outlined, size: 64, color: Colors.grey.shade600), + const SizedBox(height: AppTheme.spacingMd), + Text( + 'No alarms or timers', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey.shade500), + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + 'Alarms and timers extracted from your voice notes\nwill appear here.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +// ─── Button styles ─────────────────────────────────────────────────────── + +ButtonStyle _compactFilledStyle() { + return FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ); +} + +ButtonStyle _compactOutlinedStyle() { + return OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ); +} + +ButtonStyle _compactWarnStyle() { + return OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + side: BorderSide(color: AppTheme.primaryColor.withValues(alpha: 0.18)), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ); +} + +ButtonStyle _compactDangerStyle() { + return OutlinedButton.styleFrom( + foregroundColor: AppTheme.errorColor, + side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.18)), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ); +} 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 index 0443f6b..4b22804 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -22,7 +22,16 @@ 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. +// ═══════════════════════════════════════════════════════════════════════════ +// Filter enum for list view +// ═══════════════════════════════════════════════════════════════════════════ + +enum _NoteFilter { all, notes, tasks, reminders, timersAlarms, archived } + +// ═══════════════════════════════════════════════════════════════════════════ +// LIST VIEW +// ═══════════════════════════════════════════════════════════════════════════ + class VoiceMemosScreen extends ConsumerStatefulWidget { const VoiceMemosScreen({super.key}); @@ -33,6 +42,7 @@ class VoiceMemosScreen extends ConsumerStatefulWidget { class _VoiceMemosScreenState extends ConsumerState { late final TextEditingController _searchController; String _query = ''; + _NoteFilter _filter = _NoteFilter.notes; @override void initState() { @@ -64,6 +74,7 @@ class _VoiceMemosScreenState extends ConsumerState { } void _openMemo(VoiceMemo memo) { + FocusManager.instance.primaryFocus?.unfocus(); context.push(AppRoutes.voiceMemoDetail(memo.id), extra: memo); } @@ -75,7 +86,8 @@ class _VoiceMemosScreenState extends ConsumerState { transcriptionConfiguredProvider, ); final isConnected = ref.watch(isWatchConnectedProvider); - + final actionTypesMap = + ref.watch(memoActionTypesMapProvider).valueOrNull ?? {}; return Scaffold( appBar: AppBar( title: const Text('Voice Notes'), @@ -178,18 +190,47 @@ class _VoiceMemosScreenState extends ConsumerState { loading: () => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(), ), - Padding( + + // Filter pills + SingleChildScrollView( + scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB( AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Row( + children: [ + for (final filter in _NoteFilter.values) + Padding( + padding: const EdgeInsets.only(right: 8), + child: _FilterPill( + label: _filterLabel(filter), + icon: _filterIcon(filter), + selected: _filter == filter, + onTap: () => setState(() => _filter = filter), + ), + ), + ], + ), + ), + + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB( AppTheme.spacingMd, + AppTheme.spacingSm, AppTheme.spacingMd, 0, ), child: TextField( controller: _searchController, + autofocus: false, textInputAction: TextInputAction.search, decoration: InputDecoration( - hintText: 'Search voice notes...', + hintText: + 'Search titles, transcript text, or extracted actions...', prefixIcon: const Icon(Icons.search), suffixIcon: _query.isEmpty ? null @@ -200,10 +241,63 @@ class _VoiceMemosScreenState extends ConsumerState { ), ), ), + + // Count + sort info + memosAsync.when( + data: (memos) { + final filtered = _applyFilters( + memos, + _filter, + _query, + actionTypesMap, + ); + final label = _filter == _NoteFilter.archived + ? '${filtered.length} archived' + : '${filtered.length} active notes'; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w700, + letterSpacing: 0.06, + ), + ), + Text( + 'Sorted by newest', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w700, + letterSpacing: 0.06, + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + + // Note list Expanded( child: memosAsync.when( data: (memos) { - final filteredMemos = _filterMemos(memos, _query); + final filteredMemos = _applyFilters( + memos, + _filter, + _query, + actionTypesMap, + ); return RefreshIndicator( onRefresh: () => @@ -213,6 +307,7 @@ class _VoiceMemosScreenState extends ConsumerState { : _VoiceMemoTimeline( memos: filteredMemos, onOpenMemo: _openMemo, + showArchiveSection: _filter == _NoteFilter.all, ), ); }, @@ -229,8 +324,80 @@ class _VoiceMemosScreenState extends ConsumerState { ), ); } + + String _filterLabel(_NoteFilter filter) => switch (filter) { + _NoteFilter.all => 'All', + _NoteFilter.notes => 'Notes', + _NoteFilter.tasks => 'Tasks', + _NoteFilter.reminders => 'Reminders', + _NoteFilter.timersAlarms => 'Alarms & Timers', + _NoteFilter.archived => 'Archived', + }; + + IconData _filterIcon(_NoteFilter filter) => switch (filter) { + _NoteFilter.all => Icons.all_inbox_outlined, + _NoteFilter.notes => Icons.note_outlined, + _NoteFilter.tasks => Icons.check_circle_outline, + _NoteFilter.reminders => Icons.notifications_outlined, + _NoteFilter.timersAlarms => Icons.alarm_outlined, + _NoteFilter.archived => Icons.archive_outlined, + }; +} + +class _FilterPill extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _FilterPill({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = selected ? AppTheme.primaryColor : AppTheme.textSecondary; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: selected + ? AppTheme.primaryColor.withValues(alpha: 0.14) + : AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected + ? AppTheme.primaryColor.withValues(alpha: 0.22) + : AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } } +// ═══════════════════════════════════════════════════════════════════════════ +// DETAIL VIEW +// ═══════════════════════════════════════════════════════════════════════════ + class VoiceMemoDetailScreen extends ConsumerStatefulWidget { final int memoId; final VoiceMemo? initialMemo; @@ -270,7 +437,71 @@ class _VoiceMemoDetailScreenState extends ConsumerState { final memoAsync = ref.watch(voiceMemoByIdProvider(widget.memoId)); return Scaffold( - appBar: AppBar(title: const Text('Voice Note')), + appBar: AppBar( + title: const Text('Voice Note'), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + final memo = memoAsync.valueOrNull ?? widget.initialMemo; + if (memo == null) return; + switch (value) { + case 'archive': + ref + .read(voiceMemoActionsProvider.notifier) + .setArchived(memo.filename, archived: !memo.archived); + case 'delete': + _deleteMemo(memo); + case 'sync': + ref.read(voiceMemoActionsProvider.notifier).sync(); + } + }, + itemBuilder: (context) { + final memo = memoAsync.valueOrNull ?? widget.initialMemo; + return [ + PopupMenuItem( + value: 'archive', + child: Row( + children: [ + Icon( + memo?.archived == true + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + const SizedBox(width: 8), + Text(memo?.archived == true ? 'Unarchive' : 'Archive'), + ], + ), + ), + if (ref.watch(isWatchConnectedProvider)) + const PopupMenuItem( + value: 'sync', + child: Row( + children: [ + Icon(Icons.sync), + SizedBox(width: 8), + Text('Sync'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, color: AppTheme.errorColor), + SizedBox(width: 8), + Text( + 'Delete', + style: TextStyle(color: AppTheme.errorColor), + ), + ], + ), + ), + ]; + }, + ), + ], + ), body: memoAsync.when( data: (memo) { final effectiveMemo = memo ?? widget.initialMemo; @@ -284,170 +515,71 @@ class _VoiceMemoDetailScreenState extends ConsumerState { } return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), - child: LayoutBuilder( - builder: (context, constraints) { - final showSideBySide = constraints.maxWidth >= 430; + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category badge + metadata line + _DetailMetaLine(memo: effectiveMemo), + const SizedBox(height: 12), - 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'), - ), - ], - ), - ), - ], - ); - }, + // Large title + Text( + memoTitleText(effectiveMemo), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.15, + ), + ), + const SizedBox(height: 10), + + // Transcript (editable inline) + _InlineTranscript( + memo: effectiveMemo, + controller: _transcriptController, + isEditing: _isEditing, + isSaving: _isSaving, + onToggleEdit: () { + setState(() { + _isEditing = !_isEditing; + if (!_isEditing) { + _transcriptController.text = currentTranscript; + } + }); + }, + onSave: () => _saveTranscript(effectiveMemo), + onCancel: () { + setState(() { + _isEditing = false; + _transcriptController.text = currentTranscript; + }); + }, + ), + + // Quick status chips + const SizedBox(height: 14), + _QuickStatusChips(memo: effectiveMemo), + + // Audio player + const SizedBox(height: 18), + _DetailAudioPlayer(memo: effectiveMemo), + + // Micro tools + const SizedBox(height: 14), + _MicroTools(memo: effectiveMemo), + + // Extracted Actions (at bottom) + const SizedBox(height: 20), + _ExtractedActionsSection(memo: effectiveMemo), + + // Bottom toolbar + const SizedBox(height: 18), + _BottomToolbar( + memo: effectiveMemo, + onDelete: () => _deleteMemo(effectiveMemo), + ), + ], ), ); }, @@ -500,181 +632,247 @@ class _VoiceMemoDetailScreenState extends ConsumerState { } } -class _AISummarySection extends ConsumerWidget { +// ─── Detail sub-widgets ────────────────────────────────────────────────── + +class _DetailMetaLine extends StatelessWidget { final VoiceMemo memo; + const _DetailMetaLine({required this.memo}); + + @override + Widget build(BuildContext context) { + final local = memo.timestampUtc.toLocal(); + final dateStr = DateFormat('MMMM d, HH:mm').format(local); + return Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (memo.aiCategory != null) _CategoryBadge(category: memo.aiCategory!), + Text( + '$dateStr · ${memo.formattedDuration} · ${memo.formattedSize}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ); + } +} - const _AISummarySection({required this.memo}); +/// Combined transcript display + inline edit. Replaces both _AISummaryLede +/// and _TranscriptSection so there's a single transcript area. +class _InlineTranscript extends ConsumerWidget { + final VoiceMemo memo; + final TextEditingController controller; + final bool isEditing; + final bool isSaving; + final VoidCallback onToggleEdit; + final VoidCallback onSave; + final VoidCallback onCancel; + + const _InlineTranscript({ + required this.memo, + required this.controller, + required this.isEditing, + required this.isSaving, + required this.onToggleEdit, + required this.onSave, + required this.onCancel, + }); @override Widget build(BuildContext context, WidgetRef ref) { final aiEnabled = ref.watch(localAiEnabledProvider); - final modelDownloadedAsync = ref.watch(llmModelDownloadedProvider); - - if (!aiEnabled) { - return const SizedBox.shrink(); + final isProcessing = memo.isAiProcessing; + final hasFailed = + memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; + final currentTranscript = memo.transcription ?? ''; + final hasTranscript = currentTranscript.trim().isNotEmpty; + + // AI processing status line (compact) + if (isProcessing) { + return _aiProcessingRow(context, ref); } - return modelDownloadedAsync.when( - data: (modelDownloaded) { - if (!modelDownloaded) { - return const SizedBox.shrink(); - } + if (hasFailed) { + return _aiFailedRow(context, ref); + } - final hasSummary = memo.summary != null && memo.summary!.isNotEmpty; - final hasCategory = memo.aiCategory != null; - final isProcessing = memo.isAiProcessing; - final hasFailed = - memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; + // Editing mode + if (isEditing) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + minLines: 4, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.65, + color: const Color(0xFFC5D0DA), + ), + decoration: const InputDecoration( + hintText: 'Edit transcript text...', + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: isSaving ? null : onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: AppTheme.spacingSm), + FilledButton( + style: _compactFilledButtonStyle(), + onPressed: isSaving ? null : onSave, + child: Text(isSaving ? 'Saving...' : 'Save'), + ), + ], + ), + ], + ); + } - 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, - ), - ), - ), - ], + // Normal display + if (!hasTranscript) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Transcription will appear here after sync and transcription finish.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.65, + color: AppTheme.textSecondary, ), - ); - } + ), + if (aiEnabled && memo.summary == null) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: null, + icon: const Icon(Icons.auto_awesome, size: 16), + label: const Text('Process with AI'), + ), + ], + ], + ); + } - 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'), - ), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + currentTranscript, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.65, + color: const Color(0xFFB6C0CA), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + GestureDetector( + onTap: onToggleEdit, + child: Text( + 'Edit', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w800, ), - 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, - ), + ), + ), + if (aiEnabled && memo.summary == null) ...[ + const SizedBox(width: 16), + GestureDetector( + onTap: () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + child: Text( + 'Process with AI', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w800, ), ), + ), ], - ), - ); - }, - 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, + Widget _aiProcessingRow(BuildContext context, WidgetRef ref) { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _showAiDebugDialog(context), + 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, + ), + ], + ), + ), + ); + } + + Widget _aiFailedRow(BuildContext context, WidgetRef ref) { + return Row( + children: [ + Text( + 'AI processing failed.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.errorColor), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Retry'), + ), + ], + ); + } + + void _showAiDebugDialog(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.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, builder: (context, scrollController) => _AiDebugSheet(memo: memo, scrollController: scrollController), ), @@ -682,6 +880,822 @@ class _AISummarySection extends ConsumerWidget { } } +class _QuickStatusChips extends StatelessWidget { + final VoiceMemo memo; + const _QuickStatusChips({required this.memo}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 6, + children: [ + if (memo.isAiProcessed) _statusChip(context, 'AI processed'), + if (memo.syncedFromWatch) _statusChip(context, 'Synced from watch'), + if (hasLocalAudio(memo)) _statusChip(context, 'Stored on phone'), + if (!memo.deletedOnWatch) _statusChip(context, 'On watch'), + if (memo.archived) _statusChip(context, 'Archived'), + ], + ); + } + + Widget _statusChip(BuildContext context, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _DetailAudioPlayer extends ConsumerWidget { + final VoiceMemo memo; + const _DetailAudioPlayer({required this.memo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (hasLocalAudio(memo)) { + return _AudioPlayerBar(memo: memo); + } + + return _SyncPromptCard(memo: memo); + } +} + +class _AudioPlayerBar extends ConsumerStatefulWidget { + final VoiceMemo memo; + const _AudioPlayerBar({required this.memo}); + + @override + ConsumerState<_AudioPlayerBar> createState() => _AudioPlayerBarState(); +} + +class _AudioPlayerBarState extends ConsumerState<_AudioPlayerBar> { + 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 Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.elevatedSurfaceColor.withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + // Play button + GestureDetector( + onTap: () { + if (_isPlaying) { + _player?.pause(); + } else { + _player?.play(); + } + }, + child: Container( + width: 42, + height: 42, + decoration: const BoxDecoration( + color: AppTheme.primaryColor, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: Colors.black, + size: 22, + ), + ), + ), + const SizedBox(width: 12), + // Slider + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + padding: EdgeInsets.zero, + 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())), + ), + ), + ), + const SizedBox(width: 8), + Text( + '${_formatDuration(_position)} / ${_formatDuration(_duration)}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ); + } +} + +class _MicroTools extends ConsumerWidget { + final VoiceMemo memo; + const _MicroTools({required this.memo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentTranscript = memo.transcription ?? ''; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MicroToolButton( + label: 'Copy', + icon: Icons.copy_outlined, + 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'), + ), + ); + }, + ), + const SizedBox(width: AppTheme.spacingSm), + _TranscribeButton(memo: memo, expand: false), + if (ref.watch(localAiEnabledProvider)) ...[ + const SizedBox(width: AppTheme.spacingSm), + _MicroToolButton( + label: 'Re-process', + icon: Icons.auto_awesome_outlined, + onPressed: currentTranscript.trim().isEmpty + ? null + : () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + ), + ], + const SizedBox(width: AppTheme.spacingSm), + _MicroToolButton( + label: 'Debug', + icon: Icons.bug_report_outlined, + onPressed: () { + 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 _MicroToolButton extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onPressed; + + const _MicroToolButton({required this.label, this.icon, this.onPressed}); + + @override + Widget build(BuildContext context) { + if (icon != null) { + return OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label), + ); + } + return OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: onPressed, + child: Text(label), + ); + } +} + +class _BottomToolbar extends ConsumerWidget { + final VoiceMemo memo; + final VoidCallback onDelete; + + const _BottomToolbar({required this.memo, required this.onDelete}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + padding: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + ), + ), + ), + child: Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.errorColor, + side: BorderSide( + color: AppTheme.errorColor.withValues(alpha: 0.18), + ), + 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, + ), + ), + onPressed: onDelete, + child: const Text('Delete'), + ), + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: () { + ref + .read(voiceMemoActionsProvider.notifier) + .setArchived(memo.filename, archived: !memo.archived); + }, + child: Text(memo.archived ? 'Unarchive' : 'Archive'), + ), + if (ref.watch(isWatchConnectedProvider) && !hasLocalAudio(memo)) + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: () => + ref.read(voiceMemoActionsProvider.notifier).sync(), + child: const Text('Sync'), + ), + ], + ), + ); + } +} + +class _DividerLabel extends StatelessWidget { + final String label; + + const _DividerLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 1, + color: AppTheme.textSecondary.withValues(alpha: 0.08), + ), + ), + ], + ); + } +} + +// ─── Extracted Actions in detail view ──────────────────────────────────── + +class _ExtractedActionsSection extends ConsumerStatefulWidget { + final VoiceMemo memo; + + const _ExtractedActionsSection({required this.memo}); + + @override + ConsumerState<_ExtractedActionsSection> createState() => + _ExtractedActionsSectionState(); +} + +class _ExtractedActionsSectionState + extends ConsumerState<_ExtractedActionsSection> { + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DividerLabel(label: 'Extracted actions · ${actions.length}'), + const SizedBox(height: 10), + for (final action in actions) ...[ + _ActionCard(action: action), + const SizedBox(height: 10), + ], + ], + ); + }, + ); + } +} + +class _ActionCard extends ConsumerStatefulWidget { + final ExtractedAction action; + + const _ActionCard({required this.action}); + + @override + ConsumerState<_ActionCard> createState() => _ActionCardState(); +} + +class _ActionCardState extends ConsumerState<_ActionCard> { + bool _isCreating = false; + bool _isDismissing = false; + bool _isOpening = false; + + ExtractedAction get action => widget.action; + + Color get _accentColor => switch (action.actionType) { + ExtractedActionType.calendarEvent => AppTheme.infoColor, + ExtractedActionType.reminder => AppTheme.warningColor, + ExtractedActionType.alarm => AppTheme.primaryColor, + ExtractedActionType.timer => Colors.teal, + ExtractedActionType.task => AppTheme.successColor, + }; + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AppTheme.surfaceColor.withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Left color bar + Container(width: 4, color: _accentColor), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues( + alpha: 0.06, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _actionTypeIcon(action.actionType), + size: 16, + color: _accentColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _actionDisplayTitle(action), + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + if (_timingLabel(action) case final timing?) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + timing, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ), + _ActionStatusBadge(action: action), + ], + ), + if (action.notes != null && + action.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + action.notes!.trim(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + // Action buttons + if (action.actionType != ExtractedActionType.task || + action.startTime != null || + action.dueDate != null || + action.durationSeconds != null) ...[ + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (!action.created && !action.dismissed) + FilledButton( + style: _compactFilledButtonStyle(), + onPressed: _isCreating ? null : _createAction, + child: Text( + _isCreating + ? 'Creating...' + : _createLabel(action), + ), + ), + if (!action.created && !action.dismissed) + OutlinedButton( + style: _compactOutlinedWarnStyle(), + onPressed: _isDismissing ? null : _dismissAction, + child: const Text('Dismiss'), + ), + if (action.created && action.platformTargetId != null) + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: _isOpening ? null : _openCreatedAction, + child: const Text('Open'), + ), + ], + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + String _createLabel(ExtractedAction action) { + if (Platform.isIOS && + (action.actionType == ExtractedActionType.alarm || + action.actionType == ExtractedActionType.timer)) { + return 'Set on iPhone'; + } + return 'Create'; + } + + 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); + } + } + } +} + +ButtonStyle _compactOutlinedWarnStyle() { + return OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + side: BorderSide(color: AppTheme.primaryColor.withValues(alpha: 0.18)), + 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), + ); +} + +IconData _actionTypeIcon(ExtractedActionType type) { + return switch (type) { + ExtractedActionType.task => Icons.check_box_outlined, + ExtractedActionType.reminder => Icons.alarm, + ExtractedActionType.calendarEvent => Icons.calendar_today, + ExtractedActionType.timer => Icons.timer, + ExtractedActionType.alarm => Icons.alarm_add, + }; +} + +String _actionDisplayTitle(ExtractedAction action) { + if (action.actionType == ExtractedActionType.timer) { + final d = action.durationSeconds ?? 0; + final h = d ~/ 3600; + final m = (d % 3600) ~/ 60; + final s = d % 60; + final parts = [ + if (h > 0) '${h}h', + if (m > 0) '${m}m', + if (s > 0 || (h == 0 && m == 0)) '${s}s', + ]; + final duration = parts.join(' '); + if (action.title.isNotEmpty) { + return 'Timer $duration — ${action.title}'; + } + return 'Timer $duration'; + } + if (action.actionType == ExtractedActionType.alarm) { + final time = action.startTime; + if (time != null) { + final t = time.toLocal(); + final formatted = DateFormat.jm().format(t); + if (action.title.isNotEmpty) { + return 'Alarm at $formatted — ${action.title}'; + } + return 'Alarm at $formatted'; + } + if (action.title.isNotEmpty) return 'Alarm — ${action.title}'; + return 'Alarm'; + } + return action.title; +} + +String? _timingLabel(ExtractedAction action) { + if (action.actionType == ExtractedActionType.timer || + action.actionType == ExtractedActionType.alarm) { + return null; + } + + 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 '${dateTimeFormat.format(start)} \u2192 ${dateTimeFormat.format(end)}'; + } + return 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) { + // For simple tasks with no timing info, there's nothing to "create" + // so don't show a misleading "Pending" badge. + final isActionable = + action.actionType != ExtractedActionType.task || + action.startTime != null || + action.dueDate != null || + action.durationSeconds != null; + + final (label, color) = switch ((action.created, action.dismissed)) { + (true, _) => ('Created', AppTheme.successColor), + (_, true) => ('Dismissed', AppTheme.textSecondary), + _ when isActionable => ('Pending', AppTheme.warningColor), + _ => (null, null), + }; + + if (label == null) return const SizedBox.shrink(); + + 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.w800, + fontSize: 10, + letterSpacing: 0.05, + ), + ), + ); + } +} + +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: 5), + decoration: BoxDecoration( + color: voiceNoteCategoryColor(category).withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + voiceNoteCategoryLabel(category).toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: voiceNoteCategoryColor(category), + fontWeight: FontWeight.w800, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + ); + } +} + +// ─── AI Debug Sheet ────────────────────────────────────────────────────── + class _AiDebugSheet extends ConsumerWidget { final VoiceMemo memo; final ScrollController scrollController; @@ -691,22 +1705,18 @@ class _AiDebugSheet extends ConsumerWidget { @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( @@ -760,7 +1770,6 @@ class _AiDebugSheet extends ConsumerWidget { 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) ...[ @@ -772,7 +1781,6 @@ class _AiDebugSheet extends ConsumerWidget { ), const SizedBox(height: 12), ], - // Only show the partial-response block once tokens are flowing if (debugInfo.phase != 'loading') _debugBlock( context, @@ -786,7 +1794,6 @@ class _AiDebugSheet extends ConsumerWidget { mono: debugInfo.phase == 'classifying', ), ] else ...[ - // --- Completed view --- _metricsRow(context, debugInfo), const SizedBox(height: 16), if (debugInfo.transcriptionResult != null && @@ -987,8 +1994,6 @@ class _AiDebugSheet extends ConsumerWidget { } Widget _metricsRow(BuildContext context, AiDebugInfo info) { - final theme = Theme.of(context); - return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -1000,9 +2005,9 @@ class _AiDebugSheet extends ConsumerWidget { children: [ Text( info.modelName, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( @@ -1135,595 +2140,218 @@ class _AiDebugSheet extends ConsumerWidget { 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, - ), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), ), - ); - break; - } - } - return spans; + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), + ), + ], + ); } - Widget _resultRow(BuildContext context, AiDebugInfo info) { + 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.check_circle_outline, + Icons.compare_arrows, size: 16, color: AppTheme.textSecondary, ), const SizedBox(width: 6), Text( - 'Parsed Result', + '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: 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: [ + const SizedBox(height: 4), Container( - margin: const EdgeInsets.only(top: 2), - padding: const EdgeInsets.all(6), + width: double.infinity, + padding: const EdgeInsets.all(10), 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: 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), + ), + ), + ], + ); } - Color _actionTypeColor(ExtractedActionType type) { - return switch (type) { - ExtractedActionType.task => AppTheme.primaryColor, - ExtractedActionType.reminder => AppTheme.warningColor, - ExtractedActionType.calendarEvent => AppTheme.infoColor, - }; + 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), + ), + ); } - String? _timingLabel(ExtractedAction action) { - final dateFormat = DateFormat.yMMMd(); - final dateTimeFormat = DateFormat.yMMMd().add_jm(); + List _computeWordDiffSpans( + List origWords, + List corrWords, + BuildContext context, + ) { + final n = origWords.length; + final m = corrWords.length; - 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)}'; + 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]; + } } - return 'When: ${dateTimeFormat.format(start)}'; } - if (action.dueDate != null) { - return 'Due: ${dateFormat.format(action.dueDate!.toLocal())}'; + 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--; + } } + final orderedOps = ops.reversed.toList(); - return null; + 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; } -} - -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, + 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!), + ), + ], ); } -} - -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), - ), + Widget _kvRow(BuildContext context, String key, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - voiceNoteCategoryIcon(category), - size: 16, - color: voiceNoteCategoryColor(category), + SizedBox( + width: 100, + child: Text( + key, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), ), - const SizedBox(width: 6), - Text( - voiceNoteCategoryLabel(category), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: voiceNoteCategoryColor(category), - fontWeight: FontWeight.w600, + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodySmall, ), ), ], @@ -1732,6 +2360,8 @@ class _CategoryBadge extends StatelessWidget { } } +// ─── List view shared widgets ──────────────────────────────────────────── + class _EmptyState extends StatelessWidget { final bool hasQuery; @@ -1743,7 +2373,7 @@ class _EmptyState extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( - height: MediaQuery.of(context).size.height * 0.55, + height: MediaQuery.of(context).size.height * 0.45, child: Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -1782,372 +2412,224 @@ class _EmptyState extends StatelessWidget { class _VoiceMemoTimeline extends ConsumerWidget { final List memos; final ValueChanged onOpenMemo; + final bool showArchiveSection; - const _VoiceMemoTimeline({required this.memos, required this.onOpenMemo}); + const _VoiceMemoTimeline({ + required this.memos, + required this.onOpenMemo, + this.showArchiveSection = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { - final sections = _groupMemosByDay(memos); + // Split archived and active + final active = memos.where((m) => !m.archived).toList(); + final archived = memos.where((m) => m.archived).toList(); + final activeSections = _groupMemosByDay(active); + final archivedSections = _groupMemosByDay(archived); return ListView( padding: const EdgeInsets.fromLTRB( AppTheme.spacingMd, - AppTheme.spacingMd, + AppTheme.spacingSm, AppTheme.spacingMd, AppTheme.spacingLg, ), children: [ - for (final section in sections) ...[ + for (final section in activeSections) ...[ 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), + section.label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), ), ), 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, - ), + _MemoCardWithActionCount( + memo: memo, + onOpen: () => onOpenMemo(memo), + ), ], - ), - ); - } -} - -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), + // Archived section (only if showing all or explicit archived filter) + if (showArchiveSection && archived.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingMd, + bottom: AppTheme.spacingSm, ), - if (memo.syncedFromWatch) - const VoiceMemoMetaChip( - icon: Icons.smartphone_outlined, - label: 'Synced', - color: AppTheme.primaryColor, + child: Text( + 'ARCHIVED', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, ), - if (!memo.deletedOnWatch) - const VoiceMemoMetaChip( - icon: Icons.watch_outlined, - label: 'Still on watch', - color: AppTheme.warningColor, + ), + ), + for (final section in archivedSections) ...[ + for (final memo in section.memos) + Opacity( + opacity: 0.88, + child: _MemoCardWithActionCount( + memo: memo, + onOpen: () => onOpenMemo(memo), + ), ), ], - ), - ], - ); - } -} - -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, - ), + ], + // When showing archived filter only, show them without opacity change + if (!showArchiveSection) + for (final section in _groupMemosByDay( + memos.where((m) => m.archived).toList(), + )) ...[ + Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingSm, + bottom: AppTheme.spacingSm, + ), + child: Text( + section.label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, ), - if (trailing != null) trailing!, - ], + ), ), - const SizedBox(height: 10), - child, + for (final memo in section.memos) + _MemoCardWithActionCount( + memo: memo, + onOpen: () => onOpenMemo(memo), + ), ], - ), - ), + ], ); } } -class _AudioPlayerCard extends ConsumerStatefulWidget { +/// Wrapper that watches the action count for a memo before rendering the card. +class _MemoCardWithActionCount extends ConsumerWidget { final VoiceMemo memo; - final bool compact; - final bool alignRight; + final VoidCallback onOpen; - const _AudioPlayerCard({ - required this.memo, - this.compact = false, - this.alignRight = false, - }); + const _MemoCardWithActionCount({required this.memo, required this.onOpen}); @override - ConsumerState<_AudioPlayerCard> createState() => _AudioPlayerCardState(); + Widget build(BuildContext context, WidgetRef ref) { + final actionsAsync = ref.watch(extractedActionsForMemoProvider(memo.id)); + final actions = actionsAsync.valueOrNull ?? []; + final actionCount = actions.length; + final actionTypes = actions.map((a) => a.actionType).toSet(); + + return VoiceNoteCard( + memo: memo, + onOpen: onOpen, + extractedActionCount: actionCount, + actionTypes: actionTypes, + ); + } } -class _AudioPlayerCardState extends ConsumerState<_AudioPlayerCard> { - AudioPlayer? _player; - Duration _position = Duration.zero; - Duration _duration = Duration.zero; - bool _isPlaying = false; - String? _error; +class _MissingNoteState extends StatelessWidget { + const _MissingNoteState(); @override - void initState() { - super.initState(); - _initPlayer(); + 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, + ), + ], + ), + ); } +} - 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'); - } - } - } +class _SyncPromptCard extends ConsumerWidget { + final VoiceMemo memo; - @override - void dispose() { - _player?.dispose(); - super.dispose(); - } + const _SyncPromptCard({required this.memo}); @override - Widget build(BuildContext context) { - if (_error != null) { - return Text(_error!, style: const TextStyle(color: AppTheme.errorColor)); - } + Widget build(BuildContext context, WidgetRef ref) { + final isConnected = ref.watch(isWatchConnectedProvider); + final syncStateAsync = ref.watch(voiceMemoSyncStateProvider); - 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, + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.orange.withValues(alpha: 0.15)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.cloud_download_outlined, color: Colors.orange), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + 'This note is still on the watch. Sync to enable playback.', + style: Theme.of(context).textTheme.bodySmall, ), - 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())), + ], + ), + const SizedBox(height: 10), + syncStateAsync.when( + data: (state) { + if (!state.isSyncing) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + children: [ + const LinearProgressIndicator(), + const SizedBox(height: 4), + Text( + 'Syncing in progress...', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ), - ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: isConnected + ? () => ref.read(voiceMemoActionsProvider.notifier).sync() + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + if (!isConnected) ...[ + const SizedBox(height: 6), Text( - _formatDuration(_duration), - style: Theme.of(context).textTheme.bodySmall, + 'Connect to your watch to sync this note.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ], - ), - ], + ], + ), ); } } @@ -2171,7 +2653,7 @@ class _TranscribeButton extends ConsumerWidget { child: OutlinedButton.icon( style: _compactOutlinedButtonStyle(), icon: const Icon(Icons.settings, size: 18), - label: const Text('Set up transcription model'), + label: const Text('Set up transcription'), onPressed: () => context.push(AppRoutes.settings), ), ); @@ -2222,147 +2704,7 @@ class _TranscribeButton extends ConsumerWidget { ); }, 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, - ), - ), - ], - ], + error: (_, _) => const SizedBox.shrink(), ); } } @@ -2383,6 +2725,8 @@ class _ButtonBox extends StatelessWidget { } } +// ─── Shared helpers ────────────────────────────────────────────────────── + class _VoiceMemoTimelineSection { final String label; final List memos; @@ -2390,12 +2734,52 @@ class _VoiceMemoTimelineSection { const _VoiceMemoTimelineSection({required this.label, required this.memos}); } -List _filterMemos(List memos, String query) { - if (query.isEmpty) { - return memos; +List _applyFilters( + List memos, + _NoteFilter filter, + String query, + Map> actionTypesMap, +) { + var result = memos; + + // Apply filter + result = switch (filter) { + _NoteFilter.all => result.where((m) => !m.archived).toList(), + _NoteFilter.notes => result.where((m) { + if (m.archived) return false; + // Exclude memos that have any reminder/timer/alarm actions + final types = actionTypesMap[m.id]; + if (types != null && + types.any((t) => t == 'alarm' || t == 'timer' || t == 'reminder')) { + return false; + } + return true; + }).toList(), + _NoteFilter.tasks => result.where((m) { + if (m.archived) return false; + final types = actionTypesMap[m.id]; + return types != null && + types.any((t) => t == 'task' || t == 'calendar_event'); + }).toList(), + _NoteFilter.reminders => result.where((m) { + if (m.archived) return false; + final types = actionTypesMap[m.id]; + return types != null && types.contains('reminder'); + }).toList(), + _NoteFilter.timersAlarms => result.where((m) { + if (m.archived) return false; + final types = actionTypesMap[m.id]; + return types != null && types.any((t) => t == 'alarm' || t == 'timer'); + }).toList(), + _NoteFilter.archived => result.where((m) => m.archived).toList(), + }; + + // Apply search query + if (query.isNotEmpty) { + result = result.where((memo) => _matchesQuery(memo, query)).toList(); } - return memos.where((memo) => _matchesQuery(memo, query)).toList(); + return result; } List<_VoiceMemoTimelineSection> _groupMemosByDay(List memos) { diff --git a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart index 5ad5b4e..edad821 100644 --- a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart +++ b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart @@ -217,6 +217,15 @@ String aiFormatChronoDetails({ String show(String? value) => (value != null && value.trim().isNotEmpty) ? value.trim() : 'null'; + String formatDuration(int? seconds) { + if (seconds == null) return 'null'; + final m = seconds ~/ 60; + final s = seconds % 60; + if (m > 0 && s > 0) return '${m}m ${s}s (${seconds}s)'; + if (m > 0) return '${m}m (${seconds}s)'; + return '${seconds}s'; + } + // When multiple actions are available, show all of them. if (extractedActions.length > 1) { final buf = StringBuffer(); @@ -226,19 +235,31 @@ String aiFormatChronoDetails({ 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)}'); + if (a.intent == 'timer') { + buf.write('Duration: ${formatDuration(a.durationSeconds)}'); + } else { + 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' + final intent = a?.intent ?? extractedIntent; + if (intent == 'timer') { + return 'Intent: timer\n' + 'Title: ${show(a?.title ?? extractedTitle)}\n' + 'Duration: ${formatDuration(a?.durationSeconds)}'; + } + return 'Intent: ${show(intent)}\n' 'Title: ${show(a?.title ?? extractedTitle)}\n' 'Original time phrase: ${show(a?.datetimeExpressionOriginal ?? datetimeExpressionOriginal)}\n' 'English time phrase: ${show(a?.datetimeExpressionEnglish ?? datetimeExpressionEnglish)}\n' 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 index a10fcc7..c72fc97 100644 --- a/zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart +++ b/zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart @@ -5,27 +5,50 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../data/models/extracted_action.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. +/// +/// Swipe left to delete, swipe right to archive/unarchive. class VoiceNoteCard extends ConsumerWidget { final VoiceMemo memo; final VoidCallback onOpen; + final int extractedActionCount; + final Set actionTypes; - const VoiceNoteCard({super.key, required this.memo, required this.onOpen}); + const VoiceNoteCard({ + super.key, + required this.memo, + required this.onOpen, + this.extractedActionCount = 0, + this.actionTypes = const {}, + }); @override Widget build(BuildContext context, WidgetRef ref) { final previewText = memoPreviewText(memo); final canPlay = hasLocalAudio(memo); - final isProcessing = memo.isAiProcessing; + final titleText = memoTitleText(memo); return Dismissible( key: ValueKey('voice-note-${memo.id}'), - direction: DismissDirection.endToStart, background: Container( - margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + margin: const EdgeInsets.only(bottom: AppTheme.spacingSm), + decoration: BoxDecoration( + color: memo.archived ? AppTheme.primaryColor : AppTheme.warningColor, + borderRadius: BorderRadius.circular(AppTheme.radiusLarge), + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: AppTheme.spacingLg), + child: Icon( + memo.archived ? Icons.unarchive_outlined : Icons.archive_outlined, + color: Colors.white, + ), + ), + secondaryBackground: Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingSm), decoration: BoxDecoration( color: AppTheme.errorColor, borderRadius: BorderRadius.circular(AppTheme.radiusLarge), @@ -34,149 +57,135 @@ class VoiceNoteCard extends ConsumerWidget { padding: const EdgeInsets.only(right: AppTheme.spacingLg), child: const Icon(Icons.delete_outline, color: Colors.white), ), - confirmDismiss: (_) => confirmDeleteMemo(context, memo), + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + return confirmDeleteMemo(context, memo); + } + // Archive/unarchive — no confirmation needed + await ref + .read(voiceMemoActionsProvider.notifier) + .setArchived(memo.filename, archived: !memo.archived); + return false; // Don't remove the widget, the stream will update + }, onDismissed: (_) { ref.read(voiceMemoActionsProvider.notifier).delete(memo.filename); }, child: Card( - margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + margin: const EdgeInsets.only(bottom: AppTheme.spacingSm), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), + padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Top row: category icon + title Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (memo.aiCategory != null) - Padding( - padding: const EdgeInsets.only( - right: AppTheme.spacingSm, - ), - child: Icon( - voiceNoteCategoryIcon(memo.aiCategory!), - size: 20, - color: voiceNoteCategoryColor(memo.aiCategory!), - ), - ), + _CategoryIcon( + category: memo.aiCategory, + actionTypes: actionTypes, + ), + const SizedBox(width: 10), Expanded( - child: Text( - timelineTimestampLabel(memo.timestampUtc.toLocal()), - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.textSecondary, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + fontWeight: FontWeight.w800, + height: 1.3, + ), + ), + const SizedBox(height: 3), + Text( + timelineTimestampLabel(memo.timestampUtc.toLocal()), + style: Theme.of(context).textTheme.bodySmall + ?.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, + + // Preview text + if (previewText != titleText) ...[ + const SizedBox(height: 8), + Text( + previewText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.5, + color: const Color(0xFFB6C0CA), + ), ), - ), - const SizedBox(height: AppTheme.spacingSm), - Wrap( - spacing: AppTheme.spacingSm, - runSpacing: AppTheme.spacingSm, + ], + + // Footer: tags + play icon + const SizedBox(height: 10), + Row( 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, - ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _NoteTag(label: memo.formattedDuration), + if (extractedActionCount > 0) + _NoteTag( + label: '$extractedActionCount extracted', + color: AppTheme.primaryColor, + filled: true, + ), + if (memo.syncedFromWatch) + const _NoteTag( + label: 'synced', + color: AppTheme.successColor, + filled: true, + ), + if (!memo.deletedOnWatch) + const _NoteTag( + label: 'on watch', + color: AppTheme.infoColor, + filled: true, ), - const SizedBox(width: 4), - Text( - 'Processing', - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.w600, - fontSize: 10.5, - ), + if (memo.archived) + const _NoteTag( + label: 'archived', + color: AppTheme.textSecondary, + filled: true, ), - ], - ), + if (!memo.syncedFromWatch && + memo.transcription == null) + const _NoteTag( + label: 'phone only', + color: AppTheme.textSecondary, + filled: true, + ), + if (memo.isAiProcessing) + const _NoteTag( + label: 'processing', + color: AppTheme.primaryColor, + filled: true, + showSpinner: true, + ), + ], ), - VoiceMemoMetaChip( - icon: syncStatusIcon(memo.syncStatus), - label: syncStatusLabel(memo), - color: syncStatusColor(memo.syncStatus), ), - if (memo.syncedFromWatch) - const VoiceMemoMetaChip( - icon: Icons.smartphone_outlined, - label: 'Phone', + if (canPlay) + const Icon( + Icons.play_circle_fill_rounded, color: AppTheme.primaryColor, + size: 22, ), - 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, - ), ], ), ], @@ -188,6 +197,118 @@ class VoiceNoteCard extends ConsumerWidget { } } +class _CategoryIcon extends StatelessWidget { + final VoiceNoteCategory? category; + final Set actionTypes; + + const _CategoryIcon({this.category, this.actionTypes = const {}}); + + @override + Widget build(BuildContext context) { + final resolved = _resolveIconAndColor(); + + return Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: resolved.bgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(resolved.icon, size: 18, color: resolved.color), + ); + } + + ({IconData icon, Color color, Color bgColor}) _resolveIconAndColor() { + // Action types take priority for timer/alarm since VoiceNoteCategory + // doesn't have those values + if (actionTypes.contains(ExtractedActionType.alarm) || + actionTypes.contains(ExtractedActionType.timer)) { + return ( + icon: Icons.alarm_outlined, + color: AppTheme.warningColor, + bgColor: AppTheme.warningColor.withValues(alpha: 0.14), + ); + } + if (category != null) { + return ( + icon: voiceNoteCategoryIcon(category!), + color: voiceNoteCategoryColor(category!), + bgColor: _categoryBgColor(category!), + ); + } + return ( + icon: Icons.mic_none_rounded, + color: AppTheme.textSecondary, + bgColor: AppTheme.textSecondary.withValues(alpha: 0.08), + ); + } + + Color _categoryBgColor(VoiceNoteCategory cat) { + return switch (cat) { + VoiceNoteCategory.idea => AppTheme.primaryColor.withValues(alpha: 0.14), + VoiceNoteCategory.meeting => AppTheme.infoColor.withValues(alpha: 0.14), + VoiceNoteCategory.task => AppTheme.successColor.withValues(alpha: 0.14), + VoiceNoteCategory.reminder => AppTheme.warningColor.withValues( + alpha: 0.14, + ), + VoiceNoteCategory.note => AppTheme.textSecondary.withValues(alpha: 0.08), + }; + } +} + +class _NoteTag extends StatelessWidget { + final String label; + final Color? color; + final bool filled; + final bool showSpinner; + + const _NoteTag({ + required this.label, + this.color, + this.filled = false, + this.showSpinner = false, + }); + + @override + Widget build(BuildContext context) { + final tagColor = color ?? AppTheme.textSecondary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: filled + ? tagColor.withValues(alpha: 0.12) + : AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showSpinner) ...[ + SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: tagColor, + ), + ), + const SizedBox(width: 4), + ], + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: filled ? tagColor : AppTheme.textSecondary, + fontWeight: FontWeight.w800, + fontSize: 10, + letterSpacing: 0.04, + ), + ), + ], + ), + ); + } +} + /// Small icon+label chip used throughout the voice memo UI. class VoiceMemoMetaChip extends StatelessWidget { final IconData icon; @@ -230,9 +351,78 @@ class VoiceMemoMetaChip extends StatelessWidget { // ── Shared helpers ────────────────────────────────────────────────────────── +/// Clean up AI summary text that may contain raw JSON from a previous run. +String cleanSummary(String summary) { + final trimmed = summary.trim(); + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + // Attempt to extract a readable title from JSON + final titleMatch = RegExp(r'"title"\s*:\s*"([^"]*)"').firstMatch(trimmed); + final intentMatch = RegExp(r'"intent"\s*:\s*"([^"]*)"').firstMatch(trimmed); + final intent = intentMatch?.group(1); + final title = titleMatch?.group(1); + + if (intent == 'timer') { + final durMatch = RegExp( + r'"duration_seconds"\s*:\s*(\d+)', + ).firstMatch(trimmed); + final d = int.tryParse(durMatch?.group(1) ?? '') ?? 0; + final h = d ~/ 3600; + final m = (d % 3600) ~/ 60; + final s = d % 60; + final parts = [ + if (h > 0) '${h}h', + if (m > 0) '${m}m', + if (s > 0 || (h == 0 && m == 0)) '${s}s', + ]; + final dur = parts.join(' '); + return (title != null && title.isNotEmpty) + ? 'Timer $dur — $title' + : 'Timer $dur'; + } + if (intent == 'alarm') { + final expr = RegExp( + r'"datetime_expression_english"\s*:\s*"([^"]*)"', + ).firstMatch(trimmed)?.group(1); + if (expr != null && expr.isNotEmpty) { + return (title != null && title.isNotEmpty) + ? 'Alarm $expr — $title' + : 'Alarm $expr'; + } + return (title != null && title.isNotEmpty) ? 'Alarm — $title' : 'Alarm'; + } + if (title != null && title.isNotEmpty) return title; + } + return trimmed; +} + +/// Extract a title from the memo — prefer AI summary, fall back to transcript. +String memoTitleText(VoiceMemo memo) { + final aiSummary = memo.summary?.trim(); + if (aiSummary != null && aiSummary.isNotEmpty) { + return cleanSummary(aiSummary); + } + + final transcript = memo.transcription?.trim(); + if (transcript != null && transcript.isNotEmpty) { + // Use first sentence or first 80 chars as title + final firstLine = transcript.split('\n').first; + final periodIdx = firstLine.indexOf('. '); + if (periodIdx > 0 && periodIdx < 80) { + return firstLine.substring(0, periodIdx + 1); + } + if (firstLine.length > 80) return '${firstLine.substring(0, 77)}...'; + return firstLine; + } + + if (memo.syncedFromWatch) return 'Audio synced — transcription pending'; + return 'On watch only — sync to download'; +} + String memoPreviewText(VoiceMemo memo) { final aiSummary = memo.summary?.trim(); - if (aiSummary != null && aiSummary.isNotEmpty) return aiSummary; + if (aiSummary != null && aiSummary.isNotEmpty) { + return cleanSummary(aiSummary); + } final transcript = memo.transcription?.trim(); if (transcript != null && transcript.isNotEmpty) { diff --git a/zswatch_app/test/no_speech_filter_test.dart b/zswatch_app/test/no_speech_filter_test.dart new file mode 100644 index 0000000..4c41395 --- /dev/null +++ b/zswatch_app/test/no_speech_filter_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zswatch_app/services/ai/voice_note_ai_pipeline.dart'; + +void main() { + group('isNoSpeechTranscript', () { + // ── Should be detected as no-speech ─────────────────────────────── + + group('detects Whisper silence markers', () { + for (final marker in [ + '[BLANK_AUDIO]', + '[blank_audio]', + '[NO SPEECH]', + '', + '(no speech)', + '[silence]', + '<|nospeech|>', + '[music]', + '(music)', + ]) { + test('"$marker"', () { + expect(VoiceNoteAiPipeline.isNoSpeechTranscript(marker), isTrue); + }); + } + }); + + test('detects marker embedded in text', () { + expect( + VoiceNoteAiPipeline.isNoSpeechTranscript( + 'some text [BLANK_AUDIO] more', + ), + isTrue, + ); + }); + + group('detects pure punctuation / symbols', () { + for (final input in [ + '...', + '. . .', + '♪ ♪ ♪', + '♪♪♪', + ', , ,', + '---', + ' ... ', + ]) { + test('"$input"', () { + expect(VoiceNoteAiPipeline.isNoSpeechTranscript(input), isTrue); + }); + } + }); + + test('detects whitespace-only', () { + expect(VoiceNoteAiPipeline.isNoSpeechTranscript(' '), isTrue); + }); + + // ── Should NOT be detected as no-speech ────────────────────────── + + group('allows valid transcripts through', () { + for (final input in [ + 'Remind me to buy milk', + 'Set a timer for 5 minutes', + 'köp bröd', + 'Wecker auf 7 Uhr', + 'Meeting tomorrow at 10', + 'hmm I need to call the dentist', + 'ok', + 'yes', + 'Buy milk and eggs and bread', + 'Thank you for helping me with this project', + ]) { + test('"$input"', () { + expect(VoiceNoteAiPipeline.isNoSpeechTranscript(input), isFalse); + }); + } + }); + }); +}