| 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 | } |