diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 980013ed..d2501732 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -1085,8 +1085,8 @@ "thoughtDefaultTopic": "寻思", "thoughtAiName": "SN 酱", "thoughtUserName": "您", - "thoughtStreamingHint": "Sn-chan 正在思考...", - "thoughtInputHint": "问 sn-chan 任何问题...", + "thoughtStreamingHint": "SN 酱正在思考...", + "thoughtInputHint": "问 SN 酱任何问题...", "thoughtNewConversation": "开始新对话", "thoughtParseError": "解析 AI 响应失败", "aiThought": "寻思", diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 16c42199..e2a6eab7 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -10,12 +10,13 @@ import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/chat_summary.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/realm/realms.dart'; +import 'package:island/services/event_bus.dart'; import 'package:island/services/responsive.dart'; -import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/navigation/fab_menu.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:relative_time/relative_time.dart'; @@ -333,28 +334,30 @@ class ChatListScreen extends HookConsumerWidget { tabController.addListener(() { selectedTab.value = tabController.index; }); - return null; + + // Listen for chat rooms refresh events + final subscription = eventBus.on().listen((event) { + ref.invalidate(chatroomsJoinedProvider); + }); + + return () { + subscription.cancel(); + }; }, [tabController]); - Future createDirectMessage() async { - final result = await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (context) => const AccountPickerSheet(), - ); - if (result == null) return; - final client = ref.read(apiClientProvider); - try { - await client.post( - '/sphere/chat/direct', - data: {'related_user_id': result.id}, - ); - ref.invalidate(chatroomsJoinedProvider); - } catch (err) { - showErrorAlert(err); - } - } + useEffect(() { + // Set FAB type to chat + final fabMenuNotifier = ref.read(fabMenuTypeProvider.notifier); + WidgetsBinding.instance.addPostFrameCallback((_) { + fabMenuNotifier.state = FabMenuType.chat; + }); + return () { + // Clean up: reset FAB type to main + WidgetsBinding.instance.addPostFrameCallback((_) { + fabMenuNotifier.state = FabMenuType.main; + }); + }; + }, []); if (isAside) { return Card( @@ -491,43 +494,7 @@ class ChatListScreen extends HookConsumerWidget { const Gap(8), ], ), - floatingActionButton: FloatingActionButton( - onPressed: () { - showModalBottomSheet( - context: context, - useRootNavigator: true, - builder: - (context) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - title: const Text('createChatRoom').tr(), - leading: const Icon(Symbols.add), - onTap: () { - Navigator.pop(context); - context.pushNamed('chatNew').then((value) { - if (value != null) { - ref.invalidate(chatroomsJoinedProvider); - } - }); - }, - ), - ListTile( - title: const Text('createDirectMessage').tr(), - leading: const Icon(Symbols.person), - onTap: () { - Navigator.pop(context); - createDirectMessage(); - }, - ), - Gap(MediaQuery.of(context).padding.bottom + 16), - ], - ), - ); - }, - child: const Icon(Symbols.add), - ), + floatingActionButton: const FabMenu(), body: ChatListBodyWidget( isFloating: false, tabController: tabController, diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart index e1354cc0..79833035 100644 --- a/lib/screens/realm/realms.dart +++ b/lib/screens/realm/realms.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,6 +10,7 @@ import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/navigation/fab_menu.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -41,6 +43,20 @@ class RealmListScreen extends HookConsumerWidget { final realms = ref.watch(realmsJoinedProvider); final realmInvites = ref.watch(realmInvitesProvider); + useEffect(() { + // Set FAB type to realm + final fabMenuNotifier = ref.read(fabMenuTypeProvider.notifier); + WidgetsBinding.instance.addPostFrameCallback((_) { + fabMenuNotifier.state = FabMenuType.realm; + }); + return () { + // Clean up: reset FAB type to main + WidgetsBinding.instance.addPostFrameCallback((_) { + fabMenuNotifier.state = FabMenuType.main; + }); + }; + }, []); + return AppScaffold( isNoBackground: false, appBar: AppBar( @@ -78,17 +94,7 @@ class RealmListScreen extends HookConsumerWidget { const Gap(8), ], ), - floatingActionButton: FloatingActionButton( - heroTag: const Key("realms-page-fab"), - child: const Icon(Symbols.add), - onPressed: () { - context.pushNamed('realmNew').then((value) { - if (value != null) { - ref.invalidate(realmsJoinedProvider); - } - }); - }, - ), + floatingActionButton: const FabMenu(), body: ExtendedRefreshIndicator( child: realms.when( data: diff --git a/lib/screens/tabs.dart b/lib/screens/tabs.dart index 21185143..0e23e7af 100644 --- a/lib/screens/tabs.dart +++ b/lib/screens/tabs.dart @@ -3,14 +3,14 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:island/screens/notification.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/navigation/conditional_bottom_nav.dart'; -import 'package:island/widgets/post/compose_dialog.dart'; +import 'package:island/widgets/navigation/fab_menu.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; final currentRouteProvider = StateProvider((ref) => null); @@ -120,6 +120,10 @@ class TabsScreen extends HookConsumerWidget { .toList(), selectedIndex: currentIndex, onDestinationSelected: onDestinationSelected, + trailingAtBottom: true, + trailing: const FabMenu( + elevation: 0, + ).padding(bottom: MediaQuery.of(context).padding.bottom + 16), ), Expanded( child: ClipRRect( @@ -145,78 +149,9 @@ class TabsScreen extends HookConsumerWidget { ), child: child ?? const SizedBox.shrink(), ), - floatingActionButton: - shouldShowFab - ? FloatingActionButton( - child: const Icon(Symbols.menu), - onPressed: () { - showModalBottomSheet( - context: context, - builder: (BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(24), - ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - leading: const Icon(Symbols.post_add_rounded), - title: Text('postCompose'.tr()), - onTap: () async { - Navigator.of(context).pop(); - await PostComposeDialog.show(context); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - leading: const Icon(Symbols.bubble_chart), - title: Text('aiThoughtTitle'.tr()), - onTap: () async { - Navigator.of(context).pop(); - context.pushNamed('thought'); - }, - ), - Consumer( - builder: (context, ref, _) { - final notificationCount = ref.watch( - notificationUnreadCountNotifierProvider, - ); - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - leading: const Icon(Symbols.notifications), - trailing: Badge( - label: Text(notificationCount.toString()), - isLabelVisible: notificationCount.value! > 0, - ), - title: Text('notifications'.tr()), - onTap: () async { - Navigator.of(context).pop(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: - (context) => const NotificationSheet(), - ); - }, - ); - }, - ), - Gap(MediaQuery.of(context).padding.bottom + 16), - ], - ); - }, - ); - }, - ) - : null, + floatingActionButton: shouldShowFab ? const FabMenu() : null, floatingActionButtonLocation: - shouldShowFab ? TabbedFabLocation(context) : null, + shouldShowFab ? _DockedFabLocation(context) : null, bottomNavigationBar: ConditionalBottomNav( child: ClipRRect( borderRadius: BorderRadius.only( @@ -269,10 +204,10 @@ class TabsScreen extends HookConsumerWidget { } } -class TabbedFabLocation extends FloatingActionButtonLocation { +class _DockedFabLocation extends FloatingActionButtonLocation { final BuildContext context; - const TabbedFabLocation(this.context); + const _DockedFabLocation(this.context); @override Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart index 2c4bcecc..847c5450 100644 --- a/lib/services/event_bus.dart +++ b/lib/services/event_bus.dart @@ -11,3 +11,8 @@ class PostCreatedEvent { const PostCreatedEvent({this.postId, this.title, this.content}); } + +/// Event fired when chat rooms need to be refreshed +class ChatRoomsRefreshEvent { + const ChatRoomsRefreshEvent(); +} diff --git a/lib/widgets/navigation/fab_menu.dart b/lib/widgets/navigation/fab_menu.dart new file mode 100644 index 00000000..2407f17a --- /dev/null +++ b/lib/widgets/navigation/fab_menu.dart @@ -0,0 +1,192 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/screens/notification.dart'; +import 'package:island/services/event_bus.dart'; +import 'package:island/widgets/account/account_picker.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/post/compose_dialog.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum FabMenuType { main, chat, realm } + +/// Global state provider for FAB menu type +final fabMenuTypeProvider = StateProvider( + (ref) => FabMenuType.main, +); + +class FabMenu extends HookConsumerWidget { + final double? elevation; + const FabMenu({super.key, this.elevation}); + + Future _createDirectMessage(BuildContext context, WidgetRef ref) async { + final result = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => const AccountPickerSheet(), + ); + if (result == null) return; + final client = ref.read(apiClientProvider); + try { + await client.post( + '/sphere/chat/direct', + data: {'related_user_id': result.id}, + ); + eventBus.fire(const ChatRoomsRefreshEvent()); + } catch (err) { + showErrorAlert(err); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fabType = ref.watch(fabMenuTypeProvider); + + late final IconData icon; + late final bool useRootNavigator; + late final Widget menuContent; + + final commonEntires = [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.bubble_chart), + title: Text('aiThoughtTitle').tr(), + onTap: () async { + Navigator.of(context).pop(); + context.pushNamed('thought'); + }, + ), + Consumer( + builder: (context, ref, _) { + final notificationCount = ref.watch( + notificationUnreadCountNotifierProvider, + ); + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.notifications), + trailing: Badge( + label: Text(notificationCount.toString()), + isLabelVisible: notificationCount.value! > 0, + ), + title: Text('notifications').tr(), + onTap: () async { + Navigator.of(context).pop(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) => const NotificationSheet(), + ); + }, + ); + }, + ), + ]; + + switch (fabType) { + case FabMenuType.chat: + icon = Symbols.chat_add_on; + useRootNavigator = true; + menuContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Gap(24), + ListTile( + title: const Text('createChatRoom').tr(), + leading: const Icon(Symbols.add), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () { + Navigator.pop(context); + context.pushNamed('chatNew').then((value) { + if (value != null) { + eventBus.fire(const ChatRoomsRefreshEvent()); + } + }); + }, + ), + ListTile( + title: const Text('createDirectMessage').tr(), + leading: const Icon(Symbols.person), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () { + Navigator.pop(context); + _createDirectMessage(context, ref); + }, + ), + const Divider(), + ...commonEntires, + Gap(MediaQuery.of(context).padding.bottom + 16), + ], + ); + break; + + case FabMenuType.realm: + icon = Symbols.group_add; + useRootNavigator = false; + menuContent = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(24), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.group_add), + title: Text('createRealm').tr(), + onTap: () { + Navigator.of(context).pop(); + context.pushNamed('realmNew').then((value) { + if (value != null) { + // Fire realm refresh event if needed + // eventBus.fire(const RealmsRefreshEvent()); + } + }); + }, + ), + const Divider(), + ...commonEntires, + Gap(MediaQuery.of(context).padding.bottom + 16), + ], + ); + break; + + case FabMenuType.main: + icon = Symbols.menu; + useRootNavigator = false; + menuContent = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(24), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.post_add_rounded), + title: Text('postCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + await PostComposeDialog.show(context); + }, + ), + const Divider(), + ...commonEntires, + Gap(MediaQuery.of(context).padding.bottom + 16), + ], + ); + break; + } + + return FloatingActionButton( + elevation: elevation, + child: Icon(icon), + onPressed: () { + showModalBottomSheet( + context: context, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) => menuContent, + ); + }, + ); + } +}