diff --git a/lib/main.dart b/lib/main.dart index 7d6bbab4..f524dcaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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!"); } diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index d85b0b1d..a1856cbf 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -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(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(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: >{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(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: >{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 { - 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? shadows; diff --git a/lib/widgets/cmp/pattle.dart b/lib/widgets/cmp/pattle.dart index 3707cb50..2ad6e220 100644 --- a/lib/widgets/cmp/pattle.dart +++ b/lib/widgets/cmp/pattle.dart @@ -195,6 +195,7 @@ class CommandPattleWidget extends HookConsumerWidget { final focusNode = useFocusNode(); final searchQuery = useState(''); final focusedIndex = useState(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, ); } } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9db73d2a..dad0ecdf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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