chore: 完成v2.4.7版本迭代更新
本次更新包含多项功能优化与兼容性修复: 1. iOS/鸿蒙端添加加密出口合规配置,跳过App Store审核问卷 2. 新增学习计划设置页路由与国际化支持 3. 修复鸿蒙端剪贴板粘贴不工作问题,安装标准剪贴板拦截器 4. 优化收藏功能:兼容复合ID、添加状态同步与触觉反馈 5. 修复鸿蒙端相册保存兼容性,统一使用系统分享降级方案 6. 优化搜索快捷方式跳转逻辑,避免白屏问题 7. 更新本地化资源,新增闲情逸致、学习计划等模块翻译 8. 修复节气日期表排序与跨年边界问题 9. 优化设备信息页面显示,新增系统版本号展示 10. 重构文件传输二维码逻辑,使用纯URL提升兼容性 11. 优化设置项布局,避免文本溢出问题 12. 修复登录页记住账户功能,新增隐私协议守卫 13. 更新macOS依赖库,替换flutter_secure_storage为darwin版本
This commit is contained in:
@@ -25,6 +25,8 @@ class PasswordFormSection extends StatelessWidget {
|
||||
required this.onForgotPassword,
|
||||
required this.ext,
|
||||
required this.auth,
|
||||
this.isRemembered = false,
|
||||
this.onToggleRemember,
|
||||
});
|
||||
|
||||
final TextEditingController accountController;
|
||||
@@ -35,6 +37,12 @@ class PasswordFormSection extends StatelessWidget {
|
||||
final AppThemeExtension ext;
|
||||
final TAuth auth;
|
||||
|
||||
/// 是否记住账户
|
||||
final bool isRemembered;
|
||||
|
||||
/// 切换记住账户
|
||||
final VoidCallback? onToggleRemember;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
@@ -62,20 +70,49 @@ class PasswordFormSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
minimumSize: Size.zero,
|
||||
onPressed: onForgotPassword,
|
||||
child: Text(
|
||||
auth.forgotPassword,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.accent,
|
||||
fontWeight: FontWeight.w500,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 记住账户
|
||||
GestureDetector(
|
||||
onTap: onToggleRemember,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CupertinoCheckbox(
|
||||
value: isRemembered,
|
||||
onChanged: (_) => onToggleRemember?.call(),
|
||||
activeColor: ext.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
auth.rememberAccount,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 忘记密码
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
minimumSize: Size.zero,
|
||||
onPressed: onForgotPassword,
|
||||
child: Text(
|
||||
auth.forgotPassword,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.accent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -61,6 +61,7 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
||||
int _emsCountdown = 0;
|
||||
bool _agreedToTerms = false;
|
||||
bool _loginSuccess = false;
|
||||
bool _rememberAccount = false;
|
||||
|
||||
// 实验功能气泡相关
|
||||
bool _showExperimentalBubble = false;
|
||||
@@ -80,10 +81,13 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
||||
Future<void> _loadLastLoginAccount() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isRemembered = prefs.getBool('remember_account') ?? false;
|
||||
final lastAccount = prefs.getString('last_login_account') ?? '';
|
||||
if (lastAccount.isNotEmpty && _accountController.text.isEmpty) {
|
||||
if (isRemembered && lastAccount.isNotEmpty && _accountController.text.isEmpty) {
|
||||
_accountController.text = lastAccount;
|
||||
_rememberAccount = true;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -520,6 +524,9 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
||||
},
|
||||
ext: ext,
|
||||
auth: auth,
|
||||
isRemembered: _rememberAccount,
|
||||
onToggleRemember: () =>
|
||||
setState(() => _rememberAccount = !_rememberAccount),
|
||||
),
|
||||
CodeFormSection(
|
||||
accountController: _accountController,
|
||||
@@ -725,6 +732,8 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
||||
.login(account: account, password: password);
|
||||
|
||||
if (success && mounted) {
|
||||
// 保存或清除记住账户
|
||||
_saveRememberAccount(account);
|
||||
AppToast.showSuccess(t.auth.loginSuccess);
|
||||
_navigateAfterLogin();
|
||||
}
|
||||
@@ -924,6 +933,20 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
||||
);
|
||||
}
|
||||
|
||||
/// 保存或清除记住账户
|
||||
Future<void> _saveRememberAccount(String account) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_rememberAccount) {
|
||||
await prefs.setBool('remember_account', true);
|
||||
await prefs.setString('last_login_account', account);
|
||||
} else {
|
||||
await prefs.setBool('remember_account', false);
|
||||
await prefs.remove('last_login_account');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _navigateAfterLogin() {
|
||||
if (!mounted) return;
|
||||
setState(() => _loginSuccess = true);
|
||||
|
||||
@@ -1,16 +1,150 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 纠错状态管理
|
||||
/// 创建时间: 2026-04-28
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 作用: 纠错提交功能状态管理
|
||||
/// 上次更新: 从 providers/ 子目录移至 correction 模块根目录
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 纠错提交功能状态管理 + 纠错历史本地缓存(drift)
|
||||
/// 上次更新: 接入 drift 本地缓存,支持离线查看纠错历史
|
||||
/// ============================================================
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/storage/database/app_database.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
|
||||
/// 纠错记录视图模型(统一本地与服务端字段)
|
||||
///
|
||||
/// 用于在 UI 层展示,避免直接暴露 drift 数据类与服务端 Map。
|
||||
class CorrectionItem {
|
||||
const CorrectionItem({
|
||||
required this.type,
|
||||
required this.sourceType,
|
||||
required this.sourceId,
|
||||
required this.switchVal,
|
||||
required this.isLocal,
|
||||
required this.createtime,
|
||||
this.content = '',
|
||||
this.username = '',
|
||||
this.email = '',
|
||||
this.sourceUrl = '',
|
||||
this.isAnonymous = false,
|
||||
this.localId,
|
||||
});
|
||||
|
||||
/// 纠错类型: error/typo/missing/suggestion
|
||||
final String type;
|
||||
|
||||
/// 内容类型: article/hanzi/cy/poetry/zc/riddle/other
|
||||
final String sourceType;
|
||||
|
||||
/// 内容 ID
|
||||
final int sourceId;
|
||||
|
||||
/// 状态码: 0=待处理, 1=已处理, 2=已拒绝
|
||||
final int switchVal;
|
||||
|
||||
/// 是否本地来源
|
||||
final bool isLocal;
|
||||
|
||||
/// 服务端创建时间戳(秒级)
|
||||
final int createtime;
|
||||
|
||||
/// 纠错描述内容
|
||||
final String content;
|
||||
|
||||
/// 提交者用户名
|
||||
final String username;
|
||||
|
||||
/// 提交者邮箱
|
||||
final String email;
|
||||
|
||||
/// 来源 URL
|
||||
final String sourceUrl;
|
||||
|
||||
/// 是否匿名
|
||||
final bool isAnonymous;
|
||||
|
||||
/// 本地数据库 ID(仅本地缓存记录有值)
|
||||
final int? localId;
|
||||
|
||||
/// 从服务端 Map 构造
|
||||
factory CorrectionItem.fromServerMap(Map<String, dynamic> map) {
|
||||
final rawSourceId = map['source_id'];
|
||||
int sourceId = 0;
|
||||
if (rawSourceId is int) {
|
||||
sourceId = rawSourceId;
|
||||
} else if (rawSourceId is String) {
|
||||
sourceId = int.tryParse(rawSourceId) ?? 0;
|
||||
}
|
||||
final rawSwitch = map['switch'];
|
||||
int switchVal = 0;
|
||||
if (rawSwitch is int) {
|
||||
switchVal = rawSwitch;
|
||||
} else if (rawSwitch is String) {
|
||||
switchVal = int.tryParse(rawSwitch) ?? 0;
|
||||
}
|
||||
final rawIsLocal = map['is_local'];
|
||||
bool isLocal = false;
|
||||
if (rawIsLocal is bool) {
|
||||
isLocal = rawIsLocal;
|
||||
} else if (rawIsLocal is int) {
|
||||
isLocal = rawIsLocal == 1;
|
||||
}
|
||||
return CorrectionItem(
|
||||
type: map['type'] as String? ?? 'error',
|
||||
sourceType: map['source_type'] as String? ?? 'other',
|
||||
sourceId: sourceId,
|
||||
switchVal: switchVal,
|
||||
isLocal: isLocal,
|
||||
createtime: map['createtime'] as int? ?? 0,
|
||||
content: map['content'] as String? ?? '',
|
||||
username: map['username'] as String? ?? '',
|
||||
email: map['mail'] as String? ?? '',
|
||||
sourceUrl: map['source_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 从 drift 数据行构造
|
||||
factory CorrectionItem.fromDb(CorrectionRecord row) {
|
||||
return CorrectionItem(
|
||||
type: row.type,
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
switchVal: row.switchVal,
|
||||
isLocal: row.isLocal,
|
||||
createtime: row.createtime,
|
||||
content: row.content,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
sourceUrl: row.sourceUrl,
|
||||
isAnonymous: row.isAnonymous,
|
||||
localId: row.id,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为 drift Companion(用于本地写入)
|
||||
CorrectionRecordsCompanion toCompanion({bool sync = true}) {
|
||||
final now = DateTime.now();
|
||||
return CorrectionRecordsCompanion(
|
||||
type: Value(type),
|
||||
sourceType: Value(sourceType),
|
||||
sourceId: Value(sourceId),
|
||||
content: Value(content),
|
||||
username: Value(username),
|
||||
email: Value(email),
|
||||
sourceUrl: Value(sourceUrl),
|
||||
switchVal: Value(switchVal),
|
||||
isLocal: Value(isLocal),
|
||||
isAnonymous: Value(isAnonymous),
|
||||
isSynced: Value(sync),
|
||||
createtime: Value(createtime),
|
||||
localCreatedAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CorrectionState {
|
||||
const CorrectionState({
|
||||
this.isSubmitting = false,
|
||||
@@ -18,21 +152,31 @@ class CorrectionState {
|
||||
this.error,
|
||||
this.corrections = const [],
|
||||
this.total = 0,
|
||||
this.isLoadingFromCache = false,
|
||||
this.isSyncing = false,
|
||||
});
|
||||
|
||||
final bool isSubmitting;
|
||||
final bool isSuccess;
|
||||
final String? error;
|
||||
final List<Map<String, dynamic>> corrections;
|
||||
final List<CorrectionItem> corrections;
|
||||
final int total;
|
||||
|
||||
/// 是否正在从本地缓存加载
|
||||
final bool isLoadingFromCache;
|
||||
|
||||
/// 是否正在与服务器同步
|
||||
final bool isSyncing;
|
||||
|
||||
CorrectionState copyWith({
|
||||
bool? isSubmitting,
|
||||
bool? isSuccess,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
List<Map<String, dynamic>>? corrections,
|
||||
List<CorrectionItem>? corrections,
|
||||
int? total,
|
||||
bool? isLoadingFromCache,
|
||||
bool? isSyncing,
|
||||
}) {
|
||||
return CorrectionState(
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
@@ -40,17 +184,48 @@ class CorrectionState {
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
corrections: corrections ?? this.corrections,
|
||||
total: total ?? this.total,
|
||||
isLoadingFromCache: isLoadingFromCache ?? this.isLoadingFromCache,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CorrectionNotifier extends Notifier<CorrectionState> {
|
||||
@override
|
||||
CorrectionState build() => const CorrectionState();
|
||||
CorrectionState build() {
|
||||
// 启动时异步加载本地缓存
|
||||
Future.microtask(_loadFromCache);
|
||||
return const CorrectionState();
|
||||
}
|
||||
|
||||
CorrectionNotifier();
|
||||
|
||||
final ApiClient _api = ApiClient.instance;
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
|
||||
/// 从本地缓存加载纠错历史(离线可用)
|
||||
Future<void> _loadFromCache() async {
|
||||
state = state.copyWith(isLoadingFromCache: true);
|
||||
try {
|
||||
final rows = await _db.getCorrectionRecords();
|
||||
final items = rows.map(CorrectionItem.fromDb).toList();
|
||||
final count = await _db.getCorrectionRecordCount();
|
||||
state = state.copyWith(
|
||||
corrections: items,
|
||||
total: count,
|
||||
isLoadingFromCache: false,
|
||||
);
|
||||
Log.i('纠错历史本地缓存加载: ${items.length} 条');
|
||||
} catch (e) {
|
||||
Log.e('纠错历史本地缓存加载失败', e);
|
||||
state = state.copyWith(isLoadingFromCache: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交纠错
|
||||
///
|
||||
/// 成功后将记录写入本地缓存;失败时也写入本地(标记 isSynced=false),
|
||||
/// 便于后续同步。返回 true 表示服务器接收成功。
|
||||
Future<bool> submitCorrection({
|
||||
required String targetType,
|
||||
required int targetId,
|
||||
@@ -90,8 +265,26 @@ class CorrectionNotifier extends Notifier<CorrectionState> {
|
||||
final respData = response.data as Map<String, dynamic>;
|
||||
final code = respData['code'] as int? ?? 0;
|
||||
if (code == 1) {
|
||||
// 提交成功,写入本地缓存
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final item = CorrectionItem(
|
||||
type: type,
|
||||
sourceType: targetType,
|
||||
sourceId: targetId,
|
||||
switchVal: 0,
|
||||
isLocal: true,
|
||||
createtime: nowSec,
|
||||
content: content,
|
||||
username: username ?? '',
|
||||
email: email ?? '',
|
||||
sourceUrl: sourceUrl ?? '',
|
||||
isAnonymous: isAnonymous,
|
||||
);
|
||||
await _db.insertCorrectionRecord(item.toCompanion());
|
||||
// 刷新本地缓存视图
|
||||
await _loadFromCache();
|
||||
state = state.copyWith(isSubmitting: false, isSuccess: true);
|
||||
Log.i('纠错提交成功');
|
||||
Log.i('纠错提交成功,已写入本地缓存');
|
||||
return true;
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
@@ -102,12 +295,37 @@ class CorrectionNotifier extends Notifier<CorrectionState> {
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('纠错提交异常', e);
|
||||
// 网络异常时也写入本地(标记未同步),便于后续重试
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final item = CorrectionItem(
|
||||
type: type,
|
||||
sourceType: targetType,
|
||||
sourceId: targetId,
|
||||
switchVal: 0,
|
||||
isLocal: true,
|
||||
createtime: nowSec,
|
||||
content: content,
|
||||
username: username ?? '',
|
||||
email: email ?? '',
|
||||
sourceUrl: sourceUrl ?? '',
|
||||
isAnonymous: isAnonymous,
|
||||
);
|
||||
await _db.insertCorrectionRecord(item.toCompanion(sync: false));
|
||||
await _loadFromCache();
|
||||
state = state.copyWith(isSubmitting: false, error: '提交失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载纠错历史(先读本地 → 立即渲染 → 再请求服务器 → 更新本地)
|
||||
Future<void> loadCorrections({int page = 1, int limit = 20}) async {
|
||||
// 1. 先读本地缓存,立即渲染(离线可用)
|
||||
if (state.corrections.isEmpty) {
|
||||
await _loadFromCache();
|
||||
}
|
||||
|
||||
// 2. 请求服务器同步
|
||||
state = state.copyWith(isSyncing: true);
|
||||
try {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'/api/webapi/correction_list',
|
||||
@@ -118,17 +336,36 @@ class CorrectionNotifier extends Notifier<CorrectionState> {
|
||||
if (code == 1) {
|
||||
final result = data['data'] as Map<String, dynamic>? ?? {};
|
||||
final list = (result['list'] as List<dynamic>? ?? [])
|
||||
.map((e) => e as Map<String, dynamic>)
|
||||
.map((e) => CorrectionItem.fromServerMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
final total = result['total'] as int? ?? 0;
|
||||
|
||||
// 3. 全量替换本地缓存
|
||||
await _db.replaceCorrectionRecords(
|
||||
list.map((e) => e.toCompanion()).toList(),
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
corrections: list,
|
||||
total: result['total'] as int? ?? 0,
|
||||
total: total,
|
||||
isSyncing: false,
|
||||
);
|
||||
Log.i('纠错历史服务器同步成功: ${list.length} 条');
|
||||
} else {
|
||||
state = state.copyWith(isSyncing: false);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('加载纠错列表失败', e);
|
||||
Log.e('加载纠错列表失败(保留本地缓存)', e);
|
||||
state = state.copyWith(isSyncing: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空本地纠错缓存(用于设置页"清除缓存"等场景)
|
||||
Future<void> clearLocalCache() async {
|
||||
await _db.clearCorrectionRecords();
|
||||
state = state.copyWith(corrections: [], total: 0);
|
||||
Log.i('纠错历史本地缓存已清空');
|
||||
}
|
||||
}
|
||||
|
||||
final correctionProvider =
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../features/auth/providers/auth_provider.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
||||
import '../../../shared/widgets/containers/glass_container.dart';
|
||||
import '../../../shared/widgets/feedback/app_toast.dart';
|
||||
@@ -47,23 +48,48 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
/// 随机数生成器
|
||||
final _random = Random();
|
||||
|
||||
final _typeOptions = [
|
||||
('article', '文章', CupertinoIcons.doc_text_fill),
|
||||
('hanzi', '汉字', CupertinoIcons.textformat),
|
||||
('cy', '成语', CupertinoIcons.text_bubble_fill),
|
||||
('poetry', '诗词', CupertinoIcons.book_fill),
|
||||
('zc', '字词', CupertinoIcons.textformat_abc),
|
||||
('riddle', '谜语', CupertinoIcons.question_circle_fill),
|
||||
('other', '其他', CupertinoIcons.ellipsis_circle_fill),
|
||||
final _typeOptionKeys = [
|
||||
('article', CupertinoIcons.doc_text_fill),
|
||||
('hanzi', CupertinoIcons.textformat),
|
||||
('cy', CupertinoIcons.text_bubble_fill),
|
||||
('poetry', CupertinoIcons.book_fill),
|
||||
('zc', CupertinoIcons.textformat_abc),
|
||||
('riddle', CupertinoIcons.question_circle_fill),
|
||||
('other', CupertinoIcons.ellipsis_circle_fill),
|
||||
];
|
||||
|
||||
final _correctionTypeOptions = [
|
||||
('error', '内容错误', CupertinoIcons.xmark_circle_fill),
|
||||
('typo', '错别字', CupertinoIcons.pencil_circle_fill),
|
||||
('missing', '内容缺失', CupertinoIcons.tray_fill),
|
||||
('suggestion', '改进建议', CupertinoIcons.lightbulb_fill),
|
||||
final _correctionTypeOptionKeys = [
|
||||
('error', CupertinoIcons.xmark_circle_fill),
|
||||
('typo', CupertinoIcons.pencil_circle_fill),
|
||||
('missing', CupertinoIcons.tray_fill),
|
||||
('suggestion', CupertinoIcons.lightbulb_fill),
|
||||
];
|
||||
|
||||
/// 根据翻译键获取内容类型标签
|
||||
String _contentTypeLabel(String key, TCorrection tc) {
|
||||
return switch (key) {
|
||||
'article' => tc.catArticle,
|
||||
'hanzi' => tc.catHanzi,
|
||||
'cy' => tc.catChengyu,
|
||||
'poetry' => tc.catPoetry,
|
||||
'zc' => tc.catZc,
|
||||
'riddle' => tc.catRiddle,
|
||||
'other' => tc.catOther,
|
||||
_ => key,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根据翻译键获取纠错类型标签
|
||||
String _correctionTypeLabel(String key, TCorrection tc) {
|
||||
return switch (key) {
|
||||
'error' => tc.typeError,
|
||||
'typo' => tc.typeTypo,
|
||||
'missing' => tc.typeMissing,
|
||||
'suggestion' => tc.typeSuggestion,
|
||||
_ => key,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contentController.dispose();
|
||||
@@ -77,11 +103,13 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final state = ref.watch(correctionProvider);
|
||||
final t = ref.watch(translationsProvider);
|
||||
final tc = t.correction;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
leading: const AdaptiveBackButton(),
|
||||
middle: const Text('内容纠错'),
|
||||
middle: Text(tc.pageTitle),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _showCorrectionRecords,
|
||||
@@ -100,7 +128,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'纠错类型',
|
||||
tc.correctionType,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
@@ -109,8 +137,9 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: _correctionTypeOptions.map((opt) {
|
||||
children: _correctionTypeOptionKeys.map((opt) {
|
||||
final selected = _correctionType == opt.$1;
|
||||
final label = _correctionTypeLabel(opt.$1, tc);
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _correctionType = opt.$1),
|
||||
child: Container(
|
||||
@@ -131,7 +160,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
opt.$3,
|
||||
opt.$2,
|
||||
size: 16,
|
||||
color: selected
|
||||
? ext.accent
|
||||
@@ -139,7 +168,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
opt.$2,
|
||||
label,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: selected
|
||||
? ext.accent
|
||||
@@ -154,7 +183,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'内容类型',
|
||||
tc.contentType,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
@@ -163,8 +192,9 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: _typeOptions.map((opt) {
|
||||
children: _typeOptionKeys.map((opt) {
|
||||
final selected = _targetType == opt.$1;
|
||||
final label = _contentTypeLabel(opt.$1, tc);
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _targetType = opt.$1),
|
||||
child: Container(
|
||||
@@ -185,7 +215,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
opt.$3,
|
||||
opt.$2,
|
||||
size: 16,
|
||||
color: selected
|
||||
? ext.accent
|
||||
@@ -193,7 +223,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
opt.$2,
|
||||
label,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: selected
|
||||
? ext.accent
|
||||
@@ -210,7 +240,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'内容ID',
|
||||
tc.contentId,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
@@ -221,11 +251,11 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dlgCtx) => CupertinoAlertDialog(
|
||||
content: const Text('若无内容ID,请填写1'),
|
||||
content: Text(tc.contentIdTip),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
child: const Text('了解了'),
|
||||
child: Text(tc.contentIdTipConfirm),
|
||||
onPressed: () => Navigator.pop(dlgCtx),
|
||||
),
|
||||
],
|
||||
@@ -249,7 +279,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
child: CupertinoTextField(
|
||||
controller: _targetIdController,
|
||||
placeholder: '输入内容的ID编号',
|
||||
placeholder: tc.contentIdHint,
|
||||
placeholderStyle: AppTypography.subhead.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -267,14 +297,14 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'纠错描述',
|
||||
tc.correctionDesc,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'请至少描述10个字',
|
||||
tc.correctionDescHint,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -290,7 +320,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
child: CupertinoTextField(
|
||||
controller: _contentController,
|
||||
placeholder: '请详细描述需要纠正的内容(至少10字)...',
|
||||
placeholder: tc.correctionDescMinLength,
|
||||
placeholderStyle: AppTypography.body.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -305,8 +335,8 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ref.watch(authProvider).isLoggedIn
|
||||
? _buildEmailCheckbox(ext)
|
||||
: _buildEmailInput(ext),
|
||||
? _buildEmailCheckbox(ext, tc)
|
||||
: _buildEmailInput(ext, tc),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -329,7 +359,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'提交纠错',
|
||||
tc.submit,
|
||||
style: AppTypography.callout.copyWith(
|
||||
color: ext.textOnAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -361,7 +391,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'提交成功!感谢您的反馈,管理员会及时处理。',
|
||||
tc.submitSuccessMessage,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.accent,
|
||||
),
|
||||
@@ -389,7 +419,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
}
|
||||
|
||||
/// 已登录:显示"包含邮箱地址"复选框
|
||||
Widget _buildEmailCheckbox(AppThemeExtension ext) {
|
||||
Widget _buildEmailCheckbox(AppThemeExtension ext, TCorrection tc) {
|
||||
final user = ref.watch(authProvider).user;
|
||||
final email = user?.email ?? '';
|
||||
final username = user?.username ?? '';
|
||||
@@ -419,7 +449,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'包含邮箱地址',
|
||||
tc.includeEmail,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
@@ -430,7 +460,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
if (username.isNotEmpty || email.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'用户: $username${_includeEmail && email.isNotEmpty ? ' · $email' : ''}',
|
||||
'${tc.userLabel}: $username${_includeEmail && email.isNotEmpty ? ' · $email' : ''}',
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
@@ -441,7 +471,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
}
|
||||
|
||||
/// 未登录:显示邮箱输入框(选填)
|
||||
Widget _buildEmailInput(AppThemeExtension ext) {
|
||||
Widget _buildEmailInput(AppThemeExtension ext, TCorrection tc) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
@@ -456,7 +486,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Icon(CupertinoIcons.mail_solid, size: 16, color: ext.accent),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'联系邮箱',
|
||||
tc.contactEmail,
|
||||
style: AppTypography.subhead.copyWith(color: ext.textPrimary),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
@@ -470,7 +500,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
'选填',
|
||||
tc.emailOptional,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -488,7 +518,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
child: CupertinoTextField(
|
||||
controller: _emailController,
|
||||
placeholder: '方便我们联系您(选填)',
|
||||
placeholder: tc.emailPlaceholder,
|
||||
placeholderStyle: AppTypography.subhead.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -508,19 +538,21 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final t = ref.read(translationsProvider);
|
||||
final tc = t.correction;
|
||||
final content = _contentController.text.trim();
|
||||
final targetIdText = _targetIdController.text.trim();
|
||||
if (content.isEmpty) {
|
||||
AppToast.showWarning('请输入纠错描述');
|
||||
AppToast.showWarning(tc.emptyContent);
|
||||
return;
|
||||
}
|
||||
if (content.length < 10) {
|
||||
AppToast.showWarning('纠错描述至少需要10个字');
|
||||
AppToast.showWarning(tc.correctionDescHint);
|
||||
return;
|
||||
}
|
||||
final targetId = int.tryParse(targetIdText) ?? 0;
|
||||
if (targetId <= 0) {
|
||||
AppToast.showWarning('请输入有效的内容ID');
|
||||
AppToast.showWarning(tc.invalidId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -555,13 +587,13 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
|
||||
if (success) {
|
||||
HapticService.success();
|
||||
AppToast.showSuccess('提交成功!感谢您的反馈');
|
||||
AppToast.showSuccess(tc.submitSuccess);
|
||||
_contentController.clear();
|
||||
_targetIdController.clear();
|
||||
_emailController.clear();
|
||||
} else {
|
||||
HapticService.error();
|
||||
final error = ref.read(correctionProvider).error ?? '提交失败';
|
||||
final error = ref.read(correctionProvider).error ?? tc.submitFailed;
|
||||
AppToast.showError(error);
|
||||
}
|
||||
}
|
||||
@@ -605,6 +637,8 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
|
||||
/// 弹出数学验证码对话框,返回是否验证通过
|
||||
Future<bool> _showMathCaptcha() async {
|
||||
final t = ref.read(translationsProvider);
|
||||
final tc = t.correction;
|
||||
_generateCaptcha();
|
||||
_captchaController.clear();
|
||||
|
||||
@@ -618,7 +652,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
children: [
|
||||
Icon(CupertinoIcons.shield_fill, size: 20, color: ext.accent),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Text('安全验证'),
|
||||
Text(tc.securityVerify),
|
||||
],
|
||||
),
|
||||
content: Padding(
|
||||
@@ -627,7 +661,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'请计算以下算式结果',
|
||||
tc.captchaPrompt,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
@@ -654,7 +688,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
CupertinoTextField(
|
||||
controller: _captchaController,
|
||||
placeholder: '输入答案',
|
||||
placeholder: tc.captchaPlaceholder,
|
||||
placeholderStyle: AppTypography.body.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -680,7 +714,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.pop(dlgCtx, false),
|
||||
child: const Text('取消'),
|
||||
child: Text(tc.captchaCancel),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
@@ -691,10 +725,10 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
Navigator.pop(dlgCtx, true);
|
||||
} else {
|
||||
Navigator.pop(dlgCtx, false);
|
||||
AppToast.showWarning('验证码错误,请重新提交');
|
||||
AppToast.showWarning(tc.captchaError);
|
||||
}
|
||||
},
|
||||
child: const Text('确认'),
|
||||
child: Text(tc.captchaConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -710,6 +744,8 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
|
||||
final ext = AppTheme.ext(context);
|
||||
final state = ref.read(correctionProvider);
|
||||
final t = ref.read(translationsProvider);
|
||||
final tc = t.correction;
|
||||
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
@@ -736,7 +772,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'纠错记录',
|
||||
tc.records,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
@@ -770,7 +806,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'暂无纠错记录',
|
||||
tc.noRecords,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
@@ -788,7 +824,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
itemBuilder: (_, index) {
|
||||
final item = state.corrections[index];
|
||||
return _buildRecordItem(item, ext);
|
||||
return _buildRecordItem(item, ext, tc);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -826,7 +862,7 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'联系邮箱反馈',
|
||||
tc.contactEmailFeedback,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -845,42 +881,36 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordItem(Map<String, dynamic> item, AppThemeExtension ext) {
|
||||
final typeMap = {
|
||||
'error': '内容错误',
|
||||
'typo': '错别字',
|
||||
'missing': '内容缺失',
|
||||
'suggestion': '改进建议',
|
||||
};
|
||||
final contentTypeMap = {
|
||||
'article': '文章',
|
||||
'hanzi': '汉字',
|
||||
'cy': '成语',
|
||||
'poetry': '诗词',
|
||||
'zc': '字词',
|
||||
'riddle': '谜语',
|
||||
'other': '其他',
|
||||
};
|
||||
final statusMap = {
|
||||
0: ('待处理', CupertinoColors.systemOrange),
|
||||
1: ('已处理', CupertinoColors.systemGreen),
|
||||
2: ('已拒绝', CupertinoColors.systemRed),
|
||||
Widget _buildRecordItem(
|
||||
CorrectionItem item,
|
||||
AppThemeExtension ext,
|
||||
TCorrection tc,
|
||||
) {
|
||||
final type = _correctionTypeLabel(item.type, tc);
|
||||
final contentType = _contentTypeLabel(item.sourceType, tc);
|
||||
final sourceId = item.sourceId.toString();
|
||||
final isLocal = item.isLocal;
|
||||
final switchVal = item.switchVal;
|
||||
|
||||
// 状态文案与颜色
|
||||
final (statusText, statusColor) = switch (switchVal) {
|
||||
0 => (tc.statusPending, CupertinoColors.systemOrange),
|
||||
1 => (tc.statusProcessed, CupertinoColors.systemGreen),
|
||||
2 => (tc.statusRejected, CupertinoColors.systemRed),
|
||||
_ => (tc.statusUnknown, ext.textHint),
|
||||
};
|
||||
|
||||
final type = typeMap[item['type']] ?? item['type'] ?? '未知';
|
||||
final contentType =
|
||||
contentTypeMap[item['source_type']] ?? item['source_type'] ?? '未知';
|
||||
final sourceId = item['source_id']?.toString() ?? '-';
|
||||
final isLocal = item['is_local'] == true || item['is_local'] == 1;
|
||||
final switchVal = item['switch'] as int? ?? 0;
|
||||
final statusInfo = statusMap[switchVal] ?? ('未知', ext.textHint);
|
||||
final createdAt = item['createtime'] as int?;
|
||||
final dateStr = createdAt != null
|
||||
final createdAt = item.createtime;
|
||||
final dateStr = createdAt > 0
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
createdAt * 1000,
|
||||
).toString().substring(0, 16)
|
||||
: '-';
|
||||
|
||||
// 来源文案
|
||||
final sourceText = isLocal ? tc.sourceLocal : tc.sourceAdmin;
|
||||
final sourceColor = isLocal ? ext.accent : CupertinoColors.systemGreen;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
@@ -898,27 +928,30 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'$type · $contentType #$sourceId',
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$type · $contentType #$sourceId',
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusInfo.$2.withValues(alpha: 0.12),
|
||||
color: statusColor.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
statusInfo.$1,
|
||||
statusText,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: statusInfo.$2,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -928,9 +961,12 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
dateStr,
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
Flexible(
|
||||
child: Text(
|
||||
dateStr,
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
@@ -939,14 +975,13 @@ class _CorrectionPageState extends ConsumerState<CorrectionPage> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isLocal ? ext.accent : CupertinoColors.systemGreen)
|
||||
.withValues(alpha: 0.12),
|
||||
color: sourceColor.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
isLocal ? '📱 本地' : '👤 管理员',
|
||||
sourceText,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: isLocal ? ext.accent : CupertinoColors.systemGreen,
|
||||
color: sourceColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ import 'package:xianyan/shared/widgets/feedback/offline_banner.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/services/app_exit_service.dart';
|
||||
import 'package:xianyan/core/storage/kv_storage.dart';
|
||||
|
||||
class DiscoverPage extends ConsumerStatefulWidget {
|
||||
const DiscoverPage({super.key});
|
||||
@@ -52,6 +53,10 @@ class DiscoverPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
static const double _refreshThreshold = 80.0;
|
||||
|
||||
/// 学习计划重构提示"不再提醒"存储键
|
||||
static const String _studyPlanRestructureKey =
|
||||
'app.study_plan.restructure_dont_remind';
|
||||
static const double _toolCenterValue = 1.35;
|
||||
|
||||
double _maxDragValue = 0.0;
|
||||
@@ -449,6 +454,14 @@ class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
if (session.id == 'translate') {
|
||||
ref.read(translateProvider.notifier).clearUnread();
|
||||
}
|
||||
if (session.id == SystemSessionIds.studyPlan) {
|
||||
_showStudyPlanRestructureDialog().then((confirmed) {
|
||||
if (confirmed && context.mounted) {
|
||||
context.appPush(session.route!);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session.route != null) {
|
||||
context.appPush(session.route!);
|
||||
}
|
||||
@@ -463,6 +476,193 @@ class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示学习计划重构提示弹窗
|
||||
///
|
||||
/// 返回 true 表示用户确认继续跳转,false 表示取消。
|
||||
/// 用户选择"不再提醒"后,后续不再显示。
|
||||
Future<bool> _showStudyPlanRestructureDialog() async {
|
||||
// 检查是否已选择"不再提醒"
|
||||
final dontRemind = KvStorage.getBool(_studyPlanRestructureKey) ?? false;
|
||||
if (dontRemind) return true;
|
||||
|
||||
final t = ref.read(translationsProvider);
|
||||
final sp = t.studyPlan;
|
||||
final ext = AppTheme.ext(context);
|
||||
|
||||
final result = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) {
|
||||
return CupertinoAlertDialog(
|
||||
title: Text(sp.restructureTitle),
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
sp.restructureMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () async {
|
||||
// 标记不再提醒
|
||||
await KvStorage.setBool(_studyPlanRestructureKey, true);
|
||||
if (ctx.mounted) Navigator.of(ctx).pop(true);
|
||||
},
|
||||
child: Text(sp.restructureDontRemind),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
onPressed: () {
|
||||
// 关闭当前弹窗,显示详情页
|
||||
Navigator.of(ctx).pop(false);
|
||||
_showStudyPlanRestructureDetails();
|
||||
},
|
||||
child: Text(sp.restructureDetails),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(sp.restructureConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// 显示学习计划重构详情底部弹窗
|
||||
///
|
||||
/// 展示具体的变更内容,让用户了解下个版本的具体变化。
|
||||
void _showStudyPlanRestructureDetails() {
|
||||
final t = ref.read(translationsProvider);
|
||||
final sp = t.studyPlan;
|
||||
final ext = AppTheme.ext(context);
|
||||
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgPrimary,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.info_circle_fill,
|
||||
color: ext.accent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
sp.restructureDetailsTitle,
|
||||
style: AppTypography.headline.copyWith(
|
||||
color: ext.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(28, 28),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Icon(
|
||||
CupertinoIcons.xmark_circle_fill,
|
||||
color: ext.textHint,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: ext.textHint.withValues(alpha: 0.1)),
|
||||
// 详情内容
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
sp.restructureDetailsBody,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: ext.textPrimary,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// 提示卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.accent.withValues(alpha: 0.08),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
color: ext.accent.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.lightbulb_fill,
|
||||
color: ext.accent,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
sp.restructureMessage,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部确认按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(sp.restructureConfirm),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 宽屏模式下在右侧面板打开会话流
|
||||
void _openChatFlowPanel(ChatSession session) {
|
||||
final t = ref.read(translationsProvider);
|
||||
@@ -643,7 +843,9 @@ class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
child: Center(
|
||||
child: Text(
|
||||
pu.isIOS ? t.discover.refresh : t.discover.base.pullDownTools,
|
||||
pu.isIOS
|
||||
? t.discover.base.releaseToRefresh
|
||||
: t.discover.base.pullDownTools,
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -24,6 +24,8 @@ import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/core/theme/app_shadow.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/platform/ohos_compatibility_helper.dart';
|
||||
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
|
||||
import 'package:xianyan/shared/widgets/containers/app_sticky_header.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
@@ -1002,8 +1004,20 @@ class _ChinaColorsPageState extends ConsumerState<ChinaColorsPage> {
|
||||
final file = File('${tempDir.path}/color_card.png');
|
||||
await file.writeAsBytes(buffer.asUint8List());
|
||||
|
||||
await Gal.putImage(file.path, album: '闲言色卡');
|
||||
AppToast.showSuccess('色卡已保存到相册');
|
||||
if (pu.isOhos) {
|
||||
// 鸿蒙端gal不支持,使用系统分享降级
|
||||
final ok = await OhosCompatibilityHelper.saveImageToGalleryCompat(
|
||||
buffer.asUint8List(),
|
||||
);
|
||||
if (ok) {
|
||||
AppToast.showSuccess('色卡已通过分享保存');
|
||||
} else {
|
||||
AppToast.showError('保存失败');
|
||||
}
|
||||
} else {
|
||||
await Gal.putImage(file.path, album: '闲言色卡');
|
||||
AppToast.showSuccess('色卡已保存到相册');
|
||||
}
|
||||
|
||||
await tempDir.delete(recursive: true);
|
||||
} on ArgumentError catch (e) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_theme.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/platform/ohos_compatibility_helper.dart';
|
||||
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
import '../../../models/chat_message.dart';
|
||||
@@ -60,9 +62,22 @@ class ChatVideoBubble extends StatefulWidget {
|
||||
}
|
||||
|
||||
final compressedPath = result.file!.path;
|
||||
await Gal.putVideo(compressedPath, album: '闲言');
|
||||
|
||||
AppToast.showSuccess('已保存到相册');
|
||||
if (pu.isOhos) {
|
||||
// 鸿蒙端gal不支持视频保存,使用系统分享降级
|
||||
final ok = await OhosCompatibilityHelper.saveVideoToGalleryCompat(
|
||||
compressedPath,
|
||||
);
|
||||
if (ok) {
|
||||
AppToast.showSuccess('已通过分享保存');
|
||||
} else {
|
||||
AppToast.showError('保存失败');
|
||||
}
|
||||
} else {
|
||||
await Gal.putVideo(compressedPath, album: '闲言');
|
||||
AppToast.showSuccess('已保存到相册');
|
||||
}
|
||||
|
||||
Log.i('视频压缩保存成功: $compressedPath');
|
||||
} on ArgumentError catch (e) {
|
||||
Log.e('视频保存失败(原生库加载异常,通常为模拟器环境)', e);
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
// 创建时间: 2026-05-15
|
||||
// 更新时间: 2026-06-12
|
||||
// 作用: 从file_transfer_page.dart拆分 — 发现设备Tab的UI构建
|
||||
// 上次更新: 局域网二维码增加设备信息JSON payload,扫码后显示设备详情
|
||||
// 上次更新: 局域网二维码改为纯HTTP URL,浏览器和App均可扫码识别
|
||||
// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
@@ -226,22 +224,7 @@ mixin FileTransferDiscoveryTab<T extends ConsumerStatefulWidget>
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建局域网二维码JSON payload,包含设备信息
|
||||
String _buildLanQrPayload(String lanUrl) {
|
||||
final settings = ref.read(transferSettingsProvider);
|
||||
final deviceName = settings.deviceName;
|
||||
return jsonEncode({
|
||||
'type': 'xianyan-transfer',
|
||||
'url': lanUrl,
|
||||
'alias': deviceName,
|
||||
'ip': lanUrl.replaceFirst(RegExp(r'^https?://'), '').split(':').first,
|
||||
'port': 53317,
|
||||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
}
|
||||
|
||||
void _showLanAccessSheet(AppThemeExtension ext, String lanUrl) {
|
||||
final qrPayload = _buildLanQrPayload(lanUrl);
|
||||
final settings = ref.read(transferSettingsProvider);
|
||||
final deviceName = settings.deviceName;
|
||||
final ip = lanUrl.replaceFirst(RegExp(r'^https?://'), '').split(':').first;
|
||||
@@ -279,6 +262,7 @@ mixin FileTransferDiscoveryTab<T extends ConsumerStatefulWidget>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// 二维码:使用纯HTTP URL,浏览器和App均可识别
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
@@ -287,7 +271,7 @@ mixin FileTransferDiscoveryTab<T extends ConsumerStatefulWidget>
|
||||
border: Border.all(color: ext.bgElevated),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: qrPayload,
|
||||
data: lanUrl,
|
||||
size: 200,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
@@ -346,7 +330,7 @@ mixin FileTransferDiscoveryTab<T extends ConsumerStatefulWidget>
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
'📱 其他设备扫描此二维码即可查看设备详情并访问文件传输',
|
||||
'🌐 浏览器或闲言App扫码均可访问文件传输',
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -258,8 +258,9 @@ class FavoriteRepository {
|
||||
);
|
||||
|
||||
// 同步更新本地数据库,防止取消收藏后本地DB仍标记为收藏导致重新出现
|
||||
// 使用setFavoriteFlagForTarget兼容复合ID(如"feed_123")和纯数字ID(如"123")
|
||||
final db = AppDatabase.instance;
|
||||
await db.setFavoriteFlag(targetId.toString(), !isFav);
|
||||
await db.setFavoriteFlagForTarget(targetType, targetId, !isFav);
|
||||
|
||||
Log.i('${isFav ? "取消" : "添加"}收藏成功');
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 闲言APP — 闲言拾光栏配置弹窗
|
||||
// 创建时间: 2026-05-20
|
||||
// 更新时间: 2026-06-12
|
||||
// 更新时间: 2026-06-17
|
||||
// 作用: 配置AppBar拾光栏的显示项、自定义文案、轮播开关、角色拾光介绍
|
||||
// 上次更新: 显示项配置改为按钮点击弹出子Sheet,精简主Sheet布局
|
||||
// 上次更新: _DisplayItemsSheet改为ConsumerWidget实时监听Provider,修复配置开启后不实时更新
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart'
|
||||
@@ -88,23 +88,12 @@ class _DateConfigSheetState extends ConsumerState<DateConfigSheet> {
|
||||
}
|
||||
|
||||
/// 弹出显示项配置子Sheet
|
||||
void _showDisplayItemsSheet(
|
||||
BuildContext context,
|
||||
DateDisplayConfig config,
|
||||
Map<String, String> valueMap,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
final notifier = ref.read(dateDisplayProvider.notifier);
|
||||
void _showDisplayItemsSheet(BuildContext context) {
|
||||
AppBottomSheet.showCustom<void>(
|
||||
context: context,
|
||||
height: 0.65,
|
||||
builder: (_) => _DisplayItemsSheet(
|
||||
config: config,
|
||||
valueMap: valueMap,
|
||||
ext: ext,
|
||||
customTextController: _customTextController,
|
||||
onToggle: (key) => notifier.toggleItem(key),
|
||||
onCustomTextChanged: (text) => notifier.setCustomText(text),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -426,7 +415,7 @@ class _DateConfigSheetState extends ConsumerState<DateConfigSheet> {
|
||||
config: config,
|
||||
valueMap: valueMap,
|
||||
ext: ext,
|
||||
onTap: () => _showDisplayItemsSheet(context, config, valueMap, ext),
|
||||
onTap: () => _showDisplayItemsSheet(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
@@ -663,26 +652,63 @@ class _DisplayItemsButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示项配置子Sheet — 包含所有显示项开关
|
||||
class _DisplayItemsSheet extends StatelessWidget {
|
||||
/// 显示项配置子Sheet — 包含所有显示项开关(ConsumerWidget 实时监听配置变化)
|
||||
class _DisplayItemsSheet extends ConsumerWidget {
|
||||
const _DisplayItemsSheet({
|
||||
required this.config,
|
||||
required this.valueMap,
|
||||
required this.ext,
|
||||
required this.customTextController,
|
||||
required this.onToggle,
|
||||
required this.onCustomTextChanged,
|
||||
});
|
||||
|
||||
final DateDisplayConfig config;
|
||||
final Map<String, String> valueMap;
|
||||
final AppThemeExtension ext;
|
||||
final TextEditingController customTextController;
|
||||
final ValueChanged<String> onToggle;
|
||||
final ValueChanged<String> onCustomTextChanged;
|
||||
|
||||
/// 格式化当前日期
|
||||
String _formatDate(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
return DateFormat.MMMd(
|
||||
Localizations.localeOf(context).toString(),
|
||||
).format(now);
|
||||
} catch (_) {
|
||||
return '${now.month}月${now.day}日';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// 实时监听配置变化,确保开关状态同步
|
||||
final config = ref.watch(dateDisplayProvider);
|
||||
final ext = AppTheme.ext(context);
|
||||
final weatherState = ref.watch(weatherInfoProvider);
|
||||
final weatherBrief = weatherState.brief;
|
||||
final ipCache = ref.watch(ipCacheProvider);
|
||||
final notifier = ref.read(dateDisplayProvider.notifier);
|
||||
|
||||
// 在组件内部构建 valueMap,确保数据实时更新
|
||||
final valueMap = <String, String>{
|
||||
DateDisplayItemKey.date: _formatDate(context),
|
||||
DateDisplayItemKey.weather: (weatherBrief?.icon.isNotEmpty ?? false)
|
||||
? weatherBrief!.icon
|
||||
: '--',
|
||||
DateDisplayItemKey.temp: (weatherBrief?.temp.isNotEmpty ?? false)
|
||||
? weatherBrief!.temp
|
||||
: '--',
|
||||
DateDisplayItemKey.city: (weatherBrief?.city.isNotEmpty ?? false)
|
||||
? weatherBrief!.city
|
||||
: '--',
|
||||
DateDisplayItemKey.device: pu.isIOS
|
||||
? 'iOS'
|
||||
: pu.isAndroid
|
||||
? 'Android'
|
||||
: '📱',
|
||||
DateDisplayItemKey.battery:
|
||||
'${BatteryInfoService.instance.currentLevel}%',
|
||||
DateDisplayItemKey.ip: (ipCache?.displayCity.isNotEmpty ?? false)
|
||||
? ipCache!.displayCity
|
||||
: '--',
|
||||
DateDisplayItemKey.custom: config.customText.isNotEmpty
|
||||
? '"${config.customText}"'
|
||||
: '--',
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
@@ -742,7 +768,7 @@ class _DisplayItemsSheet extends StatelessWidget {
|
||||
valueText: valueMap[item.key] ?? '--',
|
||||
isEnabled: isEnabled,
|
||||
ext: ext,
|
||||
onChanged: (_) => onToggle(item.key),
|
||||
onChanged: (_) => notifier.toggleItem(item.key),
|
||||
trailing: isCustom
|
||||
? SizedBox(
|
||||
width: 100,
|
||||
@@ -764,7 +790,7 @@ class _DisplayItemsSheet extends StatelessWidget {
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
maxLength: 14,
|
||||
onChanged: onCustomTextChanged,
|
||||
onChanged: (text) => notifier.setCustomText(text),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 收藏页面核心操作Mixin
|
||||
/// 创建时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 收藏页面核心操作(删除/复制/分享/同步/刷新/排序/搜索/选择)
|
||||
/// 上次更新: 排序中标签和分组改用Drift状态映射groupMap/tagMap
|
||||
/// 上次更新: removeFavorite成功后添加HapticService.success()触觉反馈
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../../core/services/device/haptic_service.dart';
|
||||
import '../../../../core/storage/database/app_database.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../../l10n/translations.dart';
|
||||
@@ -28,7 +29,8 @@ mixin FavoriteActionsMixin on FavoritePageStateAccessor {
|
||||
Future<void> loadLocalStats() async {
|
||||
try {
|
||||
final db = AppDatabase.instance;
|
||||
final count = await db.getFavoriteCount();
|
||||
// 从sentences表统计isFavorite=true的记录数,而非translateFavorites表
|
||||
final count = await db.getSentenceFavoriteCount();
|
||||
final feedStats = await db.getFavoriteCountByFeedType();
|
||||
if (mounted) {
|
||||
onLoadLocalStatsResult(count, feedStats);
|
||||
@@ -198,12 +200,14 @@ mixin FavoriteActionsMixin on FavoritePageStateAccessor {
|
||||
.read(favoriteProvider.notifier)
|
||||
.toggleFavorite(targetType: item.targetType, targetId: item.targetId);
|
||||
} else {
|
||||
// 使用setFavoriteFlagForTarget兼容复合ID和纯数字ID
|
||||
final db = AppDatabase.instance;
|
||||
await db.setFavoriteFlag(item.targetId.toString(), false);
|
||||
await db.setFavoriteFlagForTarget(item.targetType, item.targetId, false);
|
||||
await ref.read(favoriteProvider.notifier).loadLocalFavoritesAsItems();
|
||||
}
|
||||
|
||||
await loadLocalStats();
|
||||
HapticService.success();
|
||||
if (mounted) AppToast.showSuccess(t.favorites.unfavoriteSuccess);
|
||||
}
|
||||
|
||||
@@ -250,7 +254,8 @@ mixin FavoriteActionsMixin on FavoritePageStateAccessor {
|
||||
.read(favoriteProvider.notifier)
|
||||
.toggleFavorite(targetType: targetType, targetId: targetId);
|
||||
} else {
|
||||
await db.setFavoriteFlag(targetId.toString(), false);
|
||||
// 使用setFavoriteFlagForTarget兼容复合ID和纯数字ID
|
||||
await db.setFavoriteFlagForTarget(targetType, targetId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 收藏卡片组件
|
||||
/// 创建时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 收藏列表卡片和网格卡片(独立ConsumerWidget)
|
||||
/// 上次更新: 添加搜索高亮 + 扩展滑动操作(归档/标签)
|
||||
/// 上次更新: 滑动操作添加HapticService触觉反馈
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -117,17 +117,24 @@ class FavoriteListCard extends ConsumerWidget {
|
||||
child: AppSlidable(
|
||||
slideKey: ValueKey(key),
|
||||
groupTag: 'favorites',
|
||||
onOpened: () => HapticService.light(),
|
||||
leftActions: [
|
||||
SlideActionConfig(
|
||||
type: SlideActionType.share,
|
||||
onPressed: onShare,
|
||||
onPressed: () {
|
||||
HapticService.light();
|
||||
onShare();
|
||||
},
|
||||
),
|
||||
SlideActionConfig(
|
||||
type: SlideActionType.archive,
|
||||
label: t.favorites.archive,
|
||||
icon: CupertinoIcons.archivebox,
|
||||
backgroundColor: CupertinoColors.systemBlue,
|
||||
onPressed: onArchive,
|
||||
onPressed: () {
|
||||
HapticService.medium();
|
||||
onArchive();
|
||||
},
|
||||
),
|
||||
],
|
||||
rightActions: [
|
||||
@@ -136,14 +143,20 @@ class FavoriteListCard extends ConsumerWidget {
|
||||
label: t.favorites.unfavorite,
|
||||
icon: CupertinoIcons.heart_slash,
|
||||
backgroundColor: CupertinoColors.systemPink,
|
||||
onPressed: onRemove,
|
||||
onPressed: () {
|
||||
HapticService.destructive();
|
||||
onRemove();
|
||||
},
|
||||
),
|
||||
SlideActionConfig(
|
||||
type: SlideActionType.tag,
|
||||
label: t.favorites.addTag,
|
||||
icon: CupertinoIcons.tag,
|
||||
backgroundColor: CupertinoColors.systemTeal,
|
||||
onPressed: onTag,
|
||||
onPressed: () {
|
||||
HapticService.selection();
|
||||
onTag();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: GlassContainer(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 收藏页面
|
||||
/// 创建时间: 2026-04-24
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 作用: 展示用户收藏列表,支持搜索+排序+批量管理+统计+视图切换+分组管理+标签+导出+双向同步
|
||||
/// 上次更新: 收藏标签和分组从KvStorage迁移至Drift,使用groupMap/tagMap状态映射
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 展示用户收藏列表,支持搜索+排序+批量管理+统计+视图切换+分组管理+标签+导出+双向同步+里程碑庆祝
|
||||
/// 上次更新: 添加收藏里程碑庆祝效果(Confetti+触觉+音效)
|
||||
/// ============================================================
|
||||
|
||||
import 'package:confetti/confetti.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
|
||||
import '../../../../core/services/audio/sfx_service.dart';
|
||||
import '../../../../core/services/device/haptic_service.dart';
|
||||
import '../../../../core/storage/database/app_database.dart';
|
||||
import '../../../../core/storage/kv_storage.dart';
|
||||
import '../../../../core/services/notification/favorite_reminder_service.dart';
|
||||
@@ -81,6 +84,9 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
/// 搜索防抖器
|
||||
final _searchDebouncer = Debouncer();
|
||||
|
||||
/// 里程碑庆祝控制器
|
||||
late ConfettiController _confettiController;
|
||||
|
||||
/// 过期收藏数量(用于提醒横幅)
|
||||
int _expiredCount = 0;
|
||||
|
||||
@@ -196,6 +202,29 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
_totalFavCount = count;
|
||||
_feedTypeStats = feedStats;
|
||||
});
|
||||
// 检查是否跨越了里程碑
|
||||
_checkMilestoneAfterLoad(count);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 里程碑庆祝
|
||||
// ============================================================
|
||||
|
||||
/// 收藏里程碑阈值
|
||||
static const _milestones = [10, 50, 100, 200, 500, 1000];
|
||||
|
||||
/// 加载完成后检测是否跨越了某个里程碑,触发庆祝效果
|
||||
void _checkMilestoneAfterLoad(int newCount) {
|
||||
final lastCount = KvStorage.getInt('last_favorite_count') ?? 0;
|
||||
KvStorage.setInt('last_favorite_count', newCount);
|
||||
for (final m in _milestones) {
|
||||
if (lastCount < m && newCount >= m) {
|
||||
_confettiController.play();
|
||||
HapticService.success();
|
||||
SfxService.instance.play(SfxType.favorite);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -205,6 +234,9 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_confettiController = ConfettiController(
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
_searchFocusNode.canRequestFocus = false;
|
||||
KeyboardManager.instance.registerPage(_pageKey);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -228,6 +260,7 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
_searchDebouncer.dispose();
|
||||
KeyboardManager.instance.unregisterPage(_pageKey);
|
||||
_searchController.dispose();
|
||||
@@ -359,7 +392,32 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.isEmbedded) return content;
|
||||
if (widget.isEmbedded) {
|
||||
return Stack(
|
||||
children: [
|
||||
content,
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConfettiWidget(
|
||||
confettiController: _confettiController,
|
||||
blastDirectionality: BlastDirectionality.explosive,
|
||||
emissionFrequency: 0.05,
|
||||
numberOfParticles: 30,
|
||||
maxBlastForce: 18,
|
||||
minBlastForce: 8,
|
||||
gravity: 0.15,
|
||||
colors: [
|
||||
ext.accent,
|
||||
CupertinoColors.systemYellow,
|
||||
CupertinoColors.systemPink,
|
||||
CupertinoColors.systemBlue,
|
||||
CupertinoColors.systemGreen,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
@@ -419,7 +477,31 @@ class _FavoritePageState extends ConsumerState<FavoritePage>
|
||||
],
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
child: Stack(
|
||||
children: [
|
||||
content,
|
||||
// 里程碑庆祝撒花效果
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConfettiWidget(
|
||||
confettiController: _confettiController,
|
||||
blastDirectionality: BlastDirectionality.explosive,
|
||||
emissionFrequency: 0.05,
|
||||
numberOfParticles: 30,
|
||||
maxBlastForce: 18,
|
||||
minBlastForce: 8,
|
||||
gravity: 0.15,
|
||||
colors: [
|
||||
ext.accent,
|
||||
CupertinoColors.systemYellow,
|
||||
CupertinoColors.systemPink,
|
||||
CupertinoColors.systemBlue,
|
||||
CupertinoColors.systemGreen,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,11 @@ class FavoriteNotifier extends Notifier<FavoriteState> {
|
||||
targetType: targetType,
|
||||
targetId: targetId,
|
||||
);
|
||||
if (success) await loadFavorites(refresh: true);
|
||||
if (success) {
|
||||
await loadFavorites(refresh: true);
|
||||
// 通知主页刷新句子收藏状态
|
||||
notifyFavoriteRefresh();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/storage/kv_storage.dart';
|
||||
import '../../../core/storage/database/app_database.dart';
|
||||
import '../../../core/sync/data_sync_compat.dart';
|
||||
import '../../../core/utils/data/bounded_collection_manager.dart';
|
||||
import '../../../core/utils/logger.dart' show Log, LogCategory;
|
||||
import '../../../core/utils/safe_init_mixin.dart';
|
||||
@@ -35,6 +36,11 @@ class HomeNotifier extends Notifier<HomeState>
|
||||
_idsManager = BoundedCollectionManager<String>(maxSize: _maxSeenSize);
|
||||
_textsManager = BoundedCollectionManager<String>(maxSize: _maxSeenSize);
|
||||
ref.onDispose(_onDispose);
|
||||
// 监听收藏刷新事件,同步更新主页句子的isFavorited状态
|
||||
_favoriteRefreshSub?.cancel();
|
||||
_favoriteRefreshSub = favoriteRefreshStream.listen((_) {
|
||||
_syncFavoriteStateFromDb();
|
||||
});
|
||||
if (pu.isOhos) Log.i('🟢 [OHOS] HomeNotifier.build() 执行', null, null, LogCategory.provider);
|
||||
safeNotifierInit(() async {
|
||||
_db = AppDatabase.instance;
|
||||
@@ -51,6 +57,7 @@ class HomeNotifier extends Notifier<HomeState>
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
Stream<List<ConnectivityResult>>? _connectivityStream;
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
StreamSubscription<void>? _favoriteRefreshSub;
|
||||
|
||||
int _currentPage = 1;
|
||||
int? _lastFeedId;
|
||||
@@ -136,10 +143,43 @@ class HomeNotifier extends Notifier<HomeState>
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地DB同步收藏状态到当前句子列表
|
||||
///
|
||||
/// 当收藏页面取消收藏后,通过favoriteRefreshStream触发,
|
||||
/// 从DB重新读取isFavorite状态并更新对应句子的isFavorited字段。
|
||||
Future<void> _syncFavoriteStateFromDb() async {
|
||||
try {
|
||||
if (state.sentences.isEmpty || !mounted) return;
|
||||
// 批量获取所有收藏句子的ID集合
|
||||
final favSentences = await _db.getFavoriteSentences();
|
||||
final favIds = favSentences.map((s) => s.id).toSet();
|
||||
bool changed = false;
|
||||
final updated = <HomeSentence>[];
|
||||
|
||||
for (final s in state.sentences) {
|
||||
final shouldBeFav = favIds.contains(s.id);
|
||||
if (shouldBeFav != s.isFavorited) {
|
||||
updated.add(s.copyWith(isFavorited: shouldBeFav));
|
||||
changed = true;
|
||||
} else {
|
||||
updated.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && mounted) {
|
||||
state = state.copyWith(sentences: updated);
|
||||
Log.i('主页句子收藏状态已从DB同步更新');
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('同步收藏状态到主页失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDispose() {
|
||||
markDisposed();
|
||||
_connectivitySub?.cancel();
|
||||
_connectivityStream = null;
|
||||
_favoriteRefreshSub?.cancel();
|
||||
}
|
||||
|
||||
Future<void> _listenConnectivity() async {
|
||||
|
||||
@@ -176,7 +176,8 @@ class HomeSentence {
|
||||
author: row.author.isEmpty ? null : row.author,
|
||||
source: row.source.isEmpty ? null : row.source,
|
||||
type: row.tags.isEmpty ? null : row.tags,
|
||||
isLiked: row.isFavorite,
|
||||
isLiked: row.isLiked,
|
||||
isFavorited: row.isFavorite,
|
||||
feedType: row.feedType.isEmpty ? null : row.feedType,
|
||||
feedName: row.feedName.isEmpty ? null : row.feedName,
|
||||
feedIcon: row.feedIcon.isEmpty ? null : row.feedIcon,
|
||||
|
||||
@@ -21,6 +21,8 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/services/app_store_service.dart';
|
||||
import '../../../l10n/app_locale.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
import '../../../shared/widgets/containers/glass_container.dart';
|
||||
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
||||
@@ -355,7 +357,7 @@ class _FeedbackSection extends ConsumerWidget {
|
||||
title: t.about.rateAppMenu,
|
||||
subtitle: t.about.rateAppMenuDesc,
|
||||
ext: ext,
|
||||
onTap: () => _onRateApp(context, t),
|
||||
onTap: () => _onRateApp(context, ref, t),
|
||||
),
|
||||
_SectionDivider(ext: ext),
|
||||
_ActionTile(
|
||||
@@ -369,61 +371,46 @@ class _FeedbackSection extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _onRateApp(BuildContext context, T t) async {
|
||||
void _onRateApp(BuildContext context, WidgetRef ref, T t) async {
|
||||
try {
|
||||
// iOS App Store
|
||||
if (pu.isIOS) {
|
||||
const appId = '6737492298';
|
||||
final uri = Uri.parse('https://apps.apple.com/app/id$appId');
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: 'App Store',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 鸿蒙应用市场
|
||||
if (pu.isOhos) {
|
||||
final uri = Uri.parse(
|
||||
'https://appgallery.huawei.com/app/detail?id=apps.xy.xianyan',
|
||||
);
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: t.about.huaweiStore,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Android Google Play
|
||||
final locale = ref.read(appLocaleProvider);
|
||||
// Android优先尝试market://协议
|
||||
if (pu.isAndroid) {
|
||||
final uri = Uri.parse('market://details?id=apps.xy.xianyan');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
final marketUri = AppStoreService.getAndroidPlayStoreUrl();
|
||||
if (await canLaunchUrl(marketUri)) {
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: 'Google Play',
|
||||
uri: marketUri,
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final webUri = Uri.parse(
|
||||
'https://play.google.com/store/apps/details?id=apps.xy.xianyan',
|
||||
);
|
||||
final webUri = AppStoreService.getAndroidPlayStoreUrl(webFallback: true);
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: webUri,
|
||||
appName: 'Google Play',
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (context.mounted) AppToast.showInfo(t.about.rateAppMenuDesc);
|
||||
|
||||
// 其他平台:通过服务获取URL
|
||||
final uri = AppStoreService.getStoreUrlByLocale(locale);
|
||||
if (uri == null) {
|
||||
if (context.mounted) AppToast.showInfo(t.about.rateAppMenuDesc);
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) AppToast.showInfo(t.about.rateAppMenuDesc);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 软件信息页面(分区组件)
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-06-13
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 技术栈、构建信息、设备信息、平台兼容、更新日志、备案信息等分区
|
||||
/// 上次更新: _getImpellerBackend增加缓存机制+WebGPU后端;_inferImpellerBackend独立方法
|
||||
/// 上次更新: 设备类型卡片增加系统版本号显示
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
@@ -33,6 +33,7 @@ import '../../../core/utils/platform/platform_utils.dart'
|
||||
isDesktop,
|
||||
platformName;
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/services/device/device_info_service.dart';
|
||||
import '../../../shared/widgets/containers/glass_container.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
import 'about_shared_widgets.dart';
|
||||
@@ -516,6 +517,23 @@ class _DeviceInfoSectionState extends State<DeviceInfoSection> {
|
||||
/// Impeller 后端缓存,避免频繁反射调用
|
||||
String? _cachedImpellerBackend;
|
||||
|
||||
/// 系统版本缓存
|
||||
String _systemVersion = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSystemVersion();
|
||||
}
|
||||
|
||||
/// 异步加载系统版本
|
||||
Future<void> _loadSystemVersion() async {
|
||||
final version = await DeviceInfoService.getSystemVersion();
|
||||
if (mounted && version.isNotEmpty) {
|
||||
setState(() => _systemVersion = version);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
@@ -567,7 +585,9 @@ class _DeviceInfoSectionState extends State<DeviceInfoSection> {
|
||||
),
|
||||
GridInfoItem(
|
||||
title: t.about.deviceType,
|
||||
value: deviceType,
|
||||
value: _systemVersion.isNotEmpty
|
||||
? '$deviceType ($_systemVersion)'
|
||||
: deviceType,
|
||||
icon: CupertinoIcons.device_phone_portrait,
|
||||
ext: ext,
|
||||
),
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/router/app_routes.dart';
|
||||
import '../../../core/layout/split_view_navigation_mixin.dart';
|
||||
import '../../../core/services/app_exit_service.dart';
|
||||
import '../../../core/services/app_store_service.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../l10n/app_locale.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
@@ -153,60 +154,45 @@ class _ProfilePageState extends ConsumerState<ProfilePage>
|
||||
Future<void> _launchAppStore() async {
|
||||
final t = ref.read(translationsProvider);
|
||||
try {
|
||||
// iOS App Store
|
||||
if (pu.isIOS) {
|
||||
const appId = '6737492298';
|
||||
final uri = Uri.parse('https://apps.apple.com/app/id$appId');
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: 'App Store',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 鸿蒙应用市场
|
||||
if (pu.isOhos) {
|
||||
final uri = Uri.parse(
|
||||
'https://appgallery.huawei.com/app/detail?id=apps.xy.xianyan',
|
||||
);
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: t.about.huaweiStore,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Android Google Play
|
||||
final locale = ref.read(appLocaleProvider);
|
||||
// Android优先尝试market://协议
|
||||
if (pu.isAndroid) {
|
||||
final uri = Uri.parse('market://details?id=apps.xy.xianyan');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
final marketUri = AppStoreService.getAndroidPlayStoreUrl();
|
||||
if (await canLaunchUrl(marketUri)) {
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: 'Google Play',
|
||||
uri: marketUri,
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 降级:打开网页版
|
||||
final webUri = Uri.parse(
|
||||
'https://play.google.com/store/apps/details?id=apps.xy.xianyan',
|
||||
);
|
||||
final webUri = AppStoreService.getAndroidPlayStoreUrl(webFallback: true);
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: webUri,
|
||||
appName: 'Google Play',
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
AppToast.showInfo(t.profile.appStoreNotFound);
|
||||
|
||||
// 其他平台:通过服务获取URL
|
||||
final uri = AppStoreService.getStoreUrlByLocale(locale);
|
||||
if (uri == null) {
|
||||
if (context.mounted) AppToast.showInfo(t.profile.appStoreNotFound);
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
await ExternalLinkDialog.launchWithConfirm(
|
||||
context,
|
||||
uri: uri,
|
||||
appName: AppStoreService.getStoreName(t),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppToast.showInfo(t.profile.appStoreNotFound);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../../../core/utils/platform/ohos_compatibility_helper.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
@@ -499,7 +500,18 @@ class _ProgressShareCardSheetState
|
||||
return;
|
||||
}
|
||||
|
||||
if (pu.isAndroid || pu.isIOS) {
|
||||
if (pu.isOhos) {
|
||||
// 鸿蒙端gal不支持,使用系统分享降级
|
||||
final ok = await OhosCompatibilityHelper.saveImageToGalleryCompat(
|
||||
byteData.buffer.asUint8List(),
|
||||
);
|
||||
if (ok) {
|
||||
AppToast.showSuccess('🖼️ 已通过分享保存');
|
||||
} else {
|
||||
AppToast.showError('保存失败');
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
} else if (pu.isAndroid || pu.isIOS) {
|
||||
await Gal.putImageBytes(byteData.buffer.asUint8List());
|
||||
AppToast.showSuccess('🖼️ 已保存到相册');
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Beta 功能页面
|
||||
/// 创建时间: 2026-05-30
|
||||
/// 更新时间: 2026-06-13
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 展示开发中/测试中/预览中的功能列表和问题列表,接入远程FeatureFlag服务
|
||||
/// 上次更新: 添加多语言支持,替换硬编码中文为翻译键
|
||||
/// 上次更新: 问卷进度实时保存(草稿持久化)、输入法遮挡修复、硬编码替换为多语言翻译键
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/storage/kv_storage.dart';
|
||||
import '../../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../../shared/widgets/feedback/app_toast.dart';
|
||||
|
||||
@@ -20,6 +22,7 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
import '../../../shared/widgets/adaptive/keyboard_safe_sheet.dart';
|
||||
import '../../../shared/widgets/containers/glass_container.dart';
|
||||
|
||||
class ExperimentalFeaturesPage extends ConsumerStatefulWidget {
|
||||
@@ -35,6 +38,7 @@ class _ExperimentalFeaturesPageState
|
||||
int _selectedTab = 0;
|
||||
String _issueFilter = 'all';
|
||||
late final PageController _pageController;
|
||||
bool _questionnaireSubmitted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,6 +55,17 @@ class _ExperimentalFeaturesPageState
|
||||
return;
|
||||
}
|
||||
_pageController = PageController(initialPage: _selectedTab);
|
||||
// 读取问卷提交状态
|
||||
_loadQuestionnaireSubmitted();
|
||||
}
|
||||
|
||||
/// 读取问卷是否已提交
|
||||
Future<void> _loadQuestionnaireSubmitted() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final submitted = prefs.getBool('beta_questionnaire_submitted') ?? false;
|
||||
if (mounted && submitted != _questionnaireSubmitted) {
|
||||
setState(() => _questionnaireSubmitted = submitted);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,7 +106,8 @@ class _ExperimentalFeaturesPageState
|
||||
children: [_buildFeaturesTab(ext, t), _buildIssuesTab(ext, t)],
|
||||
),
|
||||
),
|
||||
// 底部问卷按钮
|
||||
// 底部问卷按钮(提交后隐藏)
|
||||
if (!_questionnaireSubmitted)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md, AppSpacing.sm, AppSpacing.md, AppSpacing.md,
|
||||
@@ -108,7 +124,7 @@ class _ExperimentalFeaturesPageState
|
||||
Icon(CupertinoIcons.question_circle_fill, size: 18, color: ext.textOnAccent),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'填写问卷',
|
||||
t.beta.questionnaireBtn,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textOnAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -129,11 +145,15 @@ class _ExperimentalFeaturesPageState
|
||||
// ---- 问卷 ----
|
||||
|
||||
/// 弹出问卷Sheet
|
||||
void _showQuestionnaire(AppThemeExtension ext, T t) {
|
||||
showCupertinoModalPopup<void>(
|
||||
Future<void> _showQuestionnaire(AppThemeExtension ext, T t) async {
|
||||
await showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (_) => _QuestionnaireSheet(ext: ext, t: t),
|
||||
);
|
||||
// Sheet关闭后刷新问卷提交状态
|
||||
if (mounted) {
|
||||
_loadQuestionnaireSubmitted();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 分段控制器 ----
|
||||
@@ -845,36 +865,75 @@ class _QuestionnaireSheet extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _QuestionnaireSheetState extends State<_QuestionnaireSheet> {
|
||||
static const _draftKey = 'beta_questionnaire_draft';
|
||||
int _step = 0; // 0-3: 问题1-4, -1: 不符合, 4: 完成
|
||||
final _emailController = TextEditingController();
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDraft();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 草稿持久化 — 问卷进度实时保存
|
||||
// ============================================================
|
||||
|
||||
/// 加载草稿:恢复上次未完成的步骤和邮箱
|
||||
void _loadDraft() {
|
||||
final step = KvStorage.getInt('${_draftKey}_step');
|
||||
final email = KvStorage.getString('${_draftKey}_email') ?? '';
|
||||
if (step != null && step >= 0 && step <= 3) {
|
||||
_step = step;
|
||||
_emailController.text = email;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存草稿:当前步骤和邮箱写入 KvStorage
|
||||
void _saveDraft() {
|
||||
KvStorage.setInt('${_draftKey}_step', _step);
|
||||
KvStorage.setString('${_draftKey}_email', _emailController.text);
|
||||
}
|
||||
|
||||
/// 清除草稿:问卷完成或关闭时调用
|
||||
void _clearDraft() {
|
||||
KvStorage.remove('${_draftKey}_step');
|
||||
KvStorage.remove('${_draftKey}_email');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 保存问卷已提交标记
|
||||
Future<void> _markQuestionnaireSubmitted() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('beta_questionnaire_submitted', true);
|
||||
}
|
||||
|
||||
void _answer(bool yes) {
|
||||
if (_step == 0) {
|
||||
// 问题1: 了解Google Play
|
||||
if (!yes) { setState(() => _step = -1); return; }
|
||||
setState(() => _step = 1);
|
||||
if (!yes) { setState(() => _step = -1); _clearDraft(); return; }
|
||||
setState(() => _step = 1); _saveDraft();
|
||||
} else if (_step == 1) {
|
||||
// 问题2: 有GMS设备
|
||||
if (!yes) { setState(() => _step = -1); return; }
|
||||
setState(() => _step = 2);
|
||||
if (!yes) { setState(() => _step = -1); _clearDraft(); return; }
|
||||
setState(() => _step = 2); _saveDraft();
|
||||
} else if (_step == 2) {
|
||||
// 问题3: 愿意参与内测
|
||||
if (!yes) { setState(() => _step = -1); return; }
|
||||
setState(() => _step = 3);
|
||||
if (!yes) { setState(() => _step = -1); _clearDraft(); return; }
|
||||
setState(() => _step = 3); _saveDraft();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitEmail() async {
|
||||
final email = _emailController.text.trim();
|
||||
if (email.isEmpty || !email.contains('@') || !email.contains('gmail')) {
|
||||
AppToast.showWarning('请输入有效的Gmail邮箱');
|
||||
AppToast.showWarning(widget.t.beta.qInvalidEmail);
|
||||
return;
|
||||
}
|
||||
setState(() => _isSubmitting = true);
|
||||
@@ -884,145 +943,155 @@ class _QuestionnaireSheetState extends State<_QuestionnaireSheet> {
|
||||
source: FormCollectSource.betaQuestionnaire,
|
||||
);
|
||||
if (ok) {
|
||||
// 提交成功,保存标记并清除草稿
|
||||
await _markQuestionnaireSubmitted();
|
||||
_clearDraft();
|
||||
setState(() { _step = 4; _isSubmitting = false; });
|
||||
} else {
|
||||
AppToast.showError('提交失败,请稍后重试');
|
||||
AppToast.showError(widget.t.beta.qSubmitFailed);
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
} catch (e) {
|
||||
AppToast.showError('提交失败');
|
||||
AppToast.showError(widget.t.beta.qSubmitFailed);
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前步骤的问题文本
|
||||
String _getQuestionText() {
|
||||
return switch (_step) {
|
||||
0 => widget.t.beta.q1KnowGooglePlay,
|
||||
1 => widget.t.beta.q2HasGmsDevice,
|
||||
2 => widget.t.beta.q3WillingToBeta,
|
||||
3 => widget.t.beta.q4EnterGmail,
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
return CupertinoPopupSurface(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.6),
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgCard,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 拖拽指示器
|
||||
Container(
|
||||
width: 36, height: 5,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.textHint.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
final t = widget.t;
|
||||
return KeyboardSafeSheet(
|
||||
backgroundColor: ext.bgCard,
|
||||
topRadius: 14,
|
||||
scrollable: false,
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 拖拽指示器
|
||||
Container(
|
||||
width: 36, height: 5,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.textHint.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
if (_step >= 0 && _step <= 3) ...[
|
||||
// 进度
|
||||
Text('${_step + 1}/4', style: AppTypography.caption1.copyWith(color: ext.textHint)),
|
||||
const SizedBox(height: 12),
|
||||
// 问题
|
||||
Text(
|
||||
_questions[_step],
|
||||
style: AppTypography.headline.copyWith(color: ext.textPrimary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_step < 3) ...[
|
||||
// 是/否按钮
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoButton(
|
||||
color: ext.accent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text('是', style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: () => _answer(true),
|
||||
),
|
||||
),
|
||||
if (_step >= 0 && _step <= 3) ...[
|
||||
// 进度
|
||||
Text('${_step + 1}/4', style: AppTypography.caption1.copyWith(color: ext.textHint)),
|
||||
const SizedBox(height: 12),
|
||||
// 问题
|
||||
Text(
|
||||
_getQuestionText(),
|
||||
style: AppTypography.headline.copyWith(color: ext.textPrimary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_step < 3) ...[
|
||||
// 是/否按钮
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoButton(
|
||||
color: ext.accent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text(t.beta.qYes, style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: () => _answer(true),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CupertinoButton(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text('否', style: TextStyle(color: ext.textSecondary)),
|
||||
onPressed: () => _answer(false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CupertinoButton(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text(t.beta.qNo, style: TextStyle(color: ext.textSecondary)),
|
||||
onPressed: () => _answer(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// 问题4: Gmail输入
|
||||
CupertinoTextField(
|
||||
controller: _emailController,
|
||||
placeholder: '输入Gmail邮箱',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: ext.dividerOnCard),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
color: ext.accent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: _isSubmitting
|
||||
? CupertinoActivityIndicator(color: ext.textOnAccent)
|
||||
: Text('提交', style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: _isSubmitting ? null : _submitEmail,
|
||||
),
|
||||
),
|
||||
],
|
||||
] else if (_step == -1) ...[
|
||||
// 不符合条件
|
||||
Icon(CupertinoIcons.info_circle, size: 48, color: ext.textHint),
|
||||
const SizedBox(height: 12),
|
||||
Text('感谢您的参与', style: AppTypography.headline.copyWith(color: ext.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
Text('很遗憾,您暂时不符合内测条件。', style: AppTypography.subhead.copyWith(color: ext.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text('关闭', style: TextStyle(color: ext.textSecondary)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// 完成
|
||||
Icon(CupertinoIcons.checkmark_circle_fill, size: 48, color: ext.successColor),
|
||||
const SizedBox(height: 12),
|
||||
Text('提交成功!', style: AppTypography.headline.copyWith(color: ext.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
Text('审核通过后,您将获取闲言APP的GMS版内测资格。', style: AppTypography.subhead.copyWith(color: ext.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
// 问题4: Gmail输入
|
||||
CupertinoTextField(
|
||||
controller: _emailController,
|
||||
placeholder: t.beta.q4GmailHint,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
padding: const EdgeInsets.all(14),
|
||||
onChanged: (_) => _saveDraft(),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: ext.dividerOnCard),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
color: ext.accent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text('完成', style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: _isSubmitting
|
||||
? CupertinoActivityIndicator(color: ext.textOnAccent)
|
||||
: Text(t.beta.qSubmit, style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: _isSubmitting ? null : _submitEmail,
|
||||
),
|
||||
),
|
||||
],
|
||||
] else if (_step == -1) ...[
|
||||
// 不符合条件
|
||||
Icon(CupertinoIcons.info_circle, size: 48, color: ext.textHint),
|
||||
const SizedBox(height: 12),
|
||||
Text(t.beta.qEndThankYou, style: AppTypography.headline.copyWith(color: ext.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.beta.qEndNotQualified, style: AppTypography.subhead.copyWith(color: ext.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
color: ext.bgElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text(t.beta.close, style: TextStyle(color: ext.textSecondary)),
|
||||
onPressed: () {
|
||||
// 不符合条件关闭时也保存标记并清除草稿
|
||||
_markQuestionnaireSubmitted();
|
||||
_clearDraft();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// 完成
|
||||
Icon(CupertinoIcons.checkmark_circle_fill, size: 48, color: ext.successColor),
|
||||
const SizedBox(height: 12),
|
||||
Text(t.beta.qSubmitSuccess, style: AppTypography.headline.copyWith(color: ext.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.beta.qEndThanks, style: AppTypography.subhead.copyWith(color: ext.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
color: ext.accent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Text(t.beta.gotIt, style: TextStyle(color: ext.textOnAccent, fontWeight: FontWeight.w600)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _questions = [
|
||||
'您是否了解Google Play?',
|
||||
'您是否有支持GMS(谷歌框架)的设备?',
|
||||
'您是否愿意参与闲言APP的内测GMS版?',
|
||||
'填写你的Gmail邮箱,审核通过后,获取闲言APP的GMS版内测资格',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 节气核心模块(模型 + 服务 + 状态管理)
|
||||
/// 创建时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 合并节气数据模型、日期计算服务、状态管理为一体
|
||||
/// 上次更新: 由 solar_term_models + solar_term_service + solar_term_provider 合并
|
||||
/// 上次更新: 修复节气日期表排序(小寒大寒移至年初)+修正2026雨水日期+getCurrentTerm跨年边界
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -327,9 +327,11 @@ class SolarTermInfo {
|
||||
class SolarTermService {
|
||||
SolarTermService._();
|
||||
|
||||
/// 各年度节气日期对照表
|
||||
/// 各年度节气日期对照表(按年内时间顺序排列:小寒→大寒→立春→…→冬至)
|
||||
static const Map<String, List<Map<String, int>>> _solarTermDates = {
|
||||
'2025': [
|
||||
{'index': 22, 'month': 1, 'day': 5},
|
||||
{'index': 23, 'month': 1, 'day': 20},
|
||||
{'index': 0, 'month': 2, 'day': 3},
|
||||
{'index': 1, 'month': 2, 'day': 18},
|
||||
{'index': 2, 'month': 3, 'day': 5},
|
||||
@@ -352,12 +354,12 @@ class SolarTermService {
|
||||
{'index': 19, 'month': 11, 'day': 22},
|
||||
{'index': 20, 'month': 12, 'day': 7},
|
||||
{'index': 21, 'month': 12, 'day': 21},
|
||||
{'index': 22, 'month': 1, 'day': 5},
|
||||
{'index': 23, 'month': 1, 'day': 20},
|
||||
],
|
||||
'2026': [
|
||||
{'index': 22, 'month': 1, 'day': 5},
|
||||
{'index': 23, 'month': 1, 'day': 20},
|
||||
{'index': 0, 'month': 2, 'day': 4},
|
||||
{'index': 1, 'month': 2, 'day': 18},
|
||||
{'index': 1, 'month': 2, 'day': 19},
|
||||
{'index': 2, 'month': 3, 'day': 5},
|
||||
{'index': 3, 'month': 3, 'day': 20},
|
||||
{'index': 4, 'month': 4, 'day': 5},
|
||||
@@ -378,12 +380,10 @@ class SolarTermService {
|
||||
{'index': 19, 'month': 11, 'day': 22},
|
||||
{'index': 20, 'month': 12, 'day': 7},
|
||||
{'index': 21, 'month': 12, 'day': 21},
|
||||
{'index': 22, 'month': 1, 'day': 5},
|
||||
{'index': 23, 'month': 1, 'day': 20},
|
||||
],
|
||||
};
|
||||
|
||||
/// 获取当前节气
|
||||
/// 获取当前节气(年内最后一个已过节气;年初未到小寒时取上一年冬至)
|
||||
static SolarTermInfo? getCurrentTerm() {
|
||||
final now = DateTime.now();
|
||||
final year = now.year;
|
||||
@@ -397,6 +397,15 @@ class SolarTermService {
|
||||
current = SolarTermInfo.all[entry['index'] as int];
|
||||
}
|
||||
}
|
||||
|
||||
// 年初(1月1-4日)未到小寒时,取上一年最后一个节气(冬至)
|
||||
if (current == null) {
|
||||
final prevTerms = _getTermsForYear(year - 1);
|
||||
if (prevTerms.isNotEmpty) {
|
||||
current = SolarTermInfo.all[prevTerms.last['index'] as int];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
@@ -281,20 +281,32 @@ class SolarTermPage extends ConsumerWidget {
|
||||
SolarTermSeason.spring;
|
||||
final terms = SolarTermService.getTermsBySeason(season);
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: AppSpacing.sm,
|
||||
crossAxisSpacing: AppSpacing.sm,
|
||||
childAspectRatio: 1.4,
|
||||
),
|
||||
itemCount: terms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final term = terms[index];
|
||||
final isCurrent = state.currentTerm?.name == term.name;
|
||||
return _buildTermCard(ext, term, isCurrent, index);
|
||||
// 使用 LayoutBuilder 自适应屏幕宽度,避免小屏溢出
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 根据屏幕宽度动态计算 childAspectRatio,小屏设备给卡片更多高度
|
||||
// 卡片内容:Row(emoji+name) + 间距 + 诗句 + 间距 + 描述
|
||||
// 最小高度需求约 96px,宽度 = (constraints.maxWidth - spacing) / 2
|
||||
final cardWidth = (constraints.maxWidth - AppSpacing.sm) / 2;
|
||||
// 小屏给更低的宽高比(更高的卡片),大屏保持 1.4
|
||||
final ratio = cardWidth < 160 ? 1.15 : 1.4;
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: AppSpacing.sm,
|
||||
crossAxisSpacing: AppSpacing.sm,
|
||||
childAspectRatio: ratio,
|
||||
),
|
||||
itemCount: terms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final term = terms[index];
|
||||
final isCurrent = state.currentTerm?.name == term.name;
|
||||
return _buildTermCard(ext, term, isCurrent, index);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -314,22 +326,27 @@ class SolarTermPage extends ConsumerWidget {
|
||||
? Border.all(color: ext.accent.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
// 使用 Column + mainAxisSize.min 避免溢出,移除 Spacer
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(term.emoji, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
term.name,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: isCurrent ? ext.accent : ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
Flexible(
|
||||
child: Text(
|
||||
term.name,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: isCurrent ? ext.accent : ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isCurrent) ...[
|
||||
const Spacer(),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
@@ -350,7 +367,7 @@ class SolarTermPage extends ConsumerWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
term.poem.length > 12
|
||||
? '${term.poem.substring(0, 12)}…'
|
||||
|
||||
@@ -774,17 +774,17 @@ class _SourcePageState extends ConsumerState<SourcePage> {
|
||||
children: {
|
||||
'newest': Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(_t.source.newest, style: const TextStyle(fontSize: 13)),
|
||||
child: Text(_t.source.newest, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
'hottest': Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(_t.source.hottest, style: const TextStyle(fontSize: 13)),
|
||||
child: Text(_t.source.hottest, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -88,7 +88,8 @@ class SettingRow extends StatelessWidget {
|
||||
child: Icon(icon, size: 18, color: effectiveIconColor),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
// 使用 Flexible 让标题列在空间不足时收缩,避免 trailing 溢出
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -98,6 +99,8 @@ class SettingRow extends StatelessWidget {
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
@@ -105,11 +108,16 @@ class SettingRow extends StatelessWidget {
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 学习计划设置/管理页面
|
||||
/// 创建时间: 2026-06-17
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 管理所有学习计划(编辑/暂停/恢复/删除)+ 阅读目标设置 + 数据统计
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/storage/database/app_database.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../l10n/translations.dart';
|
||||
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
||||
import '../providers/reading_goal_provider.dart';
|
||||
import '../study_plan_models.dart';
|
||||
import '../study_plan_provider.dart';
|
||||
|
||||
class StudyPlanSettingsPage extends ConsumerStatefulWidget {
|
||||
const StudyPlanSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StudyPlanSettingsPage> createState() => _StudyPlanSettingsPageState();
|
||||
}
|
||||
|
||||
class _StudyPlanSettingsPageState extends ConsumerState<StudyPlanSettingsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(studyPlanProvider.notifier).loadPlans()).catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final t = ref.watch(translationsProvider);
|
||||
final sp = t.studyPlan;
|
||||
final state = ref.watch(studyPlanProvider);
|
||||
|
||||
final activePlans = state.plans.where((p) => (p as LearningPlan).isActive).toList();
|
||||
final pausedPlans = state.plans.where((p) => !(p as LearningPlan).isActive).toList();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: ext.bgPrimary,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
leading: const AdaptiveBackButton(),
|
||||
middle: Text(
|
||||
sp.managePlans,
|
||||
style: AppTypography.title3.copyWith(color: ext.textPrimary),
|
||||
),
|
||||
backgroundColor: ext.bgElevated.withValues(alpha: 0.85),
|
||||
border: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: state.isLoading
|
||||
? const Center(child: CupertinoActivityIndicator(radius: 16))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
// 阅读目标设置
|
||||
_buildReadingGoalSection(ext, sp),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// 进行中的计划
|
||||
if (activePlans.isNotEmpty) ...[
|
||||
_buildSectionHeader(ext, sp.activePlans, activePlans.length),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
...activePlans.map((p) => _buildPlanTile(ext, sp, p as LearningPlan)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
// 已暂停的计划
|
||||
if (pausedPlans.isNotEmpty) ...[
|
||||
_buildSectionHeader(ext, sp.pausedPlans, pausedPlans.length),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
...pausedPlans.map((p) => _buildPlanTile(ext, sp, p as LearningPlan)),
|
||||
],
|
||||
|
||||
// 空状态
|
||||
if (state.plans.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Text(
|
||||
sp.noRecords,
|
||||
style: AppTypography.subhead.copyWith(color: ext.textHint),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 阅读目标设置区域
|
||||
// ============================================================
|
||||
|
||||
Widget _buildReadingGoalSection(AppThemeExtension ext, TStudyPlan sp) {
|
||||
return Consumer(builder: (context, ref, _) {
|
||||
final goal = ref.watch(readingGoalProvider);
|
||||
final progress = goal.dailyProgress;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
ext.accent.withValues(alpha: 0.08),
|
||||
ext.accentLight.withValues(alpha: 0.04),
|
||||
],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
border: Border.all(color: ext.accent.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.book_fill, size: 18, color: ext.accent),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
sp.readingGoal,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildGoalRow(ext, sp, CupertinoIcons.eye_fill, sp.todayViews, progress.viewsToday, goal.dailyViewGoal),
|
||||
_buildGoalRow(ext, sp, CupertinoIcons.heart_fill, sp.todayFavorites, progress.favoritesToday, goal.dailyFavoriteGoal),
|
||||
_buildGoalRow(ext, sp, CupertinoIcons.doc_text_fill, sp.todayNotes, progress.notesToday, goal.dailyNoteGoal),
|
||||
_buildGoalRow(ext, sp, CupertinoIcons.checkmark_seal_fill, sp.streakDays, goal.streakDays, goal.streakGoal),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildGoalRow(
|
||||
AppThemeExtension ext,
|
||||
TStudyPlan sp,
|
||||
IconData icon,
|
||||
String label,
|
||||
int current,
|
||||
int target,
|
||||
) {
|
||||
final isComplete = target > 0 && current >= target;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: isComplete ? CupertinoColors.activeGreen : ext.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(label, style: AppTypography.caption1.copyWith(color: ext.textSecondary))),
|
||||
Text(
|
||||
'$current/$target',
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: isComplete ? CupertinoColors.activeGreen : ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 分区标题
|
||||
// ============================================================
|
||||
|
||||
Widget _buildSectionHeader(AppThemeExtension ext, String title, int count) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.title3.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.accent.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: ext.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 计划列表项
|
||||
// ============================================================
|
||||
|
||||
Widget _buildPlanTile(AppThemeExtension ext, TStudyPlan sp, LearningPlan plan) {
|
||||
final category = PlanCategory.values.firstWhere(
|
||||
(c) => c.name == plan.category,
|
||||
orElse: () => PlanCategory.custom,
|
||||
);
|
||||
final categoryColor = Color(category.colorValue);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgCard,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: ext.textHint.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: categoryColor.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(category.iconData, size: 20, color: categoryColor),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
plan.title,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
if (plan.streakDays > 0) ...[
|
||||
const Icon(CupertinoIcons.flame, size: 10, color: CupertinoColors.systemOrange),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${plan.streakDays}${sp.days}',
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: CupertinoColors.systemOrange,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
'${sp.totalCompleted}: ${plan.totalCompleted}',
|
||||
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.all(6),
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => _toggleActive(plan),
|
||||
child: Icon(
|
||||
plan.isActive ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill,
|
||||
size: 18,
|
||||
color: ext.accent,
|
||||
),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.all(6),
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => _confirmDelete(ext, sp, plan),
|
||||
child: const Icon(
|
||||
CupertinoIcons.delete,
|
||||
size: 18,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
void _toggleActive(LearningPlan plan) {
|
||||
ref.read(studyPlanProvider.notifier).togglePlanActive(plan.id, !plan.isActive);
|
||||
}
|
||||
|
||||
void _confirmDelete(AppThemeExtension ext, TStudyPlan sp, LearningPlan plan) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
title: Text(sp.confirmDelete),
|
||||
content: Text(sp.confirmDeleteMsg.replaceAll('{0}', plan.title)),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(sp.cancel),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(studyPlanProvider.notifier).deletePlan(plan.id);
|
||||
},
|
||||
child: Text(sp.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,72 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 学习计划数据模型
|
||||
/// 创建时间: 2026-05-02
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 学习计划 + 记录 + 模板 — 支持进度追踪
|
||||
/// 上次更新: 从 models/ 子目录移至 study_plan 模块根目录
|
||||
/// 上次更新: emoji替换为CupertinoIcons,label改为多语言键
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
enum PlanCategory {
|
||||
poetry('诗词', '📜', 0xFF4A90D9),
|
||||
chengyu('成语', '🔤', 0xFFEF5350),
|
||||
classic('国学', '📖', 0xFF5C6BC0),
|
||||
wisdom('名言', '💡', 0xFFD4A843),
|
||||
custom('自定义', '✨', 0xFF26A69A);
|
||||
poetry('catPoetry', 0xFF4A90D9),
|
||||
chengyu('catChengyu', 0xFFEF5350),
|
||||
classic('catClassic', 0xFF5C6BC0),
|
||||
wisdom('catWisdom', 0xFFD4A843),
|
||||
custom('catCustom', 0xFF26A69A);
|
||||
|
||||
const PlanCategory(this.label, this.emoji, this.colorValue);
|
||||
const PlanCategory(this.labelKey, this.colorValue);
|
||||
|
||||
final String label;
|
||||
final String emoji;
|
||||
/// 多语言翻译键,如 'catPoetry' → t.studyPlan.catPoetry
|
||||
final String labelKey;
|
||||
|
||||
/// 分类颜色值
|
||||
final int colorValue;
|
||||
|
||||
/// 分类图标(非常量,通过 getter 返回)
|
||||
IconData get iconData => switch (this) {
|
||||
poetry => CupertinoIcons.doc_text,
|
||||
chengyu => CupertinoIcons.textformat_abc,
|
||||
classic => CupertinoIcons.book,
|
||||
wisdom => CupertinoIcons.lightbulb,
|
||||
custom => CupertinoIcons.star,
|
||||
};
|
||||
}
|
||||
|
||||
class PlanTemplate {
|
||||
const PlanTemplate({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.titleKey,
|
||||
required this.descriptionKey,
|
||||
required this.category,
|
||||
required this.dailyGoal,
|
||||
required this.emoji,
|
||||
required this.iconDataKey,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
/// 多语言翻译键,如 'dailyPoetry5Title' → t.studyPlan.dailyPoetry5Title
|
||||
final String titleKey;
|
||||
|
||||
/// 多语言翻译键,如 'dailyPoetry5Desc' → t.studyPlan.dailyPoetry5Desc
|
||||
final String descriptionKey;
|
||||
|
||||
final PlanCategory category;
|
||||
final int dailyGoal;
|
||||
final String emoji;
|
||||
|
||||
/// 图标键名,用于获取对应的CupertinoIcons
|
||||
final String iconDataKey;
|
||||
|
||||
/// 获取模板图标
|
||||
IconData get iconData => switch (iconDataKey) {
|
||||
'doc_text' => CupertinoIcons.doc_text,
|
||||
'flower' => CupertinoIcons.leaf_arrow_circlepath,
|
||||
'textformat_abc' => CupertinoIcons.textformat_abc,
|
||||
'lightbulb' => CupertinoIcons.lightbulb,
|
||||
'book' => CupertinoIcons.book,
|
||||
'star' => CupertinoIcons.star,
|
||||
_ => CupertinoIcons.doc_text,
|
||||
};
|
||||
}
|
||||
|
||||
class PlanTemplates {
|
||||
@@ -44,51 +75,51 @@ class PlanTemplates {
|
||||
static const List<PlanTemplate> all = [
|
||||
PlanTemplate(
|
||||
id: 'daily_poetry_5',
|
||||
title: '每日五首诗',
|
||||
description: '每天阅读5首古诗词,积少成多',
|
||||
titleKey: 'dailyPoetry5Title',
|
||||
descriptionKey: 'dailyPoetry5Desc',
|
||||
category: PlanCategory.poetry,
|
||||
dailyGoal: 5,
|
||||
emoji: '📜',
|
||||
iconDataKey: 'doc_text',
|
||||
),
|
||||
PlanTemplate(
|
||||
id: 'daily_poetry_3',
|
||||
title: '诗词轻量计划',
|
||||
description: '每天3首诗,轻松坚持',
|
||||
titleKey: 'dailyPoetry3Title',
|
||||
descriptionKey: 'dailyPoetry3Desc',
|
||||
category: PlanCategory.poetry,
|
||||
dailyGoal: 3,
|
||||
emoji: '🌸',
|
||||
iconDataKey: 'flower',
|
||||
),
|
||||
PlanTemplate(
|
||||
id: 'daily_chengyu_5',
|
||||
title: '成语达人',
|
||||
description: '每天学习5个成语,丰富表达',
|
||||
titleKey: 'dailyChengyu5Title',
|
||||
descriptionKey: 'dailyChengyu5Desc',
|
||||
category: PlanCategory.chengyu,
|
||||
dailyGoal: 5,
|
||||
emoji: '🔤',
|
||||
iconDataKey: 'textformat_abc',
|
||||
),
|
||||
PlanTemplate(
|
||||
id: 'daily_wisdom_3',
|
||||
title: '名言积累',
|
||||
description: '每天3句名言,启迪智慧',
|
||||
titleKey: 'dailyWisdom3Title',
|
||||
descriptionKey: 'dailyWisdom3Desc',
|
||||
category: PlanCategory.wisdom,
|
||||
dailyGoal: 3,
|
||||
emoji: '💡',
|
||||
iconDataKey: 'lightbulb',
|
||||
),
|
||||
PlanTemplate(
|
||||
id: 'weekly_classic',
|
||||
title: '经典通读',
|
||||
description: '每周精读一篇经典名句',
|
||||
titleKey: 'weeklyClassicTitle',
|
||||
descriptionKey: 'weeklyClassicDesc',
|
||||
category: PlanCategory.classic,
|
||||
dailyGoal: 1,
|
||||
emoji: '📖',
|
||||
iconDataKey: 'book',
|
||||
),
|
||||
PlanTemplate(
|
||||
id: 'daily_mix_5',
|
||||
title: '混合学习',
|
||||
description: '每天5条混合内容,全面发展',
|
||||
titleKey: 'dailyMix5Title',
|
||||
descriptionKey: 'dailyMix5Desc',
|
||||
category: PlanCategory.custom,
|
||||
dailyGoal: 5,
|
||||
emoji: '✨',
|
||||
iconDataKey: 'star',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 闲情逸致卡片模型
|
||||
/// 创建时间: 2026-05-27
|
||||
/// 更新时间: 2026-05-28
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 时间线卡片数据模型 — 吃/玩卡片
|
||||
/// 上次更新: 新增heatLevel热度字段(1-5)
|
||||
/// 上次更新: 价格枚举扩展为平价/中等/高档三档,label改为key供翻译系统使用
|
||||
/// ============================================================
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -11,15 +11,27 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'leisure_card.freezed.dart';
|
||||
|
||||
/// 价格类型枚举
|
||||
///
|
||||
/// label 字段仅作为翻译key使用,实际显示文案由 TLeisure 提供。
|
||||
/// 新增 mid(中等)/premium(高档) 两档,替代原 free/paid 二元划分。
|
||||
enum LeisurePriceType {
|
||||
free('free', '免费', '🆓'),
|
||||
paid('paid', '付费', '💰'),
|
||||
commercial('commercial', '商业化', '🏪'),
|
||||
unknown('unknown', '未知', '❓');
|
||||
/// 平价档(原 free)
|
||||
budget('budget', 'priceBudget', '🆓'),
|
||||
/// 中等档
|
||||
mid('mid', 'priceMid', '💰'),
|
||||
/// 高档档
|
||||
premium('premium', 'pricePremium', '💎'),
|
||||
/// 付费(保留兼容旧数据)
|
||||
paid('paid', 'pricePaid', '💳'),
|
||||
/// 商业化
|
||||
commercial('commercial', 'priceCommercial', '🏪'),
|
||||
/// 未知
|
||||
unknown('unknown', 'priceUnknown', '❓');
|
||||
|
||||
const LeisurePriceType(this.id, this.label, this.emoji);
|
||||
const LeisurePriceType(this.id, this.labelKey, this.emoji);
|
||||
final String id;
|
||||
final String label;
|
||||
/// 翻译键,对应 TLeisure 中的字段名
|
||||
final String labelKey;
|
||||
final String emoji;
|
||||
|
||||
static LeisurePriceType fromId(String id) {
|
||||
@@ -28,6 +40,12 @@ enum LeisurePriceType {
|
||||
orElse: () => LeisurePriceType.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
/// 兼容旧数据:原 'free' 映射为 budget
|
||||
static LeisurePriceType fromIdCompat(String id) {
|
||||
if (id == 'free') return LeisurePriceType.budget;
|
||||
return fromId(id);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -56,13 +74,27 @@ extension LeisureCardX on LeisureCard {
|
||||
/// 是否高海拔(>3000m)
|
||||
bool get isHighAltitude => (altitude ?? 0) > 3000;
|
||||
|
||||
/// 价格显示文本
|
||||
String get priceLabel => priceType.label;
|
||||
/// 价格显示文本(已废弃,请使用 priceLabelKey 配合 TLeisure)
|
||||
@Deprecated('Use priceLabelKey + TLeisure instead')
|
||||
String get priceLabel => priceType.labelKey;
|
||||
|
||||
/// 价格翻译键
|
||||
String get priceLabelKey => priceType.labelKey;
|
||||
|
||||
/// 价格emoji
|
||||
String get priceEmoji => priceType.emoji;
|
||||
|
||||
/// 热度等级标签
|
||||
/// 热度等级翻译键
|
||||
String get heatLabelKey => switch (heatLevel) {
|
||||
5 => 'heatExtremely',
|
||||
4 => 'heatHigh',
|
||||
3 => 'heatMedium',
|
||||
2 => 'heatLow',
|
||||
_ => 'heatCold',
|
||||
};
|
||||
|
||||
/// 热度等级标签(已废弃,请使用 heatLabelKey 配合 TLeisure)
|
||||
@Deprecated('Use heatLabelKey + TLeisure instead')
|
||||
String get heatLabel => switch (heatLevel) {
|
||||
5 => '🔥🔥🔥🔥🔥 极热',
|
||||
4 => '🔥🔥🔥🔥 大热',
|
||||
@@ -98,7 +130,7 @@ extension LeisureCardX on LeisureCard {
|
||||
location: json['location'] as String? ?? '',
|
||||
province: json['province'] as String? ?? '',
|
||||
altitude: json['altitude'] as int?,
|
||||
priceType: LeisurePriceType.fromId(
|
||||
priceType: LeisurePriceType.fromIdCompat(
|
||||
json['priceType'] as String? ?? 'unknown',
|
||||
),
|
||||
priceNote: json['priceNote'] as String?,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 闲情逸致底部筛选栏
|
||||
/// 创建时间: 2026-05-27
|
||||
/// 更新时间: 2026-05-29
|
||||
/// 作用: 多选Chip筛选 — 花期/美食/高海拔/风险/免费/付费等
|
||||
/// 上次更新: Wrap替换为SingleChildScrollView+Row水平滚动
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 作用: 多选Chip筛选 — 花期/美食/高海拔/风险/价格档位等
|
||||
/// 上次更新: 接入TLeisure翻译系统,新增平价/中等/高档三档价格筛选,显示数量统计
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
@@ -14,27 +14,53 @@ import 'package:xianyan/core/theme/app_theme.dart';
|
||||
import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/models/leisure_card.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/models/leisure_node.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/providers/leisure_settings_provider.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/providers/leisure_timeline_provider.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
|
||||
/// 筛选项定义
|
||||
///
|
||||
/// [filterKey] 用于持久化和匹配,[emoji] 为图标,[labelGetter] 从 TLeisure 获取多语言文案。
|
||||
class _FilterDef {
|
||||
const _FilterDef({
|
||||
required this.filterKey,
|
||||
required this.emoji,
|
||||
required this.labelGetter,
|
||||
});
|
||||
|
||||
final String filterKey;
|
||||
final String emoji;
|
||||
final String Function(TLeisure tl) labelGetter;
|
||||
}
|
||||
|
||||
class LeisureBottomFilter extends ConsumerWidget {
|
||||
const LeisureBottomFilter({super.key});
|
||||
|
||||
static const _filters = [
|
||||
('🌸', '花期'),
|
||||
('🦞', '美食'),
|
||||
('🏔️', '高海拔'),
|
||||
('⚠️', '风险'),
|
||||
('🆓', '免费'),
|
||||
('💰', '付费'),
|
||||
('🌅', '日出'),
|
||||
('🌊', '观海'),
|
||||
/// 所有筛选项定义(顺序即显示顺序)
|
||||
static final _filters = <_FilterDef>[
|
||||
_FilterDef(filterKey: 'bloom', emoji: '🌸', labelGetter: (tl) => tl.filterBloom),
|
||||
_FilterDef(filterKey: 'food', emoji: '🦞', labelGetter: (tl) => tl.filterFood),
|
||||
_FilterDef(filterKey: 'altitude', emoji: '🏔️', labelGetter: (tl) => tl.filterAltitude),
|
||||
_FilterDef(filterKey: 'risk', emoji: '⚠️', labelGetter: (tl) => tl.filterRisk),
|
||||
_FilterDef(filterKey: 'budget', emoji: '🆓', labelGetter: (tl) => tl.priceBudget),
|
||||
_FilterDef(filterKey: 'mid', emoji: '💰', labelGetter: (tl) => tl.priceMid),
|
||||
_FilterDef(filterKey: 'premium', emoji: '💎', labelGetter: (tl) => tl.pricePremium),
|
||||
_FilterDef(filterKey: 'sunrise', emoji: '🌅', labelGetter: (tl) => tl.filterSunrise),
|
||||
_FilterDef(filterKey: 'seaside', emoji: '🌊', labelGetter: (tl) => tl.filterSeaside),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final t = ref.watch(translationsProvider);
|
||||
final tl = t.leisure;
|
||||
final activeFilters = ref.watch(leisureSettingsProvider).activeFilters;
|
||||
|
||||
// 统计各筛选项匹配的卡片数量(用于联动显示)
|
||||
final counts = _computeFilterCounts(ref);
|
||||
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
@@ -59,7 +85,7 @@ class LeisureBottomFilter extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'标注筛选',
|
||||
tl.filterLabel,
|
||||
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm - AppSpacing.xs),
|
||||
@@ -69,14 +95,15 @@ class LeisureBottomFilter extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _filters.map((f) {
|
||||
final label = '${f.$1} ${f.$2}';
|
||||
final isActive = activeFilters.contains(f.$2);
|
||||
final label = f.labelGetter(tl);
|
||||
final isActive = activeFilters.contains(f.filterKey);
|
||||
final count = counts[f.filterKey] ?? 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(leisureSettingsProvider.notifier)
|
||||
.toggleFilter(f.$2),
|
||||
.toggleFilter(f.filterKey),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm + AppSpacing.xs,
|
||||
@@ -92,13 +119,40 @@ class LeisureBottomFilter extends ConsumerWidget {
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: isActive ? ext.accent : ext.textSecondary,
|
||||
fontWeight:
|
||||
isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${f.emoji} $label',
|
||||
style: AppTypography.caption1.copyWith(
|
||||
color: isActive ? ext.accent : ext.textSecondary,
|
||||
fontWeight:
|
||||
isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? ext.accent.withValues(alpha: 0.15)
|
||||
: ext.textHint.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: isActive ? ext.accent : ext.textHint,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -113,4 +167,37 @@ class LeisureBottomFilter extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 计算每个筛选项匹配的卡片数量(联动统计)
|
||||
///
|
||||
/// 从 timeline provider 获取所有卡片,统计每个筛选条件匹配的数量。
|
||||
/// 数量为0的筛选项不显示角标。
|
||||
Map<String, int> _computeFilterCounts(WidgetRef ref) {
|
||||
final timelineState = ref.read(leisureTimelineProvider);
|
||||
// LeisureNode 通过 allCards 扩展 getter 提供 food + play 的合并列表
|
||||
final allCards = <LeisureCard>[];
|
||||
for (final node in timelineState.nodes) {
|
||||
allCards.addAll(node.allCards);
|
||||
}
|
||||
|
||||
final counts = <String, int>{};
|
||||
for (final card in allCards) {
|
||||
if (card.isFood) counts['food'] = (counts['food'] ?? 0) + 1;
|
||||
if (card.tags.contains('花期')) counts['bloom'] = (counts['bloom'] ?? 0) + 1;
|
||||
if (card.isHighAltitude) counts['altitude'] = (counts['altitude'] ?? 0) + 1;
|
||||
if (card.hasRisk) counts['risk'] = (counts['risk'] ?? 0) + 1;
|
||||
if (card.priceType == LeisurePriceType.budget) {
|
||||
counts['budget'] = (counts['budget'] ?? 0) + 1;
|
||||
}
|
||||
if (card.priceType == LeisurePriceType.mid) {
|
||||
counts['mid'] = (counts['mid'] ?? 0) + 1;
|
||||
}
|
||||
if (card.priceType == LeisurePriceType.premium) {
|
||||
counts['premium'] = (counts['premium'] ?? 0) + 1;
|
||||
}
|
||||
if (card.tags.contains('日出')) counts['sunrise'] = (counts['sunrise'] ?? 0) + 1;
|
||||
if (card.tags.contains('观海')) counts['seaside'] = (counts['seaside'] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:xianyan/features/tool_center/leisure/providers/leisure_bookmark_
|
||||
import 'package:xianyan/features/tool_center/leisure/providers/leisure_settings_provider.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/presentation/widgets/leisure_share_sheet.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/leisure_icons.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
import 'package:xianyan/shared/widgets/input/app_slidable.dart';
|
||||
|
||||
class LeisureCardWidget extends ConsumerWidget {
|
||||
@@ -76,7 +77,7 @@ class LeisureCardWidget extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(ext, settings),
|
||||
_buildHeader(ext, settings, ref),
|
||||
_buildBody(ext, settings),
|
||||
if (card.hasRisk && settings.showRiskWarning)
|
||||
_buildRiskWarning(ext, settings),
|
||||
@@ -157,7 +158,7 @@ class LeisureCardWidget extends ConsumerWidget {
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildHeader(AppThemeExtension ext, LeisureSettings settings) {
|
||||
Widget _buildHeader(AppThemeExtension ext, LeisureSettings settings, WidgetRef ref) {
|
||||
final typeColor = card.isFood
|
||||
? LeisureSeason.parseColor('#F59E0B')
|
||||
: LeisureSeason.parseColor('#4ECDC4');
|
||||
@@ -194,13 +195,15 @@ class LeisureCardWidget extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildPriceBadge(ext),
|
||||
_buildPriceBadge(ext, ref),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceBadge(AppThemeExtension ext) {
|
||||
Widget _buildPriceBadge(AppThemeExtension ext, WidgetRef ref) {
|
||||
final tl = ref.watch(translationsProvider).leisure;
|
||||
final label = _priceLabel(tl);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm - AppSpacing.xs,
|
||||
@@ -211,7 +214,7 @@ class LeisureCardWidget extends ConsumerWidget {
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Text(
|
||||
'${card.priceType.emoji} ${card.priceType.label}',
|
||||
'${card.priceType.emoji} $label',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: _priceBgColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -220,8 +223,22 @@ class LeisureCardWidget extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据价格类型获取多语言标签
|
||||
String _priceLabel(TLeisure tl) {
|
||||
return switch (card.priceType) {
|
||||
model.LeisurePriceType.budget => tl.priceBudget,
|
||||
model.LeisurePriceType.mid => tl.priceMid,
|
||||
model.LeisurePriceType.premium => tl.pricePremium,
|
||||
model.LeisurePriceType.paid => tl.pricePaid,
|
||||
model.LeisurePriceType.commercial => tl.priceCommercial,
|
||||
model.LeisurePriceType.unknown => tl.priceUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
Color get _priceBgColor => switch (card.priceType) {
|
||||
model.LeisurePriceType.free => LeisureSeason.parseColor('#10B981'),
|
||||
model.LeisurePriceType.budget => LeisureSeason.parseColor('#10B981'),
|
||||
model.LeisurePriceType.mid => LeisureSeason.parseColor('#F59E0B'),
|
||||
model.LeisurePriceType.premium => LeisureSeason.parseColor('#8B5CF6'),
|
||||
model.LeisurePriceType.paid => LeisureSeason.parseColor('#F59E0B'),
|
||||
model.LeisurePriceType.commercial => LeisureSeason.parseColor('#FF6B6B'),
|
||||
model.LeisurePriceType.unknown => LeisureSeason.parseColor('#9CA3AF'),
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:xianyan/features/tool_center/leisure/presentation/widgets/leisur
|
||||
import 'package:xianyan/features/tool_center/leisure/presentation/widgets/leisure_heatmap.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/providers/leisure_timeline_provider.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/leisure_icons.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/external_link_dialog.dart';
|
||||
import 'package:xianyan/core/services/device/calendar_service.dart';
|
||||
@@ -179,7 +180,7 @@ class _LeisureCardDetailSheetState
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
if (widget.node != null)
|
||||
Text(
|
||||
'${widget.node!.season.emoji} ${widget.node!.season.label} · ${card.priceType.emoji} ${card.priceType.label}',
|
||||
'${widget.node!.season.emoji} ${widget.node!.season.label} · ${card.priceType.emoji} ${_priceLabel(card.priceType)}',
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
@@ -191,6 +192,19 @@ class _LeisureCardDetailSheetState
|
||||
|
||||
static const double _leadingWidth = 88.0;
|
||||
|
||||
/// 根据价格类型获取多语言标签
|
||||
String _priceLabel(model.LeisurePriceType type) {
|
||||
final tl = ref.read(translationsProvider).leisure;
|
||||
return switch (type) {
|
||||
model.LeisurePriceType.budget => tl.priceBudget,
|
||||
model.LeisurePriceType.mid => tl.priceMid,
|
||||
model.LeisurePriceType.premium => tl.pricePremium,
|
||||
model.LeisurePriceType.paid => tl.pricePaid,
|
||||
model.LeisurePriceType.commercial => tl.priceCommercial,
|
||||
model.LeisurePriceType.unknown => tl.priceUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildInfoGrid(AppThemeExtension ext, model.LeisureCard card) {
|
||||
final items = <_InfoItemData>[
|
||||
_InfoItemData(LeisureIcons.location, '地点', card.location),
|
||||
@@ -200,7 +214,7 @@ class _LeisureCardDetailSheetState
|
||||
_InfoItemData(LeisureIcons.sunrise, '日出', widget.node!.sunrise!),
|
||||
if (widget.node?.sunset != null)
|
||||
_InfoItemData(LeisureIcons.sunset, '日落', widget.node!.sunset!),
|
||||
_InfoItemData(card.priceType.emoji, '价格', card.priceType.label),
|
||||
_InfoItemData(card.priceType.emoji, '价格', _priceLabel(card.priceType)),
|
||||
if (card.priceNote != null) _InfoItemData('💰', '费用', card.priceNote!),
|
||||
];
|
||||
|
||||
|
||||
@@ -105,12 +105,22 @@ class LeisureCardRow extends ConsumerWidget {
|
||||
return cards.where((c) {
|
||||
return activeFilters.any((filter) {
|
||||
return switch (filter) {
|
||||
'food' => c.isFood,
|
||||
'bloom' => c.tags.contains('花期'),
|
||||
'altitude' => c.isHighAltitude,
|
||||
'risk' => c.hasRisk,
|
||||
'budget' => c.priceType == LeisurePriceType.budget,
|
||||
'mid' => c.priceType == LeisurePriceType.mid,
|
||||
'premium' => c.priceType == LeisurePriceType.premium,
|
||||
'sunrise' => node?.sunrise != null,
|
||||
'seaside' => c.tags.contains('观海'),
|
||||
// 兼容旧筛选键
|
||||
'平价' => c.priceType == LeisurePriceType.budget,
|
||||
'付费' => c.priceType == LeisurePriceType.paid,
|
||||
'美食' => c.isFood,
|
||||
'花期' => c.tags.contains('花期'),
|
||||
'高海拔' => c.isHighAltitude,
|
||||
'风险' => c.hasRisk,
|
||||
'免费' => c.priceType == LeisurePriceType.free,
|
||||
'付费' => c.priceType == LeisurePriceType.paid,
|
||||
'日出' => node?.sunrise != null,
|
||||
'观海' => c.tags.contains('观海'),
|
||||
_ => true,
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/core/utils/platform/ohos_compatibility_helper.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
import 'package:xianyan/features/tool_center/leisure/models/leisure_card.dart'
|
||||
as model;
|
||||
@@ -488,7 +489,17 @@ class _LeisureShareSheetState extends ConsumerState<LeisureShareSheet> {
|
||||
if (_isProcessing) return;
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
if (pu.isOhos || pu.isAndroid || pu.isIOS) {
|
||||
if (pu.isOhos) {
|
||||
// 鸿蒙端gal不支持,使用系统分享降级
|
||||
final bytes = await _captureCardImage();
|
||||
final ok = await OhosCompatibilityHelper.saveImageToGalleryCompat(bytes);
|
||||
if (ok) {
|
||||
Log.i('闲情逸致卡片保存成功(分享降级): ${widget.card.id}');
|
||||
if (mounted) AppToast.showSuccess('🖼️ 已通过分享保存');
|
||||
} else {
|
||||
if (mounted) AppToast.showError('保存失败');
|
||||
}
|
||||
} else if (pu.isAndroid || pu.isIOS) {
|
||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||
if (!hasAccess) {
|
||||
final granted = await Gal.requestAccess(toAlbum: true);
|
||||
|
||||
Reference in New Issue
Block a user