316 lines
11 KiB
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();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|