main
dart 342 lines 11.6 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4 import 'package:localmind/core/theme/colors.dart';
5 import '../../data/models/conversation.dart';
6
7 class ConversationTile extends StatelessWidget {
8 const ConversationTile({
9 super.key,
10 required this.conversation,
11 required this.isActive,
12 required this.onTap,
13 required this.onRename,
14 required this.onTogglePin,
15 required this.onDelete,
16 required this.onDuplicate,
17 required this.onMoveToFolder,
18 required this.onExport,
19 required this.onArchive,
20 this.selectionMode = false,
21 this.isSelected = false,
22 this.onEnterSelectionMode,
23 this.isGenerating = false,
24 });
25
26 /// A reply for this chat is still generating (possibly in the background).
27 final bool isGenerating;
28
29 final Conversation conversation;
30 final bool isActive;
31 final VoidCallback onTap;
32 final VoidCallback onRename;
33 final VoidCallback onTogglePin;
34 final VoidCallback onDelete;
35 final VoidCallback onDuplicate;
36 final VoidCallback onMoveToFolder;
37 final VoidCallback onExport;
38 final VoidCallback onArchive;
39 final bool selectionMode;
40 final bool isSelected;
41 final VoidCallback? onEnterSelectionMode;
42
43 String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
44 final now = DateTime.now();
45 final diff = now.difference(dateTime);
46
47 if (diff.inMinutes < 1) {
48 return l10n.conversation_just_now;
49 } else if (diff.inHours < 1) {
50 return l10n.conversation_minutes_ago(diff.inMinutes);
51 } else if (diff.inHours < 24) {
52 return l10n.conversation_hours_ago(diff.inHours);
53 } else if (diff.inDays == 1) {
54 return l10n.conversation_yesterday;
55 } else if (diff.inDays < 7) {
56 return l10n.conversation_days_ago(diff.inDays);
57 } else {
58 return l10n.conversation_date(
59 dateTime.month,
60 dateTime.day,
61 dateTime.year,
62 );
63 }
64 }
65
66 @override
67 Widget build(BuildContext context) {
68 final l10n = AppLocalizations.of(context)!;
69 final theme = Theme.of(context);
70 final isDark = theme.brightness == Brightness.dark;
71
72 return Dismissible(
73 key: Key(conversation.id),
74 direction: DismissDirection.horizontal,
75 background: Container(
76 alignment: AlignmentDirectional.centerStart,
77 padding: const EdgeInsetsDirectional.only(start: 16),
78 color: Colors.blue,
79 child: HugeIcon(
80 icon: conversation.isArchived
81 ? HugeIcons.strokeRoundedArchive
82 : HugeIcons.strokeRoundedArchive,
83 color: Colors.white,
84 ),
85 ),
86 secondaryBackground: Container(
87 alignment: AlignmentDirectional.centerEnd,
88 padding: const EdgeInsetsDirectional.only(end: 16),
89 color: Colors.red,
90 child: const HugeIcon(
91 icon: HugeIcons.strokeRoundedDelete01,
92 color: Colors.white,
93 ),
94 ),
95 confirmDismiss: (direction) async {
96 if (direction == DismissDirection.startToEnd) {
97 onArchive();
98 } else {
99 onDelete();
100 }
101 return false;
102 },
103 child: Material(
104 color: isActive
105 ? (isDark
106 ? AppColors.darkAccent.withValues(alpha: 0.2)
107 : AppColors.lightAccent.withValues(alpha: 0.1))
108 : Colors.transparent,
109 child: InkWell(
110 onTap: onTap,
111 onLongPress: () => _showContextMenu(context, l10n, isDark),
112 child: Container(
113 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
114 child: Row(
115 children: [
116 if (selectionMode)
117 Checkbox(value: isSelected, onChanged: (_) => onTap())
118 else if (isGenerating)
119 SizedBox(
120 width: 20,
121 height: 20,
122 child: Padding(
123 padding: const EdgeInsets.all(2),
124 child: CircularProgressIndicator(
125 strokeWidth: 2,
126 color: isDark
127 ? AppColors.darkAccent
128 : AppColors.lightAccent,
129 ),
130 ),
131 )
132 else
133 HugeIcon(
134 icon: conversation.isPinned
135 ? HugeIcons.strokeRoundedPin
136 : HugeIcons.strokeRoundedChatting01,
137 size: 20,
138 color: isActive
139 ? (isDark
140 ? AppColors.darkAccent
141 : AppColors.lightAccent)
142 : (isDark
143 ? AppColors.darkMutedText
144 : AppColors.lightMutedText),
145 ),
146 const SizedBox(width: 12),
147 Expanded(
148 child: Column(
149 crossAxisAlignment: CrossAxisAlignment.start,
150 children: [
151 Text(
152 conversation.title,
153 style: TextStyle(
154 fontSize: 14,
155 fontWeight: isActive
156 ? FontWeight.w600
157 : FontWeight.w500,
158 color: isDark
159 ? AppColors.darkPrimaryText
160 : AppColors.lightPrimaryText,
161 ),
162 maxLines: 1,
163 overflow: TextOverflow.ellipsis,
164 ),
165 if (conversation.lastMessagePreview != null) ...[
166 const SizedBox(height: 2),
167 Text(
168 conversation.lastMessagePreview!,
169 style: TextStyle(
170 fontSize: 12,
171 color: isDark
172 ? AppColors.darkMutedText
173 : AppColors.lightMutedText,
174 ),
175 maxLines: 1,
176 overflow: TextOverflow.ellipsis,
177 ),
178 ],
179 const SizedBox(height: 2),
180 Text(
181 [
182 l10n.conversation_message_count(
183 conversation.messageCount,
184 ),
185 l10n.conversation_character_count(
186 conversation.characterCount,
187 ),
188 if (conversation.totalTokenCount != null)
189 l10n.total_tokens_count(
190 conversation.totalTokenCount!,
191 ),
192 ].join(' · '),
193 style: TextStyle(
194 fontSize: 11,
195 color: isDark
196 ? AppColors.darkMutedText
197 : AppColors.lightMutedText,
198 ),
199 ),
200 ],
201 ),
202 ),
203 const SizedBox(width: 8),
204 Text(
205 _formatTimestamp(l10n, conversation.updatedAt),
206 style: TextStyle(
207 fontSize: 12,
208 color: isDark
209 ? AppColors.darkMutedText
210 : AppColors.lightMutedText,
211 ),
212 ),
213 const SizedBox(width: 8),
214 IconButton(
215 onPressed: () => _showContextMenu(context, l10n, isDark),
216 icon: HugeIcon(
217 icon: HugeIcons.strokeRoundedMoreVertical,
218 size: 18,
219 color: isDark
220 ? AppColors.darkMutedText
221 : AppColors.lightMutedText,
222 ),
223 padding: EdgeInsets.zero,
224 constraints: const BoxConstraints(),
225 visualDensity: VisualDensity.compact,
226 tooltip: l10n.options_tooltip,
227 ),
228 ],
229 ),
230 ),
231 ),
232 ),
233 );
234 }
235
236 void _showContextMenu(
237 BuildContext context,
238 AppLocalizations l10n,
239 bool isDark,
240 ) {
241 showModalBottomSheet(
242 context: context,
243 builder: (ctx) {
244 return SafeArea(
245 child: Column(
246 mainAxisSize: MainAxisSize.min,
247 children: [
248 if (onEnterSelectionMode != null)
249 ListTile(
250 leading: const HugeIcon(
251 icon: HugeIcons.strokeRoundedCheckList,
252 ),
253 title: Text(l10n.select),
254 onTap: () {
255 Navigator.pop(ctx);
256 onEnterSelectionMode!();
257 },
258 ),
259 ListTile(
260 leading: HugeIcon(
261 icon: conversation.isPinned
262 ? HugeIcons.strokeRoundedPin
263 : HugeIcons.strokeRoundedPin,
264 ),
265 title: Text(conversation.isPinned ? l10n.unpin : l10n.pin),
266 onTap: () {
267 Navigator.pop(ctx);
268 onTogglePin();
269 },
270 ),
271 ListTile(
272 leading: const HugeIcon(
273 icon: HugeIcons.strokeRoundedPencilEdit02,
274 ),
275 title: Text(l10n.rename),
276 onTap: () {
277 Navigator.pop(ctx);
278 onRename();
279 },
280 ),
281 ListTile(
282 leading: const HugeIcon(icon: HugeIcons.strokeRoundedCopy),
283 title: Text(l10n.duplicate_chat),
284 onTap: () {
285 Navigator.pop(ctx);
286 onDuplicate();
287 },
288 ),
289 ListTile(
290 leading: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
291 title: Text(l10n.move_to_folder),
292 onTap: () {
293 Navigator.pop(ctx);
294 onMoveToFolder();
295 },
296 ),
297 ListTile(
298 leading: const HugeIcon(icon: HugeIcons.strokeRoundedUpload01),
299 title: Text(l10n.export_conversation),
300 onTap: () {
301 Navigator.pop(ctx);
302 onExport();
303 },
304 ),
305 ListTile(
306 leading: HugeIcon(
307 icon: conversation.isArchived
308 ? HugeIcons.strokeRoundedArchive
309 : HugeIcons.strokeRoundedArchive,
310 ),
311 title: Text(
312 conversation.isArchived
313 ? l10n.unarchive_chat
314 : l10n.archive_chat,
315 ),
316 onTap: () {
317 Navigator.pop(ctx);
318 onArchive();
319 },
320 ),
321 ListTile(
322 leading: const HugeIcon(
323 icon: HugeIcons.strokeRoundedDelete01,
324 color: Colors.red,
325 ),
326 title: Text(
327 l10n.delete,
328 style: const TextStyle(color: Colors.red),
329 ),
330 onTap: () {
331 Navigator.pop(ctx);
332 onDelete();
333 },
334 ),
335 const SizedBox(height: 8),
336 ],
337 ),
338 );
339 },
340 );
341 }
342 }