Merge pull request #244 from cogwheel0/widget-quick-access-actions
feat(widget): Add home screen widget with quick access actions
This commit is contained in:
@@ -10,6 +10,7 @@ import '../providers/app_providers.dart';
|
||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import '../services/navigation_service.dart';
|
||||
import '../services/app_intents_service.dart';
|
||||
import '../services/home_widget_service.dart';
|
||||
import '../services/quick_actions_service.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../services/background_streaming_handler.dart';
|
||||
@@ -172,6 +173,7 @@ class AppStartupFlow extends _$AppStartupFlow {
|
||||
keepAlive(silentLoginCoordinatorProvider);
|
||||
keepAlive(appIntentCoordinatorProvider);
|
||||
keepAlive(quickActionsCoordinatorProvider);
|
||||
keepAlive(homeWidgetCoordinatorProvider);
|
||||
|
||||
// Kick background model loading flow (non-blocking)
|
||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||
|
||||
409
lib/core/services/home_widget_service.dart
Normal file
409
lib/core/services/home_widget_service.dart
Normal file
@@ -0,0 +1,409 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import '../../features/chat/providers/chat_providers.dart';
|
||||
import '../../features/chat/services/file_attachment_service.dart';
|
||||
import '../../shared/services/tasks/task_queue.dart';
|
||||
import '../providers/app_providers.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import 'app_intents_service.dart';
|
||||
import 'navigation_service.dart';
|
||||
|
||||
part 'home_widget_service.g.dart';
|
||||
|
||||
/// Widget action identifiers matching native widget implementations.
|
||||
class WidgetActions {
|
||||
static const String newChat = 'new_chat';
|
||||
static const String mic = 'mic';
|
||||
static const String camera = 'camera';
|
||||
static const String photos = 'photos';
|
||||
static const String clipboard = 'clipboard';
|
||||
}
|
||||
|
||||
/// App group identifier for iOS widget data sharing.
|
||||
const String _appGroupId = 'group.app.cogwheel.conduit';
|
||||
|
||||
/// Android widget provider class name.
|
||||
const String _androidWidgetName = 'ConduitWidgetProvider';
|
||||
|
||||
/// iOS widget kind identifier.
|
||||
const String _iOSWidgetKind = 'ConduitWidget';
|
||||
|
||||
/// Handles home screen widget interactions for Android and iOS.
|
||||
///
|
||||
/// The widget provides quick actions:
|
||||
/// - New Chat: Start a fresh conversation
|
||||
/// - Camera: Take a photo and attach to chat
|
||||
/// - Photos: Pick from gallery and attach to chat
|
||||
/// - Clipboard: Paste clipboard content as prompt
|
||||
@Riverpod(keepAlive: true)
|
||||
class HomeWidgetCoordinator extends _$HomeWidgetCoordinator {
|
||||
StreamSubscription<Uri?>? _widgetClickSubscription;
|
||||
Uri? _pendingWidgetAction;
|
||||
|
||||
@override
|
||||
FutureOr<void> build() async {
|
||||
if (kIsWeb) return;
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||
|
||||
await _initialize();
|
||||
|
||||
ref.onDispose(() {
|
||||
_widgetClickSubscription?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
// Set app group for iOS data sharing
|
||||
if (Platform.isIOS) {
|
||||
await HomeWidget.setAppGroupId(_appGroupId);
|
||||
}
|
||||
|
||||
// Handle widget clicks
|
||||
_widgetClickSubscription = HomeWidget.widgetClicked.listen(
|
||||
_handleWidgetClick,
|
||||
onError: (error) {
|
||||
DebugLogger.error(
|
||||
'home-widget-stream',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Check for initial launch from widget
|
||||
final initialUri = await HomeWidget.initiallyLaunchedFromHomeWidget();
|
||||
if (initialUri != null) {
|
||||
DebugLogger.log(
|
||||
'Widget: Initial launch URI: $initialUri',
|
||||
scope: 'widget',
|
||||
);
|
||||
// Store for later processing once app is ready
|
||||
_pendingWidgetAction = initialUri;
|
||||
// Try to process after a delay to allow router to initialize
|
||||
_processInitialWidgetAction();
|
||||
}
|
||||
|
||||
DebugLogger.log('Home widget service initialized', scope: 'widget');
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-init',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process initial widget action after ensuring router is ready.
|
||||
Future<void> _processInitialWidgetAction() async {
|
||||
if (_pendingWidgetAction == null) return;
|
||||
|
||||
// Wait for router to be attached and app to be ready
|
||||
for (var i = 0; i < 50; i++) {
|
||||
// Try for up to 5 seconds
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Check if router is available
|
||||
if (NavigationService.currentRoute != null) {
|
||||
DebugLogger.log(
|
||||
'Widget: Router ready, processing pending action',
|
||||
scope: 'widget',
|
||||
);
|
||||
final uri = _pendingWidgetAction;
|
||||
_pendingWidgetAction = null;
|
||||
await _handleWidgetClick(uri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DebugLogger.log(
|
||||
'Widget: Timeout waiting for router, clearing pending action',
|
||||
scope: 'widget',
|
||||
);
|
||||
_pendingWidgetAction = null;
|
||||
}
|
||||
|
||||
Future<void> _handleWidgetClick(Uri? uri) async {
|
||||
if (uri == null) return;
|
||||
|
||||
// If router isn't ready yet, store for later
|
||||
if (NavigationService.currentRoute == null) {
|
||||
DebugLogger.log(
|
||||
'Widget: Router not ready, storing action for later',
|
||||
scope: 'widget',
|
||||
);
|
||||
_pendingWidgetAction = uri;
|
||||
_processInitialWidgetAction();
|
||||
return;
|
||||
}
|
||||
|
||||
final action = uri.host.isNotEmpty
|
||||
? uri.host
|
||||
: uri.pathSegments.firstOrNull;
|
||||
if (action == null || action.isEmpty) {
|
||||
// Default action: open new chat
|
||||
await _handleNewChat();
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLogger.log('Widget action: $action', scope: 'widget');
|
||||
|
||||
switch (action) {
|
||||
case WidgetActions.newChat:
|
||||
await _handleNewChat();
|
||||
break;
|
||||
case WidgetActions.mic:
|
||||
await _handleMic();
|
||||
break;
|
||||
case WidgetActions.camera:
|
||||
await _handleCamera();
|
||||
break;
|
||||
case WidgetActions.photos:
|
||||
await _handlePhotos();
|
||||
break;
|
||||
case WidgetActions.clipboard:
|
||||
await _handleClipboard();
|
||||
break;
|
||||
default:
|
||||
DebugLogger.log('Unknown widget action: $action', scope: 'widget');
|
||||
await _handleNewChat();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleNewChat() async {
|
||||
DebugLogger.log('Widget: Starting new chat', scope: 'widget');
|
||||
await _waitForNavigation();
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: true, resetChat: true);
|
||||
}
|
||||
|
||||
Future<void> _handleMic() async {
|
||||
DebugLogger.log('Widget: Starting voice call', scope: 'widget');
|
||||
await _waitForNavigation();
|
||||
try {
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.startVoiceCallFromExternal();
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-mic',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// Fall back to opening chat with focus
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: true, resetChat: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCamera() async {
|
||||
DebugLogger.log('Widget: Opening camera', scope: 'widget');
|
||||
await _waitForNavigation();
|
||||
|
||||
// Navigate to chat first
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: false, resetChat: true);
|
||||
|
||||
// Wait for navigation to settle
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Check auth state
|
||||
final navState = ref.read(authNavigationStateProvider);
|
||||
if (navState != AuthNavigationState.authenticated) {
|
||||
DebugLogger.log('Widget: Not authenticated for camera', scope: 'widget');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
await _attachFile(File(image.path));
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-camera',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePhotos() async {
|
||||
DebugLogger.log('Widget: Opening photo picker', scope: 'widget');
|
||||
await _waitForNavigation();
|
||||
|
||||
// Navigate to chat first
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: false, resetChat: true);
|
||||
|
||||
// Wait for navigation to settle
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Check auth state
|
||||
final navState = ref.read(authNavigationStateProvider);
|
||||
if (navState != AuthNavigationState.authenticated) {
|
||||
DebugLogger.log('Widget: Not authenticated for photos', scope: 'widget');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final images = await picker.pickMultiImage(imageQuality: 85);
|
||||
|
||||
if (images.isNotEmpty) {
|
||||
for (final image in images) {
|
||||
await _attachFile(File(image.path));
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-photos',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleClipboard() async {
|
||||
DebugLogger.log('Widget: Pasting from clipboard', scope: 'widget');
|
||||
await _waitForNavigation();
|
||||
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text?.trim();
|
||||
|
||||
if (text == null || text.isEmpty) {
|
||||
DebugLogger.log('Widget: Clipboard is empty', scope: 'widget');
|
||||
// Still open chat even if clipboard is empty
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: true, resetChat: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(
|
||||
prompt: text,
|
||||
focusComposer: true,
|
||||
resetChat: true,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-clipboard',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// Fall back to just opening chat
|
||||
await ref
|
||||
.read(appIntentCoordinatorProvider.notifier)
|
||||
.openChatFromExternal(focusComposer: true, resetChat: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the navigation system to be ready.
|
||||
Future<void> _waitForNavigation() async {
|
||||
// Wait for bindings to be initialized
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
Future<void> _attachFile(File file) async {
|
||||
if (!ref.mounted) return;
|
||||
|
||||
// Warm the attachment service
|
||||
final _ = ref.read(fileAttachmentServiceProvider);
|
||||
final notifier = ref.read(attachedFilesProvider.notifier);
|
||||
final taskQueue = ref.read(taskQueueProvider.notifier);
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
|
||||
final attachment = LocalAttachment(
|
||||
file: file,
|
||||
displayName: path.basename(file.path),
|
||||
);
|
||||
|
||||
notifier.addFiles([attachment]);
|
||||
|
||||
try {
|
||||
await taskQueue.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-upload',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// Focus the composer after attaching
|
||||
final tick = ref.read(inputFocusTriggerProvider);
|
||||
ref.read(inputFocusTriggerProvider.notifier).set(tick + 1);
|
||||
}
|
||||
|
||||
/// Update widget data displayed on home screen.
|
||||
///
|
||||
/// Call this when app state changes that should be reflected in widget.
|
||||
Future<void> updateWidgetData() async {
|
||||
if (kIsWeb) return;
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||
|
||||
try {
|
||||
// For now, we just trigger a widget update
|
||||
// In the future, we could pass data like recent conversations
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await HomeWidget.updateWidget(androidName: _androidWidgetName);
|
||||
} else if (Platform.isIOS) {
|
||||
await HomeWidget.updateWidget(iOSName: _iOSWidgetKind);
|
||||
}
|
||||
|
||||
DebugLogger.log('Widget data updated', scope: 'widget');
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'home-widget-update',
|
||||
scope: 'widget',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider to trigger home widget initialization at app startup.
|
||||
final homeWidgetInitializerProvider = Provider<void>((ref) {
|
||||
if (kIsWeb) return;
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||
|
||||
// Initialize the coordinator which sets up widget click handling
|
||||
ref.watch(homeWidgetCoordinatorProvider);
|
||||
});
|
||||
Reference in New Issue
Block a user