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:
Developer
2026-06-17 08:45:34 +08:00
parent 49b6323772
commit 544f77c0ce
82 changed files with 9779 additions and 5301 deletions

View File

@@ -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,
),
),
),
],
),
],
),

View File

@@ -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);

View File

@@ -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 =

View File

@@ -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,
),
),

View File

@@ -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),
),
),

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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,
),

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -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,
],
),
),
],
),
);
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,
),

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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版内测资格',
];
}

View File

@@ -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;
}

View File

@@ -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)}'

View File

@@ -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)),
),
},
),

View File

@@ -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

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -1,41 +1,72 @@
/// ============================================================
/// 闲言APP — 学习计划数据模型
/// 创建时间: 2026-05-02
/// 更新时间: 2026-06-12
/// 更新时间: 2026-06-17
/// 作用: 学习计划 + 记录 + 模板 — 支持进度追踪
/// 上次更新: 从 models/ 子目录移至 study_plan 模块根目录
/// 上次更新: emoji替换为CupertinoIconslabel改为多语言键
/// ============================================================
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',
),
];
}

View File

@@ -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?,

View File

@@ -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;
}
}

View File

@@ -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'),

View File

@@ -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!),
];

View File

@@ -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,

View File

@@ -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);