11import 'package:flutter/material.dart' ;
2+ import 'package:flutter/services.dart' ;
23import 'package:provider/provider.dart' ;
34import 'package:mobile/widgets/chat_screen_modular_widgets.dart' ;
45import 'package:mobile/controllers/chat.dart' ;
@@ -20,8 +21,25 @@ class ChatPage extends StatefulWidget {
2021class _ChatPageState extends State <ChatPage > {
2122 final ScrollController _scrollController = ScrollController ();
2223
24+ bool _showScrollToBottom = false ;
25+
26+ final Set <int > _selectedIndices = {};
27+
28+ dynamic _replyingToMessage;
29+
30+ @override
31+ void initState () {
32+ super .initState ();
33+ _scrollController.addListener (_scrollListener);
34+
35+ WidgetsBinding .instance.addPostFrameCallback ((_) {
36+ _scrollToBottom ();
37+ });
38+ }
39+
2340 @override
2441 void dispose () {
42+ _scrollController.removeListener (_scrollListener);
2543 _scrollController.dispose ();
2644 WidgetsBinding .instance.addPostFrameCallback ((_) {
2745 if (mounted) {
@@ -31,39 +49,133 @@ class _ChatPageState extends State<ChatPage> {
3149 super .dispose ();
3250 }
3351
52+ void _scrollListener () {
53+ if (! _scrollController.hasClients) return ;
54+ final isScrolledUp =
55+ _scrollController.position.maxScrollExtent - _scrollController.offset >
56+ 100 ;
57+ if (isScrolledUp != _showScrollToBottom) {
58+ setState (() {
59+ _showScrollToBottom = isScrolledUp;
60+ });
61+ }
62+ }
63+
3464 void _scrollToBottom () {
3565 if (_scrollController.hasClients) {
36- _scrollController.jumpTo (_scrollController.position.maxScrollExtent);
66+ _scrollController.animateTo (
67+ _scrollController.position.maxScrollExtent,
68+ duration: const Duration (milliseconds: 300 ),
69+ curve: Curves .easeOut,
70+ );
3771 }
3872 }
3973
4074 void _sendMessage (String text) {
4175 if (text.trim ().isEmpty) return ;
76+
4277 context.read <ChatController >().sendTextMessage (text);
78+
79+ setState (() {
80+ _replyingToMessage = null ;
81+ });
82+
83+ Future .delayed (const Duration (milliseconds: 100 ), _scrollToBottom);
84+ }
85+
86+ void _toggleSelection (int index) {
87+ setState (() {
88+ if (_selectedIndices.contains (index)) {
89+ _selectedIndices.remove (index);
90+ } else {
91+ _selectedIndices.add (index);
92+ }
93+ });
94+ }
95+
96+ void _clearSelection () {
97+ setState (() {
98+ _selectedIndices.clear ();
99+ });
100+ }
101+
102+ void _copySelectedMessages (List <dynamic > activeChat) {
103+ final sortedIndices = _selectedIndices.toList ()..sort ();
104+
105+ final selectedTexts = sortedIndices
106+ .map ((index) => activeChat[index].content.toString ())
107+ .join ('\n ' );
108+
109+ Clipboard .setData (ClipboardData (text: selectedTexts));
110+
111+ _clearSelection ();
43112 }
44113
45114 String _getPresenceStatusText (ChatController state) {
46115 if (state.isPeerTyping) return 'typing...' ;
47116 return state.isPeerOnline ? 'Online' : 'Offline' ;
48117 }
49118
119+ bool _isSameDay (DateTime date1, DateTime date2) {
120+ return date1.year == date2.year &&
121+ date1.month == date2.month &&
122+ date1.day == date2.day;
123+ }
124+
125+ String _formatDateSeparator (DateTime date) {
126+ final now = DateTime .now ();
127+ if (_isSameDay (date, now)) return 'Today' ;
128+ if (_isSameDay (date, now.subtract (const Duration (days: 1 )))) {
129+ return 'Yesterday' ;
130+ }
131+ return "${date .day }/${date .month }/${date .year }" ;
132+ }
133+
50134 @override
51135 Widget build (BuildContext context) {
52136 final chatState = context.watch <ChatController >();
53-
54- WidgetsBinding .instance. addPostFrameCallback ((_) => _scrollToBottom ()) ;
137+ final activeChat = chatState.activeChat;
138+ final isSelectionMode = _selectedIndices.isNotEmpty ;
55139
56140 return Scaffold (
57141 backgroundColor: Colors .white,
58142 extendBody: true ,
59143 extendBodyBehindAppBar: true ,
60- appBar: GlassAppBar (
61- name: widget.displayName,
62- status: _getPresenceStatusText (chatState),
63- ),
144+
145+ appBar: isSelectionMode
146+ ? AppBar (
147+ backgroundColor: Colors .blueAccent,
148+ leading: IconButton (
149+ icon: const Icon (Icons .close, color: Colors .white),
150+ onPressed: _clearSelection,
151+ ),
152+ title: Text (
153+ '${_selectedIndices .length } Selected' ,
154+ style: const TextStyle (color: Colors .white),
155+ ),
156+ actions: [
157+ IconButton (
158+ icon: const Icon (Icons .copy, color: Colors .white),
159+ onPressed: () => _copySelectedMessages (activeChat),
160+ ),
161+ IconButton (
162+ icon: const Icon (Icons .delete, color: Colors .white),
163+ onPressed: () {
164+ // TODO delete on DB
165+ _clearSelection ();
166+ },
167+ ),
168+ ],
169+ )
170+ : GlassAppBar (
171+ name: widget.displayName,
172+ status: _getPresenceStatusText (chatState),
173+ )
174+ as PreferredSizeWidget ,
175+
64176 body: Stack (
65177 children: [
66- chatState. activeChat.isEmpty
178+ activeChat.isEmpty
67179 ? const Center (
68180 child: Text (
69181 "No messages yet" ,
@@ -72,43 +184,172 @@ class _ChatPageState extends State<ChatPage> {
72184 )
73185 : ListView .builder (
74186 controller: _scrollController,
75- padding: const EdgeInsets .only (
187+ padding: EdgeInsets .only (
76188 top: 120 ,
77- bottom: 120 ,
189+ bottom: _replyingToMessage != null ? 180 : 120 ,
78190 left: 16 ,
79191 right: 16 ,
80192 ),
81- itemCount: chatState. activeChat.length,
193+ itemCount: activeChat.length,
82194 itemBuilder: (context, index) {
83- final msg = chatState.activeChat[index];
195+ final msg = activeChat[index];
196+ final bool isSelected = _selectedIndices.contains (index);
197+
198+ bool showDateSeparator = false ;
199+ if (index == 0 ) {
200+ showDateSeparator = true ;
201+ } else {
202+ final prevMsg = activeChat[index - 1 ];
203+ if (! _isSameDay (msg.createdAt, prevMsg.createdAt)) {
204+ showDateSeparator = true ;
205+ }
206+ }
84207
85208 final String cleanSenderId = msg.senderId
86209 .trim ()
87210 .toLowerCase ();
88211 final String cleanPeerId = widget.chatUserId
89212 .trim ()
90213 .toLowerCase ();
91-
92214 final bool isMe =
93215 cleanSenderId == 'me' ||
94216 (cleanSenderId.isNotEmpty &&
95217 cleanSenderId != cleanPeerId);
96218
97- return ChatBubble (
98- message: msg.content,
99- isMe: isMe,
100- timestamp: msg.createdAt,
101- isRead: msg.isRead,
219+ return Column (
220+ crossAxisAlignment: CrossAxisAlignment .stretch,
221+ children: [
222+ if (showDateSeparator)
223+ Center (
224+ child: Container (
225+ margin: const EdgeInsets .symmetric (vertical: 16 ),
226+ padding: const EdgeInsets .symmetric (
227+ horizontal: 12 ,
228+ vertical: 4 ,
229+ ),
230+ decoration: BoxDecoration (
231+ color: Colors .grey[200 ],
232+ borderRadius: BorderRadius .circular (12 ),
233+ ),
234+ child: Text (
235+ _formatDateSeparator (msg.createdAt),
236+ style: const TextStyle (
237+ fontSize: 12 ,
238+ color: Colors .black54,
239+ ),
240+ ),
241+ ),
242+ ),
243+
244+ GestureDetector (
245+ onLongPress: () => _toggleSelection (index),
246+ onTap: () {
247+ if (isSelectionMode) _toggleSelection (index);
248+ },
249+ child: Container (
250+ color: isSelected
251+ ? Colors .blue.withValues (alpha: 0.2 )
252+ : Colors .transparent,
253+ child: Dismissible (
254+ key: ValueKey (
255+ msg.createdAt.toString () + index.toString (),
256+ ),
257+ direction: DismissDirection .startToEnd,
258+ confirmDismiss: (direction) async {
259+ setState (() {
260+ _replyingToMessage = msg;
261+ });
262+ return false ;
263+ },
264+ background: Container (
265+ alignment: Alignment .centerLeft,
266+ padding: const EdgeInsets .only (left: 16 ),
267+ color: Colors .transparent,
268+ child: const Icon (
269+ Icons .reply,
270+ color: Colors .grey,
271+ ),
272+ ),
273+ child: ChatBubble (
274+ message: msg.content,
275+ isMe: isMe,
276+ timestamp: msg.createdAt,
277+ isRead: msg.isRead,
278+ ),
279+ ),
280+ ),
281+ ),
282+ ],
102283 );
103284 },
104285 ),
286+
287+ if (_showScrollToBottom)
288+ Positioned (
289+ right: 16 ,
290+ bottom: _replyingToMessage != null ? 140 : 90 ,
291+ child: FloatingActionButton (
292+ mini: true ,
293+ backgroundColor: Colors .white,
294+ foregroundColor: Colors .blue,
295+ onPressed: _scrollToBottom,
296+ child: const Icon (Icons .keyboard_arrow_down),
297+ ),
298+ ),
299+
105300 Align (
106- alignment: AlignmentGeometry .bottomCenter,
107- child: ChatInputArea (
108- onSendMessage: _sendMessage,
109- onTypingChanged: (isTyping) => context
110- .read <ChatController >()
111- .sendTypingNotification (isTyping),
301+ alignment: Alignment .bottomCenter,
302+ child: Column (
303+ mainAxisSize: MainAxisSize .min,
304+ children: [
305+ if (_replyingToMessage != null )
306+ Container (
307+ padding: const EdgeInsets .all (12 ),
308+ decoration: BoxDecoration (
309+ color: Colors .grey[200 ],
310+ border: const Border (
311+ left: BorderSide (color: Colors .blue, width: 4 ),
312+ ),
313+ ),
314+ child: Row (
315+ children: [
316+ Expanded (
317+ child: Column (
318+ crossAxisAlignment: CrossAxisAlignment .start,
319+ children: [
320+ const Text (
321+ "Replying to message" ,
322+ style: TextStyle (
323+ fontWeight: FontWeight .bold,
324+ color: Colors .blue,
325+ fontSize: 12 ,
326+ ),
327+ ),
328+ const SizedBox (height: 4 ),
329+ Text (
330+ _replyingToMessage.content,
331+ maxLines: 1 ,
332+ overflow: TextOverflow .ellipsis,
333+ style: const TextStyle (color: Colors .black87),
334+ ),
335+ ],
336+ ),
337+ ),
338+ IconButton (
339+ icon: const Icon (Icons .close, size: 20 ),
340+ onPressed: () =>
341+ setState (() => _replyingToMessage = null ),
342+ ),
343+ ],
344+ ),
345+ ),
346+ ChatInputArea (
347+ onSendMessage: _sendMessage,
348+ onTypingChanged: (isTyping) => context
349+ .read <ChatController >()
350+ .sendTypingNotification (isTyping),
351+ ),
352+ ],
112353 ),
113354 ),
114355 ],
0 commit comments