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,
);
}
}

View File

@@ -169,6 +169,10 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- HotKey (0.2.1)
- hotkey_manager_macos (0.0.1):
- FlutterMacOS
- HotKey
- irondash_engine_context (0.0.1):
- FlutterMacOS
- KeychainAccess (4.2.2)
@@ -274,6 +278,7 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
@@ -312,6 +317,7 @@ SPEC REPOS:
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- HotKey
- KeychainAccess
- nanopb
- OrderedSet
@@ -359,6 +365,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
hotkey_manager_macos:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
irondash_engine_context:
:path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
livekit_client:
@@ -437,6 +445,8 @@ SPEC CHECKSUMS:
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
livekit_client: 3df5a1787d64010ca56c4002959d9e47c03ba3fb