import 'dart:io'; import 'dart:math' as math; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:island/models/chat.dart'; import 'package:island/models/route_item.dart'; import 'package:island/pods/chat/chat_room.dart'; import 'package:island/pods/chat/chat_summary.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/route.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/chat_room_widgets.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:relative_time/relative_time.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:island/services/event_bus.dart'; import 'package:url_launcher/url_launcher.dart'; class CommandPattleWidget extends HookConsumerWidget { final VoidCallback onDismiss; const CommandPattleWidget({super.key, required this.onDismiss}); static List _getSpecialActions(BuildContext context) { return [ SpecialAction( name: 'postCompose'.tr(), description: 'postComposeDescription'.tr(), icon: Symbols.edit, action: () { eventBus.fire(const ShowComposeSheetEvent()); }, ), SpecialAction( name: 'notifications'.tr(), description: 'notificationsDescription'.tr(), searchableAliases: ['notifications', 'alert', 'bell'], icon: Symbols.notifications, action: () { eventBus.fire(const ShowNotificationSheetEvent()); }, ), ]; } @override Widget build(BuildContext context, WidgetRef ref) { final textController = useTextEditingController(); final focusNode = useFocusNode(); final searchQuery = useState(''); final focusedIndex = useState(null); final scrollController = useScrollController(); final animationController = useAnimationController( duration: const Duration(milliseconds: 200), ); final scaleAnimation = useAnimation( Tween(begin: 0.8, end: 1.0).animate( CurvedAnimation(parent: animationController, curve: Curves.easeOut), ), ); final opacityAnimation = useAnimation( Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: animationController, curve: Curves.easeOut), ), ); useEffect(() { focusNode.requestFocus(); animationController.forward(); return null; }, []); useEffect(() { void listener() { searchQuery.value = textController.text; // Reset focused index when search changes focusedIndex.value = null; } textController.addListener(listener); return () => textController.removeListener(listener); }, [textController]); final chatRooms = ref.watch(chatRoomJoinedProvider); bool isDesktop() => kIsWeb || (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)); final filteredChats = chatRooms.maybeWhen( data: (rooms) { if (searchQuery.value.isEmpty) return []; return rooms .where((room) { final title = room.name ?? ''; final desc = room.description ?? ''; final query = searchQuery.value.toLowerCase(); return title.toLowerCase().contains(query) || desc.toLowerCase().contains(query) || (room.members?.any( (member) => member.account.name.contains(query) || member.account.nick.contains(query), ) ?? false); }) .take(5) // Limit to 5 results .toList(); }, orElse: () => [], ); final filteredRoutes = searchQuery.value.isEmpty ? [] : kAvailableRoutes .where((route) { final query = searchQuery.value.toLowerCase(); return route.name.toLowerCase().contains(query) || route.description.toLowerCase().contains(query) || route.searchableAliases.any( (e) => e.toLowerCase().contains(query), ); }) .take(5) // Limit to 5 results .toList(); final filteredSpecialActions = searchQuery.value.isEmpty ? [] : _getSpecialActions(context) .where((action) { final query = searchQuery.value.toLowerCase(); return action.name.toLowerCase().contains(query) || action.description.toLowerCase().contains(query) || action.searchableAliases.any( (e) => e.toLowerCase().contains(query), ); }) .take(5) // Limit to 5 results .toList(); final filteredFallbacks = searchQuery.value.isNotEmpty && filteredChats.isEmpty && filteredSpecialActions.isEmpty && filteredRoutes.isEmpty ? _getFallbackActions(context, searchQuery.value) : []; // Combine results: fallbacks first, then chats, special actions, routes final allResults = [ ...filteredFallbacks, ...filteredChats, ...filteredSpecialActions, ...filteredRoutes, ]; // Scroll to focused item useEffect(() { if (focusedIndex.value != null && allResults.isNotEmpty) { // Wait for the next frame to ensure ScrollController is attached WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients) { // Estimate item height (ListTile is typically around 72-88 pixels) const double estimatedItemHeight = 80.0; final double itemTopOffset = focusedIndex.value! * estimatedItemHeight; final double viewportHeight = scrollController.position.viewportDimension; final double centeredOffset = itemTopOffset - (viewportHeight / 2) + (estimatedItemHeight / 2); // Animate scroll to center the focused item scrollController.animateTo( centeredOffset.clamp( 0.0, scrollController.position.maxScrollExtent, ), duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } return null; }, [focusedIndex.value]); return KeyboardListener( focusNode: FocusNode(), onKeyEvent: (event) { if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.escape) { onDismiss(); } else if (isDesktop()) { if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { final item = allResults[focusedIndex.value ?? 0]; _executeItem(context, ref, item); } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { if (allResults.isNotEmpty) { if (focusedIndex.value == null) { focusedIndex.value = 0; } else { focusedIndex.value = math.max(0, focusedIndex.value! - 1); } } } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { if (allResults.isNotEmpty) { if (focusedIndex.value == null) { focusedIndex.value = 0; } else { focusedIndex.value = math.min( allResults.length - 1, focusedIndex.value! + 1, ); } } } } } }, child: GestureDetector( onTap: onDismiss, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: Container( color: Colors.black.withOpacity(0.5), child: Align( alignment: Alignment.topCenter, child: Padding( padding: EdgeInsets.only( top: MediaQuery.of(context).size.height * 0.2, ), child: AnimatedBuilder( animation: animationController, builder: (context, child) => Opacity( opacity: opacityAnimation, child: Transform.scale(scale: scaleAnimation, child: child), ), child: GestureDetector( onTap: () {}, // Prevent tap from dismissing when tapping inside child: Container( width: math.max( MediaQuery.of(context).size.width * 0.6, 320, ), constraints: const BoxConstraints( maxWidth: 600, maxHeight: 500, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 10, spreadRadius: 2, ), ], ), child: Material( elevation: 0, color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ SearchBar( controller: textController, focusNode: focusNode, hintText: 'searchChatsAndPages'.tr(), leading: CircleAvatar( child: const Icon(Symbols.keyboard_command_key), ).padding(horizontal: 8), onSubmitted: !isDesktop() && allResults.isNotEmpty ? (value) => _executeItem( context, ref, allResults[0], ) : null, ), AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: allResults.isNotEmpty ? ConstrainedBox( constraints: const BoxConstraints( maxHeight: 300, ), child: ListView.builder( padding: EdgeInsets.zero, controller: scrollController, shrinkWrap: true, itemCount: allResults.length, itemBuilder: (context, index) { final item = allResults[index]; if (item is SnChatRoom) { return _ChatRoomSearchResult( room: item, isFocused: index == focusedIndex.value, onTap: () => _navigateToChat( context, ref, item, ), ); } else if (item is SpecialAction) { return _SpecialActionSearchResult( action: item, isFocused: index == focusedIndex.value, onTap: () { onDismiss(); item.action(); }, ); } else if (item is RouteItem) { return _RouteSearchResult( route: item, isFocused: index == focusedIndex.value, onTap: () => _navigateToRoute( context, ref, item, ), ); } else if (item is FallbackAction) { return _FallbackSearchResult( action: item, isFocused: index == focusedIndex.value, onTap: () { onDismiss(); item.action(); }, ); } return const SizedBox.shrink(); }, ), ) : const SizedBox.shrink(), ), ], ), ), ), ), ), ), ), ), ), ), ); } void _navigateToChat(BuildContext context, WidgetRef ref, SnChatRoom room) { onDismiss(); if (isWideScreen(context)) { debugPrint('${room.name}'); ref .read(routerProvider) .replaceNamed('chatRoom', pathParameters: {'id': room.id}); } else { ref .read(routerProvider) .pushNamed('chatRoom', pathParameters: {'id': room.id}); } } void _navigateToRoute(BuildContext context, WidgetRef ref, RouteItem route) { onDismiss(); ref.read(routerProvider).go(route.path); } void _executeItem(BuildContext context, WidgetRef ref, dynamic item) { if (item is SnChatRoom) { _navigateToChat(context, ref, item); } else if (item is SpecialAction) { onDismiss(); item.action(); } else if (item is RouteItem) { _navigateToRoute(context, ref, item); } else if (item is FallbackAction) { onDismiss(); item.action(); } } static List _getFallbackActions( BuildContext context, String query, ) { final List actions = []; // Check if query is a URL final Uri? uri = Uri.tryParse(query); final isValidUrl = uri != null && (uri.scheme == 'http' || uri.scheme == 'https'); final isDomain = RegExp( r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$', ).hasMatch(query); if (isValidUrl || isDomain) { final finalUri = isDomain ? Uri.parse('https://$query') : uri!; actions.add( FallbackAction( name: 'Open URL', description: 'Open ${finalUri.toString()} in browser', icon: Symbols.open_in_new, action: () async { if (await canLaunchUrl(finalUri)) { await launchUrl(finalUri, mode: LaunchMode.externalApplication); } }, ), ); } // Ask the AI // Bugged, DO NOT USE // actions.add( // FallbackAction( // name: 'Ask the AI', // description: 'Ask "$query" to the AI', // icon: Symbols.bubble_chart, // action: () { // eventBus.fire(ShowThoughtSheetEvent(initialMessage: query)); // }, // ), // ); // Search the web actions.add( FallbackAction( name: 'Search the web', description: 'Search "$query" on Google', icon: Symbols.search, action: () async { final searchUri = Uri.https('www.google.com', '/search', { 'q': query, }); if (await canLaunchUrl(searchUri)) { await launchUrl(searchUri, mode: LaunchMode.externalApplication); } }, ), ); return actions; } } class FallbackAction { final String name; final String description; final IconData icon; final VoidCallback action; final List searchableAliases; const FallbackAction({ required this.name, required this.description, required this.icon, required this.action, this.searchableAliases = const [], }); } class _RouteSearchResult extends StatelessWidget { final RouteItem route; final bool isFocused; final VoidCallback onTap; const _RouteSearchResult({ required this.route, required this.isFocused, required this.onTap, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: isFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : null, borderRadius: const BorderRadius.all(Radius.circular(28)), ), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.secondaryContainer, foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, child: Icon(route.icon), ), title: Text(route.name), subtitle: Text(route.description), onTap: onTap, ), ); } } class _SpecialActionSearchResult extends StatelessWidget { final SpecialAction action; final bool isFocused; final VoidCallback onTap; const _SpecialActionSearchResult({ required this.action, required this.isFocused, required this.onTap, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: isFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : null, borderRadius: const BorderRadius.all(Radius.circular(24)), ), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, foregroundColor: Theme.of(context).colorScheme.onTertiaryContainer, child: Icon(action.icon), ), title: Text(action.name), subtitle: Text(action.description), onTap: onTap, ), ); } } class _FallbackSearchResult extends StatelessWidget { final FallbackAction action; final bool isFocused; final VoidCallback onTap; const _FallbackSearchResult({ required this.action, required this.isFocused, required this.onTap, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: isFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : null, borderRadius: const BorderRadius.all(Radius.circular(24)), ), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, child: Icon(action.icon), ), title: Text(action.name), subtitle: Text(action.description), onTap: onTap, ), ); } } class _ChatRoomSearchResult extends HookConsumerWidget { final SnChatRoom room; final bool isFocused; final VoidCallback onTap; const _ChatRoomSearchResult({ required this.room, required this.isFocused, required this.onTap, }); @override Widget build(BuildContext context, WidgetRef ref) { final userInfo = ref.watch(userInfoProvider); final summary = ref .watch(chatSummaryProvider) .whenData((summaries) => summaries[room.id]); var validMembers = room.members ?? []; if (validMembers.isNotEmpty && userInfo.value != null) { validMembers = validMembers .where((e) => e.accountId != userInfo.value!.id) .toList(); } String titleText; if (room.type == 1 && room.name == null) { if (room.members?.isNotEmpty ?? false) { titleText = validMembers.map((e) => e.account.nick).join(', '); } else { titleText = 'Direct Message'; } } else { titleText = room.name ?? ''; } Widget buildSubtitle() { return summary.when( data: (data) => data == null ? (room.type == 1 && room.description == null ? Text( validMembers.map((e) => '@${e.account.name}').join(', '), ) : Text(room.description ?? '')) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (data.unreadCount > 0) Text( 'unreadMessages'.plural(data.unreadCount), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), if (data.lastMessage == null) room.type == 1 && room.description == null ? Text( validMembers .map((e) => '@${e.account.name}') .join(', '), ) : Text(room.description ?? '') else Row( spacing: 4, children: [ Badge( label: Text(data.lastMessage!.sender.account.nick), textColor: Theme.of(context).colorScheme.onPrimary, backgroundColor: Theme.of( context, ).colorScheme.primary, ), Expanded( child: Text( (data.lastMessage!.content?.isNotEmpty ?? false) ? data.lastMessage!.content! : 'messageNone'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ), Align( alignment: Alignment.centerRight, child: Text( RelativeTime( context, ).format(data.lastMessage!.createdAt), style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ], ), loading: () => room.type == 1 && room.description == null ? Text(validMembers.map((e) => '@${e.account.name}').join(', ')) : Text(room.description ?? ''), error: (_, _) => room.type == 1 && room.description == null ? Text(validMembers.map((e) => '@${e.account.name}').join(', ')) : Text(room.description ?? ''), ); } final isDirect = room.type == 1; return Container( decoration: BoxDecoration( color: isFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : null, borderRadius: const BorderRadius.all(Radius.circular(24)), ), child: ListTile( leading: ChatRoomAvatar( room: room, isDirect: isDirect, summary: summary, validMembers: validMembers, ), title: Text(titleText), subtitle: buildSubtitle(), onTap: onTap, ), ); } }