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

1023 lines
30 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
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';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../account/account_entry_card.dart';
import '../downloads/downloads_page.dart';
import '../favorites/favorites_page.dart';
import '../notices/notices_page.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
import '../watch_records/watch_records_page.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.history,
this.embedded = false,
this.active = true,
this.sectionRequest = 0,
this.onAction,
this.signedIn = false,
this.onSignIn,
this.onSignOut,
this.dramas,
this.onDramaSelected,
this.wallet,
});
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;
final WalletController? wallet;
@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: [
_topBar(l10n),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 18),
children: [
_userRow(l10n),
const SizedBox(height: 12),
_wallet(l10n),
if (_continueDrama != null) ...[
const SizedBox(height: 12),
_continueCard(l10n, _continueDrama!, _continueRecord!),
],
const SizedBox(height: 14),
_library(l10n),
const SizedBox(height: 14),
_languageRow(l10n),
if (widget.signedIn) ...[
const SizedBox(height: 12),
const AccountEntryCard(),
],
const SizedBox(height: 12),
_menuCard(l10n),
],
),
),
if (!widget.embedded) _bottomNav(l10n),
],
),
);
return Theme(
data: AppTheme.dark(),
child: widget.embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
),
const SizedBox(width: 8),
Text(
l10n.myTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
_RoundHeaderButton(
key: const Key('mine-search-button'),
tooltip: l10n.search,
icon: Icons.search_rounded,
onTap: () => logic.act(
context,
MyPageAction.discover,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
const SizedBox(width: 8),
Container(
key: const Key('mine-avatar'),
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
],
),
),
);
Widget _userRow(AppLocalizations l10n) {
final user = widget.signedIn ? context.watch<AuthController>().user : null;
final name = widget.signedIn
? ((user?.nickname.isNotEmpty ?? false)
? user!.nickname
: l10n.localViewer)
: l10n.localViewer;
final subtitle = widget.signedIn
? ((user?.uid.isNotEmpty ?? false)
? 'ID: ${user!.uid}'
: l10n.myAccount)
: l10n.logInHint;
return Row(
key: const Key('mine-user-row'),
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 22,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10),
),
],
),
),
if (widget.signedIn)
TextButton(
onPressed: widget.onSignOut,
style: TextButton.styleFrom(
foregroundColor: AppColors.muted,
backgroundColor: const Color(0xFF24252A),
minimumSize: const Size(52, 30),
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 10),
),
child: Text(l10n.signOut),
)
else
TextButton(
key: const Key('mine-sign-in'),
onPressed: widget.onSignIn,
style: TextButton.styleFrom(
foregroundColor: AppColors.cream,
backgroundColor: const Color(0xFF24252A),
minimumSize: const Size(64, 30),
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 10),
),
child: Text('${l10n.logIn}/${l10n.signUp}'),
),
],
);
}
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: () => _openWallet(context),
borderRadius: BorderRadius.circular(13),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.goldCard,
borderRadius: BorderRadius.circular(13),
),
child: Row(
children: [
const Icon(
Icons.monetization_on_rounded,
color: AppColors.gold,
size: 20,
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.coins,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
widget.signedIn ? value : l10n.logInHint,
style: const TextStyle(
color: AppColors.accent,
fontSize: 9,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: Color(0xFF666666),
size: 16,
),
],
),
),
);
}
void _openVip() {
if (!widget.signedIn) {
widget.onSignIn?.call();
return;
}
final wallet = widget.wallet;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VipPage(
onPurchased: wallet == null ? null : () => unawaited(wallet.load()),
),
),
);
}
void _openWallet(BuildContext context) {
if (!widget.signedIn) {
widget.onSignIn?.call();
return;
}
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const WalletPage()));
}
WatchProgress? get _continueRecord {
for (final record in logic.recent) {
if (!record.finished && record.hasPosition) return record;
}
return null;
}
Drama? get _continueDrama {
final record = _continueRecord;
if (record == null) return null;
for (final drama in <Drama>[...?widget.dramas, widget.drama]) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
return Container(
key: const Key('mine-continue-watching'),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_poster(drama, width: 50, height: 64, radius: 7),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
style: const TextStyle(
color: AppColors.accent,
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
'${_episodeText(drama, record)} · '
'${_clock(record.position)} / ${_clock(record.duration)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 60,
height: 28,
child: FilledButton(
onPressed: () => _resume(drama),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
child: Text(l10n.resume),
),
),
],
),
);
}
Widget _library(AppLocalizations l10n) {
final tabs = [l10n.history, l10n.watchlist, l10n.likedTab];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < tabs.length; index++)
Padding(
padding: EdgeInsets.only(
right: index == tabs.length - 1 ? 0 : 20,
),
child: Semantics(
selected: logic.section == index,
button: true,
child: InkWell(
key: Key('mine-library-tab-$index'),
onTap: () => logic.selectSection(index),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tabs[index],
maxLines: 1,
style: TextStyle(
color: logic.section == index
? AppColors.blue
: const Color(0xFFE4DBDD),
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 18,
height: 2,
decoration: BoxDecoration(
color: logic.section == index
? AppColors.blue
: Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
],
),
),
),
),
),
],
),
),
const SizedBox(height: 12),
if (logic.section == 1 && context.watch<FavoriteController>().loading)
const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (logic.section == 1)
..._favoriteCards(l10n)
else if (logic.section == 2)
..._likedCards(l10n)
else if (logic.recentLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (_recentDramas.isEmpty)
EmptyState(
key: const Key('mine-recent-empty'),
icon: Icons.history_rounded,
title: l10n.noStories,
subtitle: l10n.emptyHint,
compact: true,
)
else
for (final drama in _recentDramas) ...[
_historyCard(l10n, drama),
const SizedBox(height: 8),
],
],
);
}
List<Widget> _favoriteCards(AppLocalizations l10n) {
final favorites = context.watch<FavoriteController>();
if (favorites.error != null && favorites.cards.isEmpty) {
return [
EmptyState(
icon: Icons.cloud_off_rounded,
title: errorText(l10n, favorites.error, l10n.favoritesError),
actionLabel: l10n.retry,
onAction: () => unawaited(favorites.load()),
compact: true,
),
];
}
if (favorites.cards.isEmpty) {
return [
EmptyState(
key: const Key('mine-saved-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noSavedShows,
subtitle: l10n.emptyHint,
compact: true,
),
];
}
return [
for (final drama in favorites.cards) ...[
_smallDramaCard(l10n, drama),
const SizedBox(height: 8),
],
];
}
List<Widget> _likedCards(AppLocalizations l10n) {
final interactions = context.watch<InteractionController>();
final dramas = <Drama>[
...?widget.dramas,
widget.drama,
].where((drama) => interactions.isLiked(drama.id)).toList();
if (dramas.isEmpty) {
return [
EmptyState(
key: const Key('mine-saved-empty'),
icon: Icons.favorite_border_rounded,
title: l10n.noSavedShows,
subtitle: l10n.emptyHint,
compact: true,
),
];
}
return [
for (final drama in dramas) ...[
_smallDramaCard(l10n, drama),
const SizedBox(height: 8),
],
];
}
Widget _historyCard(AppLocalizations l10n, Drama drama) {
final record = _recordFor(drama.id);
return InkWell(
key: Key('mine-history-card-${drama.id}'),
onTap: () => _resume(drama),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_poster(drama, width: 48, height: 60, radius: 7),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
record == null
? l10n.episodeCount(drama.episodeCount)
: '${_episodeText(drama, record)} · '
'${_clock(record.position)} / ${_clock(record.duration)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFBBBBBB),
fontSize: 9,
),
),
],
),
),
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: Color(0xFF313137),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.blue,
size: 15,
),
),
],
),
),
);
}
Widget _smallDramaCard(AppLocalizations l10n, Drama drama) {
return InkWell(
onTap: () {
if (widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else {
_resume(drama);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_poster(drama, width: 48, height: 60, radius: 7),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(
color: Color(0xFFBBBBBB),
fontSize: 9,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.muted,
size: 16,
),
],
),
),
);
}
Widget _languageRow(AppLocalizations l10n) {
final locale = context.watch<LocaleController>();
final code = locale.locale?.languageCode ?? 'id';
final value = switch (code) {
'zh' => l10n.simplifiedChinese,
'en' => l10n.english,
_ => l10n.bahasaIndonesia,
};
return InkWell(
key: const Key('mine-language'),
onTap: () => logic.showLanguage(context),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.language_rounded,
color: AppColors.muted,
size: 14,
),
const SizedBox(width: 9),
Expanded(
child: Text(
l10n.appLanguage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
),
),
),
Text(
value,
style: const TextStyle(color: AppColors.accent, fontSize: 10),
),
const SizedBox(width: 3),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.accent,
size: 16,
),
],
),
),
);
}
Widget _menuCard(AppLocalizations l10n) {
return Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_menuTile(
icon: Icons.notifications_none_rounded,
title: l10n.notices,
key: const Key('mine-notices'),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const NoticesPage()),
),
),
_menuTile(
icon: Icons.history_rounded,
title: l10n.watchHistory,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const WatchRecordsPage()),
),
),
_menuTile(
icon: Icons.favorite_outline_rounded,
title: l10n.myFavorites,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FavoritesPage()),
),
),
_menuTile(
icon: Icons.download_outlined,
title: l10n.downloads,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DownloadsPage()),
),
),
_menuTile(
key: const Key('mine-vip'),
icon: Icons.workspace_premium_outlined,
title: l10n.vipMembership,
onTap: _openVip,
),
_menuTile(
icon: Icons.settings_rounded,
title: l10n.settings,
onTap: () => logic.showLanguage(context),
),
],
),
);
}
Widget _menuTile({
required IconData icon,
required String title,
required VoidCallback onTap,
Key? key,
}) {
return ListTile(
key: key,
dense: true,
minTileHeight: 44,
leading: Icon(icon, color: AppColors.secondary, size: 17),
title: Text(
title,
style: const TextStyle(color: AppColors.onSurface, fontSize: 11),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: AppColors.muted,
size: 16,
),
onTap: onTap,
);
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('mine-nav-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
MyPageAction.home,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
onTap: () => logic.act(
context,
MyPageAction.discover,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-current'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: true,
onTap: () {},
),
],
);
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;
}
WatchProgress? _recordFor(String dramaId) {
for (final record in logic.recent) {
if (record.dramaId == dramaId) return record;
}
return null;
}
String _episodeText(Drama drama, WatchProgress record) {
for (final episode in drama.episodes) {
if (episode.id == record.episodeId) {
return AppLocalizations.of(
context,
)!.episodeOfTotal(episode.number, drama.episodeCount);
}
}
return AppLocalizations.of(context)!.episodeCount(drama.episodeCount);
}
void _resume(Drama drama) {
if (widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else {
logic.act(
context,
MyPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
);
}
}
Widget _poster(
Drama drama, {
required double width,
required double height,
required double radius,
}) => ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: RemoteImage(drama.posterUrl, width: width, height: height),
);
String _clock(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
}
class _RoundHeaderButton extends StatelessWidget {
const _RoundHeaderButton({
super.key,
required this.tooltip,
required this.icon,
required this.onTap,
});
final String tooltip;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}