89fe8994fb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1512 lines
45 KiB
Markdown
1512 lines
45 KiB
Markdown
# Android Voice I/O Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add server-side STT and streaming TTS to the Android app — mic button in Chat, Quick Capture bar, and Briefing.
|
|
|
|
**Architecture:** A shared `VoiceNotifier` (Riverpod `Notifier`) owns the recording lifecycle, silence detection, and TTS sentence queue. Screens pass an `onTranscript` callback when entering voice mode; TTS is fed back sentence-by-sentence via `feedContent()` as the SSE response streams in. All audio I/O goes through the existing `/api/voice/*` endpoints — no backend changes needed.
|
|
|
|
**Tech Stack:** Flutter/Dart, `record ^5.0.0` (WebM/Opus recording), `just_audio ^0.9.x` (WAV playback), `permission_handler ^11.x` (already in pubspec), `path_provider ^2.x` (already in pubspec), Riverpod 3, Dio.
|
|
|
|
---
|
|
|
|
## File map
|
|
|
|
| Action | Path | Responsibility |
|
|
|---|---|---|
|
|
| Create | `lib/data/api/voice_api.dart` | Dio calls: checkStatus, transcribe, synthesise |
|
|
| Create | `lib/data/repositories/voice_repository.dart` | Thin wrapper around VoiceApi |
|
|
| Modify | `lib/providers/api_client_provider.dart` | Add voiceApiProvider, voiceRepositoryProvider |
|
|
| Create | `lib/providers/voice_provider.dart` | VoiceState + VoiceNotifier (recording, silence, TTS) |
|
|
| Create | `lib/widgets/voice_mic_button.dart` | Animated mic button widget |
|
|
| Modify | `lib/screens/chat/chat_screen.dart` | Add mic button + voice banner + TTS feed |
|
|
| Modify | `lib/app.dart` | Add mic button to _QuickCaptureBar |
|
|
| Modify | `lib/screens/briefing/briefing_screen.dart` | Add mic button + voice banner + TTS feed |
|
|
| Modify | `pubspec.yaml` | Add record, just_audio |
|
|
| Modify | `android/app/src/main/AndroidManifest.xml` | RECORD_AUDIO permission |
|
|
| Modify | `test/widget_test.dart` | Unit tests for pure voice logic |
|
|
|
|
---
|
|
|
|
## Task 1: Dependencies and permissions
|
|
|
|
**Files:**
|
|
- Modify: `pubspec.yaml`
|
|
- Modify: `android/app/src/main/AndroidManifest.xml`
|
|
|
|
- [ ] **Step 1: Add packages to pubspec.yaml**
|
|
|
|
In `pubspec.yaml`, add under `dependencies:` (after `url_launcher`):
|
|
|
|
```yaml
|
|
record: ^5.0.0
|
|
just_audio: ^0.9.39
|
|
```
|
|
|
|
- [ ] **Step 2: Add RECORD_AUDIO permission to AndroidManifest.xml**
|
|
|
|
In `android/app/src/main/AndroidManifest.xml`, add after the existing `<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>` line:
|
|
|
|
```xml
|
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
|
```
|
|
|
|
- [ ] **Step 3: Install packages**
|
|
|
|
```bash
|
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
|
flutter pub get
|
|
```
|
|
|
|
Expected: resolves `record 5.x` and `just_audio 0.9.x` without conflicts.
|
|
|
|
- [ ] **Step 4: Verify no compile errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no new errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add pubspec.yaml pubspec.lock android/app/src/main/AndroidManifest.xml
|
|
git commit -m "feat(voice): add record + just_audio dependencies, RECORD_AUDIO permission"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: VoiceApi and VoiceStatus model
|
|
|
|
**Files:**
|
|
- Create: `lib/data/api/voice_api.dart`
|
|
- Modify: `test/widget_test.dart`
|
|
|
|
- [ ] **Step 1: Write failing tests for VoiceStatus.fromJson**
|
|
|
|
Add to `test/widget_test.dart`:
|
|
|
|
```dart
|
|
import 'package:fabled_app/data/api/voice_api.dart';
|
|
```
|
|
|
|
Add a new group at the bottom of `main()`:
|
|
|
|
```dart
|
|
group('VoiceStatus.fromJson', () {
|
|
test('parses enabled with stt and tts available', () {
|
|
final status = VoiceStatus.fromJson({
|
|
'enabled': true,
|
|
'stt': true,
|
|
'tts': true,
|
|
});
|
|
expect(status.enabled, isTrue);
|
|
expect(status.stt, isTrue);
|
|
expect(status.tts, isTrue);
|
|
});
|
|
|
|
test('parses disabled state', () {
|
|
final status = VoiceStatus.fromJson({
|
|
'enabled': false,
|
|
'stt': false,
|
|
'tts': false,
|
|
});
|
|
expect(status.enabled, isFalse);
|
|
});
|
|
|
|
test('fully unavailable when enabled but stt or tts false', () {
|
|
final status = VoiceStatus.fromJson({
|
|
'enabled': true,
|
|
'stt': false,
|
|
'tts': true,
|
|
});
|
|
expect(status.fullyAvailable, isFalse);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to confirm it fails**
|
|
|
|
```bash
|
|
flutter test test/widget_test.dart
|
|
```
|
|
|
|
Expected: FAIL — `voice_api.dart` not found.
|
|
|
|
- [ ] **Step 3: Create lib/data/api/voice_api.dart**
|
|
|
|
```dart
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
import 'api_client.dart';
|
|
|
|
class VoiceStatus {
|
|
final bool enabled;
|
|
final bool stt;
|
|
final bool tts;
|
|
|
|
const VoiceStatus({
|
|
required this.enabled,
|
|
required this.stt,
|
|
required this.tts,
|
|
});
|
|
|
|
/// True only when voice is enabled AND both STT and TTS are ready.
|
|
bool get fullyAvailable => enabled && stt && tts;
|
|
|
|
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
|
|
enabled: json['enabled'] as bool? ?? false,
|
|
stt: json['stt'] as bool? ?? false,
|
|
tts: json['tts'] as bool? ?? false,
|
|
);
|
|
}
|
|
|
|
class VoiceApi {
|
|
final Dio _dio;
|
|
const VoiceApi(this._dio);
|
|
|
|
/// Check whether voice features are available on this server.
|
|
Future<VoiceStatus> checkStatus() async {
|
|
try {
|
|
final response = await _dio.get('/api/voice/status');
|
|
return VoiceStatus.fromJson(response.data as Map<String, dynamic>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// POST WebM/Opus audio bytes and return the transcript string.
|
|
/// Returns empty string on empty or error response.
|
|
Future<String> transcribe(Uint8List audioBytes) async {
|
|
try {
|
|
final formData = FormData.fromMap({
|
|
'audio': MultipartFile.fromBytes(
|
|
audioBytes,
|
|
filename: 'audio.webm',
|
|
contentType: DioMediaType('audio', 'webm'),
|
|
),
|
|
});
|
|
final response = await _dio.post(
|
|
'/api/voice/transcribe',
|
|
data: formData,
|
|
options: Options(
|
|
receiveTimeout: const Duration(seconds: 60),
|
|
contentType: 'multipart/form-data',
|
|
),
|
|
);
|
|
final data = response.data as Map<String, dynamic>;
|
|
return (data['transcript'] as String? ?? '').trim();
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// POST text and return raw WAV bytes.
|
|
Future<Uint8List> synthesise(String text) async {
|
|
try {
|
|
final response = await _dio.post(
|
|
'/api/voice/synthesise',
|
|
data: {'text': text},
|
|
options: Options(responseType: ResponseType.bytes),
|
|
);
|
|
return Uint8List.fromList(response.data as List<int>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to confirm they pass**
|
|
|
|
```bash
|
|
flutter test test/widget_test.dart
|
|
```
|
|
|
|
Expected: all tests pass including the new VoiceStatus group.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add lib/data/api/voice_api.dart test/widget_test.dart
|
|
git commit -m "feat(voice): add VoiceApi and VoiceStatus model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: VoiceRepository and provider registration
|
|
|
|
**Files:**
|
|
- Create: `lib/data/repositories/voice_repository.dart`
|
|
- Modify: `lib/providers/api_client_provider.dart`
|
|
|
|
- [ ] **Step 1: Create lib/data/repositories/voice_repository.dart**
|
|
|
|
```dart
|
|
import 'dart:typed_data';
|
|
|
|
import '../api/voice_api.dart';
|
|
|
|
class VoiceRepository {
|
|
final VoiceApi _api;
|
|
const VoiceRepository(this._api);
|
|
|
|
Future<VoiceStatus> checkStatus() => _api.checkStatus();
|
|
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes);
|
|
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register providers in lib/providers/api_client_provider.dart**
|
|
|
|
Add the following imports at the top of the file (after the existing imports):
|
|
|
|
```dart
|
|
import '../data/api/voice_api.dart';
|
|
import '../data/repositories/voice_repository.dart';
|
|
```
|
|
|
|
Add at the bottom of the file (after `settingsApiProvider`):
|
|
|
|
```dart
|
|
final voiceApiProvider = Provider<VoiceApi>((ref) {
|
|
return VoiceApi(ref.watch(dioProvider));
|
|
});
|
|
|
|
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
|
return VoiceRepository(ref.watch(voiceApiProvider));
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Verify no errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add lib/data/repositories/voice_repository.dart lib/providers/api_client_provider.dart
|
|
git commit -m "feat(voice): add VoiceRepository and provider registration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: VoiceNotifier — state, recording, silence detection, transcription
|
|
|
|
**Files:**
|
|
- Create: `lib/providers/voice_provider.dart`
|
|
- Modify: `test/widget_test.dart`
|
|
|
|
- [ ] **Step 1: Write failing tests for sentence extraction and markdown stripping**
|
|
|
|
Add the following import to `test/widget_test.dart`:
|
|
|
|
```dart
|
|
import 'package:fabled_app/providers/voice_provider.dart';
|
|
```
|
|
|
|
Add a new group at the bottom of `main()`:
|
|
|
|
```dart
|
|
group('VoiceNotifier sentence extraction', () {
|
|
test('extracts complete sentences at full stops', () {
|
|
final result = extractSentences('Hello world. How are you? I am fine!');
|
|
expect(result.sentences, equals(['Hello world.', 'How are you?', 'I am fine!']));
|
|
expect(result.remainder, equals(''));
|
|
});
|
|
|
|
test('leaves incomplete fragment in remainder', () {
|
|
final result = extractSentences('Hello world. Incomplete');
|
|
expect(result.sentences, equals(['Hello world.']));
|
|
expect(result.remainder, equals('Incomplete'));
|
|
});
|
|
|
|
test('returns empty sentences and full text when no boundary', () {
|
|
final result = extractSentences('No boundary here');
|
|
expect(result.sentences, isEmpty);
|
|
expect(result.remainder, equals('No boundary here'));
|
|
});
|
|
});
|
|
|
|
group('VoiceNotifier markdown stripping', () {
|
|
test('strips code fences', () {
|
|
expect(stripMarkdownForTts('Before\n```dart\ncode\n```\nAfter'), equals('Before After'));
|
|
});
|
|
|
|
test('strips bold and italic markers', () {
|
|
expect(stripMarkdownForTts('**bold** and *italic*'), equals('bold and italic'));
|
|
});
|
|
|
|
test('strips headers', () {
|
|
expect(stripMarkdownForTts('## Section title'), equals('Section title'));
|
|
});
|
|
|
|
test('keeps link text, removes URL', () {
|
|
expect(stripMarkdownForTts('[click here](https://example.com)'), equals('click here'));
|
|
});
|
|
|
|
test('strips list markers', () {
|
|
expect(stripMarkdownForTts('- item one\n- item two'), equals('item one item two'));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to confirm failures**
|
|
|
|
```bash
|
|
flutter test test/widget_test.dart
|
|
```
|
|
|
|
Expected: FAIL — `voice_provider.dart` not found.
|
|
|
|
- [ ] **Step 3: Create lib/providers/voice_provider.dart**
|
|
|
|
```dart
|
|
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:record/record.dart';
|
|
|
|
import 'api_client_provider.dart';
|
|
|
|
// ── Public helpers (also used by tests) ──────────────────────────────────────
|
|
|
|
class SentenceResult {
|
|
final List<String> sentences;
|
|
final String remainder;
|
|
const SentenceResult({required this.sentences, required this.remainder});
|
|
}
|
|
|
|
/// Extract completed sentences from [text] at `.`, `!`, `?` boundaries.
|
|
/// Returns the completed sentences and the unconsumed remainder.
|
|
SentenceResult extractSentences(String text) {
|
|
final boundary = RegExp(r'[.!?]+(?=\s|$)');
|
|
final sentences = <String>[];
|
|
var remaining = text;
|
|
RegExpMatch? match;
|
|
while ((match = boundary.firstMatch(remaining)) != null) {
|
|
final end = match!.end;
|
|
final sentence = remaining.substring(0, end).trim();
|
|
if (sentence.isNotEmpty) sentences.add(sentence);
|
|
remaining = remaining.substring(end).trimLeft();
|
|
}
|
|
return SentenceResult(sentences: sentences, remainder: remaining);
|
|
}
|
|
|
|
/// Strip markdown formatting before sending text to TTS.
|
|
String stripMarkdownForTts(String text) {
|
|
return text
|
|
.replaceAll(RegExp(r'```[\s\S]*?```'), '') // fenced code blocks
|
|
.replaceAll(RegExp(r'`([^`]+)`'), r'$1') // inline code — keep content
|
|
.replaceAll(RegExp(r'#{1,6}\s+'), '') // headings
|
|
.replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // bold
|
|
.replaceAll(RegExp(r'\*([^*]+)\*'), r'$1') // italic
|
|
.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') // links → text
|
|
.replaceAll(RegExp(r'^\s*[-*+]\s+', multiLine: true), '') // list markers
|
|
.replaceAll(RegExp(r'\n{2,}'), ' ') // multiple newlines → space
|
|
.replaceAll('\n', ' ')
|
|
.trim();
|
|
}
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
enum VoiceMode { idle, recording, transcribing, playing }
|
|
|
|
class VoiceState {
|
|
final VoiceMode mode;
|
|
final bool voiceModeActive;
|
|
final bool available;
|
|
|
|
const VoiceState({
|
|
this.mode = VoiceMode.idle,
|
|
this.voiceModeActive = false,
|
|
this.available = true,
|
|
});
|
|
|
|
VoiceState copyWith({
|
|
VoiceMode? mode,
|
|
bool? voiceModeActive,
|
|
bool? available,
|
|
}) =>
|
|
VoiceState(
|
|
mode: mode ?? this.mode,
|
|
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
|
|
available: available ?? this.available,
|
|
);
|
|
}
|
|
|
|
// ── Provider ──────────────────────────────────────────────────────────────────
|
|
|
|
final voiceProvider =
|
|
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
|
|
|
// ── Notifier ──────────────────────────────────────────────────────────────────
|
|
|
|
class VoiceNotifier extends Notifier<VoiceState> {
|
|
// Audio I/O
|
|
AudioRecorder? _recorder;
|
|
AudioPlayer? _player;
|
|
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
|
|
|
// Recording / silence detection
|
|
int _recordingStartMs = 0;
|
|
int _silenceMs = 0;
|
|
static const _silenceThresholdDb = -40.0;
|
|
static const _silenceDurationMs = 1500;
|
|
static const _minRecordingMs = 300;
|
|
String? _recordingPath;
|
|
|
|
// Voice mode callbacks
|
|
Future<void> Function(String transcript)? _onTranscript;
|
|
bool _enableTts = false;
|
|
|
|
// Streaming TTS state
|
|
String _sentenceBuffer = '';
|
|
int _lastSeenLength = 0;
|
|
bool _streamComplete = false;
|
|
|
|
// TTS playback queue
|
|
final _ttsQueue = Queue<Uint8List>();
|
|
bool _ttsPlaying = false;
|
|
int _ttsCounter = 0;
|
|
Directory? _tempDir;
|
|
|
|
@override
|
|
VoiceState build() {
|
|
_recorder = AudioRecorder();
|
|
_player = AudioPlayer();
|
|
ref.onDispose(() {
|
|
_amplitudeSubscription?.cancel();
|
|
_recorder?.dispose();
|
|
_player?.dispose();
|
|
});
|
|
return const VoiceState();
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
|
|
/// Enter voice mode. Checks server availability and mic permission first.
|
|
/// [onTranscript] is called with the transcript when a recording completes.
|
|
/// [enableTts] — if true, TTS plays when [feedContent] is called.
|
|
Future<void> enterVoiceMode({
|
|
required Future<void> Function(String transcript) onTranscript,
|
|
bool enableTts = false,
|
|
required void Function(String message) onError,
|
|
}) async {
|
|
if (state.voiceModeActive) {
|
|
exitVoiceMode();
|
|
return;
|
|
}
|
|
|
|
// Check server availability
|
|
try {
|
|
final status =
|
|
await ref.read(voiceRepositoryProvider).checkStatus();
|
|
if (!status.fullyAvailable) {
|
|
onError('Voice not available on this server');
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
onError('Voice not available on this server');
|
|
return;
|
|
}
|
|
|
|
// Check microphone permission
|
|
final permStatus = await Permission.microphone.request();
|
|
if (permStatus == PermissionStatus.denied ||
|
|
permStatus == PermissionStatus.permanentlyDenied) {
|
|
onError('Microphone permission required');
|
|
if (permStatus == PermissionStatus.permanentlyDenied) {
|
|
await openAppSettings();
|
|
}
|
|
return;
|
|
}
|
|
|
|
_onTranscript = onTranscript;
|
|
_enableTts = enableTts;
|
|
_tempDir = await getTemporaryDirectory();
|
|
|
|
state = state.copyWith(voiceModeActive: true, available: true);
|
|
await _startListening();
|
|
}
|
|
|
|
/// Exit voice mode, stop all recording and TTS.
|
|
void exitVoiceMode() {
|
|
_amplitudeSubscription?.cancel();
|
|
_amplitudeSubscription = null;
|
|
_recorder?.stop();
|
|
_player?.stop();
|
|
_ttsQueue.clear();
|
|
_ttsPlaying = false;
|
|
_sentenceBuffer = '';
|
|
_lastSeenLength = 0;
|
|
_streamComplete = false;
|
|
_onTranscript = null;
|
|
state = const VoiceState();
|
|
}
|
|
|
|
/// Feed streaming assistant content for TTS synthesis.
|
|
/// Call this from screens with each updated [fullContent] string.
|
|
/// Set [isComplete] to true when streaming has finished.
|
|
void feedContent(String fullContent, {required bool isComplete}) {
|
|
if (!state.voiceModeActive || !_enableTts) return;
|
|
|
|
final delta = fullContent.length > _lastSeenLength
|
|
? fullContent.substring(_lastSeenLength)
|
|
: '';
|
|
_lastSeenLength = fullContent.length;
|
|
_sentenceBuffer += delta;
|
|
|
|
_dispatchSentences(flush: isComplete);
|
|
|
|
if (isComplete) {
|
|
_streamComplete = true;
|
|
_checkRestartListening();
|
|
}
|
|
}
|
|
|
|
// ── Internal recording ──────────────────────────────────────────────────────
|
|
|
|
Future<void> _startListening() async {
|
|
if (!state.voiceModeActive) return;
|
|
|
|
_silenceMs = 0;
|
|
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
|
|
state = state.copyWith(mode: VoiceMode.recording);
|
|
|
|
final dir = _tempDir ?? await getTemporaryDirectory();
|
|
_recordingPath = '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm';
|
|
|
|
await _recorder!.start(
|
|
const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000),
|
|
path: _recordingPath!,
|
|
);
|
|
|
|
_amplitudeSubscription?.cancel();
|
|
_amplitudeSubscription = _recorder!
|
|
.onAmplitudeChanged(const Duration(milliseconds: 200))
|
|
.listen(_onAmplitude);
|
|
}
|
|
|
|
void _onAmplitude(Amplitude event) {
|
|
if (!state.voiceModeActive) return;
|
|
|
|
final elapsed =
|
|
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
|
if (elapsed < _minRecordingMs) return;
|
|
|
|
if (event.current < _silenceThresholdDb) {
|
|
_silenceMs += 200;
|
|
if (_silenceMs >= _silenceDurationMs) {
|
|
_amplitudeSubscription?.cancel();
|
|
_handleSilence();
|
|
}
|
|
} else {
|
|
_silenceMs = 0;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSilence() async {
|
|
if (!state.voiceModeActive) return;
|
|
state = state.copyWith(mode: VoiceMode.transcribing);
|
|
|
|
final path = await _recorder!.stop();
|
|
if (path == null || !state.voiceModeActive) return;
|
|
|
|
try {
|
|
final bytes = await File(path).readAsBytes();
|
|
await File(path).delete().catchError((_) {});
|
|
|
|
if (!state.voiceModeActive) return;
|
|
|
|
final transcript =
|
|
await ref.read(voiceRepositoryProvider).transcribe(bytes);
|
|
|
|
if (!state.voiceModeActive) return;
|
|
|
|
if (transcript.isEmpty) {
|
|
// Empty transcript — restart silently
|
|
await _startListening();
|
|
return;
|
|
}
|
|
|
|
// Reset TTS state for this new turn
|
|
_sentenceBuffer = '';
|
|
_lastSeenLength = 0;
|
|
_streamComplete = false;
|
|
|
|
if (_enableTts) {
|
|
state = state.copyWith(mode: VoiceMode.playing);
|
|
}
|
|
|
|
await _onTranscript?.call(transcript);
|
|
|
|
// If TTS is not enabled, loop immediately
|
|
if (!_enableTts && state.voiceModeActive) {
|
|
await _startListening();
|
|
}
|
|
} catch (_) {
|
|
// Network/API error — exit voice mode
|
|
exitVoiceMode();
|
|
}
|
|
}
|
|
|
|
// ── Internal TTS ────────────────────────────────────────────────────────────
|
|
|
|
void _dispatchSentences({required bool flush}) {
|
|
final result = extractSentences(_sentenceBuffer);
|
|
_sentenceBuffer = flush ? '' : result.remainder;
|
|
|
|
for (final sentence in result.sentences) {
|
|
_enqueueSentence(sentence);
|
|
}
|
|
if (flush && result.remainder.trim().length >= 3) {
|
|
_enqueueSentence(result.remainder.trim());
|
|
}
|
|
}
|
|
|
|
void _enqueueSentence(String sentence) {
|
|
final stripped = stripMarkdownForTts(sentence);
|
|
if (stripped.length < 3) return;
|
|
// Fire-and-forget synthesis into queue
|
|
_synthesiseSentence(stripped);
|
|
}
|
|
|
|
Future<void> _synthesiseSentence(String text) async {
|
|
try {
|
|
final wavBytes =
|
|
await ref.read(voiceRepositoryProvider).synthesise(text);
|
|
if (!state.voiceModeActive) return;
|
|
_ttsQueue.add(wavBytes);
|
|
if (!_ttsPlaying) _drainTtsQueue();
|
|
} catch (_) {
|
|
// Skip failed sentence — TTS errors are non-fatal
|
|
}
|
|
}
|
|
|
|
Future<void> _drainTtsQueue() async {
|
|
if (_ttsPlaying) return;
|
|
_ttsPlaying = true;
|
|
final dir = _tempDir ?? await getTemporaryDirectory();
|
|
|
|
try {
|
|
while (_ttsQueue.isNotEmpty && state.voiceModeActive) {
|
|
final wavBytes = _ttsQueue.removeFirst();
|
|
final path = '${dir.path}/tts_${_ttsCounter++}.wav';
|
|
final file = File(path);
|
|
await file.writeAsBytes(wavBytes);
|
|
|
|
try {
|
|
await _player!.setFilePath(path);
|
|
await _player!.play();
|
|
await _player!.processingStateStream.firstWhere(
|
|
(s) => s == ProcessingState.completed || s == ProcessingState.idle,
|
|
);
|
|
} finally {
|
|
await file.delete().catchError((_) {});
|
|
}
|
|
}
|
|
} finally {
|
|
_ttsPlaying = false;
|
|
}
|
|
|
|
_checkRestartListening();
|
|
}
|
|
|
|
void _checkRestartListening() {
|
|
if (_streamComplete &&
|
|
_ttsQueue.isEmpty &&
|
|
!_ttsPlaying &&
|
|
state.voiceModeActive) {
|
|
_streamComplete = false;
|
|
_lastSeenLength = 0;
|
|
_sentenceBuffer = '';
|
|
_startListening();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to confirm they pass**
|
|
|
|
```bash
|
|
flutter test test/widget_test.dart
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 5: Verify no analyzer errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add lib/providers/voice_provider.dart test/widget_test.dart
|
|
git commit -m "feat(voice): add VoiceNotifier with recording, silence detection, and streaming TTS"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: VoiceMicButton widget
|
|
|
|
**Files:**
|
|
- Create: `lib/widgets/voice_mic_button.dart`
|
|
|
|
- [ ] **Step 1: Create lib/widgets/voice_mic_button.dart**
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../providers/voice_provider.dart';
|
|
|
|
/// Animated mic button that reflects the current [VoiceMode].
|
|
///
|
|
/// - idle: muted background, mic_none icon
|
|
/// - recording: red with pulsing shadow ring
|
|
/// - transcribing: indigo with spinner
|
|
/// - playing: indigo with volume_up icon
|
|
class VoiceMicButton extends StatefulWidget {
|
|
final VoiceMode mode;
|
|
final bool voiceModeActive;
|
|
final VoidCallback? onTap;
|
|
|
|
const VoiceMicButton({
|
|
super.key,
|
|
required this.mode,
|
|
required this.voiceModeActive,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<VoiceMicButton> createState() => _VoiceMicButtonState();
|
|
}
|
|
|
|
class _VoiceMicButtonState extends State<VoiceMicButton>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _pulseController;
|
|
late Animation<double> _pulseAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pulseController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 900),
|
|
)..repeat(reverse: true);
|
|
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.25).animate(
|
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pulseController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Color _bgColor(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return switch (widget.mode) {
|
|
VoiceMode.recording => const Color(0xFFEF4444),
|
|
VoiceMode.transcribing || VoiceMode.playing => cs.primary,
|
|
VoiceMode.idle => cs.surfaceContainerHighest,
|
|
};
|
|
}
|
|
|
|
Widget _icon(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final iconColor = widget.mode == VoiceMode.idle
|
|
? cs.onSurfaceVariant
|
|
: Colors.white;
|
|
|
|
return switch (widget.mode) {
|
|
VoiceMode.transcribing => SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: iconColor,
|
|
),
|
|
),
|
|
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
|
|
_ => Icon(Icons.mic, color: iconColor, size: 20),
|
|
};
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isRecording = widget.mode == VoiceMode.recording;
|
|
|
|
final button = Material(
|
|
color: _bgColor(context),
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: widget.onTap,
|
|
child: SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Center(child: _icon(context)),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!isRecording) return button;
|
|
|
|
return AnimatedBuilder(
|
|
animation: _pulseAnimation,
|
|
builder: (_, child) => Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFFEF4444).withValues(alpha: 0.35),
|
|
blurRadius: 8 * _pulseAnimation.value,
|
|
spreadRadius: 2 * _pulseAnimation.value,
|
|
),
|
|
],
|
|
),
|
|
child: child,
|
|
),
|
|
child: button,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify no errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add lib/widgets/voice_mic_button.dart
|
|
git commit -m "feat(voice): add VoiceMicButton widget with animated states"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Chat screen integration
|
|
|
|
**Files:**
|
|
- Modify: `lib/screens/chat/chat_screen.dart`
|
|
|
|
The Chat screen needs:
|
|
1. A `VoiceMicButton` next to the send button
|
|
2. A red banner above the input row when voice mode is active
|
|
3. A `ref.listen` on `messagesProvider` that feeds content to `VoiceNotifier` during TTS
|
|
|
|
- [ ] **Step 1: Add voice imports and mic button to chat_screen.dart**
|
|
|
|
Replace the full contents of `lib/screens/chat/chat_screen.dart` with:
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/exceptions.dart';
|
|
import '../../data/models/message.dart';
|
|
import '../../providers/chat_provider.dart';
|
|
import '../../providers/voice_provider.dart';
|
|
import '../../widgets/chat_message_bubble.dart';
|
|
import '../../widgets/voice_mic_button.dart';
|
|
|
|
class ChatScreen extends ConsumerStatefulWidget {
|
|
final int conversationId;
|
|
const ChatScreen({super.key, required this.conversationId});
|
|
|
|
@override
|
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|
final _controller = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
_scrollController.dispose();
|
|
// Exit voice mode if the user navigates away mid-session.
|
|
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty) return;
|
|
_controller.clear();
|
|
try {
|
|
await ref
|
|
.read(messagesProvider(widget.conversationId).notifier)
|
|
.sendMessage(text);
|
|
} on AppException catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(e.message)));
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Failed to send message.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleVoiceMode() async {
|
|
final voice = ref.read(voiceProvider);
|
|
if (voice.voiceModeActive) {
|
|
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
return;
|
|
}
|
|
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
|
onTranscript: (transcript) async {
|
|
await ref
|
|
.read(messagesProvider(widget.conversationId).notifier)
|
|
.sendMessage(transcript);
|
|
},
|
|
enableTts: true,
|
|
onError: (msg) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final messagesAsync = ref.watch(messagesProvider(widget.conversationId));
|
|
final isStreaming = ref.watch(isStreamingProvider(widget.conversationId));
|
|
final voiceState = ref.watch(voiceProvider);
|
|
|
|
// Scroll when messages change.
|
|
ref.listen(messagesProvider(widget.conversationId), (_, _) {
|
|
_scrollToBottom();
|
|
});
|
|
|
|
// Feed streaming content to VoiceNotifier for TTS.
|
|
ref.listen(messagesProvider(widget.conversationId), (_, next) {
|
|
if (!voiceState.voiceModeActive) return;
|
|
final messages = next.value;
|
|
if (messages == null || messages.isEmpty) return;
|
|
final last = messages.last;
|
|
if (last.role != MessageRole.assistant) return;
|
|
final isComplete = last.status != 'generating';
|
|
ref
|
|
.read(voiceProvider.notifier)
|
|
.feedContent(last.content, isComplete: isComplete);
|
|
});
|
|
|
|
final convTitle = ref
|
|
.watch(conversationsProvider)
|
|
.value
|
|
?.where((c) => c.id == widget.conversationId)
|
|
.firstOrNull
|
|
?.title;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: messagesAsync.when(
|
|
loading: () =>
|
|
const Center(child: CircularProgressIndicator()),
|
|
error: (_, _) => const Center(
|
|
child: Text('Could not load messages.'),
|
|
),
|
|
data: (messages) {
|
|
if (messages.isEmpty) {
|
|
return const Center(
|
|
child: Text('Send a message to start.'));
|
|
}
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 12),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, i) =>
|
|
ChatMessageBubble(message: messages[i]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Voice mode banner
|
|
if (voiceState.voiceModeActive)
|
|
Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
child: const Text(
|
|
'🎤 Listening… tap mic to exit voice mode',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Color(0xFFF87171),
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
decoration: InputDecoration(
|
|
hintText: voiceState.voiceModeActive
|
|
? 'Listening…'
|
|
: 'Message…',
|
|
hintStyle: voiceState.voiceModeActive
|
|
? const TextStyle(fontStyle: FontStyle.italic)
|
|
: null,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 10),
|
|
),
|
|
maxLines:
|
|
MediaQuery.of(context).size.width >= 600 ? 2 : 4,
|
|
minLines: 1,
|
|
textInputAction: TextInputAction.newline,
|
|
enabled: !isStreaming && !voiceState.voiceModeActive,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
VoiceMicButton(
|
|
mode: voiceState.mode,
|
|
voiceModeActive: voiceState.voiceModeActive,
|
|
onTap: _toggleVoiceMode,
|
|
),
|
|
const SizedBox(width: 6),
|
|
IconButton.filled(
|
|
onPressed: (isStreaming || voiceState.voiceModeActive)
|
|
? null
|
|
: _send,
|
|
icon: isStreaming
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.send),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify no errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add lib/screens/chat/chat_screen.dart
|
|
git commit -m "feat(voice): integrate voice mode into Chat screen"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Quick Capture bar integration
|
|
|
|
**Files:**
|
|
- Modify: `lib/app.dart`
|
|
|
|
The `_QuickCaptureBarState` needs a `VoiceMicButton`. Recording is one-shot: transcript goes to `captureWorkQueueProvider.enqueue()`, no TTS, no loop. The mic button sits between the text field and the settings gear icon.
|
|
|
|
- [ ] **Step 1: Add imports to lib/app.dart**
|
|
|
|
At the top of `lib/app.dart`, add after the existing widget imports:
|
|
|
|
```dart
|
|
import 'providers/voice_provider.dart';
|
|
import 'widgets/voice_mic_button.dart';
|
|
```
|
|
|
|
- [ ] **Step 2: Add _toggleCaptureMic method to _QuickCaptureBarState**
|
|
|
|
In `_QuickCaptureBarState`, add this method after `_drainOfflineQueue`:
|
|
|
|
```dart
|
|
Future<void> _toggleCaptureMic() async {
|
|
final voice = ref.read(voiceProvider);
|
|
if (voice.voiceModeActive) {
|
|
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
return;
|
|
}
|
|
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
|
onTranscript: (transcript) async {
|
|
ref.read(captureWorkQueueProvider.notifier).enqueue(transcript);
|
|
},
|
|
enableTts: false,
|
|
onError: (msg) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(msg)));
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add VoiceMicButton to the capture bar Row**
|
|
|
|
In `_QuickCaptureBarState.build()`, locate the `Row` inside the `Padding` widget. The existing row has: `[Expanded(TextField), IconButton(settings)]`.
|
|
|
|
Replace that `Row`'s `children` list with:
|
|
|
|
```dart
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _submit(),
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
hintText: ref.watch(voiceProvider).voiceModeActive
|
|
? 'Listening…'
|
|
: _hintForLocation(location),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 14, vertical: 10),
|
|
prefixIcon: totalPending > 0
|
|
? Badge(
|
|
label: Text('$totalPending'),
|
|
child: const Icon(Icons.cloud_upload_outlined),
|
|
)
|
|
: isWorking
|
|
? const Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2),
|
|
),
|
|
)
|
|
: const Icon(Icons.auto_awesome_outlined),
|
|
suffixIcon: _controller.text.trim().isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: _submit,
|
|
tooltip: 'Capture',
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
VoiceMicButton(
|
|
mode: ref.watch(voiceProvider).mode,
|
|
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
|
|
onTap: _toggleCaptureMic,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.settings_outlined),
|
|
tooltip: 'Settings',
|
|
onPressed: () => context.push(Routes.settings),
|
|
),
|
|
],
|
|
```
|
|
|
|
- [ ] **Step 4: Verify no errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add lib/app.dart
|
|
git commit -m "feat(voice): integrate one-shot voice capture into Quick Capture bar"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Briefing screen integration
|
|
|
|
**Files:**
|
|
- Modify: `lib/screens/briefing/briefing_screen.dart`
|
|
|
|
Same pattern as Chat: mic button in the reply row, voice banner above input, and `ref.listen` on `briefingProvider` to feed TTS.
|
|
|
|
- [ ] **Step 1: Add imports to briefing_screen.dart**
|
|
|
|
At the top of `lib/screens/briefing/briefing_screen.dart`, add after existing imports:
|
|
|
|
```dart
|
|
import '../../data/models/message.dart';
|
|
import '../../providers/voice_provider.dart';
|
|
import '../../widgets/voice_mic_button.dart';
|
|
```
|
|
|
|
- [ ] **Step 2: Add exitVoiceMode to dispose**
|
|
|
|
In `_BriefingScreenState.dispose()`, add before `super.dispose()`:
|
|
|
|
```dart
|
|
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
```
|
|
|
|
- [ ] **Step 3: Add _toggleVoiceMode method**
|
|
|
|
In `_BriefingScreenState`, add after `_refresh`:
|
|
|
|
```dart
|
|
Future<void> _toggleVoiceMode() async {
|
|
final voice = ref.read(voiceProvider);
|
|
if (voice.voiceModeActive) {
|
|
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
return;
|
|
}
|
|
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
|
onTranscript: (transcript) async {
|
|
await ref.read(briefingProvider.notifier).sendReply(transcript);
|
|
},
|
|
enableTts: true,
|
|
onError: (msg) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add TTS feed listener in build**
|
|
|
|
In `_BriefingScreenState.build()`, locate the existing `ref.listen(briefingProvider, ...)` call that scrolls to bottom. Add a second `ref.listen` immediately after it:
|
|
|
|
```dart
|
|
// Feed streaming assistant content to VoiceNotifier for TTS.
|
|
ref.listen(briefingProvider, (_, next) {
|
|
final voiceState = ref.read(voiceProvider);
|
|
if (!voiceState.voiceModeActive) return;
|
|
final conv = next.value;
|
|
if (conv == null || conv.messages.isEmpty) return;
|
|
final last = conv.messages.last;
|
|
if (last.role != MessageRole.assistant) return;
|
|
final isComplete = last.status != 'generating';
|
|
ref
|
|
.read(voiceProvider.notifier)
|
|
.feedContent(last.content, isComplete: isComplete);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Add voice banner and mic button to the reply row**
|
|
|
|
Locate the reply bar section in `build()` — the `Column` inside `data: (conv)`. It currently ends with:
|
|
|
|
```dart
|
|
// Reply bar
|
|
const Divider(height: 1),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField( ... ),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_GradientSendButton(
|
|
onPressed: isStreaming ? null : _sendReply,
|
|
isStreaming: isStreaming,
|
|
),
|
|
```
|
|
|
|
Replace that section (from `// Reply bar` to the closing of its Column entry) with:
|
|
|
|
```dart
|
|
// Voice mode banner
|
|
if (voiceState.voiceModeActive)
|
|
Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16, vertical: 6),
|
|
child: const Text(
|
|
'🎤 Listening… tap mic to exit voice mode',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Color(0xFFF87171),
|
|
),
|
|
),
|
|
),
|
|
// Reply bar
|
|
const Divider(height: 1),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
decoration: InputDecoration(
|
|
hintText: voiceState.voiceModeActive
|
|
? 'Listening…'
|
|
: 'Reply to your briefing…',
|
|
hintStyle: voiceState.voiceModeActive
|
|
? const TextStyle(fontStyle: FontStyle.italic)
|
|
: null,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 10),
|
|
),
|
|
minLines: 1,
|
|
maxLines: 4,
|
|
textInputAction: TextInputAction.newline,
|
|
enabled: !isStreaming && !voiceState.voiceModeActive,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
VoiceMicButton(
|
|
mode: voiceState.mode,
|
|
voiceModeActive: voiceState.voiceModeActive,
|
|
onTap: _toggleVoiceMode,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_GradientSendButton(
|
|
onPressed: (isStreaming || voiceState.voiceModeActive)
|
|
? null
|
|
: _sendReply,
|
|
isStreaming: isStreaming,
|
|
),
|
|
```
|
|
|
|
- [ ] **Step 6: Add voiceState variable in build**
|
|
|
|
At the top of `_BriefingScreenState.build()`, just after the existing `final isStreaming = ...` line, add:
|
|
|
|
```dart
|
|
final voiceState = ref.watch(voiceProvider);
|
|
```
|
|
|
|
- [ ] **Step 7: Verify no errors**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add lib/screens/briefing/briefing_screen.dart
|
|
git commit -m "feat(voice): integrate voice mode into Briefing screen"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Final verification
|
|
|
|
- [ ] **Step 1: Run all tests**
|
|
|
|
```bash
|
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
|
flutter test test/widget_test.dart
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 2: Full static analysis**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: no errors or warnings related to voice code.
|
|
|
|
- [ ] **Step 3: Build debug APK to confirm it compiles**
|
|
|
|
```bash
|
|
flutter build apk --debug 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`
|
|
|
|
- [ ] **Step 4: Push to dev**
|
|
|
|
```bash
|
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
|
git push origin dev
|
|
```
|
|
|
|
---
|
|
|
|
## Manual smoke test checklist
|
|
|
|
After installing the debug APK on device:
|
|
|
|
- [ ] Chat screen: tap mic → permission dialog appears on first use
|
|
- [ ] Chat screen: speak → silence → transcript appears and message sends
|
|
- [ ] Chat screen: assistant response audio plays sentence by sentence
|
|
- [ ] Chat screen: tap mic again → voice mode exits, input re-enables
|
|
- [ ] Chat screen: navigate away mid-session → no audio or recording continues
|
|
- [ ] Quick Capture: tap mic → speak → transcript captured as note/task, mic returns to idle
|
|
- [ ] Briefing screen: tap mic → speak → reply sent, TTS plays response
|
|
- [ ] Server with voice disabled: tap mic → snackbar "Voice not available on this server"
|
|
- [ ] Deny mic permission: snackbar "Microphone permission required"
|