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? _widgetClickSubscription; Uri? _pendingWidgetAction; @override FutureOr build() async { if (kIsWeb) return; if (!Platform.isIOS && !Platform.isAndroid) return; await _initialize(); ref.onDispose(() { _widgetClickSubscription?.cancel(); }); } Future _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 _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.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 _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 _handleNewChat() async { DebugLogger.log('Widget: Starting new chat', scope: 'widget'); await _waitForNavigation(); await ref .read(appIntentCoordinatorProvider.notifier) .openChatFromExternal(focusComposer: true, resetChat: true); } Future _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 _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.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 _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.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 _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 _waitForNavigation() async { // Wait for bindings to be initialized WidgetsBinding.instance.addPostFrameCallback((_) {}); await Future.delayed(const Duration(milliseconds: 50)); } Future _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 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((ref) { if (kIsWeb) return; if (!Platform.isIOS && !Platform.isAndroid) return; // Initialize the coordinator which sets up widget click handling ref.watch(homeWidgetCoordinatorProvider); });