diff --git a/lib/app.dart b/lib/app.dart index ba3c8c7..970c467 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,6 +13,7 @@ import 'core/data/remote/auth_api.dart'; import 'core/data/remote/catalog_api.dart'; import 'core/data/remote/category_api.dart'; import 'core/data/remote/comment_api.dart'; +import 'core/data/remote/commerce_api.dart'; import 'core/data/remote/download_api.dart'; import 'core/data/remote/interaction_api.dart'; import 'core/data/remote/library_api.dart'; @@ -23,6 +24,7 @@ import 'core/data/repositories/catalog_repository.dart'; import 'core/data/repositories/account_repository.dart'; import 'core/data/repositories/category_repository.dart'; import 'core/data/repositories/comment_repository.dart'; +import 'core/data/repositories/commerce_repository.dart'; import 'core/data/repositories/download_repository.dart'; import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; @@ -38,6 +40,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; +import 'core/data/services/wallet_controller.dart'; import 'core/platform/haptics.dart'; import 'core/theme/app_theme.dart'; import 'core/theme/app_colors.dart'; @@ -58,6 +61,7 @@ class LumaDramaApp extends StatefulWidget { this.interactionRepository, this.noticeRepository, this.accountRepository, + this.commerceRepository, this.haptics = const FlutterHaptics(), }); @@ -74,6 +78,7 @@ class LumaDramaApp extends StatefulWidget { final InteractionRepository? interactionRepository; final NoticeRepository? noticeRepository; final AccountRepository? accountRepository; + final CommerceRepository? commerceRepository; final Haptics haptics; @override @@ -134,6 +139,13 @@ class _LumaDramaAppState extends State { _downloadRepository, ); + late final CommerceRepository _commerceRepository = + widget.commerceRepository ?? HttpCommerceRepository(CommerceApi(_apiClient)); + + late final WalletController _walletController = WalletController( + _commerceRepository, + ); + late final CommentRepository _commentRepository = widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient)); @@ -158,6 +170,8 @@ class _LumaDramaAppState extends State { // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); if (_auth.isSignedIn) unawaited(_favoriteController.load()); + if (_auth.isSignedIn) unawaited(_walletController.load()); + unawaited(_downloadController.restore()); } bool _wasSignedIn = false; @@ -168,9 +182,11 @@ class _LumaDramaAppState extends State { _interactionController.clear(); if (_auth.isSignedIn) { unawaited(_favoriteController.load()); + unawaited(_walletController.load()); } else { _favoriteController.clear(); _downloadController.reset(); + _walletController.reset(); } } @@ -179,6 +195,7 @@ class _LumaDramaAppState extends State { _auth.removeListener(_onAuthChanged); _favoriteController.dispose(); _downloadController.dispose(); + _walletController.dispose(); _interactionController.dispose(); _apiClient.close(); super.dispose(); @@ -198,6 +215,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _interactionRepository), Provider.value(value: _noticeRepository), Provider.value(value: _accountRepository), + Provider.value(value: _commerceRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), @@ -208,6 +226,7 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), + ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _auth), ], diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart index 37355fc..332eb6b 100644 --- a/lib/core/config/server_endpoint.dart +++ b/lib/core/config/server_endpoint.dart @@ -39,11 +39,11 @@ abstract final class ServerEndpoint { /// In release builds this returns immediately without reading preferences. static Future load({ bool debug = kDebugMode, - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { if (!debug) return; try { - final prefs = preferences ?? await SharedPreferences.getInstance(); + final prefs = preferences ?? SharedPreferencesAsync(); final stored = await prefs.getString(preferenceKey); AppConfig.applyRuntimeBaseUrl( resolve( @@ -60,12 +60,12 @@ abstract final class ServerEndpoint { /// Persists a normalized runtime endpoint. static Future save( String raw, { - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { final normalized = normalize(raw); if (normalized == null) return false; try { - await (preferences ?? await SharedPreferences.getInstance()).setString( + await (preferences ?? SharedPreferencesAsync()).setString( preferenceKey, normalized, ); @@ -76,9 +76,9 @@ abstract final class ServerEndpoint { } /// Removes the persisted runtime endpoint. - static Future clear({SharedPreferences? preferences}) async { + static Future clear({SharedPreferencesAsync? preferences}) async { try { - await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey); + await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); } catch (_) { // Clearing an optional developer override is best effort. } diff --git a/lib/core/data/models/wallet.dart b/lib/core/data/models/wallet.dart new file mode 100644 index 0000000..1819391 --- /dev/null +++ b/lib/core/data/models/wallet.dart @@ -0,0 +1,107 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 金币钱包与流水:对应 `GET /app/wallet` 的 `wallet` / `ledger`。 +class CoinWallet { + const CoinWallet({ + required this.coinBalance, + required this.totalRechargedCoin, + required this.totalConsumedCoin, + required this.totalRefundedCoin, + }); + + final int coinBalance; + final int totalRechargedCoin; + final int totalConsumedCoin; + final int totalRefundedCoin; + + factory CoinWallet.fromJson(Map json) { + return CoinWallet( + coinBalance: jsonToInt(json['coinBalance']), + totalRechargedCoin: jsonToInt(json['totalRechargedCoin']), + totalConsumedCoin: jsonToInt(json['totalConsumedCoin']), + totalRefundedCoin: jsonToInt(json['totalRefundedCoin']), + ); + } +} + +/// 一笔金币变动;[changeAmount] 正数入账、负数出账。 +class CoinLedgerEntry { + const CoinLedgerEntry({ + required this.id, + required this.changeAmount, + required this.balanceAfter, + required this.bizType, + required this.remark, + required this.createdAt, + }); + + final String id; + final int changeAmount; + final int balanceAfter; + final String bizType; + final String remark; + final DateTime? createdAt; + + factory CoinLedgerEntry.fromJson(Map json) { + return CoinLedgerEntry( + id: (json['id'] ?? '').toString(), + changeAmount: jsonToInt(json['changeAmount']), + balanceAfter: jsonToInt(json['balanceAfter']), + bizType: json['bizType'] as String? ?? '', + remark: json['remark'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''), + ); + } +} + +/// 钱包页一次渲染所需的余额与最近流水。 +class WalletSnapshot { + const WalletSnapshot({required this.wallet, this.ledger = const []}); + + final CoinWallet wallet; + final List ledger; + + factory WalletSnapshot.fromJson(Map json) { + final wallet = json['wallet']; + return WalletSnapshot( + wallet: wallet is Map + ? CoinWallet.fromJson(wallet) + : const CoinWallet( + coinBalance: 0, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ledger: (json['ledger'] as List? ?? const []) + .whereType>() + .map(CoinLedgerEntry.fromJson) + .toList(), + ); + } +} + +/// 金币解锁结果:`POST /app/episodes/{id}/unlock`。 +class UnlockResult { + const UnlockResult({ + required this.unlocked, + required this.accessType, + required this.coinCost, + required this.coinBalance, + }); + + final bool unlocked; + final String accessType; + final int coinCost; + final int coinBalance; + + factory UnlockResult.fromJson(Map json) { + return UnlockResult( + unlocked: json['unlocked'] == true, + accessType: json['accessType'] as String? ?? '', + coinCost: jsonToInt(json['coinCost']), + coinBalance: jsonToInt(json['coinBalance']), + ); + } +} diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index 175f753..b54855d 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -10,6 +10,67 @@ class AuthApi { final ApiClient _client; + /// 发送短信验证码;[scene] 为 REGISTER / LOGIN / RESET_PWD / BIND。 + Future sendOtp({ + required String target, + required String scene, + required String deviceId, + }) async { + await _client.post( + '/app/auth/otp', + body: { + 'scene': scene, + 'targetType': 'PHONE', + 'target': target, + 'deviceId': deviceId, + }, + ); + } + + /// 手机号 + 验证码登录。 + Future loginWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/login', + body: { + 'targetType': 'PHONE', + 'target': target, + 'code': code, + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + /// 手机号 + 验证码注册。 + Future registerWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/register', + body: { + 'targetType': 'PHONE', + 'target': target, + 'code': code, + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + Future register({ required String email, required String password, diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart new file mode 100644 index 0000000..3dedc68 --- /dev/null +++ b/lib/core/data/remote/commerce_api.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/wallet.dart'; +import 'api_client.dart'; + +/// 金币与解锁接口,对应契约 §1.8。 +class CommerceApi { + const CommerceApi(this._client); + + final ApiClient _client; + + Future wallet() async { + final data = await _client.get('/app/wallet'); + if (data is Map) return WalletSnapshot.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } + + Future unlock(String episodeId) async { + final data = await _client.post('/app/episodes/$episodeId/unlock'); + if (data is Map) return UnlockResult.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/data/repositories/commerce_repository.dart b/lib/core/data/repositories/commerce_repository.dart new file mode 100644 index 0000000..93436fc --- /dev/null +++ b/lib/core/data/repositories/commerce_repository.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/wallet.dart'; +import '../remote/commerce_api.dart'; + +/// 钱包与金币解锁的数据来源。 +abstract interface class CommerceRepository { + Future fetchWallet(); + + Future unlock(String episodeId); +} + +class HttpCommerceRepository implements CommerceRepository { + const HttpCommerceRepository(this._api); + + final CommerceApi _api; + + @override + Future fetchWallet() => _api.wallet(); + + @override + Future unlock(String episodeId) => _api.unlock(episodeId); +} diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 32713ba..2ce72dd 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -57,6 +57,43 @@ class AuthController extends ChangeNotifier { await _apply(session); } + Future sendOtp({ + required String target, + String scene = 'LOGIN', + }) async { + await _api.sendOtp( + target: target, + scene: scene, + deviceId: await _store.deviceId(), + ); + } + + Future signInWithPhone({ + required String target, + required String code, + }) async { + final session = await _api.loginWithPhone( + target: target, + code: code, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + + Future registerWithPhone({ + required String target, + required String code, + }) async { + final session = await _api.registerWithPhone( + target: target, + code: code, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + Future signOut() async { final refreshToken = _session?.refreshToken ?? ''; _session = null; diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index e03cc6b..20afccb 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -14,6 +14,11 @@ import '../models/auth_session.dart'; /// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**; /// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。 class AuthSessionStore { + AuthSessionStore({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + final SharedPreferencesAsync _preferences; + static const _kAccessToken = 'auth.accessToken'; static const _kRefreshToken = 'auth.refreshToken'; static const _kUser = 'auth.user'; @@ -23,25 +28,23 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - final prefs = await SharedPreferences.getInstance(); - var value = await prefs.getString(_kDeviceId) ?? ''; + var value = await _preferences.getString(_kDeviceId) ?? ''; if (value.isEmpty) { final random = Random(); value = 'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-' '${random.nextInt(1 << 32).toRadixString(36)}'; - await prefs.setString(_kDeviceId, value); + await _preferences.setString(_kDeviceId, value); } _cachedDeviceId = value; return value; } Future read() async { - final prefs = await SharedPreferences.getInstance(); - final accessToken = await prefs.getString(_kAccessToken) ?? ''; - final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; + final accessToken = await _preferences.getString(_kAccessToken) ?? ''; + final refreshToken = await _preferences.getString(_kRefreshToken) ?? ''; if (accessToken.isEmpty || refreshToken.isEmpty) return null; - final rawUser = await prefs.getString(_kUser); + final rawUser = await _preferences.getString(_kUser); Map user = const {}; if (rawUser != null && rawUser.isNotEmpty) { try { @@ -58,17 +61,15 @@ class AuthSessionStore { } Future save(AuthSession session) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kAccessToken, session.accessToken); - await prefs.setString(_kRefreshToken, session.refreshToken); - await prefs.setString(_kUser, jsonEncode(session.user.toJson())); + await _preferences.setString(_kAccessToken, session.accessToken); + await _preferences.setString(_kRefreshToken, session.refreshToken); + await _preferences.setString(_kUser, jsonEncode(session.user.toJson())); } Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kAccessToken); - await prefs.remove(_kRefreshToken); - await prefs.remove(_kUser); + await _preferences.remove(_kAccessToken); + await _preferences.remove(_kRefreshToken); + await _preferences.remove(_kUser); // 设备号不清:它代表"这台设备",与登录态无关 } } diff --git a/lib/core/data/services/download_controller.dart b/lib/core/data/services/download_controller.dart index 1e38ef8..b8108e7 100644 --- a/lib/core/data/services/download_controller.dart +++ b/lib/core/data/services/download_controller.dart @@ -1,11 +1,15 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import '../models/download_license.dart'; import '../models/download_policy.dart'; +import '../../config/app_config.dart'; import '../remote/api_exception.dart'; import '../repositories/download_repository.dart'; +import 'download_storage.dart'; /// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。 class DownloadActionResult { @@ -18,9 +22,15 @@ class DownloadActionResult { /// 下载授权与策略状态。 class DownloadController extends ChangeNotifier { - DownloadController(this._repository); + DownloadController(this._repository, {DownloadStorage? storage}) + : _storage = storage ?? FileDownloadStorage(); final DownloadRepository _repository; + final DownloadStorage _storage; + final Map _localPaths = {}; + final Set _downloading = {}; + final Map _progress = {}; + final Map _lastReport = {}; DownloadPolicy? policy; List licenses = const []; @@ -30,6 +40,18 @@ class DownloadController extends ChangeNotifier { String? actionError; bool _disposed = false; + bool isDownloaded(String episodeId) => _localPaths.containsKey(episodeId); + + bool isDownloading(String episodeId) => _downloading.contains(episodeId); + + double progressFor(String episodeId) => _progress[episodeId] ?? 0; + + String? localPathFor(String episodeId) => _localPaths[episodeId]; + + /// 播放器使用:只读已同步的本地缓存;下载页加载后会把磁盘文件补进来。 + Future findLocalPath(String episodeId) async => + _localPaths[episodeId]; + /// 同时拉取策略与当前设备授权列表。 Future load() async { loading = true; @@ -42,6 +64,7 @@ class DownloadController extends ChangeNotifier { ]); policy = results[0] as DownloadPolicy; licenses = results[1] as List; + await _syncLocalFiles(); } catch (_) { error = 'downloadsError'; } finally { @@ -50,6 +73,22 @@ class DownloadController extends ChangeNotifier { } } + Future _syncLocalFiles() async => restore(); + + /// 冷启动时恢复已落盘文件,播放器只依赖这份内存索引。 + Future restore() async { + try { + final paths = await _storage.restore(); + _localPaths.addAll(paths); + for (final id in paths.keys) { + _progress[id] = 1; + } + _notify(); + } catch (_) { + // 本地目录不可用时保持未下载状态。 + } + } + /// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。 Future grant( String episodeId, { @@ -76,6 +115,93 @@ class DownloadController extends ChangeNotifier { } } + /// 下载并落盘:授权已在 [grant] 完成,播放地址缺失时向服务端刷新一次。 + Future startDownload( + String episodeId, { + DownloadLicense? license, + }) async { + if (_downloading.contains(episodeId)) return const DownloadActionResult(ok: true); + final source = license ?? _licenseFor(episodeId); + if (source == null) return const DownloadActionResult(ok: false); + _beginDownload(episodeId); + try { + final resolved = await _resolvePlayUrl(source); + final media = await _storage.download( + episodeId: episodeId, + url: resolved, + onProgress: (received, total) => _onProgress(episodeId, received, total), + ); + _localPaths[episodeId] = media.filePath; + _progress[episodeId] = 1; + unawaited(_report(episodeId, 'COMPLETED', 100, media.bytes, media.bytes)); + return DownloadActionResult(ok: true, license: source); + } catch (_) { + unawaited(_report(episodeId, 'FAILED', 0, null, null, 'downloadFailed')); + actionError = 'downloadFailed'; + return const DownloadActionResult(ok: false); + } finally { + _downloading.remove(episodeId); + _notify(); + } + } + + DownloadLicense? _licenseFor(String episodeId) { + for (final item in licenses) { + if (item.episodeId == episodeId) return item; + } + return null; + } + + void _beginDownload(String episodeId) { + _downloading.add(episodeId); + _progress[episodeId] = 0; + actionError = null; + unawaited(_report(episodeId, 'DOWNLOADING', 0, 0, 0)); + _notify(); + } + + Future _resolvePlayUrl(DownloadLicense license) async { + final current = license.playUrl.isEmpty + ? await _repository.refreshUrl(license.episodeId) + : license; + return AppConfig.mediaUrl(current.playUrl); + } + + void _onProgress(String episodeId, int received, int total) { + final fraction = total > 0 ? received / total : 0.0; + _progress[episodeId] = fraction.clamp(0.0, 1.0); + final now = DateTime.now(); + final last = _lastReport[episodeId]; + if (last == null || now.difference(last) >= const Duration(seconds: 2)) { + _lastReport[episodeId] = now; + final percent = (_progress[episodeId]! * 100).round(); + unawaited(_report(episodeId, 'DOWNLOADING', percent, received, total)); + } + _notify(); + } + + Future _report( + String episodeId, + String status, + int percent, + int? received, + int? total, [ + String? error, + ]) async { + try { + await _repository.reportProgress( + episodeId, + status: status, + progressPercent: percent, + downloadedBytes: received, + totalBytes: total, + errorMsg: error, + ); + } catch (_) { + // 进度上报失败不阻断本地播放,下次打开下载页会重新同步。 + } + } + /// 更新设置,先本地展示,失败恢复服务端旧值。 Future updateSettings({bool? wifiOnly, String? maxQuality}) async { final previous = policy; @@ -109,6 +235,9 @@ class DownloadController extends ChangeNotifier { _notify(); try { await _repository.remove(episodeId); + await _storage.delete(episodeId); + _localPaths.remove(episodeId); + _progress.remove(episodeId); return true; } catch (_) { final restored = [...licenses]; @@ -134,6 +263,9 @@ class DownloadController extends ChangeNotifier { _notify(); try { await _repository.clear(); + await _storage.clear(); + _localPaths.clear(); + _progress.clear(); return true; } catch (_) { licenses = previous; @@ -153,6 +285,10 @@ class DownloadController extends ChangeNotifier { clearing = false; error = null; actionError = null; + _localPaths.clear(); + _downloading.clear(); + _progress.clear(); + _lastReport.clear(); _notify(); } diff --git a/lib/core/data/services/download_storage.dart b/lib/core/data/services/download_storage.dart new file mode 100644 index 0000000..05eb4c8 --- /dev/null +++ b/lib/core/data/services/download_storage.dart @@ -0,0 +1,182 @@ +// Author: fruit.com.cn + +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +/// 下载进度回调:[received] 与 [total] 均为字节数,未知总长时 [total] 为 0。 +typedef DownloadProgress = void Function(int received, int total); + +/// 已落盘的媒体文件。 +class DownloadedMedia { + const DownloadedMedia({ + required this.episodeId, + required this.filePath, + required this.bytes, + }); + + final String episodeId; + final String filePath; + final int bytes; +} + +/// 本地媒体存储边界;页面与控制器不直接操作文件系统。 +abstract interface class DownloadStorage { + Future> restore(); + + Future localPathFor(String episodeId); + + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }); + + Future delete(String episodeId); + + Future clear(); +} + +/// 将分集落盘到应用文档目录;临时文件完成后原子改名,避免半文件被播放。 +class FileDownloadStorage implements DownloadStorage { + FileDownloadStorage({ + Future Function()? directoryProvider, + HttpClient Function()? clientFactory, + }) : _directoryProvider = directoryProvider ?? getApplicationDocumentsDirectory, + _clientFactory = clientFactory ?? HttpClient.new; + + final Future Function() _directoryProvider; + final HttpClient Function() _clientFactory; + + static const String _folder = 'downloads'; + static const String _partSuffix = '.part'; + + @override + Future> restore() async { + try { + final directory = await _downloadDirectory(); + final files = {}; + await for (final entity in directory.list()) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (name.endsWith(_partSuffix)) continue; + final dot = name.lastIndexOf('.'); + final id = dot > 0 ? name.substring(0, dot) : name; + files[id] = entity.path; + } + return files; + } catch (_) { + return const {}; + } + } + + @override + Future localPathFor(String episodeId) async { + try { + final file = await _findFile(episodeId, includePartial: false); + return file?.path; + } catch (_) { + // 本地目录不可用时视为未下载;真正下载会单独返回错误。 + return null; + } + } + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async { + final existing = await _findFile(episodeId, includePartial: false); + if (existing != null) { + final length = await existing.length(); + onProgress?.call(length, length); + return DownloadedMedia( + episodeId: episodeId, + filePath: existing.path, + bytes: length, + ); + } + final directory = await _downloadDirectory(); + final target = File('${directory.path}/${_safe(episodeId)}${_extension(url)}'); + final partial = File('${target.path}$_partSuffix'); + final client = _clientFactory(); + var received = 0; + IOSink? sink; + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url)); + } + sink = partial.openWrite(); + await for (final chunk in response) { + sink.add(chunk); + received += chunk.length; + final total = response.contentLength > 0 ? response.contentLength : 0; + onProgress?.call(received, total); + } + await sink.close(); + sink = null; + if (await target.exists()) await target.delete(); + final file = await partial.rename(target.path); + final bytes = await file.length(); + onProgress?.call(bytes, bytes); + return DownloadedMedia( + episodeId: episodeId, + filePath: file.path, + bytes: bytes, + ); + } catch (_) { + await sink?.close(); + if (await partial.exists()) await partial.delete(); + rethrow; + } finally { + client.close(force: true); + } + } + + @override + Future delete(String episodeId) async { + final file = await _findFile(episodeId, includePartial: true); + if (file != null && await file.exists()) await file.delete(); + } + + @override + Future clear() async { + final directory = await _downloadDirectory(); + await for (final entity in directory.list()) { + if (entity is File) await entity.delete(); + } + } + + Future _findFile( + String episodeId, { + required bool includePartial, + }) async { + final directory = await _downloadDirectory(); + final prefix = _safe(episodeId); + await for (final entity in directory.list()) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (!name.startsWith(prefix)) continue; + final isPartial = name.endsWith(_partSuffix); + if (includePartial || !isPartial) return entity; + } + return null; + } + + Future _downloadDirectory() async { + final base = await _directoryProvider(); + return Directory('${base.path}/$_folder').create(recursive: true); + } + + String _safe(String episodeId) => + episodeId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + + String _extension(String url) { + final path = Uri.tryParse(url)?.path.toLowerCase() ?? ''; + if (path.endsWith('.m3u8')) return '.m3u8'; + return '.mp4'; + } +} diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index a5f573d..d5d4f40 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:io'; + import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -21,10 +23,18 @@ class VideoPlayerPort implements PlayerPort { Episode episode, Future? closedCaptionFile, ) { - final options = VideoPlayerOptions(mixWithOthers: false); // 片源统一由后端下发(详情里的可播放地址 / 播放票据) + final uri = resolveUri(episode); + final options = VideoPlayerOptions(mixWithOthers: false); + if (uri.scheme == 'file') { + return VideoPlayerController.file( + File.fromUri(uri), + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: options, + ); + } return VideoPlayerController.networkUrl( - resolveUri(episode), + uri, closedCaptionFile: closedCaptionFile, videoPlayerOptions: options, ); diff --git a/lib/core/data/services/wallet_controller.dart b/lib/core/data/services/wallet_controller.dart new file mode 100644 index 0000000..31d695d --- /dev/null +++ b/lib/core/data/services/wallet_controller.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/wallet.dart'; +import '../remote/api_exception.dart'; +import '../repositories/commerce_repository.dart'; + +/// 钱包状态:余额、最近流水与金币解锁。 +/// +/// 余额是解锁后 UI 的唯一来源;接口失败保留后端 message,页面原样展示。 +class WalletController extends ChangeNotifier { + WalletController(this._repository); + + final CommerceRepository _repository; + + WalletSnapshot? snapshot; + bool loading = false; + bool unlocking = false; + String? error; + String? unlockError; + String? unlockingEpisodeId; + bool _disposed = false; + + int get balance => snapshot?.wallet.coinBalance ?? 0; + List get ledger => snapshot?.ledger ?? const []; + + Future load() async { + loading = true; + error = null; + _notify(); + try { + snapshot = await _repository.fetchWallet(); + } on ApiException catch (exception) { + error = exception.message; + } catch (_) { + error = 'walletLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 金币解锁;成功后就地更新余额并返回后端结果。 + Future unlock(String episodeId) async { + if (unlocking) return null; + unlocking = true; + unlockingEpisodeId = episodeId; + unlockError = null; + _notify(); + try { + final result = await _repository.unlock(episodeId); + final current = snapshot; + snapshot = WalletSnapshot( + wallet: CoinWallet( + coinBalance: result.coinBalance, + totalRechargedCoin: current?.wallet.totalRechargedCoin ?? 0, + totalConsumedCoin: current?.wallet.totalConsumedCoin ?? 0, + totalRefundedCoin: current?.wallet.totalRefundedCoin ?? 0, + ), + ledger: current?.ledger ?? const [], + ); + return result; + } on ApiException catch (exception) { + unlockError = exception.message; + return null; + } catch (_) { + unlockError = 'unlockFailed'; + return null; + } finally { + unlocking = false; + unlockingEpisodeId = null; + _notify(); + } + } + + void reset() { + snapshot = null; + loading = false; + unlocking = false; + error = null; + unlockError = null; + unlockingEpisodeId = null; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart index 6da727b..4fd5bab 100644 --- a/lib/core/widgets/download_action.dart +++ b/lib/core/widgets/download_action.dart @@ -20,10 +20,16 @@ Future requestEpisodeDownload( if (!await ensureSignedIn(context)) return; if (!context.mounted) return; unawaited(context.read().lightImpact()); - final result = await context.read().grant(episodeId); + final downloads = context.read(); + final result = await downloads.grant(episodeId); if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; final hasBackendMessage = result.message?.trim().isNotEmpty ?? false; + if (result.ok) { + unawaited( + downloads.startDownload(episodeId, license: result.license), + ); + } final message = result.ok ? l10n.downloadGranted : hasBackendMessage diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b710ee7..eb5a4d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -476,5 +476,36 @@ "accountCancelDeactivation": "Cancel deletion request", "accountCancelSucceeded": "Deletion request cancelled.", "accountDeactivateFailed": "Could not request account deletion.", - "accountCancelFailed": "Could not cancel the deletion request." + "accountCancelFailed": "Could not cancel the deletion request.", + "authPhoneTitle": "Continue with phone", + "authPhoneSignUp": "Create an account with phone", + "authPhoneLabel": "Phone number", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "Enter a valid phone number", + "authCodeLabel": "Verification code", + "authCodeHint": "6-digit code", + "authCodeRequired": "Enter the verification code", + "authSendCode": "Send code", + "authResendCode": "Resend in {seconds}s", + "@authResendCode": { "placeholders": { "seconds": { "type": "int" } } }, + "authCodeSent": "Verification code sent.", + "authPhoneSignInAction": "Sign in", + "authPhoneSignUpAction": "Create account", + "unlockWithCoins": "Unlock with coins", + "unlockFailed": "Could not unlock this episode.", + "wallet": "Wallet", + "coinBalance": "Coin balance", + "coinLedger": "Coin activity", + "noCoinLedger": "No coin activity yet.", + "walletLoadFailed": "Could not load your wallet.", + "walletBalanceShort": "{coins} coins", + "@walletBalanceShort": { "placeholders": { "coins": { "type": "int" } } }, + "insufficientCoins": "Not enough coins.", + "rechargeUnavailable": "Top-up is not available yet.", + "totalRecharged": "Total recharged", + "totalConsumed": "Total spent", + "ledgerUnlock": "Episode unlock", + "ledgerRecharge": "Coin top-up", + "ledgerRefund": "Refund", + "ledgerAdjustment": "Adjustment" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6071753..4efa7a5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -476,5 +476,34 @@ "accountCancelDeactivation": "Batalkan permintaan penghapusan", "accountCancelSucceeded": "Permintaan penghapusan dibatalkan.", "accountDeactivateFailed": "Permintaan penghapusan gagal dikirim.", - "accountCancelFailed": "Permintaan penghapusan gagal dibatalkan." + "accountCancelFailed": "Permintaan penghapusan gagal dibatalkan.", + "authPhoneTitle": "Lanjutkan dengan nomor HP", + "authPhoneSignUp": "Buat akun dengan nomor HP", + "authPhoneLabel": "Nomor HP", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "Masukkan nomor HP yang valid", + "authCodeLabel": "Kode verifikasi", + "authCodeHint": "Kode 6 digit", + "authCodeRequired": "Masukkan kode verifikasi", + "authSendCode": "Kirim kode", + "authResendCode": "Kirim ulang dalam {seconds} detik", + "authCodeSent": "Kode verifikasi telah dikirim.", + "authPhoneSignInAction": "Masuk", + "authPhoneSignUpAction": "Buat akun", + "unlockWithCoins": "Buka dengan koin", + "unlockFailed": "Gagal membuka episode ini.", + "wallet": "Dompet", + "coinBalance": "Saldo koin", + "coinLedger": "Riwayat koin", + "noCoinLedger": "Belum ada aktivitas koin.", + "walletLoadFailed": "Gagal memuat dompet.", + "walletBalanceShort": "{coins} koin", + "insufficientCoins": "Koin tidak cukup.", + "rechargeUnavailable": "Isi ulang belum tersedia.", + "totalRecharged": "Total isi ulang", + "totalConsumed": "Total pemakaian", + "ledgerUnlock": "Buka episode", + "ledgerRecharge": "Isi ulang koin", + "ledgerRefund": "Pengembalian", + "ledgerAdjustment": "Penyesuaian" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 63446d1..b0f0c4f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2121,6 +2121,180 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Could not cancel the deletion request.'** String get accountCancelFailed; + + /// No description provided for @authPhoneTitle. + /// + /// In en, this message translates to: + /// **'Continue with phone'** + String get authPhoneTitle; + + /// No description provided for @authPhoneSignUp. + /// + /// In en, this message translates to: + /// **'Create an account with phone'** + String get authPhoneSignUp; + + /// No description provided for @authPhoneLabel. + /// + /// In en, this message translates to: + /// **'Phone number'** + String get authPhoneLabel; + + /// No description provided for @authPhoneHint. + /// + /// In en, this message translates to: + /// **'0812 3456 7890'** + String get authPhoneHint; + + /// No description provided for @authPhoneInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid phone number'** + String get authPhoneInvalid; + + /// No description provided for @authCodeLabel. + /// + /// In en, this message translates to: + /// **'Verification code'** + String get authCodeLabel; + + /// No description provided for @authCodeHint. + /// + /// In en, this message translates to: + /// **'6-digit code'** + String get authCodeHint; + + /// No description provided for @authCodeRequired. + /// + /// In en, this message translates to: + /// **'Enter the verification code'** + String get authCodeRequired; + + /// No description provided for @authSendCode. + /// + /// In en, this message translates to: + /// **'Send code'** + String get authSendCode; + + /// No description provided for @authResendCode. + /// + /// In en, this message translates to: + /// **'Resend in {seconds}s'** + String authResendCode(int seconds); + + /// No description provided for @authCodeSent. + /// + /// In en, this message translates to: + /// **'Verification code sent.'** + String get authCodeSent; + + /// No description provided for @authPhoneSignInAction. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get authPhoneSignInAction; + + /// No description provided for @authPhoneSignUpAction. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authPhoneSignUpAction; + + /// No description provided for @unlockWithCoins. + /// + /// In en, this message translates to: + /// **'Unlock with coins'** + String get unlockWithCoins; + + /// No description provided for @unlockFailed. + /// + /// In en, this message translates to: + /// **'Could not unlock this episode.'** + String get unlockFailed; + + /// No description provided for @wallet. + /// + /// In en, this message translates to: + /// **'Wallet'** + String get wallet; + + /// No description provided for @coinBalance. + /// + /// In en, this message translates to: + /// **'Coin balance'** + String get coinBalance; + + /// No description provided for @coinLedger. + /// + /// In en, this message translates to: + /// **'Coin activity'** + String get coinLedger; + + /// No description provided for @noCoinLedger. + /// + /// In en, this message translates to: + /// **'No coin activity yet.'** + String get noCoinLedger; + + /// No description provided for @walletLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load your wallet.'** + String get walletLoadFailed; + + /// No description provided for @walletBalanceShort. + /// + /// In en, this message translates to: + /// **'{coins} coins'** + String walletBalanceShort(int coins); + + /// No description provided for @insufficientCoins. + /// + /// In en, this message translates to: + /// **'Not enough coins.'** + String get insufficientCoins; + + /// No description provided for @rechargeUnavailable. + /// + /// In en, this message translates to: + /// **'Top-up is not available yet.'** + String get rechargeUnavailable; + + /// No description provided for @totalRecharged. + /// + /// In en, this message translates to: + /// **'Total recharged'** + String get totalRecharged; + + /// No description provided for @totalConsumed. + /// + /// In en, this message translates to: + /// **'Total spent'** + String get totalConsumed; + + /// No description provided for @ledgerUnlock. + /// + /// In en, this message translates to: + /// **'Episode unlock'** + String get ledgerUnlock; + + /// No description provided for @ledgerRecharge. + /// + /// In en, this message translates to: + /// **'Coin top-up'** + String get ledgerRecharge; + + /// No description provided for @ledgerRefund. + /// + /// In en, this message translates to: + /// **'Refund'** + String get ledgerRefund; + + /// No description provided for @ledgerAdjustment. + /// + /// In en, this message translates to: + /// **'Adjustment'** + String get ledgerAdjustment; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f777db2..2682cea 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1083,4 +1083,95 @@ class AppLocalizationsEn extends AppLocalizations { @override String get accountCancelFailed => 'Could not cancel the deletion request.'; + + @override + String get authPhoneTitle => 'Continue with phone'; + + @override + String get authPhoneSignUp => 'Create an account with phone'; + + @override + String get authPhoneLabel => 'Phone number'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => 'Enter a valid phone number'; + + @override + String get authCodeLabel => 'Verification code'; + + @override + String get authCodeHint => '6-digit code'; + + @override + String get authCodeRequired => 'Enter the verification code'; + + @override + String get authSendCode => 'Send code'; + + @override + String authResendCode(int seconds) { + return 'Resend in ${seconds}s'; + } + + @override + String get authCodeSent => 'Verification code sent.'; + + @override + String get authPhoneSignInAction => 'Sign in'; + + @override + String get authPhoneSignUpAction => 'Create account'; + + @override + String get unlockWithCoins => 'Unlock with coins'; + + @override + String get unlockFailed => 'Could not unlock this episode.'; + + @override + String get wallet => 'Wallet'; + + @override + String get coinBalance => 'Coin balance'; + + @override + String get coinLedger => 'Coin activity'; + + @override + String get noCoinLedger => 'No coin activity yet.'; + + @override + String get walletLoadFailed => 'Could not load your wallet.'; + + @override + String walletBalanceShort(int coins) { + return '$coins coins'; + } + + @override + String get insufficientCoins => 'Not enough coins.'; + + @override + String get rechargeUnavailable => 'Top-up is not available yet.'; + + @override + String get totalRecharged => 'Total recharged'; + + @override + String get totalConsumed => 'Total spent'; + + @override + String get ledgerUnlock => 'Episode unlock'; + + @override + String get ledgerRecharge => 'Coin top-up'; + + @override + String get ledgerRefund => 'Refund'; + + @override + String get ledgerAdjustment => 'Adjustment'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a417c57..66cc4bc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1082,4 +1082,95 @@ class AppLocalizationsId extends AppLocalizations { @override String get accountCancelFailed => 'Permintaan penghapusan gagal dibatalkan.'; + + @override + String get authPhoneTitle => 'Lanjutkan dengan nomor HP'; + + @override + String get authPhoneSignUp => 'Buat akun dengan nomor HP'; + + @override + String get authPhoneLabel => 'Nomor HP'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => 'Masukkan nomor HP yang valid'; + + @override + String get authCodeLabel => 'Kode verifikasi'; + + @override + String get authCodeHint => 'Kode 6 digit'; + + @override + String get authCodeRequired => 'Masukkan kode verifikasi'; + + @override + String get authSendCode => 'Kirim kode'; + + @override + String authResendCode(int seconds) { + return 'Kirim ulang dalam $seconds detik'; + } + + @override + String get authCodeSent => 'Kode verifikasi telah dikirim.'; + + @override + String get authPhoneSignInAction => 'Masuk'; + + @override + String get authPhoneSignUpAction => 'Buat akun'; + + @override + String get unlockWithCoins => 'Buka dengan koin'; + + @override + String get unlockFailed => 'Gagal membuka episode ini.'; + + @override + String get wallet => 'Dompet'; + + @override + String get coinBalance => 'Saldo koin'; + + @override + String get coinLedger => 'Riwayat koin'; + + @override + String get noCoinLedger => 'Belum ada aktivitas koin.'; + + @override + String get walletLoadFailed => 'Gagal memuat dompet.'; + + @override + String walletBalanceShort(int coins) { + return '$coins koin'; + } + + @override + String get insufficientCoins => 'Koin tidak cukup.'; + + @override + String get rechargeUnavailable => 'Isi ulang belum tersedia.'; + + @override + String get totalRecharged => 'Total isi ulang'; + + @override + String get totalConsumed => 'Total pemakaian'; + + @override + String get ledgerUnlock => 'Buka episode'; + + @override + String get ledgerRecharge => 'Isi ulang koin'; + + @override + String get ledgerRefund => 'Pengembalian'; + + @override + String get ledgerAdjustment => 'Penyesuaian'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ea78f10..db49ae5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1061,4 +1061,95 @@ class AppLocalizationsZh extends AppLocalizations { @override String get accountCancelFailed => '注销申请撤销失败。'; + + @override + String get authPhoneTitle => '使用手机号继续'; + + @override + String get authPhoneSignUp => '使用手机号注册'; + + @override + String get authPhoneLabel => '手机号'; + + @override + String get authPhoneHint => '0812 3456 7890'; + + @override + String get authPhoneInvalid => '请输入有效手机号'; + + @override + String get authCodeLabel => '验证码'; + + @override + String get authCodeHint => '6 位验证码'; + + @override + String get authCodeRequired => '请输入验证码'; + + @override + String get authSendCode => '发送验证码'; + + @override + String authResendCode(int seconds) { + return '$seconds 秒后重发'; + } + + @override + String get authCodeSent => '验证码已发送。'; + + @override + String get authPhoneSignInAction => '登录'; + + @override + String get authPhoneSignUpAction => '注册'; + + @override + String get unlockWithCoins => '金币解锁'; + + @override + String get unlockFailed => '解锁失败,请稍后重试。'; + + @override + String get wallet => '钱包'; + + @override + String get coinBalance => '金币余额'; + + @override + String get coinLedger => '金币流水'; + + @override + String get noCoinLedger => '暂无金币流水。'; + + @override + String get walletLoadFailed => '钱包加载失败。'; + + @override + String walletBalanceShort(int coins) { + return '$coins 金币'; + } + + @override + String get insufficientCoins => '金币余额不足。'; + + @override + String get rechargeUnavailable => '充值暂未开放。'; + + @override + String get totalRecharged => '累计充值'; + + @override + String get totalConsumed => '累计消费'; + + @override + String get ledgerUnlock => '剧集解锁'; + + @override + String get ledgerRecharge => '金币充值'; + + @override + String get ledgerRefund => '退款'; + + @override + String get ledgerAdjustment => '余额调整'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5267de7..5b45d6c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -438,5 +438,34 @@ "accountCancelDeactivation": "撤销注销申请", "accountCancelSucceeded": "注销申请已撤销。", "accountDeactivateFailed": "注销申请提交失败。", - "accountCancelFailed": "注销申请撤销失败。" + "accountCancelFailed": "注销申请撤销失败。", + "authPhoneTitle": "使用手机号继续", + "authPhoneSignUp": "使用手机号注册", + "authPhoneLabel": "手机号", + "authPhoneHint": "0812 3456 7890", + "authPhoneInvalid": "请输入有效手机号", + "authCodeLabel": "验证码", + "authCodeHint": "6 位验证码", + "authCodeRequired": "请输入验证码", + "authSendCode": "发送验证码", + "authResendCode": "{seconds} 秒后重发", + "authCodeSent": "验证码已发送。", + "authPhoneSignInAction": "登录", + "authPhoneSignUpAction": "注册", + "unlockWithCoins": "金币解锁", + "unlockFailed": "解锁失败,请稍后重试。", + "wallet": "钱包", + "coinBalance": "金币余额", + "coinLedger": "金币流水", + "noCoinLedger": "暂无金币流水。", + "walletLoadFailed": "钱包加载失败。", + "walletBalanceShort": "{coins} 金币", + "insufficientCoins": "金币余额不足。", + "rechargeUnavailable": "充值暂未开放。", + "totalRecharged": "累计充值", + "totalConsumed": "累计消费", + "ledgerUnlock": "剧集解锁", + "ledgerRecharge": "金币充值", + "ledgerRefund": "退款", + "ledgerAdjustment": "余额调整" } diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 5ddbde0..0edfe59 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; +import 'phone_auth_sheet.dart'; part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; @@ -334,22 +335,13 @@ class _AuthPageState extends State { style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), ), const SizedBox(height: 22), - _AuthEntryButton( - key: const Key('auth-entry-google'), - label: 'G ${l10n.authContinueGoogle}', - background: Colors.white, - foreground: const Color(0xFF1A1A1A), - leadingColor: const Color(0xFF4285F4), - onTap: () => _comingSoon(l10n), - ), - const SizedBox(height: 11), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, background: AppColors.input, foreground: AppColors.onSurface, - onTap: () => _comingSoon(l10n), + onTap: _openPhoneSheet, ), const SizedBox(height: 11), _AuthEntryButton( @@ -374,10 +366,12 @@ class _AuthPageState extends State { ], ); - void _comingSoon(AppLocalizations l10n) { - ScaffoldMessenger.of( + Future _openPhoneSheet() async { + final signedIn = await showPhoneAuthSheet( context, - ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + signUp: _logic.signUpMode, + ); + if (signedIn == true && mounted) Navigator.of(context).maybePop(); } Widget _authCard(AppLocalizations l10n) => _AuthCard( @@ -427,7 +421,6 @@ class _AuthEntryButton extends StatelessWidget { required this.foreground, required this.onTap, this.icon, - this.leadingColor, }); final String label; @@ -435,7 +428,6 @@ class _AuthEntryButton extends StatelessWidget { final Color foreground; final VoidCallback onTap; final IconData? icon; - final Color? leadingColor; @override Widget build(BuildContext context) { @@ -451,7 +443,7 @@ class _AuthEntryButton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) - Icon(icon, size: 16, color: leadingColor ?? foreground), + Icon(icon, size: 16, color: foreground), if (icon != null) const SizedBox(width: 7), Flexible( child: Text( diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart new file mode 100644 index 0000000..541e830 --- /dev/null +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -0,0 +1,358 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/remote/api_client.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +const double _fieldHeight = 52; +const double _fieldRadius = 14; +const double _sheetRadius = 28; +const double _buttonHeight = 52; +const int _resendSeconds = 60; +const int _phoneMinLength = 8; +const int _codeMinLength = 4; + +/// 打开手机号 OTP 登录/注册表单;成功登录后返回 true。 +Future showPhoneAuthSheet(BuildContext context, {bool signUp = false}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.62), + builder: (_) => PhoneAuthSheet(initialSignUp: signUp), + ); +} + +class PhoneAuthSheet extends StatefulWidget { + const PhoneAuthSheet({super.key, this.initialSignUp = false}); + + final bool initialSignUp; + + @override + State createState() => _PhoneAuthSheetState(); +} + +class _PhoneAuthSheetState extends State { + late final TextEditingController _phone; + late final TextEditingController _code; + late final FocusNode _phoneFocus; + late final FocusNode _codeFocus; + Timer? _countdown; + bool _signUp = false; + bool _sending = false; + bool _submitting = false; + int _remaining = 0; + String? _error; + + @override + void initState() { + super.initState(); + _signUp = widget.initialSignUp; + _phone = TextEditingController(); + _code = TextEditingController(); + _phoneFocus = FocusNode(debugLabel: 'phone-auth-number'); + _codeFocus = FocusNode(debugLabel: 'phone-auth-code'); + } + + @override + void dispose() { + _countdown?.cancel(); + _phone.dispose(); + _code.dispose(); + _phoneFocus.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), ''); + + bool get _phoneValid => _target.length >= _phoneMinLength; + + Future _sendCode() async { + if (_sending || _remaining > 0) return; + final l10n = AppLocalizations.of(context)!; + if (!_phoneValid) { + setState(() => _error = l10n.authPhoneInvalid); + return; + } + setState(() { + _sending = true; + _error = null; + }); + final haptics = context.read(); + final auth = context.read(); + await haptics.lightImpact(); + try { + await auth.sendOtp( + target: _target, + scene: _signUp ? 'REGISTER' : 'LOGIN', + ); + if (!mounted) return; + _startCountdown(); + setState(() => _error = null); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + _codeFocus.requestFocus(); + } on ApiException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = l10n.authNetworkError); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _startCountdown() { + _countdown?.cancel(); + _remaining = _resendSeconds; + _countdown = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return timer.cancel(); + setState(() => _remaining--); + if (_remaining <= 0) timer.cancel(); + }); + } + + Future _submit() async { + if (_submitting) return; + final l10n = AppLocalizations.of(context)!; + if (!_phoneValid) { + setState(() => _error = l10n.authPhoneInvalid); + return; + } + if (_code.text.trim().length < _codeMinLength) { + setState(() => _error = l10n.authCodeRequired); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + final haptics = context.read(); + final auth = context.read(); + await haptics.lightImpact(); + try { + if (_signUp) { + await auth.registerWithPhone(target: _target, code: _code.text.trim()); + } else { + await auth.signInWithPhone(target: _target, code: _code.text.trim()); + } + if (mounted) Navigator.of(context).pop(true); + } on ApiException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = l10n.authNetworkError); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _switchMode() { + setState(() { + _signUp = !_signUp; + _error = null; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottom), + child: Container( + key: const Key('phone-auth-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: _grabber()), + const SizedBox(height: 16), + Text( + _signUp ? l10n.authPhoneSignUp : l10n.authPhoneTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authSubtitle, + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + const SizedBox(height: 20), + _phoneField(l10n), + const SizedBox(height: 12), + _codeField(l10n), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + key: const Key('auth-phone-error'), + style: const TextStyle(color: AppColors.accent, fontSize: 12), + ), + ], + const SizedBox(height: 18), + _submitButton(l10n), + const SizedBox(height: 8), + Center( + child: TextButton( + key: const Key('auth-phone-switch'), + onPressed: _submitting ? null : _switchMode, + style: TextButton.styleFrom( + foregroundColor: AppColors.blueSoft, + minimumSize: const Size(48, 48), + ), + child: Text( + _signUp ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _grabber() => Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.subtle.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ); + + Widget _phoneField(AppLocalizations l10n) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-phone-number'), + controller: _phone, + focusNode: _phoneFocus, + enabled: !_submitting, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.telephoneNumber], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s-]')), + LengthLimitingTextInputFormatter(20), + ], + onSubmitted: (_) => _codeFocus.requestFocus(), + onChanged: (_) => setState(() => _error = null), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + decoration: _decoration(l10n.authPhoneLabel, l10n.authPhoneHint), + ), + ); + + Widget _codeField(AppLocalizations l10n) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-phone-code'), + controller: _code, + focusNode: _codeFocus, + enabled: !_submitting, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() => _error = null), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith( + suffixIcon: _sendButton(l10n), + ), + ), + ); + + Widget _sendButton(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.only(right: 6), + child: TextButton( + key: const Key('auth-send-code'), + onPressed: _sending || _remaining > 0 ? null : _sendCode, + style: TextButton.styleFrom( + foregroundColor: AppColors.blueSoft, + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: _sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _remaining > 0 + ? l10n.authResendCode(_remaining) + : l10n.authSendCode, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + ); + + Widget _submitButton(AppLocalizations l10n) => SizedBox( + width: double.infinity, + height: _buttonHeight, + child: FilledButton( + key: const Key('auth-phone-submit'), + onPressed: _submitting ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonHeight / 2), + ), + ), + child: _submitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text( + _signUp ? l10n.authPhoneSignUpAction : l10n.authPhoneSignInAction, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800), + ), + ), + ); + + InputDecoration _decoration(String label, String hint) => InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: AppColors.subtle), + hintStyle: const TextStyle(color: AppColors.subtle), + filled: true, + fillColor: AppColors.input, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + ); +} diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index ba7aff8..ea53383 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/models/download_license.dart'; import '../../core/data/models/download_policy.dart'; +import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/formatting/error_text.dart'; @@ -18,6 +19,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../player/player_view.dart'; import 'downloads_binding.dart'; /// 下载授权与状态管理页;本批不落盘,只管理服务端授权。 @@ -135,7 +137,12 @@ class _DownloadsBody extends StatelessWidget { for (final license in downloads.licenses) ...[ _LicenseTile( license: license, + downloads: downloads, onRemove: () => _remove(context, license.episodeId), + onPlay: () => _playOffline(context, license), + onDownload: () => unawaited( + downloads.startDownload(license.episodeId, license: license), + ), ), const SizedBox(height: 10), ], @@ -144,6 +151,32 @@ class _DownloadsBody extends StatelessWidget { ); } + void _playOffline(BuildContext context, DownloadLicense license) { + final drama = Drama( + id: license.dramaId, + title: license.dramaTitle, + synopsis: '', + posterUrl: '', + episodes: [ + Episode( + id: license.episodeId, + number: license.episodeNo, + title: 'Episode ${license.episodeNo}', + isFree: true, + ), + ], + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: drama, + initialIndex: 0, + downloads: downloads, + ), + ), + ); + } + Future _remove(BuildContext context, String episodeId) async { unawaited(context.read().mediumImpact()); final ok = await downloads.remove(episodeId); @@ -302,14 +335,35 @@ class _PolicyPanel extends StatelessWidget { } class _LicenseTile extends StatelessWidget { - const _LicenseTile({required this.license, required this.onRemove}); + const _LicenseTile({ + required this.license, + required this.downloads, + required this.onRemove, + required this.onPlay, + required this.onDownload, + }); final DownloadLicense license; + final DownloadController downloads; final VoidCallback onRemove; + final VoidCallback onPlay; + final VoidCallback onDownload; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final downloaded = downloads.isDownloaded(license.episodeId); + final downloading = downloads.isDownloading(license.episodeId); + final progress = downloaded + ? 1.0 + : downloading + ? downloads.progressFor(license.episodeId) + : license.progressPercent / 100; + final taskStatus = downloaded + ? 'COMPLETED' + : downloading + ? 'DOWNLOADING' + : license.taskStatus; return Container( key: ValueKey('download-license-${license.episodeId}'), padding: const EdgeInsets.all(12), @@ -335,6 +389,20 @@ class _LicenseTile extends StatelessWidget { ), ), Text(l10n.episodeLabel(license.episodeNo)), + if (downloaded) + IconButton( + key: ValueKey('download-play-${license.episodeId}'), + tooltip: l10n.play, + onPressed: onPlay, + icon: const Icon(Icons.play_circle_outline_rounded), + ) + else if (!downloading) + IconButton( + key: ValueKey('download-start-${license.episodeId}'), + tooltip: l10n.downloads, + onPressed: onDownload, + icon: const Icon(Icons.download_rounded), + ), IconButton( key: ValueKey('download-remove-${license.episodeId}'), tooltip: l10n.remove, @@ -352,7 +420,7 @@ class _LicenseTile extends StatelessWidget { style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), ), Text( - l10n.downloadTaskStatus(license.taskStatus), + l10n.downloadTaskStatus(taskStatus), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), Text( @@ -371,7 +439,7 @@ class _LicenseTile extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( minHeight: AppDimens.progressHeight, - value: (license.progressPercent / 100).clamp(0.0, 1.0), + value: progress.clamp(0.0, 1.0), backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), ), diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 128dc7f..9dc74df 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; @@ -9,6 +10,8 @@ import '../../core/data/remote/api_client.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../player/player_view.dart'; class DramaDetailLogic extends ChangeNotifier { @@ -104,7 +107,12 @@ class DramaDetailLogic extends ChangeNotifier { Future openPlayer(BuildContext context, int index) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: drama, initialIndex: index), + builder: (_) => PlayerPage( + drama: drama, + initialIndex: index, + wallet: context.read(), + downloads: context.read(), + ), ), ); await loadContinue(); diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 307b42c..f2e5fec 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -5,6 +5,8 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; @@ -94,7 +96,12 @@ class HomeLogic extends ChangeNotifier { if (!context.mounted) return; Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: playable, initialIndex: 0), + builder: (_) => PlayerPage( + drama: playable, + initialIndex: 0, + wallet: context.read(), + downloads: context.read(), + ), ), ); } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 2c9377c..9a45785 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -10,6 +10,8 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -200,6 +202,8 @@ class _HomeTabsState extends State<_HomeTabs> { onSearch: () => logic.select(1, search: true), onMenu: () => _openMenu(context), onImmersiveChanged: logic.onImmersiveChanged, + wallet: context.read(), + downloads: context.read(), ), DiscoverPage( drama: logic.currentDrama, @@ -229,6 +233,7 @@ class _HomeTabsState extends State<_HomeTabs> { signedIn: signedIn, onSignIn: () => openAuthPage(context), onSignOut: () => context.read().signOut(), + wallet: context.read(), ), ], ), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index bc1e7a5..174397f 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -13,6 +13,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; @@ -25,6 +26,7 @@ import '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; +import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; @@ -46,6 +48,7 @@ class MyPage extends StatefulWidget { this.onSignOut, this.dramas, this.onDramaSelected, + this.wallet, this.debugShowSetting = kDebugMode, }); @@ -63,6 +66,7 @@ class MyPage extends StatefulWidget { final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; + final WalletController? wallet; final bool debugShowSetting; @override @@ -308,9 +312,21 @@ class _MyPageState extends State { } Widget _wallet(AppLocalizations l10n) { + final wallet = widget.wallet; + if (wallet == null) return _walletCard(l10n, null); + return ListenableBuilder( + listenable: wallet, + builder: (context, _) => _walletCard(l10n, wallet), + ); + } + + Widget _walletCard(AppLocalizations l10n, WalletController? wallet) { + final value = wallet == null + ? l10n.coins + : l10n.walletBalanceShort(wallet.balance); return InkWell( key: const Key('mine-wallet'), - onTap: () => _mockSnack(l10n), + onTap: () => _openWallet(context), borderRadius: BorderRadius.circular(13), child: Container( padding: const EdgeInsets.all(12), @@ -340,7 +356,7 @@ class _MyPageState extends State { ), const SizedBox(height: 2), Text( - '0 ${l10n.coins} · ${l10n.topUpWallet}', + widget.signedIn ? value : l10n.logInHint, style: const TextStyle( color: AppColors.accent, fontSize: 9, @@ -360,6 +376,16 @@ class _MyPageState extends State { ); } + void _openWallet(BuildContext context) { + if (!widget.signedIn) { + widget.onSignIn?.call(); + return; + } + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + } + WatchProgress? get _continueRecord { for (final record in logic.recent) { if (!record.finished && record.hasPosition) return record; @@ -934,12 +960,6 @@ class _MyPageState extends State { } } - void _mockSnack(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); - } - Widget _poster( Drama drama, { required double width, diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 873901d..088245a 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -3,15 +3,21 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import 'player_logic.dart'; class PlayerBinding { PlayerLogic create({ required WatchHistoryRepository history, required PlaybackRepository playback, + WalletController? wallet, + DownloadController? downloads, }) => PlayerLogic( history: history, playback: playback, pictureInPicture: AndroidPictureInPicture(), + wallet: wallet, + downloads: downloads, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 321a3b6..910aff0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -14,10 +14,12 @@ import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; import '../../core/data/services/platform_share_service.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; @@ -37,11 +39,15 @@ class PlayerLogic extends ChangeNotifier { required this.history, required this.playback, required this.pictureInPicture, + this.wallet, + this.downloads, }); final WatchHistoryRepository history; final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; + final WalletController? wallet; + final DownloadController? downloads; late PageController pages; late PlaybackQueue queue; @@ -75,6 +81,10 @@ class PlayerLogic extends ChangeNotifier { bool pipPreparing = false; final Map pickedEpisode = {}; + /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 + String? unlockingEpisodeId; + String? unlockError; + bool Function() isMounted = () => false; bool homeMode = false; bool embedded = false; @@ -194,7 +204,7 @@ class PlayerLogic extends ChangeNotifier { /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), /// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。 - Future ensureTicket(int index) async { + Future ensureTicket(int index, {bool forceRefresh = false}) async { if (index < 0 || index >= sessionEpisodes.length) return false; final episode = sessionEpisodes[index]; final request = pageRequest; @@ -202,7 +212,18 @@ class PlayerLogic extends ChangeNotifier { ticketError = null; notify(); try { - final ticket = await playback.ticketFor(episode.id); + final localPath = await downloads?.findLocalPath(episode.id); + if (request != pageRequest) return false; + if (localPath != null) { + blockedTicket = null; + blockedEpisodeId = null; + sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); + return true; + } + final ticket = await playback.ticketFor( + episode.id, + forceRefresh: forceRefresh, + ); if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; @@ -231,6 +252,9 @@ class PlayerLogic extends ChangeNotifier { } } + String _fileUrl(String path) => + path.startsWith('file:') ? path : Uri.file(path).toString(); + /// 重试当前集的票据(失败态的"重试"按钮)。 Future retryTicket() async { ticketError = null; @@ -238,6 +262,36 @@ class PlayerLogic extends ChangeNotifier { unawaited(selectEpisode(session.currentIndex)); } + /// 金币解锁当前集;成功后强制刷新票据并直接起播。 + Future unlockEpisode(int index) async { + final service = wallet; + if (service == null || index < 0 || index >= sessionEpisodes.length) { + return false; + } + final episode = sessionEpisodes[index]; + unlockingEpisodeId = episode.id; + unlockError = null; + notify(); + try { + final result = await service.unlock(episode.id); + if (result == null || !result.unlocked) { + unlockError = service.unlockError; + return false; + } + final ready = await ensureTicket(index, forceRefresh: true); + if (ready) { + await session.select( + index, + preloadIndex: nextVisibleIndex(index), + ); + } + return ready; + } finally { + unlockingEpisodeId = null; + if (isMounted()) notify(); + } + } + void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); @@ -562,7 +616,12 @@ class PlayerLogic extends ChangeNotifier { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlayerPage(drama: drama, initialIndex: index), + builder: (_) => PlayerPage( + drama: drama, + initialIndex: index, + wallet: wallet, + downloads: downloads, + ), ), ); if (isMounted()) session.setRouteVisible(active); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 20ac336..5e9a109 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -10,9 +10,11 @@ import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/play_ticket.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; @@ -48,6 +50,8 @@ class PlayerPage extends StatefulWidget { this.feedDramas, this.onDramaChanged, this.onImmersiveChanged, + this.wallet, + this.downloads, }); final Drama drama; @@ -64,6 +68,8 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; + final WalletController? wallet; + final DownloadController? downloads; @override State createState() => _PlayerPageState(); @@ -80,6 +86,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic = PlayerBinding().create( history: context.read(), playback: context.read(), + wallet: widget.wallet, + downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); @@ -285,6 +293,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, accessType: logic.blockedTicket?.accessType, unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + unlocking: logic.unlockingEpisodeId == episode.id, + error: logic.unlockError, + onUnlock: logic.wallet == null + ? null + : () => _unlockEpisode(context, index), ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && @@ -484,6 +497,21 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _playbackSpeed = selected); await logic.setPlaybackSpeed(selected); } + + Future _unlockEpisode(BuildContext context, int index) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + final ok = await logic.unlockEpisode(index); + if (!context.mounted || ok) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + errorText(l10n, logic.unlockError, l10n.unlockFailed), + ), + ), + ); + } } /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 @@ -493,12 +521,18 @@ class _BlockedNotice extends StatelessWidget { required this.episode, required this.accessType, required this.unlockCoin, + required this.unlocking, + required this.error, + required this.onUnlock, }); final AppLocalizations l10n; final Episode episode; final String? accessType; final int unlockCoin; + final bool unlocking; + final String? error; + final VoidCallback? onUnlock; @override Widget build(BuildContext context) { @@ -521,7 +555,12 @@ class _BlockedNotice extends StatelessWidget { coinLabel: needsUnlock && unlockCoin > 0 ? '$unlockCoin ${l10n.coins}' : null, - footnote: needsUnlock ? l10n.unlockComingSoon : null, + footnote: needsUnlock && error != null + ? errorText(l10n, error, l10n.unlockFailed) + : null, + actionLabel: needsUnlock ? l10n.unlockWithCoins : null, + busy: unlocking, + onAction: needsUnlock ? onUnlock : null, ), ), ); @@ -535,12 +574,18 @@ class _UnlockCard extends StatelessWidget { required this.title, required this.coinLabel, required this.footnote, + this.actionLabel, + this.busy = false, + this.onAction, }); final bool locked; final String title; final String? coinLabel; final String? footnote; + final String? actionLabel; + final bool busy; + final VoidCallback? onAction; @override Widget build(BuildContext context) { @@ -601,6 +646,34 @@ class _UnlockCard extends StatelessWidget { style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], + if (actionLabel != null) ...[ + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton( + key: const Key('unlock-with-coins'), + onPressed: busy ? null : onAction, + style: FilledButton.styleFrom( + backgroundColor: AppColors.gold, + foregroundColor: AppColors.onGold, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + actionLabel!, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + ], ], ), ), diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart new file mode 100644 index 0000000..fcb12de --- /dev/null +++ b/lib/pages/wallet/wallet_page.dart @@ -0,0 +1,400 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/wallet.dart'; +import '../../core/data/services/wallet_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; + +/// 金币钱包:余额、累计收支与最近流水。 +class WalletPage extends StatefulWidget { + const WalletPage({super.key}); + + @override + State createState() => _WalletPageState(); +} + +class _WalletPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requested) return; + _requested = true; + final wallet = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(wallet.load()); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final wallet = context.watch(); + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('wallet-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onSurface, + title: Text(l10n.wallet), + ), + body: SafeArea(top: false, child: _body(l10n, wallet)), + ), + ); + } + + Widget _body(AppLocalizations l10n, WalletController wallet) { + if (wallet.loading && wallet.snapshot == null) { + return const Center(child: CircularProgressIndicator()); + } + final snapshot = wallet.snapshot; + if (snapshot == null) { + return EmptyState( + key: const Key('wallet-error'), + icon: Icons.account_balance_wallet_outlined, + title: errorText(l10n, wallet.error, l10n.walletLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('wallet-retry'), + onAction: () => unawaited(wallet.load()), + ); + } + return RefreshIndicator( + onRefresh: wallet.load, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), + children: [ + _BalanceHero(wallet: snapshot.wallet), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _SummaryTile( + icon: Icons.add_card_rounded, + label: l10n.totalRecharged, + value: snapshot.wallet.totalRechargedCoin, + color: AppColors.success, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _SummaryTile( + icon: Icons.local_fire_department_rounded, + label: l10n.totalConsumed, + value: snapshot.wallet.totalConsumedCoin, + color: AppColors.accent, + ), + ), + ], + ), + const SizedBox(height: 22), + Text( + l10n.coinLedger, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + if (snapshot.ledger.isEmpty) + _LedgerEmpty(message: l10n.noCoinLedger) + else + for (final entry in snapshot.ledger) ...[ + _LedgerTile(entry: entry), + const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +class _BalanceHero extends StatelessWidget { + const _BalanceHero({required this.wallet}); + + final CoinWallet wallet; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('wallet-balance-card'), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2B2116), Color(0xFF17171D)], + ), + border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)), + boxShadow: [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.08), + blurRadius: 28, + offset: const Offset(0, 14), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.gold.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 21, + ), + ), + const SizedBox(width: 10), + Text( + l10n.coinBalance, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${wallet.coinBalance}', + key: const Key('wallet-balance'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 42, + height: 1, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text( + l10n.coins, + style: const TextStyle( + color: AppColors.gold, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SummaryTile extends StatelessWidget { + const _SummaryTile({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + final IconData icon; + final String label; + final int value; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(height: 10), + Text( + '$value', + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.subtle, fontSize: 11), + ), + ], + ), + ); + } +} + +class _LedgerTile extends StatelessWidget { + const _LedgerTile({required this.entry}); + + final CoinLedgerEntry entry; + + @override + Widget build(BuildContext context) { + final positive = entry.changeAmount >= 0; + final color = positive ? AppColors.success : AppColors.accent; + final amount = positive ? '+${entry.changeAmount}' : '${entry.changeAmount}'; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + positive ? Icons.south_west_rounded : Icons.north_east_rounded, + color: color, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _ledgerLabel(context, entry.bizType), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (entry.remark.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + entry.remark, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + ), + ), + ], + if (entry.createdAt != null) ...[ + const SizedBox(height: 3), + Text( + formatLocalizedDateTime(context, entry.createdAt!), + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amount, + key: Key('wallet-ledger-${entry.id}'), + style: TextStyle( + color: color, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${entry.balanceAfter}', + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ], + ), + ); + } + + String _ledgerLabel(BuildContext context, String bizType) { + final l10n = AppLocalizations.of(context)!; + return switch (bizType) { + 'UNLOCK' => l10n.ledgerUnlock, + 'RECHARGE' || 'ORDER' => l10n.ledgerRecharge, + 'REFUND' => l10n.ledgerRefund, + _ => l10n.ledgerAdjustment, + }; + } +} + +class _LedgerEmpty extends StatelessWidget { + const _LedgerEmpty({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + const Icon(Icons.receipt_long_outlined, color: AppColors.subtle), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7dc0e15..f53f05f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" csslib: dependency: transitive description: @@ -175,6 +191,14 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" html: dependency: transitive description: @@ -212,6 +236,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -252,6 +300,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -292,6 +348,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -300,6 +372,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -364,6 +460,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -397,7 +509,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" diff --git a/pubspec.yaml b/pubspec.yaml index 092293d..104344a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: shared_preferences_android: 2.4.28 flutter_localizations: sdk: flutter + path_provider: 2.1.5 dev_dependencies: flutter_test: @@ -61,6 +62,7 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + shared_preferences_platform_interface: 2.4.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/auth/auth_controller_test.dart b/test/auth/auth_controller_test.dart index cbefd61..630b2fa 100644 --- a/test/auth/auth_controller_test.dart +++ b/test/auth/auth_controller_test.dart @@ -165,4 +165,56 @@ void main() { expect(auth.isSignedIn, isFalse); expect(await AuthSessionStore().read(), isNull); }); + + test('should_store_session_when_phone_code_login_succeeds', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/otp') { + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['target'], '08123456789'); + expect(body['scene'], 'LOGIN'); + expect(body['deviceId'], isNotEmpty); + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + } + expect(options.uri.path, '/app/auth/login'); + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['code'], '123456'); + return FakeHttpResponse(tokenBody(accessToken: 'p1', refreshToken: 'pr1')); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + + await auth.sendOtp(target: '08123456789', scene: 'LOGIN'); + await auth.signInWithPhone(target: '08123456789', code: '123456'); + + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'p1'); + }); + + test('should_store_session_when_phone_registration_succeeds', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + expect(options.uri.path, '/app/auth/register'); + final body = (options.data as Map).cast(); + expect(body['targetType'], 'PHONE'); + expect(body['code'], '654321'); + return FakeHttpResponse(tokenBody(accessToken: 'p2', refreshToken: 'pr2')); + }); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + AuthSessionStore(), + ); + + await auth.registerWithPhone(target: '08123456789', code: '654321'); + + expect(auth.isSignedIn, isTrue); + expect(auth.user?.uid, 'U1'); + }); } diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart index af8b6d7..63b66d8 100644 --- a/test/auth/auth_landing_test.dart +++ b/test/auth/auth_landing_test.dart @@ -18,7 +18,7 @@ import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; void main() { - testWidgets('should_show_three_login_entries_and_open_email_form', ( + testWidgets('should_show_real_login_entries_and_open_phone_sheet', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -48,14 +48,17 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsNothing); expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); - await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.tap(find.byKey(const Key('auth-entry-phone'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('phone-auth-sheet')), findsOneWidget); + + await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); - expect(find.text('Coming soon'), findsOneWidget); await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 10c94f4..ae17115 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -80,7 +80,7 @@ void main() { tester.getSize(find.byKey(const Key('auth-brand-logo'))), const Size(64, 64), ); - expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsNothing); expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart new file mode 100644 index 0000000..9656a31 --- /dev/null +++ b/test/auth/phone_auth_sheet_test.dart @@ -0,0 +1,127 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/phone_auth_sheet.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_send_otp_and_sign_in_when_phone_form_is_submitted', ( + tester, + ) async { + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/otp') { + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + } + expect(options.uri.path, '/app/auth/login'); + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': 'phone-token', + 'refreshToken': 'phone-refresh', + 'expiresIn': 7200, + 'user': {'uid': 'U9', 'nickname': 'Phone User'}, + }, + }), + ); + }); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + expect( + adapter.requests.where((item) => item.uri.path == '/app/auth/otp'), + hasLength(1), + ); + + await tester.enterText(find.byKey(const Key('auth-phone-code')), '123456'); + await tester.tap(find.byKey(const Key('auth-phone-submit'))); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'phone-token'); + }); + + testWidgets('should_keep_sheet_open_when_phone_number_is_invalid', ( + tester, + ) async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('auth-phone-number')), '12'); + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + + expect(find.text('Enter a valid phone number'), findsOneWidget); + expect(adapter.requests, isEmpty); + }); +} + +Future _pumpHost(WidgetTester tester, AuthController auth) { + return tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + key: const Key('open-phone-sheet'), + onPressed: () => showPhoneAuthSheet(context), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/test/commerce/commerce_api_test.dart b/test/commerce/commerce_api_test.dart new file mode 100644 index 0000000..02f4a02 --- /dev/null +++ b/test/commerce/commerce_api_test.dart @@ -0,0 +1,81 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/commerce_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_parse_wallet_and_ledger_when_server_returns_string_ids', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'wallet': { + 'id': '2111012753262120962', + 'coinBalance': '320', + 'totalRechargedCoin': '500', + 'totalConsumedCoin': '180', + 'totalRefundedCoin': '0', + }, + 'ledger': [ + { + 'id': '2111012753262120963', + 'changeAmount': '-50', + 'balanceAfter': '320', + 'bizType': 'UNLOCK', + 'remark': '解锁第 3 集', + 'createdAt': '2026-10-19 10:00:00', + }, + ], + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final snapshot = await api.wallet(); + + expect(adapter.requests.single.uri.path, '/app/wallet'); + expect(snapshot.wallet.coinBalance, 320); + expect(snapshot.ledger.single.id, '2111012753262120963'); + expect(snapshot.ledger.single.changeAmount, -50); + }); + + test('should_post_unlock_for_episode_when_requested', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': '12', + 'dramaId': '5', + 'unlocked': true, + 'accessType': 'UNLOCKED', + 'coinCost': '50', + 'coinBalance': '270', + }, + }), + ), + ); + final api = CommerceApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final result = await api.unlock('12'); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/episodes/12/unlock'); + expect(result.unlocked, isTrue); + expect(result.coinCost, 50); + expect(result.coinBalance, 270); + }); +} diff --git a/test/commerce/wallet_controller_test.dart b/test/commerce/wallet_controller_test.dart new file mode 100644 index 0000000..f1939dd --- /dev/null +++ b/test/commerce/wallet_controller_test.dart @@ -0,0 +1,100 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; + +void main() { + test('should_expose_server_balance_when_wallet_loads', () async { + final controller = WalletController( + FakeCommerceRepository(snapshot: _snapshot(balance: 320)), + ); + + await controller.load(); + + expect(controller.balance, 320); + expect(controller.ledger.single.changeAmount, -50); + expect(controller.error, isNull); + }); + + test('should_update_balance_and_clear_error_when_unlock_succeeds', () async { + final repository = FakeCommerceRepository( + snapshot: _snapshot(balance: 320), + unlockResult: const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 50, + coinBalance: 270, + ), + ); + final controller = WalletController(repository); + await controller.load(); + + final result = await controller.unlock('episode-9'); + + expect(result?.unlocked, isTrue); + expect(controller.balance, 270); + expect(controller.unlocking, isFalse); + expect(controller.unlockError, isNull); + }); + + test('should_keep_balance_and_expose_message_when_unlock_is_rejected', () async { + final controller = WalletController( + FakeCommerceRepository( + snapshot: _snapshot(balance: 20), + unlockError: const ApiException(100002, '金币余额不足'), + ), + ); + await controller.load(); + + final result = await controller.unlock('episode-9'); + + expect(result, isNull); + expect(controller.balance, 20); + expect(controller.unlockError, '金币余额不足'); + }); +} + +WalletSnapshot _snapshot({required int balance}) => WalletSnapshot( + wallet: CoinWallet( + coinBalance: balance, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ledger: [ + CoinLedgerEntry( + id: '1', + changeAmount: -50, + balanceAfter: balance, + bizType: 'UNLOCK', + remark: '解锁第 3 集', + createdAt: DateTime(2026, 10, 19), + ), + ], +); + +class FakeCommerceRepository implements CommerceRepository { + FakeCommerceRepository({ + required this.snapshot, + this.unlockResult, + this.unlockError, + }); + + final WalletSnapshot snapshot; + final UnlockResult? unlockResult; + final ApiException? unlockError; + final List unlockedEpisodes = []; + + @override + Future fetchWallet() async => snapshot; + + @override + Future unlock(String episodeId) async { + unlockedEpisodes.add(episodeId); + if (unlockError != null) throw unlockError!; + return unlockResult!; + } +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart index 077b54c..925dc07 100644 --- a/test/config/locale_controller_test.dart +++ b/test/config/locale_controller_test.dart @@ -4,6 +4,7 @@ import 'package:fruitshorts/core/config/locale_controller.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; void main() { setUp(() { diff --git a/test/core/haptics_scan_test.dart b/test/core/haptics_scan_test.dart index f8a448c..d216b17 100644 --- a/test/core/haptics_scan_test.dart +++ b/test/core/haptics_scan_test.dart @@ -9,7 +9,8 @@ void main() { final violations = []; for (final entity in Directory('lib').listSync(recursive: true)) { if (entity is! File || !entity.path.endsWith('.dart')) continue; - if (entity.path.endsWith('lib/core/platform/haptics.dart')) continue; + final normalized = entity.path.replaceAll('\\', '/'); + if (normalized.endsWith('lib/core/platform/haptics.dart')) continue; final lines = entity.readAsLinesSync(); for (var index = 0; index < lines.length; index++) { if (lines[index].contains('HapticFeedback.')) { diff --git a/test/download/download_controller_local_test.dart b/test/download/download_controller_local_test.dart new file mode 100644 index 0000000..369e808 --- /dev/null +++ b/test/download/download_controller_local_test.dart @@ -0,0 +1,149 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; + +void main() { + test('should_mark_episode_downloaded_and_report_progress_when_download_finishes', + () async { + final repository = _RecordingRepository(_license('ep-1')); + final storage = _FakeStorage(); + final controller = DownloadController(repository, storage: storage); + await controller.load(); + + final result = await controller.startDownload('ep-1'); + + expect(result.ok, isTrue); + expect(controller.isDownloaded('ep-1'), isTrue); + expect(controller.localPathFor('ep-1'), 'file:///tmp/ep-1.mp4'); + expect(repository.statuses, containsAll(['DOWNLOADING', 'COMPLETED'])); + }); + + test('should_keep_local_file_when_backend_delete_fails', () async { + final repository = _RecordingRepository(_license('ep-1')); + final storage = _FakeStorage(); + final controller = DownloadController(repository, storage: storage); + await controller.load(); + await controller.startDownload('ep-1'); + repository.failRemove = true; + + final ok = await controller.remove('ep-1'); + + expect(ok, isFalse); + expect(controller.isDownloaded('ep-1'), isTrue); + }); +} + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 1, + deviceId: 'device-1', + quality: 'AUTO', + playUrl: '/files/episode/ep-1.mp4?e=1&s=abc', + licenseKey: 'key', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 19), + expireAt: DateTime(2026, 11, 18), + taskStatus: 'QUEUED', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: 0, + errorMsg: '', +); + +class _FakeStorage implements DownloadStorage { + final Set files = {}; + + @override + Future> restore() async => { + for (final id in files) id: 'file:///tmp/$id.mp4', + }; + + @override + Future localPathFor(String episodeId) async => + files.contains(episodeId) ? 'file:///tmp/$episodeId.mp4' : null; + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async { + onProgress?.call(50, 100); + onProgress?.call(100, 100); + files.add(episodeId); + return DownloadedMedia( + episodeId: episodeId, + filePath: 'file:///tmp/$episodeId.mp4', + bytes: 100, + ); + } + + @override + Future delete(String episodeId) async => files.remove(episodeId); + + @override + Future clear() async => files.clear(); +} + +class _RecordingRepository implements DownloadRepository { + _RecordingRepository(this.license); + + DownloadLicense license; + bool failRemove = false; + final List statuses = []; + + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 1, + remaining: 19, + expirePolicyText: '30 days', + ); + + @override + Future> list() async => [license]; + + @override + Future grant(String episodeId, {String? quality}) async => + license; + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => license; + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async { + if (status != null) statuses.add(status); + return license; + } + + @override + Future remove(String episodeId) async { + if (failRemove) throw StateError('remove failed'); + } + + @override + Future clear() async => 0; +} diff --git a/test/download/download_storage_test.dart b/test/download/download_storage_test.dart new file mode 100644 index 0000000..f965ac0 --- /dev/null +++ b/test/download/download_storage_test.dart @@ -0,0 +1,42 @@ +// Author: fruit.com.cn + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; + +void main() { + test('should_write_media_file_and_report_progress_when_download_completes', + () async { + final directory = await Directory.systemTemp.createTemp('fruit-download-'); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final payload = Uint8List.fromList(List.generate(4096, (i) => i % 251)); + server.listen((request) async { + request.response.headers.contentLength = payload.length; + request.response.add(payload); + await request.response.close(); + }); + addTearDown(() async { + await server.close(force: true); + await directory.delete(recursive: true); + }); + final storage = FileDownloadStorage(directoryProvider: () async => directory); + final progress = []; + + final media = await storage.download( + episodeId: 'ep-1', + url: 'http://127.0.0.1:${server.port}/episode.mp4', + onProgress: (received, total) => progress.add(total == 0 ? 0 : received / total), + ); + + expect(File(media.filePath).existsSync(), isTrue); + expect(media.bytes, payload.length); + expect(progress.last, 1.0); + final localPath = await storage.localPathFor('ep-1'); + expect(localPath, isNotNull); + expect(File(localPath!).existsSync(), isTrue); + await storage.delete('ep-1'); + expect(await storage.localPathFor('ep-1'), isNull); + }); +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index ed939b3..a57e9e5 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -5,7 +5,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -14,6 +19,8 @@ import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -191,6 +198,12 @@ Widget _host(Drama drama) { ChangeNotifierProvider( create: (_) => InteractionController(interactions), ), + ChangeNotifierProvider( + create: (_) => WalletController(_FakeCommerceRepository()), + ), + ChangeNotifierProvider( + create: (_) => DownloadController(_FakeDownloadRepository()), + ), ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), ], child: MaterialApp( @@ -203,6 +216,71 @@ Widget _host(Drama drama) { ); } +class _FakeCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: 100, + ); +} + +class _FakeDownloadRepository implements DownloadRepository { + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 0, + remaining: 20, + expirePolicyText: '', + ); + + @override + Future> list() async => const []; + + @override + Future grant(String episodeId, {String? quality}) async => + throw UnimplementedError(); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => + throw UnimplementedError(); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => throw UnimplementedError(); + + @override + Future remove(String episodeId) async {} + + @override + Future clear() async => 0; +} + Drama _detailDrama({int count = 8, bool descending = false}) { final numbers = List.generate( count, diff --git a/test/integration/live_catalog_test.dart b/test/integration/live_catalog_test.dart index 274cf60..d3b03ac 100644 --- a/test/integration/live_catalog_test.dart +++ b/test/integration/live_catalog_test.dart @@ -29,7 +29,12 @@ void main() { final feed = await api.homeFeed(); expect(feed.sections, isNotEmpty); expect(feed.dramas, isNotEmpty); - final card = feed.dramas.first; + final withCover = feed.dramas.where((item) => item.posterUrl.isNotEmpty); + if (withCover.isEmpty) { + markTestSkipped('当前目录没有带封面的剧目,已跳过联调断言'); + return; + } + final card = withCover.first; expect(card.id, isNotEmpty); expect(card.title, isNotEmpty); expect(card.posterUrl, startsWith('/files/')); @@ -41,7 +46,12 @@ void main() { return; } final feed = await api.homeFeed(); - final card = feed.dramas.first; + final cards = feed.dramas.where((item) => item.posterUrl.isNotEmpty); + if (cards.isEmpty) { + markTestSkipped('当前目录没有带封面的剧目,已跳过联调断言'); + return; + } + final card = cards.first; final detail = await api.dramaDetail(card.id); expect(detail.id, card.id); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index ff0b5c9..44084d9 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -4,7 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; @@ -116,6 +121,34 @@ void main() { }); }); + test('should_play_local_file_without_ticket_when_episode_is_downloaded', + () async { + final playback = FakePlaybackRepository(); + final downloads = DownloadController( + _EmptyDownloadRepository(), + storage: _LocalStorage(), + ); + await downloads.restore(); + final drama = _drama('signal'); + final logic = PlayerLogic( + history: FakeWatchHistory(), + playback: playback, + pictureInPicture: _pip(), + downloads: downloads, + ); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + + logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); + await pumpEventQueue(); + await logic.session.playbackSettled; + + expect(playback.requests, isEmpty); + expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4'); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); @@ -430,6 +463,75 @@ void main() { }); } +class _LocalStorage implements DownloadStorage { + @override + Future> restore() async => { + 'signal-1': 'file:///local/signal-1.mp4', + }; + + @override + Future localPathFor(String episodeId) async => + episodeId == 'signal-1' ? 'file:///local/signal-1.mp4' : null; + + @override + Future download({ + required String episodeId, + required String url, + DownloadProgress? onProgress, + }) async => throw UnimplementedError(); + + @override + Future delete(String episodeId) async {} + + @override + Future clear() async {} +} + +class _EmptyDownloadRepository implements DownloadRepository { + @override + Future fetchPolicy() async => const DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: false, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 0, + remaining: 20, + expirePolicyText: '', + ); + + @override + Future> list() async => const []; + + @override + Future grant(String episodeId, {String? quality}) async => + throw UnimplementedError(); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} + + @override + Future refreshUrl(String episodeId) async => + throw UnimplementedError(); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => throw UnimplementedError(); + + @override + Future remove(String episodeId) async {} + + @override + Future clear() async => 0; +} + /// Counts native-style allocations so leaks are visible to a test. class _RecordingFactory { final List<_RecordingPlayer> created = []; diff --git a/test/wallet/wallet_page_test.dart b/test/wallet/wallet_page_test.dart new file mode 100644 index 0000000..f06ee78 --- /dev/null +++ b/test/wallet/wallet_page_test.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/wallet/wallet_page.dart'; + +void main() { + testWidgets('should_show_server_balance_and_ledger_when_wallet_opens', ( + tester, + ) async { + final controller = WalletController(_FakeCommerceRepository()); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WalletPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('wallet-balance')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('wallet-balance'))).data, + '320', + ); + expect(find.text('Episode unlock'), findsOneWidget); + expect(find.text('-50'), findsOneWidget); + expect(find.byKey(const Key('wallet-ledger-11')), findsOneWidget); + }); + + testWidgets('should_show_empty_ledger_copy_when_wallet_has_no_entries', ( + tester, + ) async { + final controller = WalletController( + _FakeCommerceRepository(includeLedger: false), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WalletPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No coin activity yet.'), findsOneWidget); + }); +} + +class _FakeCommerceRepository implements CommerceRepository { + _FakeCommerceRepository({this.includeLedger = true}); + + final bool includeLedger; + + @override + Future fetchWallet() async => WalletSnapshot( + wallet: const CoinWallet( + coinBalance: 320, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ledger: includeLedger + ? [ + CoinLedgerEntry( + id: '11', + changeAmount: -50, + balanceAfter: 320, + bizType: 'UNLOCK', + remark: '', + createdAt: DateTime(2026, 10, 19, 10), + ), + ] + : const [], + ); + + @override + Future unlock(String episodeId) async => + const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 50, + coinBalance: 270, + ); +}