主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
416 lines
15 KiB
Dart
416 lines
15 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 天气状态管理
|
||
/// 创建时间: 2026-05-02
|
||
/// 更新时间: 2026-06-23
|
||
/// 作用: 天气数据 + 天气-诗词关联推荐状态
|
||
/// 上次更新: 新增currentTag字段追踪预选词;WeatherPoetrySession添加tag字段;缓存支持tag
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:xianyan/core/utils/safe_json.dart';
|
||
|
||
import '../../../core/storage/kv_storage.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../../core/services/data/jinrishici_sdk_service.dart';
|
||
import '../../../core/services/weather/weather_models.dart';
|
||
import '../../../core/services/weather/weather_service.dart';
|
||
import '../home/services/searchall_service.dart';
|
||
|
||
class WeatherPoetrySession {
|
||
const WeatherPoetrySession({
|
||
this.tag = '',
|
||
this.jinrishiciContent = '',
|
||
this.jinrishiciTitle = '',
|
||
this.jinrishiciAuthor = '',
|
||
this.matchedPoems = const [],
|
||
});
|
||
|
||
/// 用户发送的预选词(如:雨、春、梅花)
|
||
final String tag;
|
||
final String jinrishiciContent;
|
||
final String jinrishiciTitle;
|
||
final String jinrishiciAuthor;
|
||
final List<Map<String, dynamic>> matchedPoems;
|
||
}
|
||
|
||
class WeatherHistoryDay {
|
||
const WeatherHistoryDay({
|
||
required this.date,
|
||
required this.weather,
|
||
this.mood,
|
||
});
|
||
|
||
final String date;
|
||
final WeatherData weather;
|
||
final WeatherPoetryMood? mood;
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'date': date,
|
||
'weather': {
|
||
'city': weather.city,
|
||
'tem': weather.temp,
|
||
'wea': weather.weather,
|
||
'wea_img': '',
|
||
'humidity': weather.humidity,
|
||
'win': weather.windDirection,
|
||
'win_speed': weather.windPower,
|
||
'air': weather.aqi,
|
||
'air_level': weather.aqiLevel,
|
||
'tem_day': weather.temDay,
|
||
'tem_night': weather.temNight,
|
||
'update_time': weather.updateTime,
|
||
},
|
||
if (mood != null) 'moodIndex': mood!.index,
|
||
};
|
||
|
||
factory WeatherHistoryDay.fromJson(Map<String, dynamic> json) {
|
||
final weatherJson = json['weather'] as Map<String, dynamic>?;
|
||
final moodIndex = json['moodIndex'] == null ? null : SafeJson.parseInt(json['moodIndex']);
|
||
final safeMoodIndex = moodIndex?.clamp(0, WeatherPoetryMood.values.length - 1);
|
||
return WeatherHistoryDay(
|
||
date: json['date'] as String? ?? '',
|
||
weather: weatherJson != null ? WeatherData.fromTianqi(weatherJson) : WeatherData.empty(),
|
||
mood: safeMoodIndex != null ? WeatherPoetryMood.values[safeMoodIndex] : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
class WeatherState {
|
||
const WeatherState({
|
||
this.weather,
|
||
this.mood,
|
||
this.currentTag = '',
|
||
this.matchedPoems = const [],
|
||
this.jinrishiciContent = '',
|
||
this.jinrishiciTitle = '',
|
||
this.jinrishiciAuthor = '',
|
||
this.poetrySessions = const [],
|
||
this.historyDays = const [],
|
||
this.isLoading = false,
|
||
this.isRefreshing = false,
|
||
this.error,
|
||
});
|
||
|
||
final WeatherData? weather;
|
||
final WeatherPoetryMood? mood;
|
||
/// 当前诗词对应的预选词
|
||
final String currentTag;
|
||
final List<Map<String, dynamic>> matchedPoems;
|
||
final String jinrishiciContent;
|
||
final String jinrishiciTitle;
|
||
final String jinrishiciAuthor;
|
||
final List<WeatherPoetrySession> poetrySessions;
|
||
final List<WeatherHistoryDay> historyDays;
|
||
final bool isLoading;
|
||
final bool isRefreshing;
|
||
final String? error;
|
||
|
||
bool get hasData => weather != null;
|
||
|
||
WeatherState copyWith({
|
||
WeatherData? weather,
|
||
WeatherPoetryMood? mood,
|
||
String? currentTag,
|
||
List<Map<String, dynamic>>? matchedPoems,
|
||
String? jinrishiciContent,
|
||
String? jinrishiciTitle,
|
||
String? jinrishiciAuthor,
|
||
List<WeatherPoetrySession>? poetrySessions,
|
||
List<WeatherHistoryDay>? historyDays,
|
||
bool? isLoading,
|
||
bool? isRefreshing,
|
||
String? error,
|
||
bool clearError = false,
|
||
}) {
|
||
return WeatherState(
|
||
weather: weather ?? this.weather,
|
||
mood: mood ?? this.mood,
|
||
currentTag: currentTag ?? this.currentTag,
|
||
matchedPoems: matchedPoems ?? this.matchedPoems,
|
||
jinrishiciContent: jinrishiciContent ?? this.jinrishiciContent,
|
||
jinrishiciTitle: jinrishiciTitle ?? this.jinrishiciTitle,
|
||
jinrishiciAuthor: jinrishiciAuthor ?? this.jinrishiciAuthor,
|
||
poetrySessions: poetrySessions ?? this.poetrySessions,
|
||
historyDays: historyDays ?? this.historyDays,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||
error: clearError ? null : (error ?? this.error),
|
||
);
|
||
}
|
||
}
|
||
|
||
class WeatherNotifier extends Notifier<WeatherState> {
|
||
static const _cacheKey = 'weather_poetry_cache';
|
||
|
||
@override
|
||
WeatherState build() {
|
||
final cached = _loadFromCache();
|
||
if (cached != null) {
|
||
Future.microtask(loadWeather).catchError((_) {});
|
||
return cached;
|
||
}
|
||
Future.microtask(loadWeather).catchError((_) {});
|
||
return const WeatherState();
|
||
}
|
||
|
||
// ============================================================
|
||
// 缓存读写
|
||
// ============================================================
|
||
|
||
WeatherState? _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 weatherJson = json['weather'] as Map<String, dynamic>?;
|
||
final moodIndex = json['moodIndex'] == null ? null : SafeJson.parseInt(json['moodIndex']);
|
||
final matchedPoems = (json['matchedPoems'] as List<dynamic>?)
|
||
?.map((e) => Map<String, dynamic>.from(e as Map))
|
||
.toList() ??
|
||
[];
|
||
final jinrishiciContent = json['jinrishiciContent'] as String? ?? '';
|
||
final jinrishiciTitle = json['jinrishiciTitle'] as String? ?? '';
|
||
final jinrishiciAuthor = json['jinrishiciAuthor'] as String? ?? '';
|
||
final sessions = (json['poetrySessions'] as List<dynamic>?)
|
||
?.map((e) => WeatherPoetrySession(
|
||
tag: (e as Map<String, dynamic>)['tag'] as String? ?? '',
|
||
jinrishiciContent: e['jinrishiciContent'] as String? ?? '',
|
||
jinrishiciTitle: e['jinrishiciTitle'] as String? ?? '',
|
||
jinrishiciAuthor: e['jinrishiciAuthor'] as String? ?? '',
|
||
matchedPoems: (e['matchedPoems'] as List<dynamic>?)
|
||
?.map((m) => Map<String, dynamic>.from(m as Map))
|
||
.toList() ??
|
||
[],
|
||
))
|
||
.toList() ??
|
||
[];
|
||
|
||
final safeMoodIndex = moodIndex?.clamp(0, WeatherPoetryMood.values.length - 1);
|
||
final historyDays = (json['historyDays'] as List<dynamic>?)
|
||
?.map((e) => WeatherHistoryDay.fromJson(e as Map<String, dynamic>))
|
||
.toList() ??
|
||
[];
|
||
return WeatherState(
|
||
weather: weatherJson != null ? WeatherData.fromTianqi(weatherJson) : null,
|
||
mood: safeMoodIndex != null ? WeatherPoetryMood.values[safeMoodIndex] : null,
|
||
currentTag: json['currentTag'] as String? ?? '',
|
||
matchedPoems: matchedPoems,
|
||
jinrishiciContent: jinrishiciContent,
|
||
jinrishiciTitle: jinrishiciTitle,
|
||
jinrishiciAuthor: jinrishiciAuthor,
|
||
poetrySessions: sessions,
|
||
historyDays: historyDays,
|
||
);
|
||
} catch (e) {
|
||
Log.e('天气缓存读取失败', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
void _saveToCache(WeatherState state) {
|
||
try {
|
||
if (!KvStorage.isReady) return;
|
||
final json = <String, dynamic>{
|
||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||
if (state.weather != null)
|
||
'weather': {
|
||
'city': state.weather!.city,
|
||
'tem': state.weather!.temp,
|
||
'wea': state.weather!.weather,
|
||
'wea_img': '',
|
||
'humidity': state.weather!.humidity,
|
||
'win': state.weather!.windDirection,
|
||
'win_speed': state.weather!.windPower,
|
||
'air': state.weather!.aqi,
|
||
'air_level': state.weather!.aqiLevel,
|
||
'tem_day': state.weather!.temDay,
|
||
'tem_night': state.weather!.temNight,
|
||
'update_time': state.weather!.updateTime,
|
||
},
|
||
if (state.mood != null) 'moodIndex': state.mood!.index,
|
||
'currentTag': state.currentTag,
|
||
'matchedPoems': state.matchedPoems,
|
||
'jinrishiciContent': state.jinrishiciContent,
|
||
'jinrishiciTitle': state.jinrishiciTitle,
|
||
'jinrishiciAuthor': state.jinrishiciAuthor,
|
||
'poetrySessions': state.poetrySessions
|
||
.map((s) => {
|
||
'tag': s.tag,
|
||
'jinrishiciContent': s.jinrishiciContent,
|
||
'jinrishiciTitle': s.jinrishiciTitle,
|
||
'jinrishiciAuthor': s.jinrishiciAuthor,
|
||
'matchedPoems': s.matchedPoems,
|
||
})
|
||
.toList(),
|
||
'historyDays': state.historyDays.map((h) => h.toJson()).toList(),
|
||
};
|
||
KvStorage.setString(_cacheKey, jsonEncode(json), box: HiveBoxNames.feedCache);
|
||
Log.i('天气数据已缓存');
|
||
} catch (e) {
|
||
Log.e('天气缓存写入失败', e);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 数据加载
|
||
// ============================================================
|
||
|
||
Future<void> loadWeather() async {
|
||
final hasExistingData = state.hasData;
|
||
if (hasExistingData) {
|
||
state = state.copyWith(isRefreshing: true, clearError: true);
|
||
} else {
|
||
state = state.copyWith(isLoading: true, clearError: true);
|
||
}
|
||
|
||
try {
|
||
final result = await WeatherService.fetchWeatherWithPoetry();
|
||
|
||
var sessions = List<WeatherPoetrySession>.from(state.poetrySessions);
|
||
if (state.jinrishiciContent.isNotEmpty) {
|
||
sessions.add(WeatherPoetrySession(
|
||
tag: state.currentTag,
|
||
jinrishiciContent: state.jinrishiciContent,
|
||
jinrishiciTitle: state.jinrishiciTitle,
|
||
jinrishiciAuthor: state.jinrishiciAuthor,
|
||
matchedPoems: state.matchedPoems,
|
||
));
|
||
}
|
||
if (sessions.length > 20) {
|
||
sessions = sessions.sublist(sessions.length - 20);
|
||
}
|
||
|
||
var history = List<WeatherHistoryDay>.from(state.historyDays);
|
||
final today = '${DateTime.now().year}-${DateTime.now().month.toString().padLeft(2, '0')}-${DateTime.now().day.toString().padLeft(2, '0')}';
|
||
history.removeWhere((h) => h.date == today);
|
||
history.insert(0, WeatherHistoryDay(
|
||
date: today,
|
||
weather: result.weather,
|
||
mood: result.mood,
|
||
));
|
||
if (history.length > 30) {
|
||
history = history.sublist(0, 30);
|
||
}
|
||
|
||
state = state.copyWith(
|
||
weather: result.weather,
|
||
mood: result.mood,
|
||
matchedPoems: result.matchedPoems,
|
||
jinrishiciContent: result.jinrishiciContent,
|
||
jinrishiciTitle: result.jinrishiciTitle,
|
||
jinrishiciAuthor: result.jinrishiciAuthor,
|
||
poetrySessions: sessions,
|
||
historyDays: history,
|
||
isLoading: false,
|
||
isRefreshing: false,
|
||
clearError: true,
|
||
);
|
||
_saveToCache(state);
|
||
Log.i('天气加载成功: ${result.weather.city},诗词历史: ${sessions.length}首');
|
||
} catch (e) {
|
||
Log.e('天气加载失败', e);
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
isRefreshing: false,
|
||
error: e.toString(),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
await loadWeather();
|
||
}
|
||
|
||
/// 任务5:按预选词获取诗词
|
||
///
|
||
/// 用户发送预选词后,调用 jinrishici API 获取对应标签的诗词,
|
||
/// 替换当前显示的诗词内容,并添加到诗词历史会话中。
|
||
Future<void> loadPoetryByTag(String tag) async {
|
||
final cleanTag = tag.trim();
|
||
if (cleanTag.isEmpty) return;
|
||
|
||
state = state.copyWith(isRefreshing: true, clearError: true);
|
||
|
||
try {
|
||
// 保存当前诗词到历史会话
|
||
var sessions = List<WeatherPoetrySession>.from(state.poetrySessions);
|
||
if (state.jinrishiciContent.isNotEmpty) {
|
||
sessions.add(WeatherPoetrySession(
|
||
tag: state.currentTag,
|
||
jinrishiciContent: state.jinrishiciContent,
|
||
jinrishiciTitle: state.jinrishiciTitle,
|
||
jinrishiciAuthor: state.jinrishiciAuthor,
|
||
matchedPoems: state.matchedPoems,
|
||
));
|
||
}
|
||
if (sessions.length > 20) {
|
||
sessions = sessions.sublist(sessions.length - 20);
|
||
}
|
||
|
||
// 调用 jinrishici API 获取对应标签的诗词
|
||
final jinrishiciData = await JinrishiciSdkService.fetchSentenceByTag(cleanTag);
|
||
|
||
// 搜索匹配的本地诗词
|
||
final matchedPoems = await _searchByKeyword(cleanTag);
|
||
|
||
// 任务9审计修复:直接从 jinrishiciData 提取数据,避免 state.weather!/state.mood! 空指针风险
|
||
// WeatherPoetryResult 的 weather/mood 字段在 loadPoetryByTag 场景下未使用,
|
||
// 直接提取 jinrishiciContent/Title/Author 即可
|
||
final data = jinrishiciData?['data'] as Map<String, dynamic>?;
|
||
final origin = data?['origin'] as Map<String, dynamic>?;
|
||
final jinrishiciContent = data?['content'] as String? ?? '';
|
||
final jinrishiciTitle = origin?['title'] as String? ?? '';
|
||
final dynasty = origin?['dynasty'] as String? ?? '';
|
||
final author = origin?['author'] as String? ?? '';
|
||
final jinrishiciAuthor = dynasty.isNotEmpty ? '$dynasty·$author' : author;
|
||
|
||
state = state.copyWith(
|
||
currentTag: cleanTag,
|
||
matchedPoems: matchedPoems,
|
||
jinrishiciContent: jinrishiciContent,
|
||
jinrishiciTitle: jinrishiciTitle,
|
||
jinrishiciAuthor: jinrishiciAuthor,
|
||
poetrySessions: sessions,
|
||
isRefreshing: false,
|
||
clearError: true,
|
||
);
|
||
_saveToCache(state);
|
||
Log.i('按预选词获取诗词成功: tag=$cleanTag, content=$jinrishiciContent');
|
||
} catch (e) {
|
||
Log.e('按预选词获取诗词失败: tag=$cleanTag', e);
|
||
state = state.copyWith(
|
||
isRefreshing: false,
|
||
error: '获取诗词失败: $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 按关键词搜索本地诗词库
|
||
Future<List<Map<String, dynamic>>> _searchByKeyword(String keyword) async {
|
||
try {
|
||
final result = await SearchAllService.fieldSearch(
|
||
type: 'poetry',
|
||
field: 'content',
|
||
keyword: keyword,
|
||
limit: 1,
|
||
);
|
||
return result.list.map((item) => {
|
||
'content': item.content,
|
||
'author': item.author,
|
||
'title': item.title,
|
||
'keyword': keyword,
|
||
}).toList();
|
||
} catch (e) {
|
||
Log.w('关键词诗词搜索失败: keyword=$keyword', e);
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
|
||
final weatherProvider = NotifierProvider<WeatherNotifier, WeatherState>(
|
||
WeatherNotifier.new,
|
||
);
|