Command pattle search pages

This commit is contained in:
2025-12-20 22:56:49 +08:00
parent 8c83ee9b88
commit 2ee6b3514c
4 changed files with 610 additions and 67 deletions

View File

@@ -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;
}

View File

@@ -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>(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<RouteItem> get copyWith => _$RouteItemCopyWithImpl<RouteItem>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View File

@@ -59,7 +59,7 @@ final class ChatSubscribeNotifierProvider
}
String _$chatSubscribeNotifierHash() =>
r'2b9fae96eb1f96a514a074985e5efa1c13d10aa4';
r'1aa164429aaab1628b5edbae11e33b0860abdcdc';
final class ChatSubscribeNotifierFamily extends $Family
with

View File

@@ -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<RouteItem> _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<int?>(null);
final animationController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
final scaleAnimation = useAnimation(
Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeOut),
),
);
final opacityAnimation = useAnimation(
Tween<double>(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 <SnChatRoom>[];
return rooms
@@ -77,6 +257,20 @@ class CommandPattleWidget extends HookConsumerWidget {
orElse: () => <SnChatRoom>[],
);
final filteredRoutes = searchQuery.value.isEmpty
? <RouteItem>[]
: _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,13 +316,23 @@ class CommandPattleWidget extends HookConsumerWidget {
onTap: onDismiss,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
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
onTap:
() {}, // Prevent tap from dismissing when tapping inside
child: Container(
width: math.max(MediaQuery.of(context).size.width * 0.6, 320),
width: math.max(
MediaQuery.of(context).size.width * 0.6,
320,
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 500,
@@ -149,29 +354,44 @@ class CommandPattleWidget extends HookConsumerWidget {
SearchBar(
controller: textController,
focusNode: focusNode,
hintText: 'Search chats...',
hintText: 'Search chats and pages...',
leading: const Icon(
Symbols.keyboard_command_key,
).padding(horizontal: 8),
onSubmitted: (_) {
if (filteredRooms.isNotEmpty) {
_navigateToRoom(context, ref, filteredRooms.first);
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 (filteredRooms.isNotEmpty)
if (allResults.isNotEmpty)
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: filteredRooms.length,
itemCount: allResults.length,
itemBuilder: (context, index) {
final room = filteredRooms[index];
final item = allResults[index];
if (item is SnChatRoom) {
return _ChatRoomSearchResult(
room: room,
room: item,
isFocused: index == focusedIndex.value,
onTap: () =>
_navigateToRoom(context, ref, room),
_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();
},
),
),
@@ -183,10 +403,11 @@ 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 {