后端接口从 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)
864 lines
27 KiB
Dart
864 lines
27 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../core/formatting/localized_content.dart';
|
|
import '../../core/data/models/drama.dart';
|
|
import '../../core/data/repositories/watch_history_repository.dart';
|
|
import '../../core/data/services/auth_controller.dart';
|
|
import '../../core/data/services/engagement_controller.dart';
|
|
import '../../core/data/services/favorite_controller.dart';
|
|
import '../../core/formatting/error_text.dart';
|
|
import '../../core/widgets/remote_image.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import 'mine_binding.dart';
|
|
import 'mine_logic.dart';
|
|
|
|
class MyPage extends StatefulWidget {
|
|
const MyPage({
|
|
super.key,
|
|
required this.drama,
|
|
required this.duration,
|
|
this.initialSection = 0,
|
|
this.profile = false,
|
|
this.onOpenHistory,
|
|
this.history,
|
|
this.embedded = false,
|
|
this.active = true,
|
|
this.sectionRequest = 0,
|
|
this.onAction,
|
|
this.signedIn = false,
|
|
this.onSignIn,
|
|
this.onSignOut,
|
|
this.dramas,
|
|
this.onDramaSelected,
|
|
});
|
|
|
|
final Drama drama;
|
|
final Duration duration;
|
|
final int initialSection;
|
|
final bool profile;
|
|
final VoidCallback? onOpenHistory;
|
|
final WatchHistoryRepository? history;
|
|
final bool embedded;
|
|
final bool active;
|
|
final int sectionRequest;
|
|
final ValueChanged<MyPageAction>? onAction;
|
|
final bool signedIn;
|
|
final VoidCallback? onSignIn;
|
|
final VoidCallback? onSignOut;
|
|
final List<Drama>? dramas;
|
|
final ValueChanged<Drama>? onDramaSelected;
|
|
|
|
@override
|
|
State<MyPage> createState() => _MyPageState();
|
|
}
|
|
|
|
class _MyPageState extends State<MyPage> {
|
|
late final MineLogic logic;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
logic = MineBinding().create(
|
|
drama: widget.drama,
|
|
initialSection: widget.initialSection,
|
|
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant MyPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
logic.syncPage(
|
|
active: widget.active,
|
|
wasActive: oldWidget.active,
|
|
nextDrama: widget.drama,
|
|
dramaChanged: oldWidget.drama.id != widget.drama.id,
|
|
sectionRequest: widget.sectionRequest,
|
|
previousSectionRequest: oldWidget.sectionRequest,
|
|
initialSection: widget.initialSection,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
logic.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => _page(context),
|
|
);
|
|
}
|
|
|
|
Widget _page(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final content = SafeArea(
|
|
bottom: !widget.embedded,
|
|
child: Column(
|
|
children: [
|
|
if (!widget.profile) _topBar(l10n),
|
|
Expanded(
|
|
child: FutureBuilder<Duration?>(
|
|
future: logic.position,
|
|
builder: (context, snapshot) {
|
|
final position = snapshot.data ?? Duration.zero;
|
|
final total = widget.duration > Duration.zero
|
|
? widget.duration
|
|
: const Duration(seconds: 12);
|
|
final fraction =
|
|
(position.inMilliseconds / total.inMilliseconds).clamp(
|
|
0.0,
|
|
1.0,
|
|
);
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
|
children: [
|
|
if (widget.profile)
|
|
..._accountPage(l10n)
|
|
else ...[
|
|
_library(l10n, position, total, fraction),
|
|
],
|
|
const SizedBox(height: 24),
|
|
Center(
|
|
child: Text(
|
|
l10n.brandMark,
|
|
style: TextStyle(
|
|
color: Color(0xFFD2CDD0),
|
|
fontSize: 10,
|
|
letterSpacing: 1.3,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (!widget.embedded) _bottomNav(l10n),
|
|
],
|
|
),
|
|
);
|
|
return Theme(
|
|
data: ThemeData.dark(useMaterial3: true),
|
|
child: widget.embedded
|
|
? content
|
|
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
|
|
);
|
|
}
|
|
|
|
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
|
height: 54,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
|
|
const SizedBox(width: 7),
|
|
Text(
|
|
l10n.myListTab,
|
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: l10n.search,
|
|
onPressed: () => logic.act(
|
|
context,
|
|
MyPageAction.discover,
|
|
embedded: widget.embedded,
|
|
onAction: widget.onAction,
|
|
),
|
|
icon: const Icon(Icons.search_rounded, size: 23),
|
|
),
|
|
const SizedBox(width: 6),
|
|
const CircleAvatar(
|
|
radius: 14,
|
|
backgroundColor: Color(0xFF2C2D33),
|
|
child: Icon(Icons.person_rounded, color: Colors.white, size: 19),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
List<Widget> _accountPage(AppLocalizations l10n) => [
|
|
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
|
|
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
|
|
const SizedBox(height: 14),
|
|
_menuCard(l10n),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
l10n.preferencesHeading,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 1.1,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
|
|
title: Text(l10n.appLanguage),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
onTap: () => logic.showLanguage(context),
|
|
),
|
|
];
|
|
|
|
Widget _guestRow(AppLocalizations l10n) {
|
|
return Row(
|
|
children: [
|
|
const CircleAvatar(
|
|
radius: 28,
|
|
backgroundColor: Color(0xFF32343A),
|
|
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.logInHint,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: widget.onSignIn,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
backgroundColor: AppColors.blue,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: Text(l10n.logIn),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _signedInRow(AppLocalizations l10n) {
|
|
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
|
|
final user = context.watch<AuthController>().user;
|
|
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
|
|
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
|
|
return Row(
|
|
children: [
|
|
const CircleAvatar(
|
|
radius: 28,
|
|
backgroundColor: Color(0xFF32343A),
|
|
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
uidText,
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: widget.onSignOut ?? () => _mockSnack(l10n),
|
|
style: TextButton.styleFrom(
|
|
backgroundColor: const Color(0xFF2A2C33),
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: Text(l10n.signOut),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _wallet(AppLocalizations l10n) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF17191F),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.myAccount,
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _accountTile(
|
|
label: l10n.coins,
|
|
value: '\$ 0',
|
|
action: l10n.topUpWallet,
|
|
color: AppColors.blue,
|
|
onPressed: () => _mockSnack(l10n),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _accountTile(
|
|
label: l10n.vipMembership,
|
|
value: l10n.notActive,
|
|
action: l10n.joinVip,
|
|
color: const Color(0xFFF5C451),
|
|
onPressed: () => _mockSnack(l10n),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _mockSnack(AppLocalizations l10n) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.mockNotice)));
|
|
}
|
|
|
|
Widget _accountTile({
|
|
required String label,
|
|
required String value,
|
|
required String action,
|
|
required Color color,
|
|
required VoidCallback onPressed,
|
|
}) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF12141A),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: color.withValues(alpha: 0.45)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: onPressed,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: color,
|
|
foregroundColor: color == AppColors.blue
|
|
? Colors.white
|
|
: const Color(0xFF2A2110),
|
|
visualDensity: VisualDensity.compact,
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _menuCard(AppLocalizations l10n) {
|
|
return Material(
|
|
color: const Color(0xFF17191F),
|
|
borderRadius: BorderRadius.circular(16),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.card_giftcard_rounded,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.earnRewards),
|
|
trailing: Text(
|
|
l10n.freeBonus,
|
|
style: const TextStyle(color: AppColors.blue, fontSize: 12),
|
|
),
|
|
onTap: () => _mockSnack(l10n),
|
|
),
|
|
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.history_rounded,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.watchHistory),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
onTap: widget.onOpenHistory,
|
|
),
|
|
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.settings_rounded,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.settings),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
onTap: () => logic.showLanguage(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _library(
|
|
AppLocalizations l10n,
|
|
Duration position,
|
|
Duration total,
|
|
double fraction,
|
|
) {
|
|
final tabs = [l10n.history, l10n.watchlist, l10n.likedTab];
|
|
final filters = [l10n.all, l10n.inProgress, l10n.completed];
|
|
final hasHistory =
|
|
logic.filter == 0 ||
|
|
(logic.filter == 1 && fraction < 0.99) ||
|
|
(logic.filter == 2 && fraction >= 0.99);
|
|
final engagement = context.watch<EngagementController>();
|
|
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
|
final favorites = context.watch<FavoriteController>();
|
|
final isWatchlist = logic.section == 1;
|
|
final markedDramas = isWatchlist
|
|
? favorites.cards
|
|
: (widget.dramas ?? [widget.drama])
|
|
.where((drama) => engagement.isLiked(drama.id))
|
|
.toList();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
for (var index = 0; index < tabs.length; index++) ...[
|
|
InkWell(
|
|
onTap: () {
|
|
logic.selectSection(index);
|
|
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
|
if (index == 1) unawaited(favorites.load());
|
|
},
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: logic.section == index
|
|
? AppColors.blue
|
|
: const Color(0xFF24262E),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
tabs[index],
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (index < tabs.length - 1) const SizedBox(width: 18),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 17),
|
|
if (logic.section == 0)
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
for (var index = 0; index < filters.length; index++)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 7),
|
|
child: ChoiceChip(
|
|
label: Text(filters[index]),
|
|
selected: logic.filter == index,
|
|
onSelected: (_) => logic.selectFilter(index),
|
|
showCheckmark: false,
|
|
visualDensity: VisualDensity.compact,
|
|
backgroundColor: const Color(0xFF242328),
|
|
selectedColor: _orange,
|
|
labelStyle: TextStyle(
|
|
color: logic.filter == index
|
|
? Colors.white
|
|
: const Color(0xFFD5CED0),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
side: BorderSide.none,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (logic.section == 0) const SizedBox(height: 11),
|
|
if (logic.section == 0 && hasHistory)
|
|
InkWell(
|
|
onTap: () => logic.act(
|
|
context,
|
|
MyPageAction.resume,
|
|
embedded: widget.embedded,
|
|
onAction: widget.onAction,
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(9),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1B1C21),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_poster(width: 74, height: 103),
|
|
const SizedBox(width: 11),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
localizedDramaTitle(l10n, widget.drama),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
l10n.dramaTag,
|
|
style: const TextStyle(
|
|
color: Color(0xFFE6E1E3),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
Text(
|
|
'${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.blueSoft,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const CircleAvatar(
|
|
radius: 15,
|
|
backgroundColor: Color(0xFF313137),
|
|
child: Icon(
|
|
Icons.play_arrow_rounded,
|
|
color: _orange,
|
|
size: 20,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else if (isWatchlist && favorites.loading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 22),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
)
|
|
else if (isWatchlist && favorites.error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
errorText(l10n, favorites.error, l10n.favoritesError),
|
|
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
),
|
|
TextButton(
|
|
onPressed: () => unawaited(favorites.load()),
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (logic.section != 0 && markedDramas.isNotEmpty)
|
|
for (final drama in markedDramas) ...[
|
|
_markedDramaCard(drama, l10n),
|
|
const SizedBox(height: 9),
|
|
]
|
|
else
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 22),
|
|
child: Text(
|
|
l10n.noSavedShows,
|
|
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
),
|
|
),
|
|
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
|
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
|
if (logic.section == 0) ...[
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
l10n.history,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 9),
|
|
if (logic.positionFailed)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.progressError,
|
|
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: logic.refreshPosition,
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
)
|
|
else if (logic.recentLoading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
)
|
|
else if (logic.recentError != null)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
errorText(l10n, logic.recentError, l10n.progressError),
|
|
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => unawaited(logic.refreshRecent()),
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
)
|
|
else if (_recentDramas.isEmpty)
|
|
Text(
|
|
l10n.noStories,
|
|
style: const TextStyle(color: Color(0xFFB7B3B8)),
|
|
)
|
|
else
|
|
for (final drama in _recentDramas) ...[
|
|
_markedDramaCard(drama, l10n),
|
|
const SizedBox(height: 9),
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。
|
|
List<Drama> get _recentDramas {
|
|
final pool = <Drama>[...?widget.dramas, widget.drama];
|
|
final result = <Drama>[];
|
|
for (final item in logic.recent) {
|
|
for (final drama in pool) {
|
|
if (drama.id == item.dramaId &&
|
|
!result.any((picked) => picked.id == drama.id)) {
|
|
result.add(drama);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
|
|
onTap: () {
|
|
if (widget.onDramaSelected != null) {
|
|
widget.onDramaSelected!(drama);
|
|
} else if (drama.id == widget.drama.id) {
|
|
logic.act(
|
|
context,
|
|
MyPageAction.resume,
|
|
embedded: widget.embedded,
|
|
onAction: widget.onAction,
|
|
);
|
|
}
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(9),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1B1C21),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
|
),
|
|
const SizedBox(width: 11),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 7),
|
|
Text(
|
|
l10n.episodeCount(drama.episodeCount),
|
|
style: const TextStyle(
|
|
color: Color(0xFFE6E1E3),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _bottomNav(AppLocalizations l10n) => Container(
|
|
height: 63,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF0D0D11),
|
|
border: Border(top: BorderSide(color: Color(0xFF242329))),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_NavItem(
|
|
icon: Icons.play_circle_outline_rounded,
|
|
label: l10n.homeTab,
|
|
onTap: () => logic.act(
|
|
context,
|
|
MyPageAction.home,
|
|
embedded: widget.embedded,
|
|
onAction: widget.onAction,
|
|
),
|
|
),
|
|
_NavItem(
|
|
icon: Icons.explore_outlined,
|
|
label: l10n.discoverTab,
|
|
onTap: () => logic.act(
|
|
context,
|
|
MyPageAction.discover,
|
|
embedded: widget.embedded,
|
|
onAction: widget.onAction,
|
|
),
|
|
),
|
|
_NavItem(
|
|
icon: Icons.video_library_outlined,
|
|
label: l10n.myTab,
|
|
selected: true,
|
|
onTap: () {},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _poster({required double width, required double height}) => ClipRRect(
|
|
borderRadius: BorderRadius.circular(7),
|
|
child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
|
|
);
|
|
}
|
|
|
|
class _NavItem extends StatelessWidget {
|
|
const _NavItem({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.selected = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => InkWell(
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 72,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 21,
|
|
color: selected ? _orange : const Color(0xFFCAC4C7),
|
|
),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: selected ? _orange : const Color(0xFFCAC4C7),
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _clock(Duration duration) {
|
|
final seconds = duration.inSeconds;
|
|
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
const _orange = AppColors.blue;
|