主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
466 lines
14 KiB
Dart
466 lines
14 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 今日诗词状态管理
|
||
/// 创建时间: 2026-05-02
|
||
/// 更新时间: 2026-06-22
|
||
/// 作用: 今日诗词数据 + 用户信息状态 + 统一聊天记录
|
||
/// 上次更新: 合并 poetryHistory 和 chatRecords 为统一聊天记录系统,支持多标签组合选择
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../core/storage/kv_storage.dart';
|
||
import '../../core/utils/logger.dart';
|
||
import 'jinrishici_models.dart';
|
||
import 'poetry_service.dart';
|
||
|
||
// ============================================================
|
||
// 聊天记录模型 — 统一持久化保存
|
||
// ============================================================
|
||
|
||
/// 聊天记录模型 — 统一保存所有消息(用户标签、系统提示、诗词推荐)
|
||
class PoetryChatRecord {
|
||
const PoetryChatRecord({
|
||
required this.type,
|
||
required this.content,
|
||
required this.time,
|
||
this.poetry,
|
||
this.tags,
|
||
this.id,
|
||
});
|
||
|
||
/// 消息类型: user / system / poetry
|
||
final String type;
|
||
|
||
/// 消息内容
|
||
final String content;
|
||
|
||
/// 时间
|
||
final String time;
|
||
|
||
/// 诗词完整数据(仅 poetry 类型有值)
|
||
final JinrishiciPoetry? poetry;
|
||
|
||
/// 诗词标签(仅 poetry 类型有值)
|
||
final List<String>? tags;
|
||
|
||
/// 唯一标识(用于删除)
|
||
final String? id;
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'type': type,
|
||
'content': content,
|
||
'time': time,
|
||
if (poetry != null) 'poetry': _poetryToMap(poetry!),
|
||
if (tags != null) 'tags': tags,
|
||
if (id != null) 'id': id,
|
||
};
|
||
|
||
factory PoetryChatRecord.fromJson(Map<String, dynamic> json) =>
|
||
PoetryChatRecord(
|
||
type: json['type'] as String? ?? 'system',
|
||
content: json['content'] as String? ?? '',
|
||
time: json['time'] as String? ?? '',
|
||
poetry: json['poetry'] != null
|
||
? _poetryFromMap(json['poetry'] as Map<String, dynamic>)
|
||
: null,
|
||
tags: (json['tags'] as List<dynamic>?)
|
||
?.map((e) => e.toString())
|
||
.toList(),
|
||
id: json['id'] as String?,
|
||
);
|
||
|
||
// 序列化辅助
|
||
static Map<String, dynamic> _poetryToMap(JinrishiciPoetry p) => {
|
||
'content': p.content,
|
||
'title': p.title,
|
||
'dynasty': p.dynasty,
|
||
'author': p.author,
|
||
'fullContent': p.fullContent,
|
||
'translate': p.translate,
|
||
'matchTags': p.matchTags,
|
||
'recommendedReason': p.recommendedReason,
|
||
'id': p.id,
|
||
'popularity': p.popularity,
|
||
};
|
||
|
||
static JinrishiciPoetry _poetryFromMap(Map<String, dynamic> m) =>
|
||
JinrishiciPoetry(
|
||
content: m['content'] as String? ?? '',
|
||
title: m['title'] as String? ?? '',
|
||
dynasty: m['dynasty'] as String? ?? '',
|
||
author: m['author'] as String? ?? '',
|
||
fullContent: (m['fullContent'] as List<dynamic>?)
|
||
?.map((e) => e.toString())
|
||
.toList() ??
|
||
[],
|
||
translate: (m['translate'] as List<dynamic>?)
|
||
?.map((e) => e.toString())
|
||
.toList() ??
|
||
[],
|
||
matchTags: (m['matchTags'] as List<dynamic>?)
|
||
?.map((e) => e.toString())
|
||
.toList() ??
|
||
[],
|
||
recommendedReason: m['recommendedReason'] as String? ?? '',
|
||
id: m['id']?.toString() ?? '',
|
||
popularity: m['popularity'] as int? ?? 0,
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 诗词状态
|
||
// ============================================================
|
||
|
||
class PoetryState {
|
||
const PoetryState({
|
||
this.poetry,
|
||
this.userInfo,
|
||
this.isLoading = false,
|
||
this.isRefreshing = false,
|
||
this.error,
|
||
this.selectedTags = const [],
|
||
this.city,
|
||
this.chatRecords = const [],
|
||
});
|
||
|
||
/// 当前诗词(最新一首)
|
||
final JinrishiciPoetry? poetry;
|
||
|
||
/// 用户信息(IP/地区/天气)
|
||
final JinrishiciUserInfo? userInfo;
|
||
|
||
final bool isLoading;
|
||
final bool isRefreshing;
|
||
final String? error;
|
||
|
||
/// 当前选中的预选词标签列表(支持多选组合)
|
||
final List<String> selectedTags;
|
||
|
||
/// 当前城市(来自设置页面)
|
||
final String? city;
|
||
|
||
/// 统一聊天记录 — 替代原 poetryHistory + chatRecords
|
||
final List<PoetryChatRecord> chatRecords;
|
||
|
||
JinrishiciPoetry get currentPoetry => poetry ?? JinrishiciPoetry.empty();
|
||
|
||
bool get hasData => poetry != null;
|
||
|
||
/// 聊天记录数量
|
||
int get recordCount => chatRecords.length;
|
||
|
||
/// 是否接近上限
|
||
bool get isNearLimit => chatRecords.length > 180;
|
||
|
||
/// 从聊天记录中提取所有诗词(用于日历视图等)
|
||
List<PoetryChatRecord> get poetryRecords =>
|
||
chatRecords.where((r) => r.type == 'poetry').toList();
|
||
|
||
PoetryState copyWith({
|
||
JinrishiciPoetry? poetry,
|
||
JinrishiciUserInfo? userInfo,
|
||
bool? isLoading,
|
||
bool? isRefreshing,
|
||
String? error,
|
||
bool clearError = false,
|
||
List<String>? selectedTags,
|
||
bool clearSelectedTags = false,
|
||
String? city,
|
||
bool clearCity = false,
|
||
List<PoetryChatRecord>? chatRecords,
|
||
}) {
|
||
return PoetryState(
|
||
poetry: poetry ?? this.poetry,
|
||
userInfo: userInfo ?? this.userInfo,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||
error: clearError ? null : (error ?? this.error),
|
||
selectedTags: clearSelectedTags ? [] : (selectedTags ?? this.selectedTags),
|
||
city: clearCity ? null : (city ?? this.city),
|
||
chatRecords: chatRecords ?? this.chatRecords,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 诗词状态管理器
|
||
// ============================================================
|
||
|
||
class PoetryNotifier extends Notifier<PoetryState> {
|
||
static const _cacheKey = 'poetry_cache_v2';
|
||
|
||
@override
|
||
PoetryState build() {
|
||
final cached = _loadFromCache();
|
||
if (cached != null) {
|
||
Future.microtask(loadPoetry).catchError((_) {});
|
||
return cached;
|
||
}
|
||
Future.microtask(loadPoetry).catchError((_) {});
|
||
return const PoetryState();
|
||
}
|
||
|
||
// ============================================================
|
||
// 缓存读写
|
||
// ============================================================
|
||
|
||
PoetryState? _loadFromCache() {
|
||
try {
|
||
if (!KvStorage.isReady) return null;
|
||
final raw = KvStorage.getString(_cacheKey, box: HiveBoxNames.feedCache);
|
||
if (raw == null || raw.isEmpty) return null;
|
||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||
|
||
final poetryJson = json['poetry'] as Map<String, dynamic>?;
|
||
final userInfoJson = json['userInfo'] as Map<String, dynamic>?;
|
||
|
||
return PoetryState(
|
||
poetry: poetryJson != null
|
||
? PoetryChatRecord._poetryFromMap(poetryJson)
|
||
: null,
|
||
userInfo: userInfoJson != null ? _userInfoFromMap(userInfoJson) : null,
|
||
selectedTags: (json['selectedTags'] as List<dynamic>?)
|
||
?.map((e) => e.toString())
|
||
.toList() ??
|
||
[],
|
||
city: json['city'] as String?,
|
||
chatRecords: (json['chatRecords'] as List<dynamic>?)
|
||
?.map((e) =>
|
||
PoetryChatRecord.fromJson(e as Map<String, dynamic>))
|
||
.toList() ??
|
||
[],
|
||
);
|
||
} catch (e) {
|
||
Log.e('诗词缓存读取失败', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
void _saveToCache(PoetryState state) {
|
||
try {
|
||
if (!KvStorage.isReady) return;
|
||
final json = <String, dynamic>{
|
||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||
if (state.poetry != null)
|
||
'poetry': PoetryChatRecord._poetryToMap(state.poetry!),
|
||
if (state.userInfo != null)
|
||
'userInfo': _userInfoToMap(state.userInfo!),
|
||
'selectedTags': state.selectedTags,
|
||
if (state.city != null) 'city': state.city,
|
||
'chatRecords': state.chatRecords.map((r) => r.toJson()).toList(),
|
||
};
|
||
KvStorage.setString(
|
||
_cacheKey, jsonEncode(json), box: HiveBoxNames.feedCache);
|
||
} catch (e) {
|
||
Log.e('诗词缓存写入失败', e);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 序列化辅助
|
||
// ============================================================
|
||
|
||
Map<String, dynamic> _userInfoToMap(JinrishiciUserInfo u) => {
|
||
'ipAddress': u.ipAddress,
|
||
'region': u.region,
|
||
'weather': u.weather,
|
||
'temperature': u.temperature,
|
||
};
|
||
|
||
JinrishiciUserInfo _userInfoFromMap(Map<String, dynamic> m) =>
|
||
JinrishiciUserInfo(
|
||
ipAddress: m['ipAddress'] as String? ?? '',
|
||
region: m['region'] as String? ?? '',
|
||
weather: m['weather'] as String? ?? '',
|
||
temperature: m['temperature'] as String? ?? '',
|
||
);
|
||
|
||
// ============================================================
|
||
// 数据加载
|
||
// ============================================================
|
||
|
||
Future<void> loadPoetry() async {
|
||
final hasExistingData = state.hasData;
|
||
if (hasExistingData) {
|
||
state = state.copyWith(isRefreshing: true, clearError: true);
|
||
} else {
|
||
state = state.copyWith(isLoading: true, clearError: true);
|
||
}
|
||
|
||
JinrishiciPoetry? newPoetry;
|
||
JinrishiciUserInfo? newUserInfo;
|
||
|
||
try {
|
||
newPoetry = await PoetryService.fetchTodayPoetry();
|
||
} catch (e) {
|
||
Log.e('今日诗词获取失败', e);
|
||
}
|
||
|
||
try {
|
||
newUserInfo = await PoetryService.fetchUserInfo();
|
||
} catch (e) {
|
||
Log.e('用户信息获取失败', e);
|
||
}
|
||
|
||
if (newPoetry == null && newUserInfo == null) {
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isRefreshing: false,
|
||
error: '诗词和用户信息均获取失败',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 添加诗词到聊天记录
|
||
final updatedRecords = List<PoetryChatRecord>.from(state.chatRecords);
|
||
if (newPoetry != null) {
|
||
updatedRecords.add(PoetryChatRecord(
|
||
type: 'system',
|
||
content: '今日诗词推荐',
|
||
time: DateTime.now().toIso8601String(),
|
||
id: 'sys_${DateTime.now().millisecondsSinceEpoch}',
|
||
));
|
||
updatedRecords.add(PoetryChatRecord(
|
||
type: 'poetry',
|
||
content: newPoetry.content,
|
||
time: DateTime.now().toIso8601String(),
|
||
poetry: newPoetry,
|
||
tags: newPoetry.matchTags,
|
||
id: 'poetry_${newPoetry.id}_${DateTime.now().millisecondsSinceEpoch}',
|
||
));
|
||
}
|
||
|
||
// 最多保留 200 条记录
|
||
if (updatedRecords.length > 200) {
|
||
updatedRecords.removeRange(0, updatedRecords.length - 200);
|
||
}
|
||
|
||
state = state.copyWith(
|
||
poetry: newPoetry ?? state.poetry,
|
||
userInfo: newUserInfo ?? state.userInfo,
|
||
chatRecords: updatedRecords,
|
||
isLoading: false,
|
||
isRefreshing: false,
|
||
clearError: true,
|
||
);
|
||
_saveToCache(state);
|
||
Log.i('今日诗词加载完成,聊天记录: ${updatedRecords.length}条');
|
||
}
|
||
|
||
/// 按标签获取诗词 — 支持多标签组合
|
||
Future<void> loadPoetryByTag(String tag) async {
|
||
state = state.copyWith(
|
||
isRefreshing: true,
|
||
clearError: true,
|
||
selectedTags: [...state.selectedTags, tag],
|
||
);
|
||
|
||
JinrishiciPoetry? newPoetry;
|
||
JinrishiciUserInfo? newUserInfo;
|
||
|
||
try {
|
||
newPoetry = await PoetryService.fetchPoetryByTag(tag);
|
||
} catch (e) {
|
||
Log.e('按标签获取诗词失败: $tag', e);
|
||
}
|
||
|
||
try {
|
||
newUserInfo = await PoetryService.fetchUserInfo();
|
||
} catch (e) {
|
||
Log.e('用户信息获取失败', e);
|
||
}
|
||
|
||
if (newPoetry == null) {
|
||
state = state.copyWith(
|
||
isRefreshing: false,
|
||
error: '获取诗词失败,请稍后重试',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 添加诗词到聊天记录
|
||
final updatedRecords = List<PoetryChatRecord>.from(state.chatRecords);
|
||
updatedRecords.add(PoetryChatRecord(
|
||
type: 'poetry',
|
||
content: newPoetry.content,
|
||
time: DateTime.now().toIso8601String(),
|
||
poetry: newPoetry,
|
||
tags: newPoetry.matchTags,
|
||
id: 'poetry_${newPoetry.id}_${DateTime.now().millisecondsSinceEpoch}',
|
||
));
|
||
|
||
// 最多保留 200 条记录
|
||
if (updatedRecords.length > 200) {
|
||
updatedRecords.removeRange(0, updatedRecords.length - 200);
|
||
}
|
||
|
||
state = state.copyWith(
|
||
poetry: newPoetry,
|
||
userInfo: newUserInfo ?? state.userInfo,
|
||
chatRecords: updatedRecords,
|
||
isRefreshing: false,
|
||
clearError: true,
|
||
);
|
||
_saveToCache(state);
|
||
Log.i('按标签「$tag」获取诗词完成,聊天记录: ${updatedRecords.length}条');
|
||
}
|
||
|
||
/// 更新城市设置
|
||
void setCity(String? city) {
|
||
state = state.copyWith(
|
||
city: city,
|
||
clearCity: city == null,
|
||
);
|
||
_saveToCache(state);
|
||
}
|
||
|
||
/// 添加聊天记录
|
||
void addChatRecord(PoetryChatRecord record) {
|
||
final updated = List<PoetryChatRecord>.from(state.chatRecords);
|
||
updated.add(record);
|
||
if (updated.length > 200) {
|
||
updated.removeRange(0, updated.length - 200);
|
||
}
|
||
state = state.copyWith(chatRecords: updated);
|
||
_saveToCache(state);
|
||
}
|
||
|
||
/// 删除单条聊天记录
|
||
void removeChatRecord(String id) {
|
||
final updated =
|
||
state.chatRecords.where((r) => r.id != id).toList();
|
||
state = state.copyWith(chatRecords: updated);
|
||
_saveToCache(state);
|
||
}
|
||
|
||
/// 清空聊天记录
|
||
void clearChatRecords() {
|
||
state = state.copyWith(chatRecords: []);
|
||
_saveToCache(state);
|
||
Log.i('诗词聊天记录已清空');
|
||
}
|
||
|
||
/// 清除选中标签
|
||
void clearSelectedTags() {
|
||
state = state.copyWith(clearSelectedTags: true);
|
||
_saveToCache(state);
|
||
}
|
||
|
||
/// 移除单个选中标签
|
||
void removeSelectedTag(String tag) {
|
||
final updated = state.selectedTags.where((t) => t != tag).toList();
|
||
state = state.copyWith(selectedTags: updated);
|
||
_saveToCache(state);
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
await loadPoetry();
|
||
}
|
||
}
|
||
|
||
final poetryProvider = NotifierProvider<PoetryNotifier, PoetryState>(
|
||
PoetryNotifier.new,
|
||
);
|