add
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地轻量配置存储(SharedPreferences)
|
||||
/// device_id 为游客计费与风控的设备指纹:
|
||||
/// 生产要求 Android 存 Keystore 加密的 SharedPreferences、iOS 存 Keychain(卸载重装保留),
|
||||
/// 骨架阶段先持久化到 SharedPreferences,接入原生通道时替换此处实现即可。
|
||||
class LocalStore {
|
||||
LocalStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _keyDeviceId = 'device_id';
|
||||
static const String _keyCachedDomain = 'cached_domain';
|
||||
static const String _keyPendingInviteCode = 'pending_invite_code';
|
||||
static const String _keyInviteMatchReported = 'invite_match_reported';
|
||||
|
||||
static LocalStore? _instance;
|
||||
|
||||
/// 初始化(main 中 await 完成后再 runApp)
|
||||
static Future<LocalStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = LocalStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static LocalStore get I => _instance!;
|
||||
|
||||
/// 读取设备 ID;不存在时生成 UUID v4 并持久化
|
||||
String ensureDeviceId(String Function() uuidV4) {
|
||||
final existing = _prefs.getString(_keyDeviceId);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final id = uuidV4();
|
||||
_prefs.setString(_keyDeviceId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
String get deviceId => _prefs.getString(_keyDeviceId) ?? '';
|
||||
|
||||
/// 待绑定邀请码(首启口令解析/模糊归因命中后写入,注册成功或手填后清除)
|
||||
String? get pendingInviteCode => _prefs.getString(_keyPendingInviteCode);
|
||||
|
||||
Future<void> savePendingInviteCode(String code) =>
|
||||
_prefs.setString(_keyPendingInviteCode, code);
|
||||
|
||||
Future<void> clearPendingInviteCode() => _prefs.remove(_keyPendingInviteCode);
|
||||
|
||||
/// 模糊归因是否已上报过(每安装仅一次,避免重复请求)
|
||||
bool get inviteMatchReported => _prefs.getBool(_keyInviteMatchReported) ?? false;
|
||||
|
||||
Future<void> markInviteMatchReported() =>
|
||||
_prefs.setBool(_keyInviteMatchReported, true);
|
||||
|
||||
/// API 域名缓存(含 https:// 前缀的 Base URL)
|
||||
String? get cachedBaseUrl => _prefs.getString(_keyCachedDomain);
|
||||
|
||||
Future<void> saveBaseUrl(String baseUrl) =>
|
||||
_prefs.setString(_keyCachedDomain, baseUrl);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 搜索历史本地存储(《01-开发文档》2.5.8:App 端本地存储最近 10 条,不上报服务端)
|
||||
/// 最新搜索排最前,重复关键词去重置顶,超出 10 条截断。
|
||||
class SearchHistoryStore {
|
||||
SearchHistoryStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _key = 'search_history';
|
||||
static const int maxCount = 10;
|
||||
|
||||
static SearchHistoryStore? _instance;
|
||||
|
||||
/// 初始化(main 阶段调用)
|
||||
static Future<SearchHistoryStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
}
|
||||
|
||||
static SearchHistoryStore get I => _instance!;
|
||||
|
||||
/// 历史列表(最新在前)
|
||||
List<String> get history => _prefs.getStringList(_key) ?? const [];
|
||||
|
||||
/// 记录一次搜索:去重置顶,保留最近 [maxCount] 条
|
||||
Future<void> add(String keyword) async {
|
||||
final word = keyword.trim();
|
||||
if (word.isEmpty) return;
|
||||
final list = [...history]..remove(word);
|
||||
list.insert(0, word);
|
||||
if (list.length > maxCount) list.length = maxCount;
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 删除单条
|
||||
Future<void> remove(String keyword) async {
|
||||
final list = [...history]..remove(keyword);
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
Future<void> clear() => _prefs.remove(_key);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Token 安全存储(02-技术文档:flutter_secure_storage)
|
||||
/// 仅存 access / refresh,业务代码不落明文日志。
|
||||
class TokenStore {
|
||||
TokenStore._();
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static const String _keyAccess = 'access_token';
|
||||
static const String _keyRefresh = 'refresh_token';
|
||||
|
||||
/// 内存缓存:Dio 拦截器同步读取,避免每请求异步取 IO
|
||||
static String? accessToken;
|
||||
static String? refreshToken;
|
||||
|
||||
static Future<void> save({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
await _storage.write(key: _keyAccess, value: access);
|
||||
await _storage.write(key: _keyRefresh, value: refresh);
|
||||
}
|
||||
|
||||
/// 启动时恢复登录态
|
||||
static Future<void> restore() async {
|
||||
accessToken = await _storage.read(key: _keyAccess);
|
||||
refreshToken = await _storage.read(key: _keyRefresh);
|
||||
}
|
||||
|
||||
/// 登出 / Token 失效:清空内存与本地
|
||||
static Future<void> clear() async {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
await _storage.delete(key: _keyAccess);
|
||||
await _storage.delete(key: _keyRefresh);
|
||||
}
|
||||
|
||||
static bool get hasToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 观看上报去重存储(《03-接口文档》5.3:客户端本地按 video_id+日期 去重,
|
||||
/// 同日同视频只上报一次)。游客免费额度按设备计费,杀进程重进若重复上报会
|
||||
/// 白扣游客当日次数,因此必须持久化而非仅内存。
|
||||
///
|
||||
/// key 按日分组:`watch_yyyyMMdd` / `complete_yyyyMMdd`,值为当日已上报的
|
||||
/// video_id 列表;每次访问时惰性清理非今日 key。
|
||||
class WatchDedupStore {
|
||||
WatchDedupStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _watchPrefix = 'watch_';
|
||||
static const String _completePrefix = 'complete_';
|
||||
|
||||
static WatchDedupStore? _instance;
|
||||
|
||||
/// 初始化(splash 阶段调用)
|
||||
static Future<WatchDedupStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
}
|
||||
|
||||
static WatchDedupStore get I => _instance!;
|
||||
|
||||
String get _today {
|
||||
final now = DateTime.now();
|
||||
return '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 是否已上报过 watch
|
||||
bool hasWatched(int videoId) => _contains(_watchPrefix, videoId);
|
||||
|
||||
/// 标记已上报 watch
|
||||
Future<void> markWatched(int videoId) => _mark(_watchPrefix, videoId);
|
||||
|
||||
/// 是否已上报过 complete
|
||||
bool hasCompleted(int videoId) => _contains(_completePrefix, videoId);
|
||||
|
||||
/// 标记已上报 complete
|
||||
Future<void> markCompleted(int videoId) => _mark(_completePrefix, videoId);
|
||||
|
||||
bool _contains(String prefix, int videoId) {
|
||||
_cleanup(prefix);
|
||||
final list = _prefs.getStringList('$prefix$_today') ?? const [];
|
||||
return list.contains(videoId.toString());
|
||||
}
|
||||
|
||||
Future<void> _mark(String prefix, int videoId) async {
|
||||
_cleanup(prefix);
|
||||
final key = '$prefix$_today';
|
||||
final list = _prefs.getStringList(key) ?? <String>[];
|
||||
final id = videoId.toString();
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
await _prefs.setStringList(key, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// 惰性清理:删除该前缀下所有非今日的 key
|
||||
void _cleanup(String prefix) {
|
||||
final todayKey = '$prefix$_today';
|
||||
final stale = _prefs
|
||||
.getKeys()
|
||||
.where((k) => k.startsWith(prefix) && k != todayKey)
|
||||
.toList();
|
||||
for (final k in stale) {
|
||||
_prefs.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user