refactor(chat): 重构聊天模块并集成历史消息加载功能

- 删除冗余的 chat_history_repository 和 home_mock_data
- 简化 ag_ui_event fromJson 使用工厂映射表
- 提取 ChatBloc 事件处理方法,添加 loadHistory/loadMoreHistory
- HomeScreen 集成 ChatBloc 实现历史消息加载和下拉刷新
- 更新 AGENTS.md 文档约束
This commit is contained in:
qzl
2026-03-02 15:05:10 +08:00
parent 6b32990986
commit e161ca22c4
16 changed files with 915 additions and 752 deletions
@@ -34,6 +34,7 @@ enum AgUiEventType {
unknown,
}
// wire 类型到枚举的映射
const _wireToTypeMap = {
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
@@ -49,6 +50,7 @@ const _wireToTypeMap = {
AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot,
};
// 枚举到 wire 类型的映射
const _typeToWireMap = {
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
@@ -70,6 +72,23 @@ AgUiEventType agUiEventTypeFromWire(String wire) =>
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? '';
// 类型到工厂函数的映射,用于简化 fromJson
final _typeToFactory = {
AgUiEventType.runStarted: RunStartedEvent.fromJson,
AgUiEventType.runFinished: RunFinishedEvent.fromJson,
AgUiEventType.runError: RunErrorEvent.fromJson,
AgUiEventType.textMessageStart: TextMessageStartEvent.fromJson,
AgUiEventType.textMessageContent: TextMessageContentEvent.fromJson,
AgUiEventType.textMessageEnd: TextMessageEndEvent.fromJson,
AgUiEventType.toolCallStart: ToolCallStartEvent.fromJson,
AgUiEventType.toolCallArgs: ToolCallArgsEvent.fromJson,
AgUiEventType.toolCallEnd: ToolCallEndEvent.fromJson,
AgUiEventType.toolCallResult: ToolCallResultEvent.fromJson,
AgUiEventType.toolCallError: ToolCallErrorEvent.fromJson,
AgUiEventType.messagesSnapshot: MessagesSnapshotEvent.fromJson,
AgUiEventType.unknown: UnknownAgUiEvent.fromJson,
};
@JsonSerializable()
class AgUiEvent {
final AgUiEventType type;
@@ -79,35 +98,7 @@ class AgUiEvent {
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String? ?? '';
final type = agUiEventTypeFromWire(typeStr);
switch (type) {
case AgUiEventType.runStarted:
return RunStartedEvent.fromJson(json);
case AgUiEventType.runFinished:
return RunFinishedEvent.fromJson(json);
case AgUiEventType.runError:
return RunErrorEvent.fromJson(json);
case AgUiEventType.textMessageStart:
return TextMessageStartEvent.fromJson(json);
case AgUiEventType.textMessageContent:
return TextMessageContentEvent.fromJson(json);
case AgUiEventType.textMessageEnd:
return TextMessageEndEvent.fromJson(json);
case AgUiEventType.toolCallStart:
return ToolCallStartEvent.fromJson(json);
case AgUiEventType.toolCallArgs:
return ToolCallArgsEvent.fromJson(json);
case AgUiEventType.toolCallEnd:
return ToolCallEndEvent.fromJson(json);
case AgUiEventType.toolCallResult:
return ToolCallResultEvent.fromJson(json);
case AgUiEventType.toolCallError:
return ToolCallErrorEvent.fromJson(json);
case AgUiEventType.messagesSnapshot:
return MessagesSnapshotEvent.fromJson(json);
case AgUiEventType.unknown:
return UnknownAgUiEvent.fromJson(json);
}
return _typeToFactory[type]?.call(json) ?? UnknownAgUiEvent.fromJson(json);
}
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
@@ -322,6 +313,7 @@ class SnapshotMessage {
final String? content;
final String? toolCallId;
final UiCard? ui;
final DateTime? timestamp;
SnapshotMessage({
required this.id,
@@ -329,6 +321,7 @@ class SnapshotMessage {
this.content,
this.toolCallId,
this.ui,
this.timestamp,
});
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
@@ -25,6 +25,7 @@ const _$AgUiEventTypeEnumMap = {
AgUiEventType.toolCallEnd: 'toolCallEnd',
AgUiEventType.toolCallResult: 'toolCallResult',
AgUiEventType.toolCallError: 'toolCallError',
AgUiEventType.messagesSnapshot: 'messagesSnapshot',
AgUiEventType.unknown: 'unknown',
};
@@ -157,3 +158,39 @@ Map<String, dynamic> _$ToolCallErrorEventToJson(ToolCallErrorEvent instance) =>
'error': instance.error,
'code': instance.code,
};
MessagesSnapshotEvent _$MessagesSnapshotEventFromJson(
Map<String, dynamic> json,
) => MessagesSnapshotEvent(
messages: (json['messages'] as List<dynamic>)
.map((e) => SnapshotMessage.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$MessagesSnapshotEventToJson(
MessagesSnapshotEvent instance,
) => <String, dynamic>{'messages': instance.messages};
SnapshotMessage _$SnapshotMessageFromJson(Map<String, dynamic> json) =>
SnapshotMessage(
id: json['id'] as String,
role: json['role'] as String,
content: json['content'] as String?,
toolCallId: json['toolCallId'] as String?,
ui: json['ui'] == null
? null
: UiCard.fromJson(json['ui'] as Map<String, dynamic>),
timestamp: json['timestamp'] == null
? null
: DateTime.parse(json['timestamp'] as String),
);
Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
<String, dynamic>{
'id': instance.id,
'role': instance.role,
'content': instance.content,
'toolCallId': instance.toolCallId,
'ui': instance.ui,
'timestamp': instance.timestamp?.toIso8601String(),
};
@@ -21,7 +21,7 @@ Map<String, dynamic> _$ToolResultToJson(ToolResult instance) =>
UiCard _$UiCardFromJson(Map<String, dynamic> json) => UiCard(
cardType: json['type'] as String,
schemaVersion: json['version'] as String? ?? 'v1',
schemaVersion: json['version'] as String? ?? _defaultSchemaVersion,
data: json['data'] as Map<String, dynamic>,
actions: (json['actions'] as List<dynamic>?)
?.map((e) => CardAction.fromJson(e as Map<String, dynamic>))
@@ -1,62 +0,0 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ChatHistoryRepository {
static const String _messagesKey = 'chat_messages_';
static const String _lastRunIdKey = 'chat_last_run_id_';
static const String _calendarEventsKey = 'calendar_events';
final String threadId;
ChatHistoryRepository({this.threadId = 'default'});
String get _msgKey => '$_messagesKey$threadId';
String get _runIdKey => '$_lastRunIdKey$threadId';
Future<void> saveMessages(List<Map<String, dynamic>> messages) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_msgKey, jsonEncode(messages));
}
Future<List<Map<String, dynamic>>?> loadMessages() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getString(_msgKey);
if (data == null) return null;
final list = jsonDecode(data) as List;
return list.cast<Map<String, dynamic>>();
}
Future<void> saveLastRunId(String runId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_runIdKey, runId);
}
Future<String?> loadLastRunId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_runIdKey);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_msgKey);
await prefs.remove(_runIdKey);
}
Future<void> saveCalendarEvent(Map<String, dynamic> event) async {
final prefs = await SharedPreferences.getInstance();
final eventsJson = prefs.getString(_calendarEventsKey);
final events = eventsJson != null
? jsonDecode(eventsJson) as Map<String, dynamic>
: <String, dynamic>{};
events[event['id']] = event;
await prefs.setString(_calendarEventsKey, jsonEncode(events));
}
Future<List<Map<String, dynamic>>> loadCalendarEvents() async {
final prefs = await SharedPreferences.getInstance();
final eventsJson = prefs.getString(_calendarEventsKey);
if (eventsJson == null) return [];
final events = jsonDecode(eventsJson) as Map<String, dynamic>;
return events.values.cast<Map<String, dynamic>>().toList();
}
}
@@ -44,8 +44,6 @@ class AgUiService {
Future<void> loadHistory({DateTime? beforeDate}) async {
if (Env.isMockApi) {
await _mockLoadHistory(beforeDate: beforeDate);
} else {
throw UnimplementedError('Real API not implemented');
}
}
@@ -58,8 +56,10 @@ class AgUiService {
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
await Future.delayed(const Duration(milliseconds: 10));
DateTime targetDate;
// Determine target date, end early if no earlier history
final DateTime targetDate;
if (beforeDate != null) {
final prevDate = _historyService.getPreviousDay(beforeDate);
if (prevDate == null) {
@@ -72,9 +72,8 @@ class AgUiService {
}
final messages = _historyService.getHistoryForDay(targetDate);
onEvent(MessagesSnapshotEvent(messages: messages));
await Future.delayed(const Duration(milliseconds: 10));
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
}
@@ -6,40 +6,35 @@ class MockHistoryService {
factory MockHistoryService() => _instance;
MockHistoryService._internal();
/// Normalize DateTime to date-only (midnight)
DateTime _toDateOnly(DateTime date) =>
DateTime(date.year, date.month, date.day);
List<SnapshotMessage> getHistoryForDay(DateTime date) {
final dayStart = DateTime(date.year, date.month, date.day);
final dayStart = _toDateOnly(date);
final allHistory = _generateAllHistory();
return allHistory.where((msg) {
if (msg.ui != null) {
final data = msg.ui!.data;
final startAtStr = data['startAt'] as String?;
if (startAtStr != null) {
try {
final startAt = DateTime.parse(startAtStr);
final msgDate = DateTime(startAt.year, startAt.month, startAt.day);
return msgDate == dayStart;
} catch (_) {}
}
}
return false;
if (msg.timestamp == null) return false;
final msgDate = _toDateOnly(msg.timestamp!);
return msgDate == dayStart;
}).toList();
}
DateTime? getLatestHistoryDate() {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day);
final allHistory = _generateAllHistory();
if (allHistory.isEmpty) return null;
return allHistory
.where((msg) => msg.timestamp != null)
.map((msg) => _toDateOnly(msg.timestamp!))
.reduce((a, b) => a.isAfter(b) ? a : b);
}
DateTime? getPreviousDay(DateTime currentDate) {
final allDates = _getAllHistoryDates();
final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a));
final currentDateOnly = DateTime(
currentDate.year,
currentDate.month,
currentDate.day,
);
final currentDateOnly = _toDateOnly(currentDate);
for (final date in sortedDates) {
if (date.isBefore(currentDateOnly)) {
@@ -51,29 +46,35 @@ class MockHistoryService {
bool hasEarlierHistory(DateTime fromDate) {
final allDates = _getAllHistoryDates();
final fromDateOnly = DateTime(fromDate.year, fromDate.month, fromDate.day);
final fromDateOnly = _toDateOnly(fromDate);
return allDates.any((date) => date.isBefore(fromDateOnly));
}
Set<DateTime> _getAllHistoryDates() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final today = _toDateOnly(now);
final yesterday = today.subtract(const Duration(days: 1));
return {today, yesterday};
}
List<SnapshotMessage> _generateAllHistory() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final today = _toDateOnly(now);
final yesterday = today.subtract(const Duration(days: 1));
return [
SnapshotMessage(id: 'hist-m1', role: 'user', content: '明天提醒我开会'),
SnapshotMessage(
id: 'hist-m1',
role: 'user',
content: '明天提醒我开会',
timestamp: today.add(const Duration(hours: 10)),
),
SnapshotMessage(
id: 'hist-t1',
role: 'tool',
toolCallId: 'hist-tc1',
timestamp: today.add(const Duration(hours: 10)),
ui: UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
@@ -104,21 +105,26 @@ class MockHistoryService {
id: 'hist-m2',
role: 'assistant',
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
timestamp: today.add(const Duration(hours: 10)),
),
SnapshotMessage(
id: 'hist-m3',
role: 'user',
content: '下周一之前提交项目报告',
timestamp: yesterday.add(const Duration(hours: 14)),
),
SnapshotMessage(id: 'hist-m3', role: 'user', content: '下周一之前提交项目报告'),
SnapshotMessage(
id: 'hist-t2',
role: 'tool',
toolCallId: 'hist-tc2',
timestamp: yesterday.add(const Duration(hours: 14)),
ui: UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
id: 'hist-s2',
title: '提交项目报告',
description: '完成并提交Q2项目报告',
startAt: yesterday
.subtract(const Duration(days: 3))
.toIso8601String(),
startAt: yesterday.add(const Duration(days: 5)).toIso8601String(),
endAt: null,
timezone: 'Asia/Shanghai',
location: null,
@@ -138,11 +144,13 @@ class MockHistoryService {
id: 'hist-m4',
role: 'assistant',
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
timestamp: yesterday.add(const Duration(hours: 14)),
),
SnapshotMessage(
id: 'hist-m5',
role: 'assistant',
content: '你好,我有什么可以帮你的?',
timestamp: yesterday.add(const Duration(hours: 9)),
),
];
}
@@ -63,126 +63,218 @@ class ChatBloc extends Cubit<ChatState> {
switch (event.type) {
case AgUiEventType.runStarted:
emit(state.copyWith(isLoading: true, error: null));
break;
case AgUiEventType.runFinished:
emit(state.copyWith(isLoading: false, currentMessageId: null));
break;
case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent;
emit(state.copyWith(isLoading: false, error: errorEvent.message));
break;
case AgUiEventType.textMessageStart:
final startEvent = event as TextMessageStartEvent;
final newMessage = TextMessageItem(
id: startEvent.messageId,
content: '',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
);
emit(
state.copyWith(
items: [...state.items, newMessage],
currentMessageId: startEvent.messageId,
),
);
break;
_handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
final contentEvent = event as TextMessageContentEvent;
final updatedItems = state.items.map((item) {
if (item.id == contentEvent.messageId && item is TextMessageItem) {
return item.copyWith(content: item.content + contentEvent.delta);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
break;
_handleTextMessageContent(event as TextMessageContentEvent);
case AgUiEventType.textMessageEnd:
final endEvent = event as TextMessageEndEvent;
final updatedItems = state.items.map((item) {
if (item.id == endEvent.messageId && item is TextMessageItem) {
return item.copyWith(isStreaming: false);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems, currentMessageId: null));
break;
_handleTextMessageEnd(event as TextMessageEndEvent);
case AgUiEventType.toolCallStart:
final startEvent = event as ToolCallStartEvent;
_toolCallArgsBuffer[startEvent.toolCallId] = '';
final newToolCall = ToolCallItem(
id: startEvent.toolCallId,
callId: startEvent.toolCallId,
toolName: startEvent.toolCallName,
args: {},
status: ToolCallStatus.pending,
timestamp: DateTime.now(),
sender: MessageSender.ai,
);
emit(state.copyWith(items: [...state.items, newToolCall]));
break;
_handleToolCallStart(event as ToolCallStartEvent);
case AgUiEventType.toolCallArgs:
final argsEvent = event as ToolCallArgsEvent;
_toolCallArgsBuffer[argsEvent.toolCallId] =
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
break;
_handleToolCallArgs(event as ToolCallArgsEvent);
case AgUiEventType.toolCallEnd:
final endEvent = event as ToolCallEndEvent;
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
Map<String, dynamic> parsedArgs = {};
if (argsBuffer.isNotEmpty) {
try {
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
} catch (_) {}
}
_toolCallArgsBuffer.remove(endEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
return item.copyWith(
args: parsedArgs,
status: ToolCallStatus.executing,
);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
break;
_handleToolCallEnd(event as ToolCallEndEvent);
case AgUiEventType.toolCallResult:
final resultEvent = event as ToolCallResultEvent;
final filteredItems = state.items.where((item) {
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
return false;
}
return true;
}).toList();
final resultItem = ToolResultItem(
id: resultEvent.messageId,
callId: resultEvent.toolCallId,
uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}),
timestamp: DateTime.now(),
sender: MessageSender.ai,
);
emit(state.copyWith(items: [...filteredItems, resultItem]));
break;
_handleToolCallResult(event as ToolCallResultEvent);
case AgUiEventType.toolCallError:
final errorEvent = event as ToolCallErrorEvent;
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
return item.copyWith(
status: ToolCallStatus.error,
errorMessage: errorEvent.error,
);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
break;
_handleToolCallError(event as ToolCallErrorEvent);
case AgUiEventType.messagesSnapshot:
_handleMessagesSnapshot(event as MessagesSnapshotEvent);
case AgUiEventType.unknown:
break;
}
}
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
final newMessage = TextMessageItem(
id: startEvent.messageId,
content: '',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
);
emit(
state.copyWith(
items: [...state.items, newMessage],
currentMessageId: startEvent.messageId,
),
);
}
void _handleTextMessageContent(TextMessageContentEvent contentEvent) {
final updatedItems = state.items.map((item) {
if (item.id == contentEvent.messageId && item is TextMessageItem) {
return item.copyWith(content: item.content + contentEvent.delta);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
}
void _handleTextMessageEnd(TextMessageEndEvent endEvent) {
final updatedItems = state.items.map((item) {
if (item.id == endEvent.messageId && item is TextMessageItem) {
return item.copyWith(isStreaming: false);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems, currentMessageId: null));
}
void _handleToolCallStart(ToolCallStartEvent startEvent) {
_toolCallArgsBuffer[startEvent.toolCallId] = '';
final newToolCall = ToolCallItem(
id: startEvent.toolCallId,
callId: startEvent.toolCallId,
toolName: startEvent.toolCallName,
args: {},
status: ToolCallStatus.pending,
timestamp: DateTime.now(),
sender: MessageSender.ai,
);
emit(state.copyWith(items: [...state.items, newToolCall]));
}
void _handleToolCallArgs(ToolCallArgsEvent argsEvent) {
_toolCallArgsBuffer[argsEvent.toolCallId] =
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
}
void _handleToolCallEnd(ToolCallEndEvent endEvent) {
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
Map<String, dynamic> parsedArgs = {};
if (argsBuffer.isNotEmpty) {
try {
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
} catch (_) {}
}
_toolCallArgsBuffer.remove(endEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
return item.copyWith(
args: parsedArgs,
status: ToolCallStatus.executing,
);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
}
void _handleToolCallResult(ToolCallResultEvent resultEvent) {
final filteredItems = state.items.where((item) {
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
return false;
}
return true;
}).toList();
final resultItem = ToolResultItem(
id: resultEvent.messageId,
callId: resultEvent.toolCallId,
uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}),
timestamp: DateTime.now(),
sender: MessageSender.ai,
);
emit(state.copyWith(items: [...filteredItems, resultItem]));
}
void _handleToolCallError(ToolCallErrorEvent errorEvent) {
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
return item.copyWith(
status: ToolCallStatus.error,
errorMessage: errorEvent.error,
);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
}
void _handleMessagesSnapshot(MessagesSnapshotEvent snapshotEvent) {
final newItems = _convertSnapshotMessages(snapshotEvent.messages);
final allItems = [...newItems, ...state.items];
// Determine oldest date and history availability
DateTime? newOldestDate = state.oldestLoadedDate;
bool newHasEarlierHistory = false;
if (newItems.isNotEmpty) {
newOldestDate = _extractDateFromItems(newItems);
if (newOldestDate != null) {
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
}
} else if (newOldestDate != null) {
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
}
emit(
state.copyWith(
items: allItems,
oldestLoadedDate: newOldestDate,
hasEarlierHistory: newHasEarlierHistory,
),
);
}
List<ChatListItem> _convertSnapshotMessages(List<SnapshotMessage> messages) {
return messages.map((msg) {
final timestamp = msg.timestamp ?? DateTime.now();
switch (msg.role) {
case 'user':
return TextMessageItem(
id: msg.id,
content: msg.content ?? '',
timestamp: timestamp,
sender: MessageSender.user,
);
case 'assistant':
return TextMessageItem(
id: msg.id,
content: msg.content ?? '',
timestamp: timestamp,
sender: MessageSender.ai,
);
case 'tool' when msg.ui != null:
return ToolResultItem(
id: msg.id,
callId: msg.toolCallId ?? '',
uiCard: msg.ui!,
timestamp: timestamp,
sender: MessageSender.ai,
);
default:
return TextMessageItem(
id: msg.id,
content: msg.content ?? '',
timestamp: timestamp,
sender: MessageSender.ai,
);
}
}).toList();
}
DateTime? _extractDateFromItems(List<ChatListItem> items) {
if (items.isEmpty) return null;
return items
.map(
(item) => DateTime(
item.timestamp.year,
item.timestamp.month,
item.timestamp.day,
),
)
.reduce((a, b) => a.isBefore(b) ? a : b);
}
Future<void> sendMessage(String content) async {
final userMessage = TextMessageItem(
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
@@ -194,6 +286,18 @@ class ChatBloc extends Cubit<ChatState> {
await _service.sendMessage(content);
}
Future<void> loadHistory() async {
if (state.isLoading) return;
await _service.loadHistory();
}
Future<void> loadMoreHistory() async {
if (state.isLoading || !state.hasEarlierHistory) return;
if (state.oldestLoadedDate == null) return;
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
}
void clearError() {
emit(state.copyWith(error: null));
}
@@ -1,295 +0,0 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/chat_bubble.dart';
enum ChatItemType { message, schedule }
abstract class ChatListItem {
String get id;
DateTime get timestamp;
ChatItemType get type;
MessageSender get sender;
}
class HomeMockData {
static List<ChatListItem> getTodayItems() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return _getMockItems().where((item) {
final itemDate = DateTime(
item.timestamp.year,
item.timestamp.month,
item.timestamp.day,
);
return itemDate == today;
}).toList();
}
static Future<List<ChatListItem>> loadMoreItems(DateTime beforeDate) async {
return _getOlderMockItems(beforeDate);
}
static List<ChatListItem> _getMockItems() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final todayStart = DateTime(today.year, today.month, today.day);
return [
ChatMessageItem(
id: 'm4',
content: '明天提醒我开会',
timestamp: todayStart.add(const Duration(hours: 14)),
sender: MessageSender.user,
),
ScheduleItemWrapper(
id: 's1',
scheduleItem: ScheduleItemModel(
id: 's1',
title: '产品评审会议',
description: '讨论Q2产品路线图',
startAt: todayStart.add(const Duration(days: 1, hours: 10)),
endAt: todayStart.add(const Duration(days: 1, hours: 11)),
timezone: 'Asia/Shanghai',
sourceType: ScheduleSourceType.agentGenerated,
status: ScheduleStatus.active,
metadata: ScheduleMetadata(
color: '#4F46E5',
location: '会议室A / 在线',
notes: '需要提前准备Q2数据',
attachments: [
Attachment(
name: 'Q2路线图.pdf',
type: AttachmentType.document,
url: 'https://example.com/q2.pdf',
),
],
),
createdAt: todayStart.subtract(const Duration(hours: 5)),
),
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
sender: MessageSender.ai,
),
ChatMessageItem(
id: 'm5',
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
sender: MessageSender.ai,
),
];
}
static List<ChatListItem> _getOlderMockItems(DateTime beforeDate) {
final before = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
final dayBefore = before.subtract(const Duration(days: 1));
return [
ChatMessageItem(
id: 'm1',
content: '你好,我有什么可以帮你的?',
timestamp: dayBefore.add(const Duration(hours: 10)),
sender: MessageSender.ai,
),
ChatMessageItem(
id: 'm2',
content: '下周一之前提交项目报告',
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 55)),
sender: MessageSender.user,
),
ScheduleItemWrapper(
id: 's0',
scheduleItem: ScheduleItemModel(
id: 's0',
title: '提交项目报告',
description: '完成并提交Q2项目报告',
startAt: before.subtract(const Duration(days: 3)),
endAt: null,
timezone: 'Asia/Shanghai',
sourceType: ScheduleSourceType.agentGenerated,
status: ScheduleStatus.active,
metadata: ScheduleMetadata(
color: '#F59E0B',
location: null,
notes: '记得附上数据附件',
attachments: [],
),
createdAt: dayBefore.add(const Duration(hours: 9, minutes: 50)),
),
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
sender: MessageSender.ai,
),
ChatMessageItem(
id: 'm3',
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
sender: MessageSender.ai,
),
];
}
}
class ChatMessageItem extends ChatListItem {
@override
final String id;
final String content;
@override
final DateTime timestamp;
@override
final MessageSender sender;
ChatMessageItem({
required this.id,
required this.content,
required this.timestamp,
required this.sender,
});
@override
ChatItemType get type => ChatItemType.message;
}
class ScheduleItemWrapper extends ChatListItem {
@override
final String id;
final ScheduleItemModel scheduleItem;
@override
final DateTime timestamp;
@override
final MessageSender sender;
ScheduleItemWrapper({
required this.id,
required this.scheduleItem,
required this.timestamp,
required this.sender,
});
@override
ChatItemType get type => ChatItemType.schedule;
}
enum ScheduleSourceType { manual, imported, agentGenerated }
enum ScheduleStatus { active, completed, canceled, archived }
class ScheduleItemModel {
final String id;
final String title;
final String? description;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final ScheduleSourceType sourceType;
final ScheduleStatus status;
final ScheduleMetadata? metadata;
final DateTime createdAt;
ScheduleItemModel({
required this.id,
required this.title,
this.description,
required this.startAt,
this.endAt,
required this.timezone,
required this.sourceType,
required this.status,
this.metadata,
required this.createdAt,
});
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
return ScheduleItemModel(
id: json['id'],
title: json['title'],
description: json['description'],
startAt: DateTime.parse(json['start_at']),
endAt: json['end_at'] != null ? DateTime.parse(json['end_at']) : null,
timezone: json['timezone'] ?? 'UTC',
sourceType: ScheduleSourceType.values.firstWhere(
(e) => e.name == json['source_type'],
orElse: () => ScheduleSourceType.manual,
),
status: ScheduleStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => ScheduleStatus.active,
),
metadata: json['metadata'] != null
? ScheduleMetadata(
color: json['metadata']['color'],
location: json['metadata']['location'],
notes: json['metadata']['notes'],
attachments:
(json['metadata']['attachments'] as List<dynamic>?)
?.map(
(a) => Attachment(
name: a['name'],
type: a['type'] == 'document'
? AttachmentType.document
: AttachmentType.reminder,
url: a['url'],
content: a['content'],
note: a['note'],
),
)
.toList() ??
[],
)
: null,
createdAt: DateTime.parse(json['created_at']),
);
}
}
class ScheduleMetadata {
final String? color;
final String? location;
final String? notes;
final List<Attachment> attachments;
ScheduleMetadata({
this.color,
this.location,
this.notes,
this.attachments = const [],
});
}
enum AttachmentType { document, reminder }
class Attachment {
final String name;
final AttachmentType type;
final String? url;
final String? content;
final String? note;
Attachment({
required this.name,
required this.type,
this.url,
this.content,
this.note,
});
}
extension ScheduleSourceTypeExtension on ScheduleSourceType {
String get displayName {
switch (this) {
case ScheduleSourceType.manual:
return '手动创建';
case ScheduleSourceType.imported:
return '导入';
case ScheduleSourceType.agentGenerated:
return 'AI生成';
}
}
IconData get icon {
switch (this) {
case ScheduleSourceType.manual:
return Icons.edit_calendar;
case ScheduleSourceType.imported:
return Icons.download;
case ScheduleSourceType.agentGenerated:
return Icons.auto_awesome;
}
}
}
@@ -16,8 +16,6 @@ const _defaultPadding = 20.0;
const _itemSpacing = 16.0;
const _inputPadding = 16.0;
const _iconSize = 24.0;
const _avatarSize = 32.0;
const _botIconSize = 18.0;
const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0;
const _cornerRadius = 12.0;
@@ -39,6 +37,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -46,6 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
void initState() {
super.initState();
_messageController.addListener(_onMessageChanged);
_chatBloc = ChatBloc();
_chatBloc.loadHistory();
}
@override
@@ -53,6 +54,7 @@ class _HomeScreenState extends State<HomeScreen> {
_messageController.removeListener(_onMessageChanged);
_messageController.dispose();
_scrollController.dispose();
_chatBloc.close();
super.dispose();
}
@@ -62,8 +64,8 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatBloc(),
return BlocProvider.value(
value: _chatBloc,
child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state.error != null) {
@@ -132,6 +134,10 @@ class _HomeScreenState extends State<HomeScreen> {
}
Widget _buildChatArea(BuildContext context, ChatState state) {
if (state.isLoading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) {
return const Center(
child: Text(
@@ -141,30 +147,96 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
}
});
return RefreshIndicator(
onRefresh: () => _onRefresh(context),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(_defaultPadding),
itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0 && state.hasEarlierHistory) {
return _buildLoadMoreButton(context, state.isLoading);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(_defaultPadding),
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items[index];
return Padding(
padding: const EdgeInsets.only(bottom: _itemSpacing),
child: _buildChatItem(item),
);
},
final itemIndex = state.hasEarlierHistory ? index - 1 : index;
final item = state.items[itemIndex];
final showDateDivider =
itemIndex == 0 ||
!_isSameDay(state.items[itemIndex - 1].timestamp, item.timestamp);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDateDivider) _buildDateDivider(item.timestamp),
Padding(
padding: const EdgeInsets.only(bottom: _itemSpacing),
child: _buildChatItem(item),
),
],
);
},
),
);
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
Widget _buildDateDivider(DateTime date) {
final now = DateTime.now();
final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final weekday = weekdays[date.weekday - 1];
// For all dates (today/yesterday/this year), use the same format
// Only add year prefix for dates from previous years
final label = date.year == now.year
? '${date.month}${date.day}$weekday'
: '${date.year}${date.month}${date.day}$weekday';
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
alignment: Alignment.center,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
),
);
}
Widget _buildLoadMoreButton(BuildContext context, bool isLoading) {
return GestureDetector(
onTap: isLoading ? null : () => _onLoadMore(context),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
child: isLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: AppColors.slate400,
),
)
: const Text(
'查看历史',
style: TextStyle(fontSize: 12, color: AppColors.slate400),
),
),
);
}
Future<void> _onRefresh(BuildContext context) async {
await context.read<ChatBloc>().loadMoreHistory();
}
void _onLoadMore(BuildContext context) {
context.read<ChatBloc>().loadMoreHistory();
}
Widget _buildChatItem(ChatListItem item) {
switch (item.type) {
case ChatItemType.message:
@@ -182,24 +254,8 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
Container(
width: _avatarSize,
height: _avatarSize,
decoration: BoxDecoration(
color: AppColors.blue100,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.bot,
size: _botIconSize,
color: AppColors.blue600,
),
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
@@ -222,8 +278,6 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
),
if (isUser) const SizedBox(width: 40),
if (!isUser) const SizedBox(width: 40),
],
);
}
@@ -365,6 +419,16 @@ class _HomeScreenState extends State<HomeScreen> {
if (content.isEmpty) return;
_messageController.clear();
context.read<ChatBloc>().sendMessage(content);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
}
});
}
void _showBottomSheet(BuildContext context) {