diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dbfaccd7..7c61b0ea 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -314,7 +314,7 @@ struct CheckNotificationsIntent: AppIntent { let value = result["value"] as? String ?? "You have new notifications" return .result( value: value, - dialog: "Dialog: \(value)" + dialog: "\(value)" ) } else { let errorMessage = result["error"] as? String ?? "Failed to check notifications" @@ -323,6 +323,142 @@ struct CheckNotificationsIntent: AppIntent { } } +@available(iOS 16.0, *) +struct SendMessageIntent: AppIntent { + static var title: LocalizedStringResource = "Send Message" + static var description = IntentDescription("Send a message to a chat channel") + static var isDiscoverable = true + static var openAppWhenRun = false + + @Parameter(title: "Channel ID") + var channelId: String? + + @Parameter(title: "Message Content") + var content: String? + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + guard let channelId = channelId, !channelId.isEmpty else { + throw AppIntentError.executionFailed("Channel ID is required") + } + + guard let content = content, !content.isEmpty else { + throw AppIntentError.executionFailed("Message content is required") + } + + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "send_message", + parameters: ["channelId": channelId, "content": content] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Message sent" + return .result( + value: value, + dialog: "\(value)" + ) + } else { + let errorMessage = result["error"] as? String ?? "Failed to send message" + throw AppIntentError.executionFailed(errorMessage) + } + } +} + +@available(iOS 16.0, *) +struct ReadMessagesIntent: AppIntent { + static var title: LocalizedStringResource = "Read Messages" + static var description = IntentDescription("Read recent messages from a chat channel") + static var isDiscoverable = true + static var openAppWhenRun = false + + @Parameter(title: "Channel ID") + var channelId: String? + + @Parameter(title: "Number of Messages", default: "5") + var limit: String? + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + guard let channelId = channelId, !channelId.isEmpty else { + throw AppIntentError.executionFailed("Channel ID is required") + } + + let limit = limit ?? "5" + var parameters: [String: Any] = ["channelId": channelId] + parameters["limit"] = limit + + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "read_messages", + parameters: parameters + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Messages retrieved" + return .result( + value: value, + dialog: "\(value)" + ) + } else { + let errorMessage = result["error"] as? String ?? "Failed to read messages" + throw AppIntentError.executionFailed(errorMessage) + } + } +} + +@available(iOS 16.0, *) +struct CheckUnreadChatsIntent: AppIntent { + static var title: LocalizedStringResource = "Check Unread Chats" + static var description = IntentDescription("Check number of unread chat messages") + static var isDiscoverable = true + static var openAppWhenRun = false + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "check_unread_chats", + parameters: [:] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "No unread messages" + return .result( + value: value, + dialog: "\(value)" + ) + } else { + let errorMessage = result["error"] as? String ?? "Failed to check unread chats" + throw AppIntentError.executionFailed(errorMessage) + } + } +} + +@available(iOS 16.0, *) +struct MarkNotificationsReadIntent: AppIntent { + static var title: LocalizedStringResource = "Mark Notifications Read" + static var description = IntentDescription("Mark all notifications as read") + static var isDiscoverable = true + static var openAppWhenRun = false + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "mark_notifications_read", + parameters: [:] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Notifications marked as read" + return .result( + value: value, + dialog: "\(value)" + ) + } else { + let errorMessage = result["error"] as? String ?? "Failed to mark notifications as read" + throw AppIntentError.executionFailed(errorMessage) + } + } +} + enum AppIntentError: Error { case executionFailed(String) } @@ -373,6 +509,42 @@ struct AppShortcuts: AppShortcutsProvider { "Get notifications using \(.applicationName)", "Do I have notifications in \(.applicationName)" ] + ), + // Send message + AppShortcut( + intent: SendMessageIntent(), + phrases: [ + "Send message with \(.applicationName)", + "Post message using \(.applicationName)", + "Send text using \(.applicationName)" + ] + ), + // Read messages + AppShortcut( + intent: ReadMessagesIntent(), + phrases: [ + "Read messages with \(.applicationName)", + "Get chat using \(.applicationName)", + "Show messages with \(.applicationName)" + ] + ), + // Check unread chats + AppShortcut( + intent: CheckUnreadChatsIntent(), + phrases: [ + "Check unread chats with \(.applicationName)", + "Do I have messages using \(.applicationName)", + "Get unread messages with \(.applicationName)" + ] + ), + // Mark notifications read + AppShortcut( + intent: MarkNotificationsReadIntent(), + phrases: [ + "Mark notifications read with \(.applicationName)", + "Clear notifications using \(.applicationName)", + "Mark all read with \(.applicationName)" + ] ) ] } diff --git a/lib/services/app_intents/ios.dart b/lib/services/app_intents/ios.dart index 3bf3b21f..670c5434 100644 --- a/lib/services/app_intents/ios.dart +++ b/lib/services/app_intents/ios.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:island/models/auth.dart'; import 'package:island/pods/config.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:island/services/event_bus.dart'; import 'package:island/talker.dart'; import 'package:island/route.dart'; @@ -165,6 +166,75 @@ class AppIntentsService { .build(), _handleCheckNotificationsIntent, ); + + // Message Intents + await _client!.registerIntent( + AppIntentBuilder() + .identifier('send_message') + .title('Send Message') + .description('Send a message to a chat channel') + .parameter( + const AppIntentParameter( + name: 'channelId', + title: 'Channel ID', + type: AppIntentParameterType.string, + isOptional: false, + ), + ) + .parameter( + const AppIntentParameter( + name: 'content', + title: 'Message Content', + type: AppIntentParameterType.string, + isOptional: false, + ), + ) + .build(), + _handleSendMessageIntent, + ); + + await _client!.registerIntent( + AppIntentBuilder() + .identifier('read_messages') + .title('Read Messages') + .description('Read recent messages from a chat channel') + .parameter( + const AppIntentParameter( + name: 'channelId', + title: 'Channel ID', + type: AppIntentParameterType.string, + isOptional: false, + ), + ) + .parameter( + const AppIntentParameter( + name: 'limit', + title: 'Number of Messages', + type: AppIntentParameterType.string, + isOptional: true, + ), + ) + .build(), + _handleReadMessagesIntent, + ); + + await _client!.registerIntent( + AppIntentBuilder() + .identifier('check_unread_chats') + .title('Check Unread Chats') + .description('Check number of unread chat messages') + .build(), + _handleCheckUnreadChatsIntent, + ); + + await _client!.registerIntent( + AppIntentBuilder() + .identifier('mark_notifications_read') + .title('Mark Notifications Read') + .description('Mark all notifications as read') + .build(), + _handleMarkNotificationsReadIntent, + ); } void dispose() { @@ -232,11 +302,7 @@ class AppIntentsService { try { talker.info('[AppIntents] Opening compose screen'); - if (rootNavigatorKey.currentContext == null) { - return AppIntentResult.failed(error: 'App context not available'); - } - - rootNavigatorKey.currentContext!.push('/posts/compose'); + eventBus.fire(ShowComposeSheetEvent()); return AppIntentResult.successful( value: 'Opening compose screen', @@ -254,11 +320,7 @@ class AppIntentsService { try { talker.info('[AppIntents] Composing new post'); - if (rootNavigatorKey.currentContext == null) { - return AppIntentResult.failed(error: 'App context not available'); - } - - rootNavigatorKey.currentContext!.push('/posts/compose'); + eventBus.fire(ShowComposeSheetEvent()); return AppIntentResult.successful( value: 'Opening compose screen', @@ -360,6 +422,189 @@ class AppIntentsService { } } + Future _handleSendMessageIntent( + Map parameters, + ) async { + try { + final channelId = parameters['channelId'] as String?; + final content = parameters['content'] as String?; + + if (channelId == null) { + throw ArgumentError('channelId is required'); + } + if (content == null || content.isEmpty) { + throw ArgumentError('content is required'); + } + + talker.info('[AppIntents] Sending message to $channelId: $content'); + + if (_dio == null) { + return AppIntentResult.failed(error: 'API client not initialized'); + } + + try { + final nonce = _generateNonce(); + + await _dio!.post( + '/messager/chat/$channelId/messages', + data: {'content': content, 'nonce': nonce}, + ); + + talker.info('[AppIntents] Message sent successfully'); + return AppIntentResult.successful( + value: 'Message sent to channel $channelId', + needsToContinueInApp: false, + ); + } on DioException catch (e) { + talker.error('[AppIntents] API error sending message', e); + return AppIntentResult.failed( + error: 'Failed to send message: ${e.message ?? 'Network error'}', + ); + } + } catch (e, stack) { + talker.error('[AppIntents] Failed to send message', e, stack); + return AppIntentResult.failed(error: 'Failed to send message: $e'); + } + } + + Future _handleReadMessagesIntent( + Map parameters, + ) async { + try { + final channelId = parameters['channelId'] as String?; + final limitParam = parameters['limit'] as String?; + final limit = limitParam != null ? int.tryParse(limitParam) ?? 5 : 5; + + if (channelId == null) { + throw ArgumentError('channelId is required'); + } + if (limit < 1 || limit > 20) { + return AppIntentResult.failed(error: 'limit must be between 1 and 20'); + } + + talker.info('[AppIntents] Reading $limit messages from $channelId'); + + if (_dio == null) { + return AppIntentResult.failed(error: 'API client not initialized'); + } + + try { + final response = await _dio!.get( + '/messager/chat/$channelId/messages', + queryParameters: {'offset': 0, 'take': limit}, + ); + + final messages = response.data as List; + if (messages.isEmpty) { + return AppIntentResult.successful( + value: 'No messages found in channel $channelId', + needsToContinueInApp: false, + ); + } + + final formattedMessages = messages + .map((msg) { + final senderName = + msg['sender']?['account']?['name'] ?? 'Unknown'; + final messageContent = msg['content'] ?? ''; + return '$senderName: $messageContent'; + }) + .join('\n'); + + talker.info('[AppIntents] Retrieved ${messages.length} messages'); + return AppIntentResult.successful( + value: formattedMessages, + needsToContinueInApp: false, + ); + } on DioException catch (e) { + talker.error('[AppIntents] API error reading messages', e); + return AppIntentResult.failed( + error: 'Failed to read messages: ${e.message ?? 'Network error'}', + ); + } + } catch (e, stack) { + talker.error('[AppIntents] Failed to read messages', e, stack); + return AppIntentResult.failed(error: 'Failed to read messages: $e'); + } + } + + String _generateNonce() { + return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecondsSinceEpoch}'; + } + + Future _handleCheckUnreadChatsIntent( + Map parameters, + ) async { + try { + talker.info('[AppIntents] Checking unread chats count'); + + if (_dio == null) { + return AppIntentResult.failed(error: 'API client not initialized'); + } + + try { + final response = await _dio!.get('/messager/chat/unread'); + final count = response.data as int? ?? 0; + + String message; + if (count == 0) { + message = 'You have no unread messages'; + } else if (count == 1) { + message = 'You have 1 unread message'; + } else { + message = 'You have $count unread messages'; + } + + return AppIntentResult.successful( + value: message, + needsToContinueInApp: false, + ); + } on DioException catch (e) { + talker.error('[AppIntents] API error checking unread chats', e); + return AppIntentResult.failed( + error: + 'Failed to fetch unread chats: ${e.message ?? 'Network error'}', + ); + } + } catch (e, stack) { + talker.error('[AppIntents] Failed to check unread chats', e, stack); + return AppIntentResult.failed(error: 'Failed to check unread chats: $e'); + } + } + + Future _handleMarkNotificationsReadIntent( + Map parameters, + ) async { + try { + talker.info('[AppIntents] Marking all notifications as read'); + + if (_dio == null) { + return AppIntentResult.failed(error: 'API client not initialized'); + } + + try { + await _dio!.post('/ring/notifications/all/read'); + + talker.info('[AppIntents] Notifications marked as read'); + return AppIntentResult.successful( + value: 'All notifications marked as read', + needsToContinueInApp: false, + ); + } on DioException catch (e) { + talker.error('[AppIntents] API error marking notifications read', e); + return AppIntentResult.failed( + error: + 'Failed to mark notifications: ${e.message ?? 'Network error'}', + ); + } + } catch (e, stack) { + talker.error('[AppIntents] Failed to mark notifications read', e, stack); + return AppIntentResult.failed( + error: 'Failed to mark notifications read: $e', + ); + } + } + // Donation Methods - to be called manually from your app code Future donateOpenChat(String channelId) async { @@ -440,4 +685,72 @@ class AppIntentsService { ); } } + + Future donateSendMessage(String channelId, String content) async { + if (!_initialized) return; + try { + await FlutterAppIntentsService.donateIntentWithMetadata( + 'send_message', + {'channelId': channelId, 'content': content}, + relevanceScore: 0.8, + context: {'feature': 'chat', 'userAction': true}, + ); + talker.info('[AppIntents] Donated send_message intent'); + } catch (e, stack) { + talker.error('[AppIntents] Failed to donate send_message', e, stack); + } + } + + Future donateReadMessages(String channelId) async { + if (!_initialized) return; + try { + await FlutterAppIntentsService.donateIntentWithMetadata( + 'read_messages', + {'channelId': channelId}, + relevanceScore: 0.7, + context: {'feature': 'chat', 'userAction': true}, + ); + talker.info('[AppIntents] Donated read_messages intent'); + } catch (e, stack) { + talker.error('[AppIntents] Failed to donate read_messages', e, stack); + } + } + + Future donateCheckUnreadChats() async { + if (!_initialized) return; + try { + await FlutterAppIntentsService.donateIntentWithMetadata( + 'check_unread_chats', + {}, + relevanceScore: 0.7, + context: {'feature': 'chat', 'userAction': true}, + ); + talker.info('[AppIntents] Donated check_unread_chats intent'); + } catch (e, stack) { + talker.error( + '[AppIntents] Failed to donate check_unread_chats', + e, + stack, + ); + } + } + + Future donateMarkNotificationsRead() async { + if (!_initialized) return; + try { + await FlutterAppIntentsService.donateIntentWithMetadata( + 'mark_notifications_read', + {}, + relevanceScore: 0.6, + context: {'feature': 'notifications', 'userAction': true}, + ); + talker.info('[AppIntents] Donated mark_notifications_read intent'); + } catch (e, stack) { + talker.error( + '[AppIntents] Failed to donate mark_notifications_read', + e, + stack, + ); + } + } }