💄 Optimized CMP
✨ CMP now available to search the web
This commit is contained in:
@@ -19,6 +19,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:relative_time/relative_time.dart';
|
import 'package:relative_time/relative_time.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:island/services/event_bus.dart';
|
import 'package:island/services/event_bus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class CommandPattleWidget extends HookConsumerWidget {
|
class CommandPattleWidget extends HookConsumerWidget {
|
||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
@@ -143,23 +144,22 @@ class CommandPattleWidget extends HookConsumerWidget {
|
|||||||
.take(5) // Limit to 5 results
|
.take(5) // Limit to 5 results
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Combine results: chats first, then special actions, then routes
|
final filteredFallbacks =
|
||||||
|
searchQuery.value.isNotEmpty &&
|
||||||
|
filteredChats.isEmpty &&
|
||||||
|
filteredSpecialActions.isEmpty &&
|
||||||
|
filteredRoutes.isEmpty
|
||||||
|
? _getFallbackActions(searchQuery.value)
|
||||||
|
: <FallbackAction>[];
|
||||||
|
|
||||||
|
// Combine results: fallbacks first, then chats, special actions, routes
|
||||||
final allResults = [
|
final allResults = [
|
||||||
|
...filteredFallbacks,
|
||||||
...filteredChats,
|
...filteredChats,
|
||||||
...filteredSpecialActions,
|
...filteredSpecialActions,
|
||||||
...filteredRoutes,
|
...filteredRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Update focused index when results change
|
|
||||||
useEffect(() {
|
|
||||||
if (allResults.isNotEmpty && focusedIndex.value == null) {
|
|
||||||
focusedIndex.value = 0;
|
|
||||||
} else if (allResults.isEmpty) {
|
|
||||||
focusedIndex.value = null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [allResults]);
|
|
||||||
|
|
||||||
// Scroll to focused item
|
// Scroll to focused item
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (focusedIndex.value != null && allResults.isNotEmpty) {
|
if (focusedIndex.value != null && allResults.isNotEmpty) {
|
||||||
@@ -209,6 +209,9 @@ class CommandPattleWidget extends HookConsumerWidget {
|
|||||||
item.action();
|
item.action();
|
||||||
} else if (item is RouteItem) {
|
} else if (item is RouteItem) {
|
||||||
_navigateToRoute(context, ref, item);
|
_navigateToRoute(context, ref, item);
|
||||||
|
} else if (item is FallbackAction) {
|
||||||
|
onDismiss();
|
||||||
|
item.action();
|
||||||
}
|
}
|
||||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||||
if (allResults.isNotEmpty) {
|
if (allResults.isNotEmpty) {
|
||||||
@@ -239,107 +242,123 @@ class CommandPattleWidget extends HookConsumerWidget {
|
|||||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.5),
|
color: Colors.black.withOpacity(0.5),
|
||||||
child: Center(
|
child: Align(
|
||||||
child: AnimatedBuilder(
|
alignment: Alignment.topCenter,
|
||||||
animation: animationController,
|
child: Padding(
|
||||||
builder: (context, child) => Opacity(
|
padding: EdgeInsets.only(
|
||||||
opacity: opacityAnimation,
|
top: MediaQuery.of(context).size.height * 0.2,
|
||||||
child: Transform.scale(scale: scaleAnimation, child: child),
|
|
||||||
),
|
),
|
||||||
child: GestureDetector(
|
child: AnimatedBuilder(
|
||||||
onTap:
|
animation: animationController,
|
||||||
() {}, // Prevent tap from dismissing when tapping inside
|
builder: (context, child) => Opacity(
|
||||||
child: Container(
|
opacity: opacityAnimation,
|
||||||
width: math.max(
|
child: Transform.scale(scale: scaleAnimation, child: child),
|
||||||
MediaQuery.of(context).size.width * 0.6,
|
),
|
||||||
320,
|
child: GestureDetector(
|
||||||
),
|
onTap:
|
||||||
constraints: const BoxConstraints(
|
() {}, // Prevent tap from dismissing when tapping inside
|
||||||
maxWidth: 600,
|
child: Container(
|
||||||
maxHeight: 500,
|
width: math.max(
|
||||||
),
|
MediaQuery.of(context).size.width * 0.6,
|
||||||
decoration: BoxDecoration(
|
320,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
),
|
||||||
borderRadius: BorderRadius.circular(28),
|
constraints: const BoxConstraints(
|
||||||
boxShadow: [
|
maxWidth: 600,
|
||||||
BoxShadow(
|
maxHeight: 500,
|
||||||
color: Colors.black.withOpacity(0.3),
|
),
|
||||||
blurRadius: 10,
|
decoration: BoxDecoration(
|
||||||
spreadRadius: 2,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
),
|
borderRadius: BorderRadius.circular(28),
|
||||||
],
|
boxShadow: [
|
||||||
),
|
BoxShadow(
|
||||||
child: Material(
|
color: Colors.black.withOpacity(0.3),
|
||||||
elevation: 0,
|
blurRadius: 10,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
spreadRadius: 2,
|
||||||
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),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -369,6 +388,62 @@ class CommandPattleWidget extends HookConsumerWidget {
|
|||||||
onDismiss();
|
onDismiss();
|
||||||
ref.read(routerProvider).go(route.path);
|
ref.read(routerProvider).go(route.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<FallbackAction> _getFallbackActions(String query) {
|
||||||
|
final List<FallbackAction> actions = [];
|
||||||
|
|
||||||
|
// Check if query is a URL
|
||||||
|
final Uri? uri = Uri.tryParse(query);
|
||||||
|
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) {
|
||||||
|
actions.add(
|
||||||
|
FallbackAction(
|
||||||
|
name: 'Open URL',
|
||||||
|
description: 'Open $query in browser',
|
||||||
|
icon: Symbols.open_in_new,
|
||||||
|
action: () async {
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 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<String> searchableAliases;
|
||||||
|
|
||||||
|
const FallbackAction({
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.icon,
|
||||||
|
required this.action,
|
||||||
|
this.searchableAliases = const [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RouteSearchResult extends StatelessWidget {
|
class _RouteSearchResult extends StatelessWidget {
|
||||||
@@ -439,6 +514,40 @@ class _SpecialActionSearchResult extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _ChatRoomSearchResult extends HookConsumerWidget {
|
||||||
final SnChatRoom room;
|
final SnChatRoom room;
|
||||||
final bool isFocused;
|
final bool isFocused;
|
||||||
|
|||||||
Reference in New Issue
Block a user