Files
ReelShort/lib/pages/mine/mine_view.dart
T

912 lines
29 KiB
Dart

// Author: fruit.com.cn
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/interaction_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 '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
import '../watch_records/watch_records_page.dart';
import '../favorites/favorites_page.dart';
import '../notices/notices_page.dart';
import '../downloads/downloads_page.dart';
class MyPage extends StatefulWidget {
const MyPage({
super.key,
required this.drama,
required this.duration,
this.initialSection = 0,
this.profile = false,
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 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: AppTheme.dark(),
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: _accent, 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.blueSoft, fontSize: 12),
),
onTap: () => _mockSnack(l10n),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
key: const Key('mine-notices'),
leading: const Icon(
Icons.notifications_none_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.notices),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const NoticesPage()),
),
),
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: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const WatchRecordsPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.favorite_outline_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.myFavorites),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FavoritesPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.download_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.downloads),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DownloadsPage()),
),
),
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 interactions = context.watch<InteractionController>();
// 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。
final favorites = context.watch<FavoriteController>();
final isWatchlist = logic.section == 1;
final markedDramas = isWatchlist
? favorites.cards
: (widget.dramas ?? [widget.drama])
.where((drama) => interactions.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.bluePressed
: 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: _accent,
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: _accent,
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 ? _accent : const Color(0xFFCAC4C7),
),
Text(
label,
style: TextStyle(
color: selected ? _accent : 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 _accent = AppColors.blueSoft;