Skip to content

Commit 7608429

Browse files
Merge pull request #52 from ChandruWritesCode/21-copy-share-message
#21 copy share message
2 parents e040f8d + 89d6782 commit 7608429

2 files changed

Lines changed: 266 additions & 24 deletions

File tree

mobile/lib/pages/chat_page.dart

Lines changed: 265 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
23
import 'package:provider/provider.dart';
34
import 'package:mobile/widgets/chat_screen_modular_widgets.dart';
45
import 'package:mobile/controllers/chat.dart';
@@ -20,8 +21,25 @@ class ChatPage extends StatefulWidget {
2021
class _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
],

mobile/lib/widgets/chat_screen_modular_widgets.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ class _ChatInputAreaState extends State<ChatInputArea> {
222222
@override
223223
Widget build(BuildContext context) {
224224
return SafeArea(
225+
top: false,
225226
bottom: false,
226227
child: ClipRRect(
227228
child: BackdropFilter(

0 commit comments

Comments
 (0)