feat(profile): Add Android assistant trigger customization option

This commit is contained in:
cogwheel0
2025-11-24 15:07:46 +05:30
parent 11107e68af
commit 4822d1ed38
16 changed files with 410 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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');
}
}
}