Better command pattle

This commit is contained in:
2025-12-20 23:56:43 +08:00
parent 6010c17900
commit 5f094aca4b
4 changed files with 245 additions and 225 deletions

View File

@@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:image_picker_android/image_picker_android.dart';
import 'package:island/talker.dart';
import 'package:island/firebase_options.dart';
@@ -53,7 +54,8 @@ void main() async {
if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) {
talker.info("[SplashScreen] Initializing desktop window manager...");
await protocolHandler.register('myprotocol');
await protocolHandler.register('solian');
await hotKeyManager.unregisterAll();
talker.info("[SplashScreen] Desktop window manager is ready!");
}

View File

@@ -8,6 +8,7 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:island/pods/config.dart';
import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart';
@@ -39,7 +40,6 @@ class WindowScaffold extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isMaximized = useState(false);
final showPalette = useState(false);
final lastShiftTime = useState<DateTime?>(null);
final keyboardFocusNode = useFocusNode();
useEffect(() {
@@ -111,169 +111,158 @@ class WindowScaffold extends HookConsumerWidget {
const Gap(8),
];
final popHotKey = HotKey(
identifier: 'return_previous_page',
key: PhysicalKeyboardKey.escape,
scope: HotKeyScope.inapp,
);
final cmpHotKey = HotKey(
identifier: 'open_command_pattle',
key: PhysicalKeyboardKey.tab,
modifiers: [HotKeyModifier.shift],
scope: HotKeyScope.inapp,
);
useEffect(() {
hotKeyManager.register(
popHotKey,
keyDownHandler: (_) {
if (closeTopmostOverlayDialog()) {
return;
}
// If no overlay to close, pop the route
if (ref.watch(routerProvider).canPop()) {
ref.read(routerProvider).pop();
}
},
);
hotKeyManager.register(
cmpHotKey,
keyDownHandler: (_) {
showPalette.value = true;
},
);
return () {
hotKeyManager.unregister(popHotKey);
};
}, []);
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
},
child: KeyboardListener(
focusNode: keyboardFocusNode,
onKeyEvent: (event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.shiftLeft ||
event.logicalKey == LogicalKeyboardKey.shiftRight)) {
final now = DateTime.now();
if (lastShiftTime.value != null &&
now.difference(lastShiftTime.value!).inMilliseconds < 300) {
showPalette.value = true;
}
lastShiftTime.value = now;
}
},
child: Actions(
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
DragToMoveArea(
child: Platform.isMacOS
? Stack(
alignment: Alignment.center,
return Material(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
DragToMoveArea(
child: Platform.isMacOS
? Stack(
alignment: Alignment.center,
children: [
if (isWideScreen(context))
Row(
children: [
if (isWideScreen(context))
Row(
children: [
const Spacer(),
...pageActionsButton,
],
)
else
SizedBox(height: 32),
Text(
'Solar Network',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
),
const Spacer(),
...pageActionsButton,
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
else
SizedBox(height: 32),
Text(
'Solar Network',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Expanded(
child: Row(
children: [
Image.asset(
Theme.of(context).brightness ==
Brightness.dark
? 'assets/icons/icon-dark.png'
: 'assets/icons/icon.png',
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
'Solar Network',
textAlign: TextAlign.start,
),
],
).padding(horizontal: 12, vertical: 5),
Image.asset(
Theme.of(context).brightness ==
Brightness.dark
? 'assets/icons/icon-dark.png'
: 'assets/icons/icon.png',
width: 20,
height: 20,
),
IconButton(
icon: Icon(Symbols.minimize),
onPressed: () => windowManager.minimize(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(
isMaximized.value
? Symbols.fullscreen_exit
: Symbols.fullscreen,
),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.restore();
} else {
windowManager.maximize();
}
},
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.close),
onPressed: () => windowManager.hide(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
const SizedBox(width: 8),
Text(
'Solar Network',
textAlign: TextAlign.start,
),
],
).padding(horizontal: 12, vertical: 5),
),
IconButton(
icon: Icon(Symbols.minimize),
onPressed: () => windowManager.minimize(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(
isMaximized.value
? Symbols.fullscreen_exit
: Symbols.fullscreen,
),
),
Expanded(child: child),
],
),
_WebSocketIndicator(),
const UploadOverlay(),
if (showPalette.value)
CommandPattleWidget(
onDismiss: () => showPalette.value = false,
),
],
),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.restore();
} else {
windowManager.maximize();
}
},
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.close),
onPressed: () => windowManager.hide(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
],
),
),
Expanded(child: child),
],
),
),
_WebSocketIndicator(),
const UploadOverlay(),
if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false),
],
),
);
}
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
},
child: KeyboardListener(
focusNode: keyboardFocusNode,
onKeyEvent: (event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.shiftLeft ||
event.logicalKey == LogicalKeyboardKey.shiftRight)) {
final now = DateTime.now();
if (lastShiftTime.value != null &&
now.difference(lastShiftTime.value!).inMilliseconds < 300) {
showPalette.value = true;
}
lastShiftTime.value = now;
}
},
child: Actions(
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: child),
_WebSocketIndicator(),
const UploadOverlay(),
if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false),
],
),
),
),
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: child),
_WebSocketIndicator(),
const UploadOverlay(),
if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false),
],
);
}
}
@@ -405,29 +394,6 @@ class AppScaffold extends HookConsumerWidget {
}
}
class PopIntent extends Intent {
const PopIntent();
}
class PopAction extends Action<PopIntent> {
final WidgetRef ref;
PopAction(this.ref);
@override
void invoke(PopIntent intent) {
// First, try to close any overlay dialogs
if (closeTopmostOverlayDialog()) {
return;
}
// If no overlay to close, pop the route
if (ref.watch(routerProvider).canPop()) {
ref.read(routerProvider).pop();
}
}
}
class PageBackButton extends StatelessWidget {
final Color? color;
final List<Shadow>? shadows;

View File

@@ -195,6 +195,7 @@ class CommandPattleWidget extends HookConsumerWidget {
final focusNode = useFocusNode();
final searchQuery = useState('');
final focusedIndex = useState<int?>(null);
final scrollController = useScrollController();
final animationController = useAnimationController(
duration: const Duration(milliseconds: 200),
@@ -271,6 +272,48 @@ class CommandPattleWidget extends HookConsumerWidget {
// Combine results: chats first, then routes
final allResults = [...filteredChats, ...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
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) {
@@ -280,14 +323,11 @@ class CommandPattleWidget extends HookConsumerWidget {
} else if (isDesktop()) {
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
if (focusedIndex.value != null &&
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);
}
final item = allResults[focusedIndex.value ?? 0];
if (item is SnChatRoom) {
_navigateToChat(context, ref, item);
} else if (item is RouteItem) {
_navigateToRoute(context, ref, item);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (allResults.isNotEmpty) {
@@ -358,16 +398,6 @@ class CommandPattleWidget extends HookConsumerWidget {
leading: CircleAvatar(
child: 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);
}
}
},
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
@@ -378,6 +408,7 @@ class CommandPattleWidget extends HookConsumerWidget {
maxHeight: 300,
),
child: ListView.builder(
controller: scrollController,
shrinkWrap: true,
itemCount: allResults.length,
itemBuilder: (context, index) {
@@ -426,6 +457,7 @@ class CommandPattleWidget extends HookConsumerWidget {
void _navigateToChat(BuildContext context, WidgetRef ref, SnChatRoom room) {
onDismiss();
if (isWideScreen(context)) {
debugPrint('${room.name}');
ref
.read(routerProvider)
.replaceNamed('chatRoom', pathParameters: {'id': room.id});
@@ -455,18 +487,23 @@ class _RouteSearchResult extends StatelessWidget {
@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,
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.secondaryContainer,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
child: Icon(route.icon),
),
title: Text(route.name),
subtitle: Text(route.description),
onTap: onTap,
),
title: Text(route.name),
subtitle: Text(route.description),
onTap: onTap,
);
}
}
@@ -578,30 +615,35 @@ class _ChatRoomSearchResult extends HookConsumerWidget {
final isDirect = room.type == 1;
return ListTile(
tileColor: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
leading: Badge(
isLabelVisible: summary.maybeWhen(
data: (data) => (data?.unreadCount ?? 0) > 0,
orElse: () => false,
),
child: (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(
fileId: room.picture?.id,
), // Placeholder for now
return Container(
decoration: BoxDecoration(
color: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
child: ListTile(
leading: Badge(
isLabelVisible: summary.maybeWhen(
data: (data) => (data?.unreadCount ?? 0) > 0,
orElse: () => false,
),
child: (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(
fileId: room.picture?.id,
), // Placeholder for now
),
title: Text(titleText),
subtitle: buildSubtitle(),
onTap: onTap,
),
title: Text(titleText),
subtitle: buildSubtitle(),
onTap: onTap,
);
}
}