it0/it0_app/lib/features/chat/presentation/widgets/conversation_drawer.dart

316 lines
11 KiB
Dart

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<String, dynamic> 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<String, dynamic> json) {
final meta = json['metadata'] as Map<String, dynamic>?;
// 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<DateGroup, List<SessionSummary>> _groupByDate(List<SessionSummary> 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 = <DateGroup, List<SessionSummary>>{};
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();
},
),
],
),
),
);
},
);
}
}