This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
/// 全局配置常量(域名发现、公共请求头、版本号,对应《01-开发文档》5.5/5.6/5.7)
class AppConfig {
AppConfig._();
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
static const String appVersion = '1.0.0';
static const int appVersionCode = 1;
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
static const String platformNameIOS = 'ios';
static const String platformNameAndroid = 'android';
/// 域名发现:DNS TXT 记录域名与兜底域名后缀
static const String dnsTxtHost = 'txt.256kb.top';
static const String fallbackDomainSuffix = '_jsnbhh.vip';
/// DoH 服务(阿里 / 腾讯),TXT 解析走 JSON API
static const List<String> dohServers = [
'https://223.5.5.5/resolve',
'https://119.29.29.29/dns-query',
];
/// 缓存域名连续失败达到该阈值后,重新走完整发现流程
static const int domainFailureThreshold = 3;
/// 开发调试:--dart-define=API_BASE=http://localhost:8000 可直接指定本地服务,
/// 跳过域名发现(生产构建不传即走 TXT/兜底逻辑)
static const String devApiBase = String.fromEnvironment('API_BASE');
/// 微信支付 iOS 回跳 Universal Link:
/// --dart-define=WECHAT_UNIVERSAL_LINK=https://app.256kb.top/wechat
/// 须与 pubspec.yaml 中 fluwx.ios.universal_link 保持一致;未传时 iOS 端无法回跳
static const String wechatUniversalLink =
String.fromEnvironment('WECHAT_UNIVERSAL_LINK');
/// 请求超时
static const Duration connectTimeout = Duration(seconds: 10);
static const Duration receiveTimeout = Duration(seconds: 20);
}
+20
View File
@@ -0,0 +1,20 @@
import '../../models/video_item.dart';
/// 开发期播放地址钩子:
/// 服务端 /hls 转码产物未就绪前,用 --dart-define=TEST_VIDEO_URL=xxx 注入公网
/// 测试 m3u8,把 video_url 为空或相对路径的条目替换为测试流以验证播放器。
/// 生产构建不传该 define,零开销;服务端 /hls 就绪后此钩子可保留作降级兜底。
const String _testStreamUrl = String.fromEnvironment('TEST_VIDEO_URL');
/// 解析实际播放地址。
/// - 注入了测试流且原始地址不可用(空/非 http 绝对地址)→ 返回测试流
/// - 否则返回原始 video_url
///
/// [testUrl] 可选参数便于单元测试(fromEnvironment 在测试中恒为空)。
String resolvePlayUrl(VideoItem item, {String? testUrl}) {
final injected = testUrl ?? _testStreamUrl;
final raw = item.videoUrl.trim();
final playable = raw.startsWith('http://') || raw.startsWith('https://');
if (!playable && injected.isNotEmpty) return injected;
return raw;
}
+186
View File
@@ -0,0 +1,186 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
import 'api_domain.dart';
import 'api_exception.dart';
import 'endpoints.dart';
import '../config/app_config.dart';
import '../storage/local_store.dart';
import '../storage/token_store.dart';
/// 统一网络客户端:
/// - Base URL 来自域名发现(ApiDomain),不硬编码任何 API 域名
/// - 公共请求头:X-Device-ID / X-App-Version / X-Platform / X-Request-ID
/// - 登录态:Authorization: Bearer `access_token`(TokenStore 内存缓存)
/// - 统一响应解包:code=0 → data;非 0 → ApiException;HTTP 401 → 静默刷新重试一次或登出
/// - 缓存域名连续失败达阈值 → 重新域名发现并切换 Base URL
class ApiClient {
ApiClient._();
static late Dio _dio;
static Dio get I => _dio;
/// 401 且无法刷新时的回调(由 AuthProvider 注册:清登录态并跳登录页)
static void Function()? onSessionExpired;
/// 初始化(splash 阶段调用一次)
static Future<void> init() async {
final base = await ApiDomain.resolve();
_dio = Dio(BaseOptions(
baseUrl: base,
connectTimeout: AppConfig.connectTimeout,
receiveTimeout: AppConfig.receiveTimeout,
// 业务错误统一 HTTP 200 + code,服务端 4xx/5xx 也走统一处理
validateStatus: (status) => status != null && status < 500,
));
_dio.interceptors.addAll([
_CommonHeadersInterceptor(),
_UnwrapInterceptor(),
_ErrorInterceptor(),
]);
}
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
static Future<T> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
return resp.data as T;
}
static Future<T> post<T>(String path,
{Object? data, ProgressCallback? onSendProgress}) async {
final resp = await _dio.post<dynamic>(path,
data: data, onSendProgress: onSendProgress);
return resp.data as T;
}
static Future<T> put<T>(String path, {Object? data}) async {
final resp = await _dio.put<dynamic>(path, data: data);
return resp.data as T;
}
static Future<T> delete<T>(String path, {Object? data}) async {
final resp = await _dio.delete<dynamic>(path, data: data);
return resp.data as T;
}
}
/// 公共请求头注入
class _CommonHeadersInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['X-Device-ID'] = LocalStore.I.deviceId;
options.headers['X-App-Version'] = AppConfig.appVersion;
options.headers['X-Platform'] = Platform.isIOS
? AppConfig.platformNameIOS
: AppConfig.platformNameAndroid;
options.headers['X-Request-ID'] = const Uuid().v4();
final token = TokenStore.accessToken;
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
class _UnwrapInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
ApiDomain.markSuccess();
final body = response.data;
if (body is Map && body.containsKey('code')) {
final code = body['code'] as int;
if (code == 0) {
response.data = body['data'];
return handler.next(response);
}
return handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
error: ApiException(
code,
(body['message'] as String?) ?? '请求失败',
data: body['data'] is Map<String, dynamic>
? body['data'] as Map<String, dynamic>
: null,
),
));
}
handler.next(response);
}
}
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
class _ErrorInterceptor extends Interceptor {
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
// 网络层失败(域名不可达等):计数并按需重新发现域名
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout) {
await ApiDomain.rediscoverIfBroken();
ApiClient._dio.options.baseUrl = ApiDomain.current;
}
final apiError = err.error;
final status = err.response?.statusCode;
// 401:静默刷新重试一次
if (status == 401 &&
err.requestOptions.extra['retried'] != true &&
TokenStore.refreshToken != null) {
final newToken = await _refreshToken();
if (newToken != null) {
try {
final opts = err.requestOptions;
opts.extra['retried'] = true;
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} catch (_) {
// 重试仍失败 → 走下方登出
}
}
await TokenStore.clear();
ApiClient.onSessionExpired?.call();
}
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
final code = apiError is ApiException ? apiError.code : -1;
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(code, message),
));
}
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
Future<String?> _refreshToken() async {
final refresh = TokenStore.refreshToken;
if (refresh == null) return null;
try {
final bare = Dio(BaseOptions(
baseUrl: ApiDomain.current,
validateStatus: (s) => s != null && s < 500,
));
final resp = await bare.post<Map<String, dynamic>>(
Endpoints.tokenRefresh,
data: {'refresh_token': refresh},
);
final body = resp.data;
if (body != null && body['code'] == 0) {
final data = body['data'] as Map<String, dynamic>;
await TokenStore.save(
access: data['access_token'] as String,
refresh: data['refresh_token'] as String,
);
return TokenStore.accessToken;
}
} catch (_) {
// 刷新失败视为会话过期
}
return null;
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'api_exception.dart';
import '../config/app_config.dart';
import '../storage/local_store.dart';
/// API 域名发现(《01-开发文档》5.5):
/// 1. 优先使用 --dart-define=API_BASE 调试覆盖
/// 2. 本地缓存域名
/// 3. DoH 解析 txt.256kb.top 的 TXT 记录,内容即真实 API 域名
/// 4. 兜底:md5(yyyyMM) + "_jsnbhh.vip"
/// 结果统一拼 https:// 作为 Base URL 并写入缓存。
class ApiDomain {
ApiDomain._();
static final Dio _doh = Dio(BaseOptions(
connectTimeout: AppConfig.connectTimeout,
receiveTimeout: AppConfig.connectTimeout,
));
static int _failCount = 0;
/// 当前生效的 Base URL(含 https:// 前缀,无尾斜杠)
static String? _current;
static String get current => _current ?? '';
/// 启动时解析域名:按优先级逐级回退
static Future<String> resolve() async {
// 0. 调试覆盖
if (AppConfig.devApiBase.isNotEmpty) {
_current = _normalize(AppConfig.devApiBase);
return _current!;
}
// 1. 本地缓存
final cached = LocalStore.I.cachedBaseUrl;
if (cached != null && cached.isNotEmpty) {
_current = cached;
return cached;
}
// 2/3. TXT → 兜底域名
_current = await _discover();
return _current!;
}
/// 缓存域名连续失败达到阈值时重新发现(由 ApiClient 调用)
static Future<String> rediscoverIfBroken() async {
_failCount++;
if (_failCount < AppConfig.domainFailureThreshold) return current;
_failCount = 0;
final next = await _discover();
if (next != current) {
_current = next;
await LocalStore.I.saveBaseUrl(next);
}
return current;
}
/// 请求成功时清零失败计数
static void markSuccess() => _failCount = 0;
/// 完整发现流程:TXT → 兜底
static Future<String> _discover() async {
final host = await _resolveTxt();
if (host != null && host.isNotEmpty) {
final base = _normalize(host);
await LocalStore.I.saveBaseUrl(base);
return base;
}
final base = _normalize(_fallbackDomain());
await LocalStore.I.saveBaseUrl(base);
return base;
}
/// DoH 解析 TXT:优先阿里 223.5.5.5,失败切腾讯 119.29.29.29
static Future<String?> _resolveTxt() async {
for (final server in AppConfig.dohServers) {
try {
final resp = await _doh.get<dynamic>(
server,
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
);
final body = resp.data;
final answers = body is Map ? body['Answer'] as List? : null;
if (answers == null || answers.isEmpty) continue;
// TXT 记录值形如 "\"api.xxx.com\"",去掉引号
final raw = (answers.first['data'] as String? ?? '').replaceAll('"', '').trim();
if (raw.isNotEmpty) return raw;
} catch (_) {
continue; // 单个 DoH 失败不影响整体回退
}
}
return null;
}
/// 兜底域名:md5(yyyyMM)(32 位小写)+ 后缀
static String _fallbackDomain() {
final now = DateTime.now();
final ym = '${now.year}${now.month.toString().padLeft(2, '0')}';
final digest = md5.convert(utf8.encode(ym)).toString();
return '$digest${AppConfig.fallbackDomainSuffix}';
}
/// 归一化为 Base URL:补 https://,去尾斜杠
static String _normalize(String hostOrUrl) {
var s = hostOrUrl.trim();
if (!s.startsWith('http://') && !s.startsWith('https://')) {
s = 'https://$s';
}
return s.endsWith('/') ? s.substring(0, s.length - 1) : s;
}
/// 统一业务错误转 ApiException(域名探测等场景复用)
static ApiException wrap(Object error) {
if (error is ApiException) return error;
return ApiException(-1, '网络异常,请稍后重试');
}
}
+23
View File
@@ -0,0 +1,23 @@
/// 业务异常:对应统一响应结构 { code, message, data }
/// code=0 为成功;非 0 抛出该异常由 UI 层捕获提示(错误码表见《03-接口文档》1.3)
class ApiException implements Exception {
ApiException(this.code, this.message, {this.data});
final int code;
final String message;
/// 错误响应携带的 data(如 2001 积分不足时含当前 points、2002 含 need_login)
final Map<String, dynamic>? data;
/// 未登录或 Token 无效/过期
bool get isUnauthorized => code == 1002;
/// 游客今日免费次数已用完(需登录引导)
bool get isGuestLimit => code == 2002;
/// 积分不足(需引导充值/开通会员)
bool get isPointsInsufficient => code == 2001;
@override
String toString() => 'ApiException($code, $message)';
}
+74
View File
@@ -0,0 +1,74 @@
/// 接口路径常量(《03-接口文档》第 2~10 章)
/// 统一响应已由 ApiClient 解包,各 Service 只关心 data。
class Endpoints {
Endpoints._();
// ---------- 认证 ----------
static const smsSend = '/auth/sms/send';
static const emailSend = '/auth/email/send';
static const register = '/auth/register';
static const login = '/auth/login';
static const tokenRefresh = '/auth/token/refresh';
static const logout = '/auth/logout';
static const passwordReset = '/auth/password/reset';
// ---------- 用户 ----------
static const me = '/users/me';
static const meAvatar = '/users/me/avatar';
static const meWallet = '/users/me/wallet';
static const mePointsRecords = '/users/me/points/records';
static const meDeactivate = '/users/me/deactivate';
static String userDetail(int userId) => '/users/$userId';
static String userVideos(int userId) => '/users/$userId/videos';
static const meVideos = '/users/me/videos';
static const meFavorites = '/users/me/favorites';
static const meHistory = '/users/me/history';
// ---------- 视频 ----------
static const feed = '/videos/feed';
static const categories = '/videos/categories';
static const uploadToken = '/videos/upload/token';
static const uploadFile = '/videos/upload/file';
static const publish = '/videos';
static const search = '/videos/search';
static const hotSearch = '/search/hot';
static String videoDetail(int videoId) => '/videos/$videoId';
static String videoWatch(int videoId) => '/videos/$videoId/watch';
static String videoWatchComplete(int videoId) => '/videos/$videoId/watch/complete';
static String videoLike(int videoId) => '/videos/$videoId/like';
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
static String videoComments(int videoId) => '/videos/$videoId/comments';
static String videoEdit(int videoId) => '/videos/$videoId';
static String videoDelete(int videoId) => '/videos/$videoId';
static String tagVideos(String tagName) => '/tags/$tagName/videos';
// ---------- 互动 ----------
static String commentDelete(int commentId) => '/comments/$commentId';
// ---------- 会员 ----------
static const membershipPlans = '/membership/plans';
static const membershipMe = '/membership/me';
static const membershipRedeem = '/membership/redeem';
// ---------- 支付 ----------
static const payPackages = '/payments/packages';
static const recharge = '/payments/recharge';
static String orderDetail(String orderNo) => '/payments/orders/$orderNo';
static String orderCancel(String orderNo) => '/payments/orders/$orderNo/cancel';
// ---------- 激励 / 推广归因 ----------
static const referralMe = '/referral/me';
static const referralBind = '/referral/bind';
static const inviteMatch = '/invite/match';
static const assetsMe = '/assets/me';
static const assetsRecords = '/assets/records';
static const assetsWithdraw = '/assets/withdraw';
static const withdrawRecords = '/assets/withdraw/records';
// ---------- 通用 ----------
static const appVersion = '/app/version';
static const notifications = '/notifications';
static const notificationsUnread = '/notifications/unread_count';
static const notificationsRead = '/notifications/read';
static const report = '/reports';
}
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
class PlayerSlot {
PlayerSlot(this.player);
final Player player;
late final VideoController controller = VideoController(player);
/// 当前槽位绑定的 videoId(-1 表示空槽)
int videoId = -1;
/// 是否已回调过 complete(每视频每次绑定只触发一次)
bool completeNotified = false;
}
/// Feed 播放器池:3 槽位循环复用,防止 PageView 无限列表下 OOM。
///
/// 策略:
/// - 窗口 [current-1, current, current+1] 映射到 index % 3 槽位
/// - 当前槽 play,相邻槽仅 open 预缓冲,出窗槽 stop 复位
/// - 每个槽位独立订阅进度流:position ≥ duration×95% 或 completed → 回调一次 complete
///
/// 构造接受 [playerFactory] 便于测试注入 fake(本机无播放器环境)。
class FeedPlayerPool {
FeedPlayerPool({
Player Function()? playerFactory,
this.onWatchComplete,
}) : _playerFactory = playerFactory;
final Player Function()? _playerFactory;
/// 完整观看回调:(videoId, watchDuration秒),由上层做去重与上报
final void Function(int videoId, int watchDurationSeconds)? onWatchComplete;
static const int slotCount = 3;
final List<PlayerSlot> _slots = [];
final List<StreamSubscription> _subs = [];
int _currentIndex = 0;
int get currentIndex => _currentIndex;
/// 初始化(创建 3 槽位并订阅各自进度流)
void init() {
for (var i = 0; i < slotCount; i++) {
final player = _playerFactory?.call() ?? Player();
final slot = PlayerSlot(player);
_slots.add(slot);
_hookSlot(slot);
}
}
/// 槽位号(PageView 页用 index % 3 取对应 controller 渲染)
int slotOf(int index) => index % slotCount;
PlayerSlot slotAt(int slotIndex) => _slots[slotIndex];
VideoController controllerAt(int slotIndex) => _slots[slotIndex].controller;
PlayerSlot get currentSlot => _slots[slotOf(_currentIndex)];
bool get isCurrentPlaying => currentSlot.player.state.playing;
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
void bindWindow(int index, List<({int videoId, String url})> items) {
_currentIndex = index;
// 1. 窗口内槽位按需加载
for (var offset = -1; offset <= 1; offset++) {
final i = index + offset;
if (i < 0 || i >= items.length) continue;
final slot = _slots[slotOf(i)];
final item = items[i];
if (slot.videoId == item.videoId) continue; // 已加载,避免重复 open
slot.videoId = item.videoId;
slot.completeNotified = false;
if (item.url.isNotEmpty) {
slot.player.open(Media(item.url), play: false);
}
}
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
for (var s = 0; s < slotCount; s++) {
final slot = _slots[s];
if (s == slotOf(index)) continue;
if (slot.videoId == -1) continue;
if (_inWindow(slot.videoId, index, items)) {
slot.player.pause(); // 窗口内非当前:预缓冲不播放
} else {
slot.player.stop();
slot.videoId = -1; // 出窗复位
}
}
currentSlot.player.play();
}
bool _inWindow(int videoId, int index, List<({int videoId, String url})> items) {
for (var offset = -1; offset <= 1; offset++) {
final i = index + offset;
if (i >= 0 && i < items.length && items[i].videoId == videoId) return true;
}
return false;
}
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
void _hookSlot(PlayerSlot slot) {
var lastPosition = Duration.zero;
_subs.add(slot.player.stream.position.listen((pos) {
lastPosition = pos;
if (slot.videoId < 0 || slot.completeNotified) return;
// 仅当前槽才上报(预缓冲槽不应触发)
if (slot != currentSlot) return;
final dur = slot.player.state.duration;
if (dur.inMilliseconds > 0 &&
pos.inMilliseconds >= dur.inMilliseconds * 0.95) {
slot.completeNotified = true;
onWatchComplete?.call(slot.videoId, pos.inSeconds);
}
}));
_subs.add(slot.player.stream.completed.listen((done) {
if (!done) return;
if (slot.videoId < 0 || slot.completeNotified) return;
if (slot != currentSlot) return;
slot.completeNotified = true;
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
}));
}
/// 暂停/继续/切换当前槽(手势与生命周期用)
void pauseCurrent() => currentSlot.player.pause();
void playCurrent() => currentSlot.player.play();
void toggleCurrent() => currentSlot.player.playOrPause();
/// 释放全部资源(Feed 页 dispose 时调用)
Future<void> dispose() async {
for (final sub in _subs) {
await sub.cancel();
}
_subs.clear();
for (final slot in _slots) {
await slot.player.dispose();
}
}
}
+58
View File
@@ -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);
}
+44
View File
@@ -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);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
/// UI 样式规范(《01-开发文档》5.4):深色沉浸 + 品牌红/青 + 会员金
class AppColors {
AppColors._();
static const Color background = Color(0xFF121212);
static const Color surface = Color(0xFF1E1E1E);
static const Color primary = Color(0xFFFE2C55); // 品牌红
static const Color secondary = Color(0xFF25F4EE); // 辅助青
static const Color textPrimary = Color(0xFFFFFFFF);
static const Color textSecondary = Color(0xFFB0B0B0);
static const Color textWeak = Color(0xFF6E6E6E);
static const Color gold = Color(0xFFF7D794);
static const Color goldDeep = Color(0xFFD4A017);
/// 会员金色渐变
static const LinearGradient goldGradient = LinearGradient(
colors: [gold, goldDeep],
);
}
/// 全局深色主题
class AppTheme {
AppTheme._();
static ThemeData get dark => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: AppColors.background,
colorScheme: const ColorScheme.dark(
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: AppColors.surface,
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.background,
elevation: 0,
centerTitle: true,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
hintStyle: const TextStyle(color: AppColors.textWeak),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.textPrimary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
dividerColor: AppColors.textWeak.withValues(alpha: 0.2),
);
}
+18
View File
@@ -0,0 +1,18 @@
import '../../models/video_item.dart';
import '../config/test_stream.dart';
/// 分享文案:「标题」#标签 #标签 播放链接(链接不可用时省略)
/// 一期走系统分享面板;分享落地页/海报属二期规划(《01-开发文档》2.10)
String buildShareText(VideoItem item, {String? testUrl}) {
final buffer = StringBuffer();
buffer.write(item.title.isNotEmpty ? '「${item.title}」' : '精彩视频');
for (final tag in item.tags) {
buffer.write(' #$tag');
}
final url = resolvePlayUrl(item, testUrl: testUrl);
// 仅 http(s) 绝对链接可分享(相对路径对外不可达)
if (url.startsWith('http://') || url.startsWith('https://')) {
buffer.write('\n$url');
}
return buffer.toString();
}