feat(profile): Add Android assistant trigger customization option
This commit is contained in:
@@ -29,6 +29,7 @@ final class PreferenceKeys {
|
||||
static const String ttsServerVoiceId = 'tts_server_voice_id';
|
||||
static const String ttsServerVoiceName = 'tts_server_voice_name';
|
||||
static const String voiceSilenceDuration = 'voice_silence_duration';
|
||||
static const String androidAssistantTrigger = 'android_assistant_trigger';
|
||||
}
|
||||
|
||||
final class LegacyPreferenceKeys {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer' as developer;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../persistence/hive_bootstrap.dart';
|
||||
import '../persistence/hive_boxes.dart';
|
||||
@@ -17,6 +18,22 @@ enum SttPreference { deviceOnly, serverOnly }
|
||||
/// TTS engine selection
|
||||
enum TtsEngine { device, server }
|
||||
|
||||
/// Action to take when the Android digital assistant is triggered.
|
||||
enum AndroidAssistantTrigger { overlay, newChat, voiceCall }
|
||||
|
||||
extension AndroidAssistantTriggerStorage on AndroidAssistantTrigger {
|
||||
String get storageValue {
|
||||
switch (this) {
|
||||
case AndroidAssistantTrigger.overlay:
|
||||
return 'overlay';
|
||||
case AndroidAssistantTrigger.newChat:
|
||||
return 'new_chat';
|
||||
case AndroidAssistantTrigger.voiceCall:
|
||||
return 'voice_call';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing app-wide settings including accessibility preferences
|
||||
class SettingsService {
|
||||
static const String _reduceMotionKey = PreferenceKeys.reduceMotion;
|
||||
@@ -41,6 +58,8 @@ class SettingsService {
|
||||
// Voice silence duration for auto-stop (milliseconds)
|
||||
static const String _voiceSilenceDurationKey =
|
||||
PreferenceKeys.voiceSilenceDuration;
|
||||
static const String _androidAssistantTriggerKey =
|
||||
PreferenceKeys.androidAssistantTrigger;
|
||||
static Box<dynamic> _preferencesBox() =>
|
||||
Hive.box<dynamic>(HiveBoxNames.preferences);
|
||||
|
||||
@@ -153,6 +172,8 @@ class SettingsService {
|
||||
PreferenceKeys.ttsEngine: settings.ttsEngine.name,
|
||||
PreferenceKeys.voiceSttPreference: settings.sttPreference.name,
|
||||
_voiceSilenceDurationKey: settings.voiceSilenceDuration,
|
||||
_androidAssistantTriggerKey:
|
||||
settings.androidAssistantTrigger.storageValue,
|
||||
};
|
||||
|
||||
await box.putAll(updates);
|
||||
@@ -191,6 +212,8 @@ class SettingsService {
|
||||
} else {
|
||||
await box.delete(PreferenceKeys.ttsServerVoiceName);
|
||||
}
|
||||
|
||||
await _writeAssistantTriggerToSharedPrefs(settings.androidAssistantTrigger);
|
||||
}
|
||||
|
||||
static TtsEngine _parseTtsEngine(String? raw) {
|
||||
@@ -219,6 +242,20 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
static AndroidAssistantTrigger _parseAndroidAssistantTrigger(String? raw) {
|
||||
switch ((raw ?? '').toLowerCase()) {
|
||||
case 'new_chat':
|
||||
case 'newchat':
|
||||
return AndroidAssistantTrigger.newChat;
|
||||
case 'voice_call':
|
||||
case 'voicecall':
|
||||
return AndroidAssistantTrigger.voiceCall;
|
||||
case 'overlay':
|
||||
default:
|
||||
return AndroidAssistantTrigger.overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// Voice input specific settings
|
||||
static Future<String?> getVoiceLocaleId() {
|
||||
final value = _preferencesBox().get(_voiceLocaleKey) as String?;
|
||||
@@ -309,6 +346,30 @@ class SettingsService {
|
||||
return _preferencesBox().put(_voiceSilenceDurationKey, sanitized);
|
||||
}
|
||||
|
||||
static Future<void> setAndroidAssistantTrigger(
|
||||
AndroidAssistantTrigger trigger,
|
||||
) async {
|
||||
await _preferencesBox().put(
|
||||
_androidAssistantTriggerKey,
|
||||
trigger.storageValue,
|
||||
);
|
||||
await _writeAssistantTriggerToSharedPrefs(trigger);
|
||||
}
|
||||
|
||||
static Future<void> _writeAssistantTriggerToSharedPrefs(
|
||||
AndroidAssistantTrigger trigger,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
PreferenceKeys.androidAssistantTrigger,
|
||||
trigger.storageValue,
|
||||
);
|
||||
} catch (_) {
|
||||
// SharedPreferences writes are best-effort for Android assistant access
|
||||
}
|
||||
}
|
||||
|
||||
/// Get effective animation duration considering all settings
|
||||
static Duration getEffectiveAnimationDuration(
|
||||
BuildContext context,
|
||||
@@ -372,6 +433,9 @@ class SettingsService {
|
||||
sttPreference: _parseSttPreference(
|
||||
box.get(PreferenceKeys.voiceSttPreference) as String?,
|
||||
),
|
||||
androidAssistantTrigger: _parseAndroidAssistantTrigger(
|
||||
box.get(_androidAssistantTriggerKey) as String?,
|
||||
),
|
||||
voiceSilenceDuration: (box.get(_voiceSilenceDurationKey) as int? ?? 2000)
|
||||
.clamp(300, 3000),
|
||||
);
|
||||
@@ -406,6 +470,7 @@ class AppSettings {
|
||||
final TtsEngine ttsEngine;
|
||||
final String? ttsServerVoiceId;
|
||||
final String? ttsServerVoiceName;
|
||||
final AndroidAssistantTrigger androidAssistantTrigger;
|
||||
final int voiceSilenceDuration;
|
||||
const AppSettings({
|
||||
this.reduceMotion = false,
|
||||
@@ -429,6 +494,7 @@ class AppSettings {
|
||||
this.ttsEngine = TtsEngine.device,
|
||||
this.ttsServerVoiceId,
|
||||
this.ttsServerVoiceName,
|
||||
this.androidAssistantTrigger = AndroidAssistantTrigger.overlay,
|
||||
this.voiceSilenceDuration = 2000,
|
||||
});
|
||||
|
||||
@@ -455,6 +521,7 @@ class AppSettings {
|
||||
Object? ttsServerVoiceId = const _DefaultValue(),
|
||||
Object? ttsServerVoiceName = const _DefaultValue(),
|
||||
int? voiceSilenceDuration,
|
||||
AndroidAssistantTrigger? androidAssistantTrigger,
|
||||
}) {
|
||||
return AppSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
@@ -486,6 +553,8 @@ class AppSettings {
|
||||
ttsServerVoiceName: ttsServerVoiceName is _DefaultValue
|
||||
? this.ttsServerVoiceName
|
||||
: ttsServerVoiceName as String?,
|
||||
androidAssistantTrigger:
|
||||
androidAssistantTrigger ?? this.androidAssistantTrigger,
|
||||
voiceSilenceDuration: voiceSilenceDuration ?? this.voiceSilenceDuration,
|
||||
);
|
||||
}
|
||||
@@ -513,6 +582,7 @@ class AppSettings {
|
||||
other.ttsEngine == ttsEngine &&
|
||||
other.ttsServerVoiceId == ttsServerVoiceId &&
|
||||
other.ttsServerVoiceName == ttsServerVoiceName &&
|
||||
other.androidAssistantTrigger == androidAssistantTrigger &&
|
||||
other.voiceSilenceDuration == voiceSilenceDuration &&
|
||||
_listEquals(other.quickPills, quickPills);
|
||||
// socketTransportMode intentionally not included in == to avoid frequent rebuilds
|
||||
@@ -541,6 +611,7 @@ class AppSettings {
|
||||
ttsEngine,
|
||||
ttsServerVoiceId,
|
||||
ttsServerVoiceName,
|
||||
androidAssistantTrigger,
|
||||
voiceSilenceDuration,
|
||||
Object.hashAllUnordered(quickPills),
|
||||
]);
|
||||
@@ -715,6 +786,16 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
await SettingsService.setVoiceSilenceDuration(milliseconds);
|
||||
}
|
||||
|
||||
Future<void> setAndroidAssistantTrigger(
|
||||
AndroidAssistantTrigger trigger,
|
||||
) async {
|
||||
if (state.androidAssistantTrigger == trigger) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(androidAssistantTrigger: trigger);
|
||||
await SettingsService.setAndroidAssistantTrigger(trigger);
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
const defaultSettings = AppSettings();
|
||||
await SettingsService.saveSettings(defaultSettings);
|
||||
|
||||
@@ -46,19 +46,27 @@ class AndroidAssistantHandler {
|
||||
await _processScreenshot(screenshotPath);
|
||||
} else if (call.method == 'startVoiceCall') {
|
||||
await _startVoiceCall();
|
||||
} else if (call.method == 'startNewChat') {
|
||||
await _startNewChat();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processScreenshot(String screenshotPath) async {
|
||||
try {
|
||||
DebugLogger.log('Processing screenshot: $screenshotPath', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'Processing screenshot: $screenshotPath',
|
||||
scope: 'assistant',
|
||||
);
|
||||
|
||||
// Wait for app to be ready (authenticated and model available)
|
||||
final navState = _ref.read(authNavigationStateProvider);
|
||||
final model = _ref.read(selectedModelProvider);
|
||||
|
||||
if (navState != AuthNavigationState.authenticated || model == null) {
|
||||
DebugLogger.log('App not ready for screenshot processing', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'App not ready for screenshot processing',
|
||||
scope: 'assistant',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +83,10 @@ class AndroidAssistantHandler {
|
||||
// Add screenshot as attachment
|
||||
final file = File(screenshotPath);
|
||||
if (!await file.exists()) {
|
||||
DebugLogger.log('Screenshot file not found: $screenshotPath', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'Screenshot file not found: $screenshotPath',
|
||||
scope: 'assistant',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,15 +102,23 @@ class AndroidAssistantHandler {
|
||||
// Enqueue upload via task queue
|
||||
final activeConv = _ref.read(activeConversationProvider);
|
||||
try {
|
||||
await _ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
await _ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: attachment.file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: await attachment.file.length(),
|
||||
);
|
||||
DebugLogger.log('Screenshot uploaded successfully', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'Screenshot uploaded successfully',
|
||||
scope: 'assistant',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to upload screenshot: $e', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'Failed to upload screenshot: $e',
|
||||
scope: 'assistant',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -130,7 +149,10 @@ class AndroidAssistantHandler {
|
||||
// Get the current BuildContext from the navigation service
|
||||
final context = NavigationService.navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
DebugLogger.log('No context available for voice call navigation', scope: 'assistant');
|
||||
DebugLogger.log(
|
||||
'No context available for voice call navigation',
|
||||
scope: 'assistant',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,4 +169,28 @@ class AndroidAssistantHandler {
|
||||
DebugLogger.log('Failed to start voice call: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startNewChat() async {
|
||||
try {
|
||||
DebugLogger.log('Starting new chat from assistant', scope: 'assistant');
|
||||
|
||||
final navState = _ref.read(authNavigationStateProvider);
|
||||
final model = _ref.read(selectedModelProvider);
|
||||
|
||||
if (navState != AuthNavigationState.authenticated || model == null) {
|
||||
DebugLogger.log('App not ready for new chat', scope: 'assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
|
||||
if (!isOnChatRoute) {
|
||||
await NavigationService.navigateToChat();
|
||||
}
|
||||
|
||||
startNewChat(_ref);
|
||||
DebugLogger.log('New chat started from assistant', scope: 'assistant');
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to start new chat: $e', scope: 'assistant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user