chore: 完成v6.5.58版本迭代更新
本次更新包含多项功能优化与bug修复: 1. 新增flutter_keyboard_visibility依赖替代MediaQuery轮询获取键盘状态 2. 添加远程功能标志API支持与FeatureFlag服务 3. 重构壁纸背景渲染组件,统一全局壁纸展示逻辑 4. 延迟初始化壁纸源健康检测至用户同意协议后 5. 修复预测返回/长按预览锁定问题并移除相关配置项 6. 优化日志输出控制,release模式仅保留错误日志 7. 新增进度模块多语言翻译与相关UI字段 8. 优化稍后读功能,取消时同步删除聊天消息 9. 更新权限说明文档,移除冗余的存储写入权限配置 10. 重构部分UI组件减少参数传递,优化性能
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 应用布局壳
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-05-29
|
||||
// 更新时间: 2026-05-30
|
||||
// 作用: ShellRoute 布局壳,宽屏分屏 + 窄屏底部导航
|
||||
// 上次更新: 桌面端键盘快捷键(Ctrl+1/2/3切换Tab,Ctrl+W关闭面板)+集成SmartAppBar
|
||||
// 上次更新: 集成WallpaperBackground壁纸背景渲染(窄屏Stack+宽屏Stack)
|
||||
// ============================================================
|
||||
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
@@ -24,7 +24,9 @@ import '../../features/mine/settings/providers/theme_settings_provider.dart';
|
||||
import '../../l10n/translations.dart';
|
||||
import '../../shared/widgets/animation/tab_icon_sprite.dart';
|
||||
import '../../main.dart' show liquidGlassReady;
|
||||
import '../../shared/widgets/containers/wallpaper_background.dart';
|
||||
import '../../shared/widgets/containers/glass_bottom_nav_bar.dart';
|
||||
import '../../shared/widgets/feedback/app_error_boundary.dart';
|
||||
import 'adaptive_split_view.dart';
|
||||
import 'adaptive_nav_bar.dart';
|
||||
import 'overview_dashboard.dart';
|
||||
@@ -97,12 +99,16 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
|
||||
final Widget splitView = isTripleColumn
|
||||
? TripleColumnView(
|
||||
leftPanel: RepaintBoundary(child: widget.child),
|
||||
leftPanel: RepaintBoundary(
|
||||
child: AppErrorBoundary(label: '主页面', child: widget.child),
|
||||
),
|
||||
centerPanel: _buildRightPanel(context),
|
||||
rightPanel: _buildThirdPanel(context),
|
||||
)
|
||||
: AdaptiveSplitView(
|
||||
leftPanel: RepaintBoundary(child: widget.child),
|
||||
leftPanel: RepaintBoundary(
|
||||
child: AppErrorBoundary(label: '主页面', child: widget.child),
|
||||
),
|
||||
rightPanel: _buildRightPanel(context),
|
||||
);
|
||||
|
||||
@@ -111,12 +117,17 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
if (isNavBarVertical) {
|
||||
final isLeft = navBarPosition == NavBarPosition.left;
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (isLeft) navBar,
|
||||
Expanded(child: splitView),
|
||||
if (!isLeft) navBar,
|
||||
const Positioned.fill(child: WallpaperBackground()),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isLeft) navBar,
|
||||
Expanded(child: splitView),
|
||||
if (!isLeft) navBar,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -124,11 +135,16 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
|
||||
final isTop = navBarPosition == NavBarPosition.top;
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
if (isTop) navBar,
|
||||
Expanded(child: splitView),
|
||||
if (!isTop) navBar,
|
||||
const Positioned.fill(child: WallpaperBackground()),
|
||||
Column(
|
||||
children: [
|
||||
if (isTop) navBar,
|
||||
Expanded(child: splitView),
|
||||
if (!isTop) navBar,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -370,7 +386,14 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
size.width < kSplitViewBreakpoint &&
|
||||
size.height < size.width;
|
||||
|
||||
final content = Stack(children: [RepaintBoundary(child: widget.child)]);
|
||||
final content = Stack(
|
||||
children: [
|
||||
const WallpaperBackground(),
|
||||
RepaintBoundary(
|
||||
child: AppErrorBoundary(label: '主页面', child: widget.child),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isLandscapeNarrow) {
|
||||
return Center(
|
||||
|
||||
@@ -118,7 +118,7 @@ enum AppPermission {
|
||||
'存储空间',
|
||||
Permission.storage,
|
||||
CupertinoIcons.folder_fill,
|
||||
'用于保存编辑的卡片、壁纸到本地,导出字体文件和数据。仅Android 9及以下(API≤29)需要写入权限,Android 10+使用分区存储;Android 12及以下(API≤32)需要读取权限,Android 13+由相册权限替代。',
|
||||
'用于保存编辑的卡片、壁纸到本地,导出字体文件和数据。Android 12及以下(API≤32)需要读取权限,Android 13+由相册权限替代;写入操作使用分区存储,无需额外写入权限。',
|
||||
Color(0xFFFF9500),
|
||||
usageScenes: [
|
||||
'保存卡片 — 导出到本地',
|
||||
|
||||
458
lib/core/services/data/image_cache_metadata_service.dart
Normal file
458
lib/core/services/data/image_cache_metadata_service.dart
Normal file
@@ -0,0 +1,458 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 图片缓存元数据服务
|
||||
/// 创建时间: 2026-05-30
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 作用: 基于Hive的图片缓存元数据索引,支持按类型/日期分组、过期清理
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import 'image_cache_manager.dart';
|
||||
|
||||
// ============================================================
|
||||
// 缓存元数据条目
|
||||
// ============================================================
|
||||
|
||||
class CacheMetaEntry {
|
||||
const CacheMetaEntry({
|
||||
required this.path,
|
||||
required this.size,
|
||||
required this.createdAt,
|
||||
this.category,
|
||||
this.sourceUrl,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final int size;
|
||||
final String? category;
|
||||
final String? sourceUrl;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'size': size,
|
||||
'category': category,
|
||||
'sourceUrl': sourceUrl,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory CacheMetaEntry.fromJson(Map<String, dynamic> json) {
|
||||
return CacheMetaEntry(
|
||||
path: json['path'] as String,
|
||||
size: json['size'] as int,
|
||||
category: json['category'] as String?,
|
||||
sourceUrl: json['sourceUrl'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isExpired {
|
||||
if (expiresAt == null) return false;
|
||||
return DateTime.now().isAfter(expiresAt!);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 缓存分类常量
|
||||
// ============================================================
|
||||
|
||||
class CacheCategory {
|
||||
CacheCategory._();
|
||||
|
||||
static const String avatar = 'avatar';
|
||||
static const String wallpaper = 'wallpaper';
|
||||
static const String feed = 'feed';
|
||||
static const String card = 'card';
|
||||
static const String other = 'other';
|
||||
|
||||
static const List<String> all = [avatar, wallpaper, feed, card, other];
|
||||
|
||||
static String label(String category) {
|
||||
return switch (category) {
|
||||
avatar => '👤 头像',
|
||||
wallpaper => '🖼️ 壁纸',
|
||||
feed => '📰 Feed',
|
||||
card => '🃏 卡片',
|
||||
_ => '📦 其他',
|
||||
};
|
||||
}
|
||||
|
||||
static String guessFromPath(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
if (lower.contains('avatar') || lower.contains('user')) return avatar;
|
||||
if (lower.contains('wallpaper') || lower.contains('bg_')) return wallpaper;
|
||||
if (lower.contains('feed') || lower.contains('sentence')) return feed;
|
||||
if (lower.contains('card') || lower.contains('template')) return card;
|
||||
return other;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日期分组
|
||||
// ============================================================
|
||||
|
||||
class DateGroup {
|
||||
DateGroup._();
|
||||
|
||||
static const String today = 'today';
|
||||
static const String week = 'week';
|
||||
static const String month = 'month';
|
||||
static const String older = 'older';
|
||||
|
||||
static String groupOf(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final todayStart = DateTime(now.year, now.month, now.day);
|
||||
final weekStart = todayStart.subtract(const Duration(days: 7));
|
||||
final monthStart = todayStart.subtract(const Duration(days: 30));
|
||||
|
||||
if (dt.isAfter(todayStart)) return today;
|
||||
if (dt.isAfter(weekStart)) return week;
|
||||
if (dt.isAfter(monthStart)) return month;
|
||||
return older;
|
||||
}
|
||||
|
||||
static String label(String group) {
|
||||
return switch (group) {
|
||||
today => '📅 今天',
|
||||
week => '📆 近7天',
|
||||
month => '🗓️ 近30天',
|
||||
_ => '⏳ 更早',
|
||||
};
|
||||
}
|
||||
|
||||
static const List<String> ordered = [today, week, month, older];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自动清理策略
|
||||
// ============================================================
|
||||
|
||||
class AutoCleanPolicy {
|
||||
AutoCleanPolicy._();
|
||||
|
||||
static const String days7 = '7';
|
||||
static const String days14 = '14';
|
||||
static const String days30 = '30';
|
||||
static const String off = 'off';
|
||||
|
||||
static const List<String> all = [off, days7, days14, days30];
|
||||
|
||||
static String label(String policy) {
|
||||
return switch (policy) {
|
||||
days7 => '7天',
|
||||
days14 => '14天',
|
||||
days30 => '30天',
|
||||
_ => '关闭',
|
||||
};
|
||||
}
|
||||
|
||||
static int? toDays(String policy) {
|
||||
return switch (policy) {
|
||||
days7 => 7,
|
||||
days14 => 14,
|
||||
days30 => 30,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元数据服务
|
||||
// ============================================================
|
||||
|
||||
class ImageCacheMetadataService {
|
||||
ImageCacheMetadataService._();
|
||||
|
||||
static Box<dynamic>? _box;
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
static bool get isReady => _initialized;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
_box = Hive.box<dynamic>(HiveBoxNames.imageCacheMeta);
|
||||
_initialized = true;
|
||||
Log.i('ImageCacheMetadataService: 初始化完成');
|
||||
} catch (e) {
|
||||
Log.e('ImageCacheMetadataService: 初始化失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
static Box<dynamic>? _safeBox() {
|
||||
if (!_initialized || _box == null) {
|
||||
Log.w('ImageCacheMetadataService: 未初始化');
|
||||
return null;
|
||||
}
|
||||
return _box;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 索引操作
|
||||
// ============================================================
|
||||
|
||||
static Future<void> indexFile(
|
||||
String path, {
|
||||
String? sourceUrl,
|
||||
String? category,
|
||||
}) async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
|
||||
try {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return;
|
||||
|
||||
final stat = await file.stat();
|
||||
final cat = category ?? CacheCategory.guessFromPath(path);
|
||||
final now = DateTime.now();
|
||||
final expiresAt = now.add(const Duration(days: 7));
|
||||
|
||||
final entry = CacheMetaEntry(
|
||||
path: path,
|
||||
size: stat.size,
|
||||
category: cat,
|
||||
sourceUrl: sourceUrl,
|
||||
createdAt: stat.modified,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
|
||||
await box.put(path, entry.toJson());
|
||||
} catch (e) {
|
||||
Log.w('ImageCacheMetadataService: 索引文件失败 $path', e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> indexFromCacheManager() async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final cacheDir = Directory('${tempDir.path}/${CustomCacheManager.key}');
|
||||
|
||||
if (!await cacheDir.exists()) return;
|
||||
|
||||
final existingPaths = <String>{};
|
||||
await for (final entity in cacheDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
existingPaths.add(entity.path);
|
||||
if (!box.containsKey(entity.path)) {
|
||||
await indexFile(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final keysToDelete = <dynamic>[];
|
||||
for (final key in box.keys) {
|
||||
if (key is String && !existingPaths.contains(key)) {
|
||||
if (!await File(key).exists()) {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final key in keysToDelete) {
|
||||
await box.delete(key);
|
||||
}
|
||||
|
||||
Log.i(
|
||||
'ImageCacheMetadataService: 同步完成, '
|
||||
'索引${existingPaths.length}个文件, '
|
||||
'清理${keysToDelete.length}个失效条目',
|
||||
);
|
||||
} catch (e) {
|
||||
Log.e('ImageCacheMetadataService: 从CacheManager同步索引失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 查询操作
|
||||
// ============================================================
|
||||
|
||||
static Future<List<CacheMetaEntry>> getAllEntries() async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return [];
|
||||
|
||||
try {
|
||||
final entries = <CacheMetaEntry>[];
|
||||
for (final key in box.keys) {
|
||||
final value = box.get(key);
|
||||
if (value is Map) {
|
||||
try {
|
||||
entries.add(
|
||||
CacheMetaEntry.fromJson(Map<String, dynamic>.from(value)),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return entries..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
} catch (e) {
|
||||
Log.e('ImageCacheMetadataService: 获取所有条目失败', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CacheMetaEntry>> getByCategory(String category) async {
|
||||
final all = await getAllEntries();
|
||||
return all.where((e) => e.category == category).toList();
|
||||
}
|
||||
|
||||
static Future<List<CacheMetaEntry>> getExpired() async {
|
||||
final all = await getAllEntries();
|
||||
return all.where((e) => e.isExpired).toList();
|
||||
}
|
||||
|
||||
static Future<Map<String, int>> getCategoryStats() async {
|
||||
final all = await getAllEntries();
|
||||
final stats = <String, int>{};
|
||||
for (final entry in all) {
|
||||
final cat = entry.category ?? CacheCategory.other;
|
||||
stats[cat] = (stats[cat] ?? 0) + entry.size;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
static Future<Map<String, int>> getCategoryCounts() async {
|
||||
final all = await getAllEntries();
|
||||
final counts = <String, int>{};
|
||||
for (final entry in all) {
|
||||
final cat = entry.category ?? CacheCategory.other;
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
static Future<Map<String, List<CacheMetaEntry>>> getGroupedByDate() async {
|
||||
final all = await getAllEntries();
|
||||
final groups = <String, List<CacheMetaEntry>>{};
|
||||
for (final entry in all) {
|
||||
final group = DateGroup.groupOf(entry.createdAt);
|
||||
groups.putIfAbsent(group, () => []).add(entry);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
static Future<int> getTotalSize() async {
|
||||
final all = await getAllEntries();
|
||||
return all.fold<int>(0, (sum, e) => sum + e.size);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自动清理策略
|
||||
// ============================================================
|
||||
|
||||
static String getAutoCleanPolicy() {
|
||||
final box = _safeBox();
|
||||
if (box == null) return AutoCleanPolicy.off;
|
||||
return box.get('_auto_clean_policy') as String? ?? AutoCleanPolicy.days7;
|
||||
}
|
||||
|
||||
static Future<void> setAutoCleanPolicy(String policy) async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
await box.put('_auto_clean_policy', policy);
|
||||
Log.i('ImageCacheMetadataService: 自动清理策略设为 $policy');
|
||||
}
|
||||
|
||||
static Future<void> autoCleanExpired() async {
|
||||
final policy = getAutoCleanPolicy();
|
||||
final days = AutoCleanPolicy.toDays(policy);
|
||||
if (days == null) return;
|
||||
|
||||
final all = await getAllEntries();
|
||||
final cutoff = DateTime.now().subtract(Duration(days: days));
|
||||
int cleaned = 0;
|
||||
|
||||
for (final entry in all) {
|
||||
if (entry.createdAt.isBefore(cutoff)) {
|
||||
final file = File(entry.path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
cleaned++;
|
||||
}
|
||||
await removeEntry(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
Log.i('ImageCacheMetadataService: 自动清理$cleaned个过期缓存');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 删除操作
|
||||
// ============================================================
|
||||
|
||||
static Future<void> removeEntry(String path) async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
await box.delete(path);
|
||||
}
|
||||
|
||||
static Future<void> clearAll() async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
final policy = box.get('_auto_clean_policy');
|
||||
await box.clear();
|
||||
if (policy != null) {
|
||||
await box.put('_auto_clean_policy', policy);
|
||||
}
|
||||
Log.i('ImageCacheMetadataService: 所有索引已清除');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全量扫描文件系统并重建索引
|
||||
// ============================================================
|
||||
|
||||
static Future<void> rebuildIndex() async {
|
||||
final box = _safeBox();
|
||||
if (box == null) return;
|
||||
|
||||
final policy = box.get('_auto_clean_policy');
|
||||
await box.clear();
|
||||
if (policy != null) {
|
||||
await box.put('_auto_clean_policy', policy);
|
||||
}
|
||||
|
||||
await indexFromCacheManager();
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final imageDir = Directory('${tempDir.path}/image_cache');
|
||||
if (await imageDir.exists()) {
|
||||
await for (final entity in imageDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
await indexFile(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final docImageDir = Directory('${docDir.path}/image_cache');
|
||||
if (await docImageDir.exists()) {
|
||||
await for (final entity in docImageDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
await indexFile(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i('ImageCacheMetadataService: 索引重建完成');
|
||||
}
|
||||
}
|
||||
149
lib/core/services/data/offline_cache_service.dart
Normal file
149
lib/core/services/data/offline_cache_service.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 离线数据预缓存服务
|
||||
// 创建时间: 2026-05-30
|
||||
// 更新时间: 2026-05-30
|
||||
// 作用: WiFi时自动预缓存用户常用数据,离线时优先读取本地缓存
|
||||
// 上次更新: 初始创建
|
||||
// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/storage/kv_storage.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../features/home/services/feed_service.dart';
|
||||
import '../../../features/home/models/feed_model.dart';
|
||||
|
||||
class OfflineCacheService {
|
||||
static const _cachePrefix = 'offline_cache_';
|
||||
static const _timePrefix = 'offline_time_';
|
||||
static const Duration defaultTTL = Duration(hours: 6);
|
||||
|
||||
Future<void> precacheHomeSentences() async {
|
||||
try {
|
||||
final result = await FeedService.fetchList(FeedListParams(page: 1));
|
||||
await setCache('home_sentences', result.list);
|
||||
Log.i('预缓存首页句子: ${result.list.length}条');
|
||||
} catch (e) {
|
||||
Log.w('预缓存首页句子失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> precacheFavorites() async {
|
||||
try {
|
||||
final result = await FeedService.fetchFavorites(page: 1, limit: 50);
|
||||
await setCache('favorites', result.list);
|
||||
Log.i('预缓存收藏: ${result.list.length}条');
|
||||
} catch (e) {
|
||||
Log.w('预缓存收藏失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> precacheAll() async {
|
||||
await Future.wait([precacheHomeSentences(), precacheFavorites()]);
|
||||
}
|
||||
|
||||
T? getCached<T>(String key, {Duration? ttl}) {
|
||||
final timeKey = '$_timePrefix$key';
|
||||
final cachedTime = KvStorage.getString(timeKey);
|
||||
if (cachedTime == null) return null;
|
||||
|
||||
final cacheTime = DateTime.tryParse(cachedTime);
|
||||
if (cacheTime == null) return null;
|
||||
|
||||
final effectiveTTL = ttl ?? defaultTTL;
|
||||
if (DateTime.now().difference(cacheTime) > effectiveTTL) return null;
|
||||
|
||||
final raw = KvStorage.getString('$_cachePrefix$key');
|
||||
if (raw == null) return null;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (T == String) return decoded as T;
|
||||
return decoded as T;
|
||||
} catch (e) {
|
||||
Log.w('缓存解析失败: $key, $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<FeedItem>? getCachedFeedItems(String key, {Duration? ttl}) {
|
||||
final timeKey = '$_timePrefix$key';
|
||||
final cachedTime = KvStorage.getString(timeKey);
|
||||
if (cachedTime == null) return null;
|
||||
|
||||
final cacheTime = DateTime.tryParse(cachedTime);
|
||||
if (cacheTime == null) return null;
|
||||
|
||||
final effectiveTTL = ttl ?? defaultTTL;
|
||||
if (DateTime.now().difference(cacheTime) > effectiveTTL) return null;
|
||||
|
||||
final raw = KvStorage.getString('$_cachePrefix$key');
|
||||
if (raw == null) return null;
|
||||
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => FeedItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
Log.w('FeedItem缓存解析失败: $key, $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setCache<T>(String key, T data) async {
|
||||
final timeKey = '$_timePrefix$key';
|
||||
final cacheKey = '$_cachePrefix$key';
|
||||
|
||||
await KvStorage.setString(timeKey, DateTime.now().toIso8601String());
|
||||
|
||||
if (data is String) {
|
||||
await KvStorage.setString(cacheKey, data);
|
||||
} else if (data is List) {
|
||||
final encoded = jsonEncode(data);
|
||||
await KvStorage.setString(cacheKey, encoded);
|
||||
} else {
|
||||
final encoded = jsonEncode(data);
|
||||
await KvStorage.setString(cacheKey, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
bool isExpired(String key, {Duration? ttl}) {
|
||||
final timeKey = '$_timePrefix$key';
|
||||
final cachedTime = KvStorage.getString(timeKey);
|
||||
if (cachedTime == null) return true;
|
||||
|
||||
final cacheTime = DateTime.tryParse(cachedTime);
|
||||
if (cacheTime == null) return true;
|
||||
|
||||
final effectiveTTL = ttl ?? defaultTTL;
|
||||
return DateTime.now().difference(cacheTime) > effectiveTTL;
|
||||
}
|
||||
|
||||
Future<int> cleanExpired() async {
|
||||
int cleaned = 0;
|
||||
final keys = ['home_sentences', 'favorites'];
|
||||
for (final key in keys) {
|
||||
if (isExpired(key)) {
|
||||
await KvStorage.remove('$_cachePrefix$key');
|
||||
await KvStorage.remove('$_timePrefix$key');
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
final keys = ['home_sentences', 'favorites'];
|
||||
for (final key in keys) {
|
||||
await KvStorage.remove('$_cachePrefix$key');
|
||||
await KvStorage.remove('$_timePrefix$key');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final offlineCacheServiceProvider = Provider<OfflineCacheService>((ref) {
|
||||
return OfflineCacheService();
|
||||
});
|
||||
73
lib/core/services/feature/feature_flag_provider.dart
Normal file
73
lib/core/services/feature/feature_flag_provider.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 远程功能标志 Provider
|
||||
/// 创建时间: 2026-05-30
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 作用: Riverpod Provider 封装远程FeatureFlag服务,支持异步加载和单个Flag查询
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'feature_flag_service.dart';
|
||||
|
||||
final remoteFeatureFlagServiceProvider = Provider<RemoteFeatureFlagService>((ref) {
|
||||
return RemoteFeatureFlagService.instance;
|
||||
});
|
||||
|
||||
final remoteFeatureFlagsProvider =
|
||||
AsyncNotifierProvider<RemoteFeatureFlagsNotifier, List<FeatureFlagItem>>(
|
||||
RemoteFeatureFlagsNotifier.new,
|
||||
);
|
||||
|
||||
class RemoteFeatureFlagsNotifier extends AsyncNotifier<List<FeatureFlagItem>> {
|
||||
@override
|
||||
Future<List<FeatureFlagItem>> build() async {
|
||||
final service = ref.watch(remoteFeatureFlagServiceProvider);
|
||||
|
||||
if (!service.isInitialized) {
|
||||
await service.init();
|
||||
}
|
||||
|
||||
try {
|
||||
await service.fetchFlags();
|
||||
} catch (_) {}
|
||||
|
||||
return service.getAllFlags();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final service = ref.read(remoteFeatureFlagServiceProvider);
|
||||
await service.fetchFlags();
|
||||
return service.getAllFlags();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final remoteFeatureFlagEnabledProvider =
|
||||
Provider.family<bool, String>((ref, key) {
|
||||
final flagsAsync = ref.watch(remoteFeatureFlagsProvider);
|
||||
return flagsAsync.when(
|
||||
data: (flags) {
|
||||
try {
|
||||
final flag = flags.firstWhere((f) => f.key == key);
|
||||
return flag.enabled && !flag.isExpired;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
});
|
||||
|
||||
final remoteFeatureFlagsByStatusProvider =
|
||||
Provider.family<List<FeatureFlagItem>, FeatureFlagStatus>((ref, status) {
|
||||
final flagsAsync = ref.watch(remoteFeatureFlagsProvider);
|
||||
return flagsAsync.when(
|
||||
data: (flags) => flags.where((f) => f.status == status).toList(),
|
||||
loading: () => [],
|
||||
error: (_, __) => [],
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,26 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 功能标志服务
|
||||
/// 创建时间: 2026-05-27
|
||||
/// 更新时间: 2026-05-27
|
||||
/// 作用: 统一管理功能可用性,替代分散的"设备不支持"硬编码
|
||||
/// 上次更新: v6.5.57 初始创建,包含15个功能标志
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 作用: 统一管理功能可用性,支持远程配置、本地缓存、灰度发布、A/B测试
|
||||
/// 上次更新: 新增远程FeatureFlagItem模型+RemoteFeatureFlagService+灰度/A/B测试
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../network/api_client.dart';
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
// ============================================================
|
||||
// 本地功能标志枚举(原有,保持向后兼容)
|
||||
// ============================================================
|
||||
|
||||
/// 功能标志枚举
|
||||
enum FeatureFlag {
|
||||
nearbyDiscovery(
|
||||
id: 'nearby_discovery',
|
||||
@@ -93,40 +104,32 @@ enum FeatureFlag {
|
||||
required this.unsupportedMessage,
|
||||
});
|
||||
|
||||
/// 标识符
|
||||
final String id;
|
||||
|
||||
/// 功能名称
|
||||
final String title;
|
||||
|
||||
/// 不支持时的提示文案
|
||||
final String unsupportedMessage;
|
||||
|
||||
/// 是否启用(默认false,可通过远程配置开启)
|
||||
static final Map<String, bool> _overrides = {};
|
||||
|
||||
/// 设置功能标志覆盖
|
||||
static void setOverride(String flagId, bool enabled) {
|
||||
_overrides[flagId] = enabled;
|
||||
}
|
||||
|
||||
/// 清除所有覆盖
|
||||
static void clearOverrides() {
|
||||
_overrides.clear();
|
||||
}
|
||||
|
||||
/// 检查功能是否启用
|
||||
bool get isEnabled => _overrides[id] ?? false;
|
||||
}
|
||||
|
||||
/// 功能标志状态
|
||||
// ============================================================
|
||||
// 本地功能标志状态(原有,保持向后兼容)
|
||||
// ============================================================
|
||||
|
||||
class FeatureFlagState {
|
||||
const FeatureFlagState();
|
||||
|
||||
/// 检查功能是否启用
|
||||
bool isEnabled(FeatureFlag flag) => flag.isEnabled;
|
||||
|
||||
/// 获取所有功能标志及其状态
|
||||
Map<FeatureFlag, bool> get allFlags {
|
||||
return Map.fromEntries(
|
||||
FeatureFlag.values.map((f) => MapEntry(f, f.isEnabled)),
|
||||
@@ -134,37 +137,302 @@ class FeatureFlagState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 功能标志 Notifier
|
||||
class FeatureFlagNotifier extends Notifier<FeatureFlagState> {
|
||||
@override
|
||||
FeatureFlagState build() => const FeatureFlagState();
|
||||
|
||||
/// 设置功能标志覆盖
|
||||
void setOverride(FeatureFlag flag, bool enabled) {
|
||||
FeatureFlag.setOverride(flag.id, enabled);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
/// 清除所有覆盖
|
||||
void clearOverrides() {
|
||||
FeatureFlag.clearOverrides();
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
|
||||
/// 功能标志 Provider
|
||||
final featureFlagProvider =
|
||||
NotifierProvider<FeatureFlagNotifier, FeatureFlagState>(
|
||||
FeatureFlagNotifier.new,
|
||||
);
|
||||
|
||||
/// 功能标志服务 — 提供静态方法供各页面调用
|
||||
// ============================================================
|
||||
// 远程功能标志状态枚举
|
||||
// ============================================================
|
||||
|
||||
enum FeatureFlagStatus {
|
||||
developing('developing', '开发中', '🚧'),
|
||||
testing('testing', '测试中', '🧪'),
|
||||
preview('preview', '预览中', '👀'),
|
||||
released('released', '已发布', '✅');
|
||||
|
||||
const FeatureFlagStatus(this.id, this.label, this.emoji);
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final String emoji;
|
||||
|
||||
static FeatureFlagStatus fromId(String id) {
|
||||
return FeatureFlagStatus.values.firstWhere(
|
||||
(e) => e.id == id,
|
||||
orElse: () => FeatureFlagStatus.developing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 远程功能标志数据模型
|
||||
// ============================================================
|
||||
|
||||
class FeatureFlagItem {
|
||||
const FeatureFlagItem({
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.status,
|
||||
required this.progress,
|
||||
this.rolloutPercentage = 1.0,
|
||||
this.targetGroup,
|
||||
this.expiresAt,
|
||||
this.emoji,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final FeatureFlagStatus status;
|
||||
final double progress;
|
||||
final double rolloutPercentage;
|
||||
final String? targetGroup;
|
||||
final DateTime? expiresAt;
|
||||
final String? emoji;
|
||||
|
||||
bool get isExpired {
|
||||
if (expiresAt == null) return false;
|
||||
return DateTime.now().isAfter(expiresAt!);
|
||||
}
|
||||
|
||||
factory FeatureFlagItem.fromJson(Map<String, dynamic> json) {
|
||||
return FeatureFlagItem(
|
||||
key: json['key'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
status: FeatureFlagStatus.fromId(json['status'] as String? ?? 'developing'),
|
||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||
rolloutPercentage: (json['rollout_percentage'] as num?)?.toDouble() ?? 1.0,
|
||||
targetGroup: json['target_group'] as String?,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.tryParse(json['expires_at'] as String)
|
||||
: null,
|
||||
emoji: json['emoji'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'key': key,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'enabled': enabled,
|
||||
'status': status.id,
|
||||
'progress': progress,
|
||||
'rollout_percentage': rolloutPercentage,
|
||||
'target_group': targetGroup,
|
||||
'expires_at': expiresAt?.toIso8601String(),
|
||||
'emoji': emoji,
|
||||
};
|
||||
}
|
||||
|
||||
FeatureFlagItem copyWith({
|
||||
bool? enabled,
|
||||
FeatureFlagStatus? status,
|
||||
double? progress,
|
||||
double? rolloutPercentage,
|
||||
String? targetGroup,
|
||||
}) {
|
||||
return FeatureFlagItem(
|
||||
key: key,
|
||||
name: name,
|
||||
description: description,
|
||||
enabled: enabled ?? this.enabled,
|
||||
status: status ?? this.status,
|
||||
progress: progress ?? this.progress,
|
||||
rolloutPercentage: rolloutPercentage ?? this.rolloutPercentage,
|
||||
targetGroup: targetGroup ?? this.targetGroup,
|
||||
expiresAt: expiresAt,
|
||||
emoji: emoji,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 远程功能标志服务
|
||||
// ============================================================
|
||||
|
||||
class RemoteFeatureFlagService {
|
||||
RemoteFeatureFlagService._();
|
||||
|
||||
static final RemoteFeatureFlagService _instance = RemoteFeatureFlagService._();
|
||||
static RemoteFeatureFlagService get instance => _instance;
|
||||
|
||||
static const String _cacheKey = 'remote_flags';
|
||||
static const String _cacheTimestampKey = 'remote_flags_timestamp';
|
||||
static const Duration _cacheExpiry = Duration(hours: 6);
|
||||
|
||||
List<FeatureFlagItem> _flags = [];
|
||||
final _controller = StreamController<List<FeatureFlagItem>>.broadcast();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
Stream<List<FeatureFlagItem>> get flagsStream => _controller.stream;
|
||||
|
||||
List<FeatureFlagItem> get flags => List.unmodifiable(_flags);
|
||||
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
await _loadFromCache();
|
||||
_initialized = true;
|
||||
Log.i('RemoteFeatureFlagService: 初始化完成,加载 ${_flags.length} 个标志');
|
||||
} catch (e) {
|
||||
Log.e('RemoteFeatureFlagService: 初始化失败', e);
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchFlags() async {
|
||||
try {
|
||||
final response = await ApiClient.instance.get<Map<String, dynamic>>(
|
||||
'/api/feature_flag/list',
|
||||
forceRefresh: true,
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data != null && data['code'] == 1) {
|
||||
final flagsRaw = data['data'] is Map<String, dynamic>
|
||||
? (data['data'] as Map<String, dynamic>)['flags']
|
||||
: null;
|
||||
final flagsData = flagsRaw as List?;
|
||||
if (flagsData != null) {
|
||||
_flags = flagsData
|
||||
.map((f) => FeatureFlagItem.fromJson(f as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _saveToCache();
|
||||
_controller.add(List.unmodifiable(_flags));
|
||||
Log.i('RemoteFeatureFlagService: 获取 ${_flags.length} 个标志');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('RemoteFeatureFlagService: 获取标志失败', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
bool isEnabled(String key) {
|
||||
final flag = getFlag(key);
|
||||
if (flag == null) return false;
|
||||
if (!flag.enabled) return false;
|
||||
if (flag.isExpired) return false;
|
||||
return isRolloutAllowed(key);
|
||||
}
|
||||
|
||||
FeatureFlagItem? getFlag(String key) {
|
||||
try {
|
||||
return _flags.firstWhere((f) => f.key == key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<FeatureFlagItem> getAllFlags() => List.unmodifiable(_flags);
|
||||
|
||||
List<FeatureFlagItem> getFlagsByStatus(FeatureFlagStatus status) {
|
||||
return _flags.where((f) => f.status == status).toList();
|
||||
}
|
||||
|
||||
bool isRolloutAllowed(String key) {
|
||||
final flag = getFlag(key);
|
||||
if (flag == null) return false;
|
||||
if (flag.rolloutPercentage >= 1.0) return true;
|
||||
if (flag.rolloutPercentage <= 0.0) return false;
|
||||
|
||||
final userId = KvStorage.getString('user_id') ?? '';
|
||||
final hash = _hashUserId(key, userId);
|
||||
return (hash % 100) < (flag.rolloutPercentage * 100);
|
||||
}
|
||||
|
||||
int _hashUserId(String flagKey, String userId) {
|
||||
var hash = 0;
|
||||
final combined = '$flagKey:$userId';
|
||||
for (var i = 0; i < combined.length; i++) {
|
||||
hash = ((hash << 5) - hash + combined.codeUnitAt(i)) & 0x7FFFFFFF;
|
||||
}
|
||||
return hash % 10000;
|
||||
}
|
||||
|
||||
String? getAbTestGroup(String key) {
|
||||
final flag = getFlag(key);
|
||||
return flag?.targetGroup;
|
||||
}
|
||||
|
||||
Future<void> _loadFromCache() async {
|
||||
final box = Hive.box<dynamic>(HiveBoxNames.featureFlags);
|
||||
final raw = box.get(_cacheKey) as String?;
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
try {
|
||||
final List<dynamic> list = jsonDecode(raw) as List<dynamic>;
|
||||
_flags = list
|
||||
.map((f) => FeatureFlagItem.fromJson(f as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final timestamp = box.get(_cacheTimestampKey) as int?;
|
||||
if (timestamp != null) {
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
if (DateTime.now().difference(cacheTime) > _cacheExpiry) {
|
||||
Log.i('RemoteFeatureFlagService: 缓存已过期,将在下次请求时刷新');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('RemoteFeatureFlagService: 缓存解析失败', e);
|
||||
_flags = [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToCache() async {
|
||||
final box = Hive.box<dynamic>(HiveBoxNames.featureFlags);
|
||||
final json = jsonEncode(_flags.map((f) => f.toJson()).toList());
|
||||
await box.put(_cacheKey, json);
|
||||
await box.put(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final box = Hive.box<dynamic>(HiveBoxNames.featureFlags);
|
||||
await box.clear();
|
||||
_flags = [];
|
||||
_controller.add([]);
|
||||
Log.i('RemoteFeatureFlagService: 缓存已清除');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 功能标志服务 — 静态方法供各页面调用(原有,保持向后兼容)
|
||||
// ============================================================
|
||||
|
||||
class FeatureFlagService {
|
||||
FeatureFlagService._();
|
||||
|
||||
/// 检查功能是否可用
|
||||
/// 如果功能未启用,弹出 CupertinoAlertDialog 并返回 false
|
||||
/// 如果功能已启用,返回 true
|
||||
static bool check(BuildContext context, FeatureFlag flag) {
|
||||
if (flag.isEnabled) return true;
|
||||
|
||||
@@ -196,11 +464,8 @@ class FeatureFlagService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 静默检查功能是否可用(不弹窗)
|
||||
static bool isEnabled(FeatureFlag flag) => flag.isEnabled;
|
||||
|
||||
/// 根据 setting id 获取对应的 FeatureFlag
|
||||
/// 用于 general_settings_page 的 isPlaceholder 映射
|
||||
static FeatureFlag? fromSettingId(String id) {
|
||||
switch (id) {
|
||||
case 'nearby_discovery':
|
||||
@@ -212,8 +477,6 @@ class FeatureFlagService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 FeatureFlag 判断是否为 placeholder
|
||||
/// 用于 general_settings_sections 中替换 isPlaceholder: true
|
||||
static bool isPlaceholder(FeatureFlag? flag) {
|
||||
if (flag == null) return false;
|
||||
return !flag.isEnabled;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 创建时间: 2026-05-30
|
||||
// 更新时间: 2026-05-30
|
||||
// 作用: 将权限敏感的服务初始化延迟到用户同意协议后执行
|
||||
// 上次更新: 首次创建,从main.dart拆分权限敏感初始化
|
||||
// 上次更新: 新增WallpaperHealthService.checkAllSources(),网络请求延迟到协议同意后
|
||||
// ============================================================
|
||||
|
||||
import '../storage/kv_storage.dart';
|
||||
@@ -20,6 +20,7 @@ import 'notification/readlater_reminder_service.dart';
|
||||
import 'data/home_widget_service.dart';
|
||||
import 'readlater/sharing_receiver_service.dart';
|
||||
import '../../features/discover/services/chat_migration_service.dart';
|
||||
import '../../features/template/services/wallpaper_health_service.dart';
|
||||
|
||||
class PostAgreementInitializer {
|
||||
PostAgreementInitializer._();
|
||||
@@ -122,6 +123,15 @@ class PostAgreementInitializer {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (WallpaperHealthService.shouldCheck()) {
|
||||
await WallpaperHealthService.checkAllSources();
|
||||
Log.i('壁纸源健康检测完成');
|
||||
}
|
||||
} catch (e, st) {
|
||||
Log.e('壁纸源健康检测启动检查失败', e, st);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
Log.i('PostAgreementInitializer: 所有权限敏感服务初始化完成 ✓');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 作用: 基于 Hive 的统一 KV 存储,合并原 KvStorage(SP) + AppKVStore(Hive)
|
||||
/// 上次更新: 新增featureFlags box命名空间
|
||||
/// 上次更新: StorageKeys 新增命名空间前缀系统
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -32,6 +32,7 @@ class HiveBoxNames {
|
||||
static const String translateHistory = 'translateHistory';
|
||||
static const String leisure = 'leisure';
|
||||
static const String imageCacheMeta = 'image_cache_meta';
|
||||
static const String featureFlags = 'feature_flags';
|
||||
|
||||
static const List<String> all = [
|
||||
app,
|
||||
@@ -45,6 +46,7 @@ class HiveBoxNames {
|
||||
wallpaperFavorites,
|
||||
leisure,
|
||||
imageCacheMeta,
|
||||
featureFlags,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -55,6 +57,35 @@ class HiveBoxNames {
|
||||
class StorageKeys {
|
||||
StorageKeys._();
|
||||
|
||||
// ============================================================
|
||||
// 命名空间前缀
|
||||
// ============================================================
|
||||
|
||||
static const String _nsApp = 'app';
|
||||
static const String _nsTheme = 'theme';
|
||||
static const String _nsAuth = 'auth';
|
||||
static const String _nsHome = 'home';
|
||||
static const String _nsDiscover = 'discover';
|
||||
static const String _nsMine = 'mine';
|
||||
static const String _nsSettings = 'settings';
|
||||
static const String _nsDevice = 'device';
|
||||
static const String _nsFeature = 'feature';
|
||||
static const String _nsCache = 'cache';
|
||||
static const String _nsTool = 'tool';
|
||||
static const String _nsCharacter = 'character';
|
||||
static const String _nsProgress = 'progress';
|
||||
static const String _nsReadlater = 'readlater';
|
||||
static const String _nsFavorite = 'favorite';
|
||||
|
||||
/// 生成带命名空间的键
|
||||
/// 例如: namespaced('home', 'shake_enabled') → 'home.shake_enabled'
|
||||
static String namespaced(String namespace, String key) =>
|
||||
'$namespace.$key';
|
||||
|
||||
// ============================================================
|
||||
// 现有键常量(向后兼容,保持原值不变)
|
||||
// ============================================================
|
||||
|
||||
static const String themeMode = 'theme_mode';
|
||||
static const String locale = 'locale';
|
||||
static const String firstLaunch = 'first_launch';
|
||||
@@ -65,6 +96,66 @@ class StorageKeys {
|
||||
static const String onboardingCompleted = 'onboarding_completed';
|
||||
static const String showOnboarding = 'show_onboarding';
|
||||
static const String channelOrder = 'channel_order';
|
||||
|
||||
// ============================================================
|
||||
// 带命名空间的新键(推荐使用)
|
||||
// ============================================================
|
||||
|
||||
// — app 命名空间 —
|
||||
static String get nsFirstLaunch => namespaced(_nsApp, 'first_launch');
|
||||
static String get nsDebugMode => namespaced(_nsApp, 'debug_mode');
|
||||
static String get nsLocale => namespaced(_nsApp, 'locale');
|
||||
|
||||
// — theme 命名空间 —
|
||||
static String get nsThemeMode => namespaced(_nsTheme, 'theme_mode');
|
||||
|
||||
// — auth 命名空间 —
|
||||
static String get nsAuthToken => namespaced(_nsAuth, 'token');
|
||||
static String get nsAuthUserId => namespaced(_nsAuth, 'user_id');
|
||||
|
||||
// — home 命名空间 —
|
||||
static String get nsHomeShakeEnabled => namespaced(_nsHome, 'shake_enabled');
|
||||
static String get nsHomeChannelOrder => namespaced(_nsHome, 'channel_order');
|
||||
static String get nsHomeLastReadId => namespaced(_nsHome, 'last_read_id');
|
||||
|
||||
// — discover 命名空间 —
|
||||
static String get nsDiscoverCategory => namespaced(_nsDiscover, 'category');
|
||||
|
||||
// — mine 命名空间 —
|
||||
static String get nsMineProfile => namespaced(_nsMine, 'profile');
|
||||
|
||||
// — settings 命名空间 —
|
||||
static String get nsSettingsOnboarding => namespaced(_nsSettings, 'onboarding_completed');
|
||||
static String get nsSettingsShowOnboarding => namespaced(_nsSettings, 'show_onboarding');
|
||||
|
||||
// — device 命名空间 —
|
||||
static String get nsDeviceId => namespaced(_nsDevice, 'device_id');
|
||||
|
||||
// — feature 命名空间 —
|
||||
static String get nsFeatureFlags => namespaced(_nsFeature, 'flags');
|
||||
|
||||
// — cache 命名空间 —
|
||||
static String get nsCacheTimestamp => namespaced(_nsCache, 'timestamp');
|
||||
|
||||
// — tool 命名空间 —
|
||||
static String get nsToolDelPrefix => namespaced(_nsTool, 'del_');
|
||||
static String get nsToolRecentTemplate => namespaced(_nsTool, 'recent_template_id');
|
||||
|
||||
// — character 命名空间 —
|
||||
static String get nsCharacterTipsCategories => namespaced(_nsCharacter, 'tips_categories');
|
||||
|
||||
// — progress 命名空间 —
|
||||
static String get nsProgressReading => namespaced(_nsProgress, 'reading');
|
||||
|
||||
// — readlater 命名空间 —
|
||||
static String get nsReadlaterIds => namespaced(_nsReadlater, 'ids');
|
||||
|
||||
// — favorite 命名空间 —
|
||||
static String get nsFavoriteIds => namespaced(_nsFavorite, 'ids');
|
||||
|
||||
/// 生成带工具删除前缀的键
|
||||
/// 例如: toolDelKey('123') → 'tool.del_123'
|
||||
static String toolDelKey(String id) => '${namespaced(_nsTool, 'del_')}$id';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 日志工具
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-26
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 作用: 统一日志封装,支持分级与格式化 + 日志查看器 + 日志导出
|
||||
/// 上次更新: 新增exportToCsv方法+按时间范围筛选+关键词搜索
|
||||
/// 上次更新: 新增生产环境日志级别控制,release模式下d/i/w不输出,仅保留e/f
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -13,8 +13,21 @@ import 'package:logger/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// 全局日志器
|
||||
final appLogger = Logger(printer: PrettyPrinter(), level: Level.debug);
|
||||
/// 全局日志器(release模式仅输出error级别,避免敏感信息泄露)
|
||||
final appLogger = Logger(
|
||||
printer: PrettyPrinter(),
|
||||
level: _isDebugMode ? Level.debug : Level.error,
|
||||
);
|
||||
|
||||
/// 是否为调试模式(编译期常量,release模式下为false)
|
||||
bool get _isDebugMode {
|
||||
bool? val;
|
||||
assert(() {
|
||||
val = true;
|
||||
return true;
|
||||
}());
|
||||
return val ?? false;
|
||||
}
|
||||
|
||||
/// 日志级别
|
||||
enum LogLevel {
|
||||
@@ -87,31 +100,34 @@ class Log {
|
||||
}
|
||||
}
|
||||
|
||||
/// 调试日志
|
||||
/// 调试日志(release模式下不输出)
|
||||
static void d(dynamic message, [dynamic error, StackTrace? stackTrace]) {
|
||||
if (!_isDebugMode) return;
|
||||
appLogger.d(message, error: error, stackTrace: stackTrace);
|
||||
_addEntry(LogLevel.debug, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 信息日志
|
||||
/// 信息日志(release模式下不输出)
|
||||
static void i(dynamic message, [dynamic error, StackTrace? stackTrace]) {
|
||||
if (!_isDebugMode) return;
|
||||
appLogger.i(message, error: error, stackTrace: stackTrace);
|
||||
_addEntry(LogLevel.info, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 警告日志
|
||||
/// 警告日志(release模式下不输出)
|
||||
static void w(dynamic message, [dynamic error, StackTrace? stackTrace]) {
|
||||
if (!_isDebugMode) return;
|
||||
appLogger.w(message, error: error, stackTrace: stackTrace);
|
||||
_addEntry(LogLevel.warning, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 错误日志
|
||||
/// 错误日志(始终输出,错误必须记录)
|
||||
static void e(dynamic message, [dynamic error, StackTrace? stackTrace]) {
|
||||
appLogger.e(message, error: error, stackTrace: stackTrace);
|
||||
_addEntry(LogLevel.error, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 致命错误日志
|
||||
/// 致命错误日志(始终输出,错误必须记录)
|
||||
static void f(dynamic message, [dynamic error, StackTrace? stackTrace]) {
|
||||
appLogger.f(message, error: error, stackTrace: stackTrace);
|
||||
_addEntry(LogLevel.error, message, error, stackTrace);
|
||||
|
||||
Reference in New Issue
Block a user