From 6ed6f60fbc047b13c4b3f003ba87a24a1c4e764f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 27 Sep 2025 22:59:16 +0800 Subject: [PATCH] :lipstick: New chat UI --- lib/pods/chat/messages_notifier.dart | 23 ++- lib/screens/chat/chat.dart | 278 +++++++++++++++++++-------- lib/screens/chat/room.dart | 2 +- lib/widgets/chat/chat_input.dart | 34 ++-- lib/widgets/chat/message_item.dart | 2 +- 5 files changed, 230 insertions(+), 109 deletions(-) diff --git a/lib/pods/chat/messages_notifier.dart b/lib/pods/chat/messages_notifier.dart index 3dfb6bce..48dff854 100644 --- a/lib/pods/chat/messages_notifier.dart +++ b/lib/pods/chat/messages_notifier.dart @@ -40,6 +40,7 @@ class MessagesNotifier extends _$MessagesNotifier { bool _hasMore = true; bool _isSyncing = false; bool _isJumping = false; + DateTime? _lastPauseTime; @override FutureOr> build(String roomId) async { @@ -68,12 +69,28 @@ class MessagesNotifier extends _$MessagesNotifier { if (identity != null) { ref.listen(appLifecycleStateProvider, (_, next) { next.whenData((state) { - if (state == AppLifecycleState.resumed) { + if (state == AppLifecycleState.paused) { + _lastPauseTime = DateTime.now(); developer.log( - 'App resumed, syncing messages', + 'App paused, recording time', name: 'MessagesNotifier', ); - syncMessages(); + } else if (state == AppLifecycleState.resumed) { + if (_lastPauseTime != null) { + final diff = DateTime.now().difference(_lastPauseTime!); + if (diff > const Duration(minutes: 1)) { + developer.log( + 'App resumed after >1 min, syncing messages', + name: 'MessagesNotifier', + ); + syncMessages(); + } else { + developer.log( + 'App resumed within 1 min, skipping sync', + name: 'MessagesNotifier', + ); + } + } } }); }); diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index fc1f486e..c4236641 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -178,6 +178,102 @@ Future> chatroomsJoined(Ref ref) async { .toList(); } +class ChatListBodyWidget extends HookConsumerWidget { + final bool isFloating; + final TabController tabController; + final ValueNotifier selectedTab; + + const ChatListBodyWidget({ + super.key, + this.isFloating = false, + required this.tabController, + required this.selectedTab, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chats = ref.watch(chatroomsJoinedProvider); + final callState = ref.watch(callNotifierProvider); + + Widget bodyWidget = Column( + children: [ + Consumer( + builder: (context, ref, _) { + final summaryState = ref.watch(chatSummaryProvider); + return summaryState.maybeWhen( + loading: + () => const LinearProgressIndicator( + minHeight: 2, + borderRadius: BorderRadius.zero, + ), + orElse: () => const SizedBox.shrink(), + ); + }, + ), + Expanded( + child: chats.when( + data: + (items) => RefreshIndicator( + onRefresh: + () => Future.sync(() { + ref.invalidate(chatroomsJoinedProvider); + }), + child: ListView.builder( + padding: getTabbedPadding( + context, + bottom: callState.isConnected ? 96 : null, + ), + itemCount: + items + .where( + (item) => + selectedTab.value == 0 || + (selectedTab.value == 1 && item.type == 1) || + (selectedTab.value == 2 && item.type != 1), + ) + .length, + itemBuilder: (context, index) { + final filteredItems = + items + .where( + (item) => + selectedTab.value == 0 || + (selectedTab.value == 1 && + item.type == 1) || + (selectedTab.value == 2 && item.type != 1), + ) + .toList(); + final item = filteredItems[index]; + return ChatRoomListTile( + room: item, + isDirect: item.type == 1, + onTap: () { + context.pushNamed( + 'chatRoom', + pathParameters: {'id': item.id}, + ); + }, + ); + }, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => ResponseErrorWidget( + error: error, + onRetry: () { + ref.invalidate(chatroomsJoinedProvider); + }, + ), + ), + ), + ], + ); + + return isFloating ? Card(child: bodyWidget) : bodyWidget; + } +} + class ChatShellScreen extends HookConsumerWidget { final Widget child; const ChatShellScreen({super.key, required this.child}); @@ -191,9 +287,23 @@ class ChatShellScreen extends HookConsumerWidget { isRoot: true, child: Row( children: [ - Flexible(flex: 2, child: ChatListScreen(isAside: true)), - const VerticalDivider(width: 1), - Flexible(flex: 4, child: child), + Flexible( + flex: 2, + child: ChatListScreen( + isAside: true, + isFloating: true, + ).padding(left: 16, vertical: 16), + ), + const Gap(8), + Flexible( + flex: 4, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + ), + child: child, + ).padding(top: 16), + ), ], ), ); @@ -205,24 +315,23 @@ class ChatShellScreen extends HookConsumerWidget { class ChatListScreen extends HookConsumerWidget { final bool isAside; - const ChatListScreen({super.key, this.isAside = false}); + final bool isFloating; + const ChatListScreen({ + super.key, + this.isAside = false, + this.isFloating = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { final isWide = isWideScreen(context); - if (isWide && !isAside) { - return const EmptyPageHolder(); - } - final chats = ref.watch(chatroomsJoinedProvider); final chatInvites = ref.watch(chatroomInvitesProvider); final tabController = useTabController(initialLength: 3); final selectedTab = useState( 0, ); // 0 for All, 1 for Direct Messages, 2 for Group Chats - final callState = ref.watch(callNotifierProvider); - useEffect(() { tabController.addListener(() { selectedTab.value = tabController.index; @@ -250,6 +359,76 @@ class ChatListScreen extends HookConsumerWidget { } } + if (isAside) { + return Card( + margin: EdgeInsets.zero, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TabBar( + dividerColor: Colors.transparent, + controller: tabController, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: [ + const Tab(icon: Icon(Symbols.chat)), + const Tab(icon: Icon(Symbols.person)), + const Tab(icon: Icon(Symbols.group)), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: Badge( + label: Text( + chatInvites.when( + data: (invites) => invites.length.toString(), + error: (_, _) => '0', + loading: () => '0', + ), + ), + isLabelVisible: chatInvites.when( + data: (invites) => invites.isNotEmpty, + error: (_, _) => false, + loading: () => false, + ), + child: const Icon(Symbols.email), + ), + onPressed: () { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) => const _ChatInvitesSheet(), + ); + }, + ), + ), + ], + ).padding(horizontal: 8), + const Divider(height: 1), + Expanded( + child: ChatListBodyWidget( + isFloating: false, + tabController: tabController, + selectedTab: selectedTab, + ), + ), + ], + ), + ), + ); + } + + if (isWide && !isAside) { + return const EmptyPageHolder(); + } + return AppScaffold( extendBody: false, // Prevent conflicts with tabs navigation appBar: AppBar( @@ -353,81 +532,10 @@ class ChatListScreen extends HookConsumerWidget { child: const Icon(Symbols.add), ), floatingActionButtonLocation: TabbedFabLocation(context), - body: Column( - children: [ - Consumer( - builder: (context, ref, _) { - final summaryState = ref.watch(chatSummaryProvider); - return summaryState.maybeWhen( - loading: - () => const LinearProgressIndicator( - minHeight: 2, - borderRadius: BorderRadius.zero, - ), - orElse: () => const SizedBox.shrink(), - ); - }, - ), - Expanded( - child: chats.when( - data: - (items) => RefreshIndicator( - onRefresh: - () => Future.sync(() { - ref.invalidate(chatroomsJoinedProvider); - }), - child: ListView.builder( - padding: getTabbedPadding( - context, - bottom: callState.isConnected ? 96 : null, - ), - itemCount: - items - .where( - (item) => - selectedTab.value == 0 || - (selectedTab.value == 1 && - item.type == 1) || - (selectedTab.value == 2 && item.type != 1), - ) - .length, - itemBuilder: (context, index) { - final filteredItems = - items - .where( - (item) => - selectedTab.value == 0 || - (selectedTab.value == 1 && - item.type == 1) || - (selectedTab.value == 2 && - item.type != 1), - ) - .toList(); - final item = filteredItems[index]; - return ChatRoomListTile( - room: item, - isDirect: item.type == 1, - onTap: () { - context.pushNamed( - 'chatRoom', - pathParameters: {'id': item.id}, - ); - }, - ); - }, - ), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => ResponseErrorWidget( - error: error, - onRetry: () { - ref.invalidate(chatroomsJoinedProvider); - }, - ), - ), - ), - ], + body: ChatListBodyWidget( + isFloating: false, + tabController: tabController, + selectedTab: selectedTab, ), ); } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 119b584d..3489d5c3 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -402,7 +402,7 @@ class ChatRoomScreen extends HookConsumerWidget { listController: listController, padding: EdgeInsets.only( top: 16, - bottom: 88 + MediaQuery.of(context).padding.bottom, + bottom: 80 + MediaQuery.of(context).padding.bottom, ), controller: scrollController, reverse: true, // Show newest messages at the bottom diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index dae81f6f..5bb6ea92 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -9,6 +9,7 @@ import "package:image_picker/image_picker.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; import "package:island/pods/config.dart"; +import "package:island/services/responsive.dart"; import "package:island/widgets/content/attachment_preview.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:pasteboard/pasteboard.dart"; @@ -117,8 +118,16 @@ class ChatInput extends HookConsumerWidget { return KeyEventResult.ignored; }; + final double leftMargin = isWideScreen(context) ? 8 : 16; + final double rightMargin = isWideScreen(context) ? leftMargin + 8 : 16; + const double bottomMargin = 16; + return Container( - margin: const EdgeInsets.all(16), + margin: EdgeInsets.only( + left: leftMargin, + right: rightMargin, + bottom: bottomMargin, + ), child: Material( elevation: 2, color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -131,10 +140,7 @@ class ChatInput extends HookConsumerWidget { duration: const Duration(milliseconds: 150), switchInCurve: Curves.fastEaseInToSlowEaseOut, switchOutCurve: Curves.fastEaseInToSlowEaseOut, - transitionBuilder: ( - Widget child, - Animation animation, - ) { + transitionBuilder: (Widget child, Animation animation) { return SlideTransition( position: Tween( begin: const Offset(0, -0.3), @@ -148,10 +154,7 @@ class ChatInput extends HookConsumerWidget { child: SizeTransition( sizeFactor: animation, axisAlignment: -1.0, - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ), ); }, @@ -177,18 +180,11 @@ class ChatInput extends HookConsumerWidget { chatSubscribe.length, args: [ chatSubscribe - .map( - (x) => - x.nick ?? - x.account.nick, - ) + .map((x) => x.nick ?? x.account.nick) .join(', '), ], ), - style: - Theme.of( - context, - ).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall, ), ), ], @@ -224,7 +220,7 @@ class ChatInput extends HookConsumerWidget { }, separatorBuilder: (_, _) => const Gap(8), ), - ).padding(top: 12), + ).padding(vertical: 12), if (messageReplyingTo != null || messageForwardingTo != null || messageEditingTo != null) diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index ad3f7629..e08225bc 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -11,10 +11,10 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/embed.dart'; +import 'package:island/pods/chat/chat_rooms.dart'; import 'package:island/pods/chat/messages_notifier.dart'; import 'package:island/pods/translate.dart'; import 'package:island/pods/config.dart'; -import 'package:island/screens/chat/room.dart'; import 'package:island/utils/mapping.dart'; import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/app_scaffold.dart';