diff --git a/lib/models/route_item.dart b/lib/models/route_item.dart new file mode 100644 index 00000000..cb7b4d92 --- /dev/null +++ b/lib/models/route_item.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'route_item.freezed.dart'; + +@freezed +sealed class RouteItem with _$RouteItem { + const factory RouteItem({ + required String name, + required String path, + required String description, + required IconData icon, + }) = _RouteItem; +} diff --git a/lib/models/route_item.freezed.dart b/lib/models/route_item.freezed.dart new file mode 100644 index 00000000..9b07637e --- /dev/null +++ b/lib/models/route_item.freezed.dart @@ -0,0 +1,274 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'route_item.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RouteItem { + + String get name; String get path; String get description; IconData get icon; +/// Create a copy of RouteItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RouteItemCopyWith get copyWith => _$RouteItemCopyWithImpl(this as RouteItem, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RouteItem&&(identical(other.name, name) || other.name == name)&&(identical(other.path, path) || other.path == path)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,path,description,icon); + +@override +String toString() { + return 'RouteItem(name: $name, path: $path, description: $description, icon: $icon)'; +} + + +} + +/// @nodoc +abstract mixin class $RouteItemCopyWith<$Res> { + factory $RouteItemCopyWith(RouteItem value, $Res Function(RouteItem) _then) = _$RouteItemCopyWithImpl; +@useResult +$Res call({ + String name, String path, String description, IconData icon +}); + + + + +} +/// @nodoc +class _$RouteItemCopyWithImpl<$Res> + implements $RouteItemCopyWith<$Res> { + _$RouteItemCopyWithImpl(this._self, this._then); + + final RouteItem _self; + final $Res Function(RouteItem) _then; + +/// Create a copy of RouteItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? path = null,Object? description = null,Object? icon = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as IconData, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RouteItem]. +extension RouteItemPatterns on RouteItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RouteItem value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RouteItem() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RouteItem value) $default,){ +final _that = this; +switch (_that) { +case _RouteItem(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RouteItem value)? $default,){ +final _that = this; +switch (_that) { +case _RouteItem() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String path, String description, IconData icon)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RouteItem() when $default != null: +return $default(_that.name,_that.path,_that.description,_that.icon);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String path, String description, IconData icon) $default,) {final _that = this; +switch (_that) { +case _RouteItem(): +return $default(_that.name,_that.path,_that.description,_that.icon);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String path, String description, IconData icon)? $default,) {final _that = this; +switch (_that) { +case _RouteItem() when $default != null: +return $default(_that.name,_that.path,_that.description,_that.icon);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RouteItem implements RouteItem { + const _RouteItem({required this.name, required this.path, required this.description, required this.icon}); + + +@override final String name; +@override final String path; +@override final String description; +@override final IconData icon; + +/// Create a copy of RouteItem +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RouteItemCopyWith<_RouteItem> get copyWith => __$RouteItemCopyWithImpl<_RouteItem>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RouteItem&&(identical(other.name, name) || other.name == name)&&(identical(other.path, path) || other.path == path)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name,path,description,icon); + +@override +String toString() { + return 'RouteItem(name: $name, path: $path, description: $description, icon: $icon)'; +} + + +} + +/// @nodoc +abstract mixin class _$RouteItemCopyWith<$Res> implements $RouteItemCopyWith<$Res> { + factory _$RouteItemCopyWith(_RouteItem value, $Res Function(_RouteItem) _then) = __$RouteItemCopyWithImpl; +@override @useResult +$Res call({ + String name, String path, String description, IconData icon +}); + + + + +} +/// @nodoc +class __$RouteItemCopyWithImpl<$Res> + implements _$RouteItemCopyWith<$Res> { + __$RouteItemCopyWithImpl(this._self, this._then); + + final _RouteItem _self; + final $Res Function(_RouteItem) _then; + +/// Create a copy of RouteItem +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? path = null,Object? description = null,Object? icon = null,}) { + return _then(_RouteItem( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as IconData, + )); +} + + +} + +// dart format on diff --git a/lib/pods/chat/chat_subscribe.g.dart b/lib/pods/chat/chat_subscribe.g.dart index 0f704c2d..da6ad242 100644 --- a/lib/pods/chat/chat_subscribe.g.dart +++ b/lib/pods/chat/chat_subscribe.g.dart @@ -59,7 +59,7 @@ final class ChatSubscribeNotifierProvider } String _$chatSubscribeNotifierHash() => - r'2b9fae96eb1f96a514a074985e5efa1c13d10aa4'; + r'1aa164429aaab1628b5edbae11e33b0860abdcdc'; final class ChatSubscribeNotifierFamily extends $Family with diff --git a/lib/widgets/cmp/pattle.dart b/lib/widgets/cmp/pattle.dart index 3afbb519..6a28d286 100644 --- a/lib/widgets/cmp/pattle.dart +++ b/lib/widgets/cmp/pattle.dart @@ -8,6 +8,7 @@ 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'; @@ -23,6 +24,171 @@ class CommandPattleWidget extends HookConsumerWidget { const CommandPattleWidget({super.key, required this.onDismiss}); + static final List _availableRoutes = [ + RouteItem( + name: 'Dashboard', + path: '/', + description: 'Main dashboard', + icon: Symbols.home, + ), + RouteItem( + name: 'Explore', + path: '/explore', + description: 'Discover content', + icon: Symbols.explore, + ), + RouteItem( + name: 'Post Search', + path: '/posts/search', + description: 'Search posts', + icon: Symbols.search, + ), + RouteItem( + name: 'Post Shuffle', + path: '/posts/shuffle', + description: 'Random posts', + icon: Symbols.shuffle, + ), + RouteItem( + name: 'Post Categories', + path: '/posts/categories', + description: 'Browse categories', + icon: Symbols.category, + ), + RouteItem( + name: 'Discovery Realms', + path: '/discovery/realms', + description: 'Explore realms', + icon: Symbols.public, + ), + RouteItem( + name: 'Chat', + path: '/chat', + description: 'Messages and conversations', + icon: Symbols.chat, + ), + RouteItem( + name: 'Realms', + path: '/realms', + description: 'Community realms', + icon: Symbols.group, + ), + RouteItem( + name: 'Account', + path: '/account', + description: 'Your profile and settings', + icon: Symbols.person, + ), + RouteItem( + name: 'Sticker Marketplace', + path: '/stickers', + description: 'Browse sticker packs', + icon: Symbols.emoji_emotions, + ), + RouteItem( + name: 'Web Feeds', + path: '/feeds', + description: 'RSS and web feeds', + icon: Symbols.feed, + ), + RouteItem( + name: 'Wallet', + path: '/account/wallet', + description: 'Your digital wallet', + icon: Symbols.account_balance_wallet, + ), + RouteItem( + name: 'Relationships', + path: '/account/relationships', + description: 'Friends and connections', + icon: Symbols.people, + ), + RouteItem( + name: 'Update Profile', + path: '/account/me/update', + description: 'Edit your profile', + icon: Symbols.edit, + ), + RouteItem( + name: 'Leveling', + path: '/account/me/leveling', + description: 'Your progress and levels', + icon: Symbols.trending_up, + ), + RouteItem( + name: 'Account Settings', + path: '/account/me/settings', + description: 'App preferences', + icon: Symbols.settings, + ), + RouteItem( + name: 'Reports', + path: '/safety/reports/me', + description: 'Your abuse reports', + icon: Symbols.report, + ), + RouteItem( + name: 'Files', + path: '/files', + description: 'File manager', + icon: Symbols.folder, + ), + RouteItem( + name: 'Thought', + path: '/thought', + description: 'AI assistant', + icon: Symbols.psychology, + ), + RouteItem( + name: 'Creator Hub', + path: '/creators', + description: 'Content creation tools', + icon: Symbols.create, + ), + RouteItem( + name: 'Developer Hub', + path: '/developers', + description: 'Developer tools', + icon: Symbols.code, + ), + RouteItem( + name: 'Logs', + path: '/logs', + description: 'Application logs', + icon: Symbols.bug_report, + ), + RouteItem( + name: 'Articles', + path: '/feeds/articles', + description: 'Web articles', + icon: Symbols.article, + ), + RouteItem( + name: 'Login', + path: '/auth/login', + description: 'Sign in to your account', + icon: Symbols.login, + ), + RouteItem( + name: 'Create Account', + path: '/auth/create-account', + description: 'Create a new account', + icon: Symbols.person_add, + ), + RouteItem( + name: 'Settings', + path: '/settings', + description: 'Application settings', + icon: Symbols.settings, + ), + RouteItem( + name: 'About', + path: '/about', + description: 'About this app', + icon: Symbols.info, + ), + ]; + @override Widget build(BuildContext context, WidgetRef ref) { final textController = useTextEditingController(); @@ -30,8 +196,23 @@ class CommandPattleWidget extends HookConsumerWidget { final searchQuery = useState(''); final focusedIndex = useState(null); + 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; }, []); @@ -47,14 +228,13 @@ class CommandPattleWidget extends HookConsumerWidget { }, [textController]); final chatRooms = ref.watch(chatRoomJoinedProvider); - final userInfo = ref.watch(userInfoProvider); bool isDesktop() => kIsWeb || (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)); - final filteredRooms = chatRooms.maybeWhen( + final filteredChats = chatRooms.maybeWhen( data: (rooms) { if (searchQuery.value.isEmpty) return []; return rooms @@ -77,6 +257,20 @@ class CommandPattleWidget extends HookConsumerWidget { orElse: () => [], ); + final filteredRoutes = searchQuery.value.isEmpty + ? [] + : _availableRoutes + .where((route) { + final query = searchQuery.value.toLowerCase(); + return route.name.toLowerCase().contains(query) || + route.description.toLowerCase().contains(query); + }) + .take(5) // Limit to 5 results + .toList(); + + // Combine results: chats first, then routes + final allResults = [...filteredChats, ...filteredRoutes]; + return KeyboardListener( focusNode: FocusNode(), onKeyEvent: (event) { @@ -87,15 +281,16 @@ class CommandPattleWidget extends HookConsumerWidget { if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { if (focusedIndex.value != null && - focusedIndex.value! < filteredRooms.length) { - _navigateToRoom( - context, - ref, - filteredRooms[focusedIndex.value!], - ); + focusedIndex.value! < allResults.length) { + final item = allResults[focusedIndex.value!]; + if (item is SnChatRoom) { + _navigateToChat(context, ref, item); + } else if (item is RouteItem) { + _navigateToRoute(context, ref, item); + } } } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - if (filteredRooms.isNotEmpty) { + if (allResults.isNotEmpty) { if (focusedIndex.value == null) { focusedIndex.value = 0; } else { @@ -103,12 +298,12 @@ class CommandPattleWidget extends HookConsumerWidget { } } } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - if (filteredRooms.isNotEmpty) { + if (allResults.isNotEmpty) { if (focusedIndex.value == null) { focusedIndex.value = 0; } else { focusedIndex.value = math.min( - filteredRooms.length - 1, + allResults.length - 1, focusedIndex.value! + 1, ); } @@ -121,61 +316,87 @@ class CommandPattleWidget extends HookConsumerWidget { onTap: onDismiss, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Container( - color: Colors.black.withOpacity(0.5), - child: Center( - 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(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SearchBar( - controller: textController, - focusNode: focusNode, - hintText: 'Search chats...', - leading: const Icon( - Symbols.keyboard_command_key, - ).padding(horizontal: 8), - onSubmitted: (_) { - if (filteredRooms.isNotEmpty) { - _navigateToRoom(context, ref, filteredRooms.first); - } - }, - ), - if (filteredRooms.isNotEmpty) - Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: filteredRooms.length, - itemBuilder: (context, index) { - final room = filteredRooms[index]; - return _ChatRoomSearchResult( - room: room, - isFocused: index == focusedIndex.value, - onTap: () => - _navigateToRoom(context, ref, room), - ); - }, - ), + child: AnimatedBuilder( + animation: animationController, + builder: (context, child) => Opacity( + opacity: opacityAnimation, + child: Transform.scale(scale: scaleAnimation, child: child), + ), + child: Container( + color: Colors.black.withOpacity(0.5), + child: Center( + 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(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + spreadRadius: 2, ), - ], + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SearchBar( + controller: textController, + focusNode: focusNode, + hintText: 'Search chats and pages...', + leading: const Icon( + Symbols.keyboard_command_key, + ).padding(horizontal: 8), + onSubmitted: (_) { + if (allResults.isNotEmpty) { + final item = allResults.first; + if (item is SnChatRoom) { + _navigateToChat(context, ref, item); + } else if (item is RouteItem) { + _navigateToRoute(context, ref, item); + } + } + }, + ), + if (allResults.isNotEmpty) + Flexible( + child: ListView.builder( + 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 RouteItem) { + return _RouteSearchResult( + route: item, + isFocused: index == focusedIndex.value, + onTap: () => + _navigateToRoute(context, ref, item), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ], + ), ), ), ), @@ -186,7 +407,7 @@ class CommandPattleWidget extends HookConsumerWidget { ); } - void _navigateToRoom(BuildContext context, WidgetRef ref, SnChatRoom room) { + void _navigateToChat(BuildContext context, WidgetRef ref, SnChatRoom room) { onDismiss(); if (isWideScreen(context)) { ref @@ -198,6 +419,40 @@ class CommandPattleWidget extends HookConsumerWidget { .pushNamed('chatRoom', pathParameters: {'id': room.id}); } } + + void _navigateToRoute(BuildContext context, WidgetRef ref, RouteItem route) { + onDismiss(); + ref.read(routerProvider).go(route.path); + } +} + +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 ListTile( + tileColor: isFocused + ? Theme.of(context).colorScheme.surfaceContainerHighest + : null, + leading: CircleAvatar( + child: Icon(route.icon), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + ), + title: Text(route.name), + subtitle: Text(route.description), + onTap: onTap, + ); + } } class _ChatRoomSearchResult extends HookConsumerWidget {