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:
Developer
2026-05-30 05:30:49 +08:00
parent adfa0af825
commit 0da8906f5d
72 changed files with 9137 additions and 3312 deletions

View File

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

View File

@@ -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: [
'保存卡片 — 导出到本地',

View 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: 索引重建完成');
}
}

View 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();
});

View 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: (_, __) => [],
);
});

View File

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

View File

@@ -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: 所有权限敏感服务初始化完成 ✓');
}

View File

@@ -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';
}
// ============================================================

View File

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