-
-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2388 from gtalha07/chat-ng-list
[Chat-NG]: Room messages UI implementation
- Loading branch information
Showing
15 changed files
with
1,254 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
- [Labs] Chat-NG: | ||
- supports new UI message bubbles with initial message types support .i.e. text, image, video. | ||
- messages list now also supports initial added state events .i.e. member, redacted, encrypted. | ||
- pagination to load more messages upon scrolling. | ||
- Initial scroll to new messages on entering room. | ||
- Updated test coverage. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import 'package:acter/common/themes/acter_theme.dart'; | ||
import 'package:flutter/material.dart'; | ||
import 'package:acter/common/extensions/options.dart'; | ||
import 'package:flutter_gen/gen_l10n/l10n.dart'; | ||
|
||
class ChatBubble extends StatelessWidget { | ||
final Widget child; | ||
final int? messageWidth; | ||
final bool wasEdited; | ||
final bool nextMessageGroup; | ||
final BoxDecoration decoration; | ||
final CrossAxisAlignment bubbleAlignment; | ||
|
||
// default private constructor | ||
const ChatBubble._inner({ | ||
super.key, | ||
required this.child, | ||
required this.wasEdited, | ||
required this.bubbleAlignment, | ||
required this.decoration, | ||
this.messageWidth, | ||
this.nextMessageGroup = false, | ||
}); | ||
|
||
// factory bubble constructor | ||
factory ChatBubble({ | ||
required Widget child, | ||
required BuildContext context, | ||
int? messageWidth, | ||
bool wasEdited = false, | ||
bool nextMessageGroup = false, | ||
}) { | ||
final theme = Theme.of(context); | ||
return ChatBubble._inner( | ||
wasEdited: wasEdited, | ||
messageWidth: messageWidth, | ||
nextMessageGroup: nextMessageGroup, | ||
decoration: BoxDecoration( | ||
color: theme.colorScheme.surface, | ||
borderRadius: BorderRadius.only( | ||
topLeft: Radius.circular(16), | ||
topRight: Radius.circular(16), | ||
bottomLeft: Radius.circular(nextMessageGroup ? 16 : 4), | ||
bottomRight: Radius.circular(16), | ||
), | ||
), | ||
bubbleAlignment: CrossAxisAlignment.start, | ||
child: child, | ||
); | ||
} | ||
|
||
// for user's own messages | ||
factory ChatBubble.user({ | ||
Key? key, | ||
required BuildContext context, | ||
required Widget child, | ||
int? messageWidth, | ||
bool wasEdited = false, | ||
bool nextMessageGroup = false, | ||
}) { | ||
final theme = Theme.of(context); | ||
return ChatBubble._inner( | ||
key: key, | ||
messageWidth: messageWidth, | ||
wasEdited: wasEdited, | ||
nextMessageGroup: nextMessageGroup, | ||
decoration: BoxDecoration( | ||
color: theme.colorScheme.primary, | ||
borderRadius: BorderRadius.only( | ||
topLeft: Radius.circular(16), | ||
topRight: Radius.circular(16), | ||
bottomLeft: Radius.circular(16), | ||
bottomRight: Radius.circular(nextMessageGroup ? 16 : 4), | ||
), | ||
), | ||
bubbleAlignment: CrossAxisAlignment.end, | ||
child: DefaultTextStyle.merge( | ||
style: TextStyle(color: theme.colorScheme.onPrimary), | ||
child: child, | ||
), | ||
); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final chatTheme = Theme.of(context).chatTheme; | ||
final size = MediaQuery.sizeOf(context); | ||
final msgWidth = messageWidth.map((w) => w.toDouble()); | ||
|
||
return Container( | ||
margin: const EdgeInsets.symmetric(horizontal: 8), | ||
child: Column( | ||
crossAxisAlignment: bubbleAlignment, | ||
children: [ | ||
Container( | ||
constraints: BoxConstraints( | ||
maxWidth: msgWidth ?? size.width, | ||
), | ||
width: msgWidth, | ||
decoration: decoration, | ||
child: Padding( | ||
padding: const EdgeInsets.symmetric( | ||
horizontal: 12, | ||
vertical: 8, | ||
), | ||
child: child, | ||
), | ||
), | ||
Visibility( | ||
visible: wasEdited, | ||
child: Text( | ||
L10n.of(context).edited, | ||
style: chatTheme.emptyChatPlaceholderTextStyle | ||
.copyWith(fontSize: 12), | ||
), | ||
), | ||
], | ||
), | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,131 @@ | ||
import 'package:acter/features/chat/widgets/rooms_list.dart'; | ||
import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; | ||
import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; | ||
import 'package:acter/features/chat_ng/widgets/events/chat_event_widget.dart'; | ||
import 'package:flutter/widgets.dart'; | ||
import 'package:acter/features/chat_ng/widgets/events/chat_event.dart'; | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||
|
||
class ChatMessages extends ConsumerWidget { | ||
class ChatMessages extends ConsumerStatefulWidget { | ||
final String roomId; | ||
const ChatMessages({super.key, required this.roomId}); | ||
|
||
@override | ||
Widget build(BuildContext context, WidgetRef ref) { | ||
final messages = ref | ||
.watch(chatStateProvider(roomId).select((value) => value.messageList)); | ||
final animatedListKey = ref.watch(animatedListChatMessagesProvider(roomId)); | ||
|
||
return AnimatedList( | ||
initialItemCount: messages.length, | ||
reverse: false, | ||
key: animatedListKey, | ||
itemBuilder: (_, index, animation) => ChatEventWidget( | ||
roomId: roomId, | ||
eventId: messages[index], | ||
animation: animation, | ||
ConsumerState<ChatMessages> createState() => _ChatMessagesConsumerState(); | ||
} | ||
|
||
class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> { | ||
final ScrollController _scrollController = | ||
ScrollController(keepScrollOffset: true); | ||
|
||
bool get isLoading => ref.watch( | ||
chatStateProvider(widget.roomId).select((v) => v.loading.isLoading), | ||
); | ||
|
||
@override | ||
void initState() { | ||
super.initState(); | ||
// for first time messages load, should scroll at the latest (bottom) | ||
ref.listenManual( | ||
chatStateProvider(widget.roomId) | ||
.select((value) => value.messageList.length), (_, __) { | ||
if (_scrollController.hasClients && | ||
_scrollController.position.pixels > | ||
_scrollController.position.maxScrollExtent - 150) { | ||
WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd()); | ||
} | ||
}); | ||
_scrollController.addListener(onScroll); | ||
} | ||
|
||
@override | ||
void dispose() { | ||
_scrollController.dispose(); | ||
super.dispose(); | ||
} | ||
|
||
Future<void> onScroll() async { | ||
if (isLoading) return; | ||
|
||
// Check if we're near the top of the list | ||
if (_scrollController.position.pixels <= | ||
_scrollController.position.minScrollExtent) { | ||
if (isLoading) return; | ||
|
||
// Get the notifier to load more messages | ||
final notifier = ref.read(chatStateProvider(widget.roomId).notifier); | ||
await notifier.loadMore(); | ||
} | ||
} | ||
|
||
void scrollToEnd() { | ||
if (!mounted || !_scrollController.hasClients) return; | ||
|
||
_scrollController.animateTo( | ||
_scrollController.position.maxScrollExtent, | ||
duration: const Duration(milliseconds: 300), | ||
curve: Curves.easeOut, | ||
); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final messages = ref.watch( | ||
chatStateProvider(widget.roomId).select((value) => value.messageList), | ||
); | ||
|
||
return PageStorage( | ||
bucket: bucketGlobal, | ||
child: Column( | ||
children: [ | ||
Expanded( | ||
child: Stack( | ||
children: [ | ||
_buildMessagesList(messages), | ||
_buildScrollIndicator(), | ||
], | ||
), | ||
), | ||
], | ||
), | ||
); | ||
} | ||
|
||
Widget _buildMessagesList( | ||
List<String> messages, | ||
) => | ||
KeyedSubtree( | ||
key: PageStorageKey('chat_list_${widget.roomId}'), | ||
child: AnimatedList( | ||
initialItemCount: messages.length, | ||
key: ref.watch(animatedListChatMessagesProvider(widget.roomId)), | ||
controller: _scrollController, | ||
reverse: false, | ||
padding: const EdgeInsets.only( | ||
top: 40, | ||
), | ||
itemBuilder: (_, index, animation) => Padding( | ||
padding: const EdgeInsets.symmetric(vertical: 12), | ||
child: ChatEvent( | ||
roomId: widget.roomId, | ||
eventId: messages[index], | ||
), | ||
), | ||
), | ||
); | ||
|
||
Widget _buildScrollIndicator() => Positioned( | ||
top: 12, | ||
left: 0, | ||
right: 0, | ||
child: Center( | ||
child: AnimatedContainer( | ||
duration: const Duration(milliseconds: 200), | ||
height: isLoading ? 14 : 0, | ||
width: isLoading ? 14 : 0, | ||
child: Center( | ||
child: isLoading ? const CircularProgressIndicator() : null, | ||
), | ||
), | ||
), | ||
); | ||
} |
Oops, something went wrong.