import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../providers/chat_providers.dart'; /// Session summary returned from the backend. class SessionSummary { final String id; final String title; final String status; final DateTime createdAt; final DateTime updatedAt; const SessionSummary({ required this.id, required this.title, required this.status, required this.createdAt, required this.updatedAt, }); factory SessionSummary.fromJson(Map json) { return SessionSummary( id: json['id'] as String, // NOTE: Never use systemPrompt as title — it's an internal engine instruction // (voice sessions set systemPrompt to the voice-mode rules string, which caused // every voice conversation to appear as "你正在通过语音与用户实时对话。请…"). // Always derive the display title from metadata or the session creation time. title: _generateTitle(json), status: json['status'] as String? ?? 'active', createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? DateTime.now(), ); } static String _generateTitle(Map json) { final meta = json['metadata'] as Map?; // 1. Use the explicit title stored by the backend on first task (text sessions) final storedTitle = meta?['title'] as String?; if (storedTitle != null && storedTitle.isNotEmpty) return storedTitle; final createdAt = DateTime.tryParse(json['createdAt'] as String? ?? ''); // 2. Voice sessions: prefix with 🎙 and show time final isVoice = meta?['voiceMode'] as bool? ?? false; if (isVoice && createdAt != null) { return '语音对话 ${createdAt.month}/${createdAt.day} ${createdAt.hour}:${createdAt.minute.toString().padLeft(2, '0')}'; } // 3. Fallback: date-based title if (createdAt != null) { return '对话 ${createdAt.month}/${createdAt.day} ${createdAt.hour}:${createdAt.minute.toString().padLeft(2, '0')}'; } return '新对话'; } } /// Groups sessions by date category. enum DateGroup { today, yesterday, previous7Days, previous30Days, older } /// Left drawer showing conversation history list. /// Pattern: ChatGPT / Claude / Gemini style side drawer. class ConversationDrawer extends ConsumerWidget { const ConversationDrawer({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final sessions = ref.watch(sessionListProvider); final currentSessionId = ref.watch(currentSessionIdProvider); return Drawer( backgroundColor: AppColors.background, child: SafeArea( child: Column( children: [ // Header: New Chat button Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () { ref.read(chatProvider.notifier).startNewChat(); Navigator.of(context).pop(); }, icon: const Icon(Icons.add, size: 18), label: const Text('新对话'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, side: BorderSide(color: AppColors.surfaceLight.withOpacity(0.6)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), ), ), const Divider(color: AppColors.surfaceLight, height: 1), // Session list Expanded( child: sessions.when( data: (list) { if (list.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.chat_bubble_outline, size: 48, color: AppColors.textMuted), const SizedBox(height: 12), Text('暂无对话历史', style: TextStyle(color: AppColors.textMuted, fontSize: 14)), ], ), ); } final grouped = _groupByDate(list); return ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: grouped.entries.expand((entry) { return [ // Date group header Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( _dateGroupLabel(entry.key), style: TextStyle( color: AppColors.textMuted, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ), // Sessions in this group ...entry.value.map((session) => _SessionTile( session: session, isActive: session.id == currentSessionId, onTap: () { ref.read(chatProvider.notifier).switchSession(session.id); Navigator.of(context).pop(); }, onDelete: () { _confirmDelete(context, ref, session); }, )), ]; }).toList(), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, color: AppColors.error), const SizedBox(height: 8), Text('加载失败', style: TextStyle(color: AppColors.error)), TextButton( onPressed: () => ref.invalidate(sessionListProvider), child: const Text('重试'), ), ], ), ), ), ), ], ), ), ); } Map> _groupByDate(List sessions) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final week = today.subtract(const Duration(days: 7)); final month = today.subtract(const Duration(days: 30)); final grouped = >{}; for (final session in sessions) { final date = DateTime(session.updatedAt.year, session.updatedAt.month, session.updatedAt.day); DateGroup group; if (!date.isBefore(today)) { group = DateGroup.today; } else if (!date.isBefore(yesterday)) { group = DateGroup.yesterday; } else if (!date.isBefore(week)) { group = DateGroup.previous7Days; } else if (!date.isBefore(month)) { group = DateGroup.previous30Days; } else { group = DateGroup.older; } grouped.putIfAbsent(group, () => []).add(session); } return grouped; } String _dateGroupLabel(DateGroup group) { switch (group) { case DateGroup.today: return '今天'; case DateGroup.yesterday: return '昨天'; case DateGroup.previous7Days: return '最近7天'; case DateGroup.previous30Days: return '最近30天'; case DateGroup.older: return '更早'; } } void _confirmDelete(BuildContext context, WidgetRef ref, SessionSummary session) { showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.surface, title: const Text('删除对话', style: TextStyle(color: AppColors.textPrimary)), content: Text( '确定要删除「${session.title}」吗?此操作不可恢复。', style: const TextStyle(color: AppColors.textSecondary), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消'), ), TextButton( onPressed: () { Navigator.of(ctx).pop(); ref.read(chatProvider.notifier).deleteSession(session.id); ref.invalidate(sessionListProvider); }, style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('删除'), ), ], ), ); } } /// Individual session list tile with long-press menu. class _SessionTile extends StatelessWidget { final SessionSummary session; final bool isActive; final VoidCallback onTap; final VoidCallback onDelete; const _SessionTile({ required this.session, required this.isActive, required this.onTap, required this.onDelete, }); @override Widget build(BuildContext context) { return ListTile( dense: true, selected: isActive, selectedTileColor: AppColors.surfaceLight.withOpacity(0.4), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: Icon( Icons.chat_bubble_outline, size: 18, color: isActive ? AppColors.primary : AppColors.textMuted, ), title: Text( session.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isActive ? AppColors.textPrimary : AppColors.textSecondary, fontSize: 13, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, ), ), onTap: onTap, onLongPress: () { showModalBottomSheet( context: context, backgroundColor: AppColors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.delete_outline, color: AppColors.error), title: const Text('删除对话', style: TextStyle(color: AppColors.error)), onTap: () { Navigator.of(ctx).pop(); onDelete(); }, ), ], ), ), ); }, ); } }