feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI

后端接口从 6 个扩到 13 个,全部走真数据:

- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
  library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
  FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
  不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
  进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
  播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等

测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
This commit is contained in:
Vicky committed 2026-10-16 17:39:05 +08:00
1 parent 94ff9f1177
commit d7eff4ca59
48 files changed
+2411 -377

No files matched your search

+20 -2
View File
@@ -1,12 +1,18 @@
import 'json_numbers.dart';
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
class PlayTicket {
const PlayTicket({
required this.episodeId,
required this.accessible,
required this.accessType,
this.dramaId = '',
this.number = 0,
this.title = '',
this.playUrl = '',
this.durationSec = 0,
this.unlockCoin = 0,
this.previewSeconds = 0,
});
static const String free = 'FREE';
@@ -15,23 +21,35 @@ class PlayTicket {
static const String noMedia = 'NO_MEDIA';
final String episodeId;
final String dramaId;
final int number;
final String title;
final bool accessible;
final String accessType;
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
final String playUrl;
final int durationSec;
final int unlockCoin;
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
factory PlayTicket.fromJson(Map<String, dynamic> json) {
return PlayTicket(
episodeId: (json['episodeId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
number: jsonToInt(json['number']),
title: json['title'] as String? ?? '',
accessible: json['accessible'] == true,
accessType: json['accessType'] as String? ?? noMedia,
playUrl: json['playUrl'] as String? ?? '',
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0,
durationSec: jsonToInt(json['durationSec']),
unlockCoin: jsonToInt(json['unlockCoin']),
previewSeconds: jsonToInt(json['previewSeconds']),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
///
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
class WatchProgress {
const WatchProgress({
required this.dramaId,
required this.episodeId,
required this.position,
required this.duration,
this.finished = false,
this.playCount = 0,
});
final String dramaId;
final String episodeId;
final Duration position;
final Duration duration;
final bool finished;
final int playCount;
bool get hasPosition => position > Duration.zero;
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
double get fraction {
if (duration <= Duration.zero) return 0;
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
}
factory WatchProgress.fromJson(Map<String, dynamic> json) {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
);
}
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
};
}
/// 续看点:从哪一集的哪个位置继续看。
class ResumePoint {
const ResumePoint({required this.episodeId, required this.position});
final String episodeId;
final Duration position;
}
+8
View File
@@ -79,6 +79,14 @@ class ApiClient {
);
}
Future<Object?> delete(
String path, {
Object? body,
Map<String, String>? query,
}) {
return _request('DELETE', path, body: body, query: query);
}
Future<Object?> _request(
String method,
String path, {
-7
View File
@@ -1,6 +1,5 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 客户端目录接口,对应后端 `/app/**`。
@@ -23,12 +22,6 @@ class CatalogApi {
);
}
Future<PlayTicket> playTicket(String episodeId) async {
return PlayTicket.fromJson(
_asMap(await _client.get('/app/episodes/$episodeId/play')),
);
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
+35
View File
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
///
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
class LibraryApi {
const LibraryApi(this._client);
final ApiClient _client;
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
Future<bool> toggleFavorite(String dramaId) async {
final data = await _client.post(
'/app/favorites/toggle',
body: {'dramaId': dramaId},
);
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
}
Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
+20
View File
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
///
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
class PlaybackApi {
const PlaybackApi(this._client);
final ApiClient _client;
Future<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
}
+59
View File
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
import '../models/watch_progress.dart';
import 'api_client.dart';
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
class WatchApi {
const WatchApi(this._client);
final ApiClient _client;
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
Future<WatchProgress?> saveProgress({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
String source = 'app',
}) async {
final data = await _client.post(
'/app/watch/progress',
body: {
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
if (duration != null) 'durationMs': duration.inMilliseconds,
'finished': finished,
'source': source,
},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 某部剧的续看位置;没有记录时后端返回空 data。
Future<WatchProgress?> progressOfDrama(String dramaId) async {
final data = await _client.get(
'/app/watch/progress',
query: {'dramaId': dramaId},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
}
/// 删除某部剧的观看记录(记录页清单条用)。
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
}
@@ -1,19 +1,17 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../models/play_ticket.dart';
import '../remote/catalog_api.dart';
/// 目录数据来源:首页/发现页聚合、详情、播放票据。
/// 目录数据来源:首页/发现页聚合、详情。
///
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
abstract interface class CatalogRepository {
Future<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
Future<PlayTicket> resolvePlay(String episodeId);
}
class HttpCatalogRepository implements CatalogRepository {
@@ -30,7 +28,4 @@ class HttpCatalogRepository implements CatalogRepository {
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
@override
Future<PlayTicket> resolvePlay(String episodeId) =>
_api.playTicket(episodeId);
}
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
Future<void> remove(String dramaId);
}
class HttpFavoriteRepository implements FavoriteRepository {
const HttpFavoriteRepository(this._api, {this.isSignedIn});
final LibraryApi _api;
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
}
@override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
}
/// 内存实现:单测与 widget 测试注入用。
class InMemoryFavoriteRepository implements FavoriteRepository {
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
: _saved = {...?saved},
_cards = [...?cards];
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
int toggleCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
toggleCalls += 1;
if (failToggle) throw StateError('favorite unavailable');
if (!_saved.add(dramaId)) _saved.remove(dramaId);
return _saved.contains(dramaId);
}
@override
Future<List<Drama>> list() async {
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
}
@override
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
}
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import '../remote/playback_api.dart';
/// 播放票据来源。
///
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
abstract interface class PlaybackRepository {
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
bool isFresh(String episodeId);
}
class HttpPlaybackRepository implements PlaybackRepository {
HttpPlaybackRepository(
this._api, {
this.ttl = const Duration(minutes: 10),
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
final PlaybackApi _api;
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
final Duration ttl;
final DateTime Function() _clock;
final Map<String, _CachedTicket> _cache = {};
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
final cached = _cache[episodeId];
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
return cached.ticket;
}
final ticket = await _api.ticket(episodeId);
_cache[episodeId] = _CachedTicket(ticket, _clock());
return ticket;
}
@override
bool isFresh(String episodeId) {
final cached = _cache[episodeId];
if (cached == null) return false;
return !cached.isExpired(_clock(), ttl);
}
/// 供测试与"退出播放页"清理用。
void clear() => _cache.clear();
}
class _CachedTicket {
const _CachedTicket(this.ticket, this.fetchedAt);
final PlayTicket ticket;
final DateTime fetchedAt;
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
}
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
class FakePlaybackRepository implements PlaybackRepository {
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
PlayTicket? ticket;
Object? error;
final List<String> requests = [];
final List<bool> forced = [];
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
bool fresh;
@override
bool isFresh(String episodeId) => fresh;
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
requests.add(episodeId);
forced.add(forceRefresh);
if (error != null) throw error!;
return ticket ??
PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
);
}
}
@@ -1,78 +1,142 @@
import 'package:shared_preferences/shared_preferences.dart';
// Author: fruit.com.cn
import '../models/drama.dart';
const demoProfileId = 'demo-local-v1';
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
/// 观看进度来源。
///
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
abstract interface class WatchHistoryRepository {
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
);
Future<String?> lastEpisodeId(String profileId, String dramaId);
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
);
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
Future<WatchProgress?> progressFor(String dramaId);
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
}
class SharedPreferencesWatchHistoryRepository
implements WatchHistoryRepository {
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
class HttpWatchHistoryRepository implements WatchHistoryRepository {
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
final SharedPreferencesAsync _preferences;
final WatchApi _api;
String _positionKey(String profile, String drama, String episode) =>
'watch.v1.$profile.$drama.$episode.positionMs';
String _lastKey(String profile, String drama) =>
'watch.v1.$profile.$drama.lastEpisode';
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
final milliseconds = await _preferences.getInt(
_positionKey(profileId, dramaId, episodeId),
Future<WatchProgress?> progressFor(String dramaId) async {
if (!_signedIn) return null;
return _api.progressOfDrama(dramaId);
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (!_signedIn) return null;
return _api.saveProgress(
episodeId: episodeId,
position: position,
duration: duration,
finished: finished,
);
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
_preferences.getString(_lastKey(profileId, dramaId));
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
}
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
await _preferences.setInt(
_positionKey(profileId, dramaId, episodeId),
position.inMilliseconds,
Future<void> remove(String dramaId) async {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
final Map<String, WatchProgress> _records;
int failReads = 0;
bool failWrites = false;
final List<String> writes = [];
@override
Future<WatchProgress?> progressFor(String dramaId) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records[dramaId];
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (failWrites) throw StateError('progress unavailable');
writes.add('$episodeId|${position.inMilliseconds}|$finished');
final existing = _records[episodeId];
final record = WatchProgress(
dramaId: existing?.dramaId ?? '',
episodeId: episodeId,
position: position,
duration: duration ?? Duration.zero,
finished: finished,
);
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
return record;
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
}
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
}
/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。
extension WatchHistoryResume on WatchHistoryRepository {
Future<Duration?> resumePositionFor(String profileId, Drama drama) async {
final episodeId = drama.episodes.isNotEmpty
? drama.episodes.first.id
: await lastEpisodeId(profileId, drama.id);
if (episodeId == null) return null;
return positionFor(profileId, drama.id, episodeId);
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
extension WatchProgressResume on WatchHistoryRepository {
Future<ResumePoint?> resumePointFor(String dramaId) async {
final progress = await progressFor(dramaId);
if (progress == null || progress.episodeId.isEmpty) return null;
return ResumePoint(
episodeId: progress.episodeId,
position: progress.position,
);
}
}
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
@@ -46,13 +46,6 @@ class CatalogController extends ChangeNotifier {
return detail;
}
/// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。
Future<Episode> resolvePlay(Episode episode) async {
if (episode.videoUrl.isNotEmpty) return episode;
final ticket = await repository.resolvePlay(episode.id);
return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode;
}
void _replace(Drama detail) {
final sections = feed.sections
.map(
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/favorite_repository.dart';
/// 收藏状态:**以服务端为准**。
///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
final FavoriteRepository _repository;
final Set<String> _saved = {};
List<Drama> cards = const [];
bool loading = false;
String? error;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
bool isSaved(String dramaId) => _saved.contains(dramaId);
void notify() {
if (!_disposed) notifyListeners();
}
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
loading = true;
error = null;
notify();
try {
final list = await _repository.list();
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
} catch (_) {
error = 'favoritesError';
} finally {
loading = false;
notify();
}
}
/// 未登录时不要调用(服务端要求登录态)。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
} else {
_saved.add(dramaId);
}
notify();
try {
final saved = await _repository.toggle(dramaId);
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
}
notify();
return true;
} catch (_) {
// 回滚
if (wasSaved) {
_saved.add(dramaId);
} else {
_saved.remove(dramaId);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final removed = cards.firstWhere(
(drama) => drama.id == dramaId,
orElse: () => const Drama(
id: '',
title: '',
synopsis: '',
posterUrl: '',
),
);
final hadCard = removed.id.isNotEmpty;
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
if (hadCard) cards = [...cards, removed];
_saved.add(dramaId);
notify();
return false;
}
}
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_saved.clear();
cards = const [];
error = null;
notify();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) {
if (code == null || code.isEmpty) return fallback;
return switch (code) {
'networkError' => l10n.networkError,
'progressError' => l10n.progressError,
'favoritesError' => l10n.favoritesError,
'ticketError' => l10n.playbackError,
final value => value,
};
}