610 lines
18 KiB
Dart
610 lines
18 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../core/data/models/drama.dart';
|
|
import '../../core/data/repositories/playback_repository.dart';
|
|
import '../../core/data/repositories/watch_history_repository.dart';
|
|
import '../../core/data/services/catalog_controller.dart';
|
|
import '../../core/data/services/favorite_controller.dart';
|
|
import '../../core/data/services/interaction_controller.dart';
|
|
import '../../core/data/services/platform_share_service.dart';
|
|
import '../../core/formatting/error_text.dart';
|
|
import '../../core/formatting/localized_content.dart';
|
|
import '../../core/platform/haptics.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../core/theme/app_dimens.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../core/widgets/auth_gate.dart';
|
|
import '../../core/widgets/download_action.dart';
|
|
import '../../core/widgets/empty_state.dart';
|
|
import '../../core/widgets/remote_image.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../interaction/widgets/report_dialog.dart';
|
|
import 'drama_detail_binding.dart';
|
|
import 'drama_detail_logic.dart';
|
|
|
|
const String _menuFavorite = 'favorite';
|
|
const String _menuDownload = 'download';
|
|
const String _menuShare = 'share';
|
|
const String _menuReport = 'report';
|
|
|
|
class DramaDetailPage extends StatefulWidget {
|
|
const DramaDetailPage({super.key, required this.drama});
|
|
|
|
final Drama drama;
|
|
|
|
@override
|
|
State<DramaDetailPage> createState() => _DramaDetailPageState();
|
|
}
|
|
|
|
class _DramaDetailPageState extends State<DramaDetailPage> {
|
|
late final DramaDetailLogic logic;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
logic = DramaDetailBinding().create(
|
|
drama: widget.drama,
|
|
history: context.read<WatchHistoryRepository>(),
|
|
playback: context.read<PlaybackRepository>(),
|
|
favorites: context.read<FavoriteController>(),
|
|
);
|
|
_load();
|
|
}
|
|
|
|
void _load() {
|
|
unawaited(
|
|
logic.load(
|
|
() => context.read<CatalogController>().requireDetail(widget.drama),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
logic.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Theme(
|
|
data: AppTheme.light(),
|
|
child: ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => _page(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _page(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
if (logic.loading) return _loading(l10n);
|
|
if (logic.failed) return _failed(l10n);
|
|
final drama = logic.drama;
|
|
if (drama.episodes.isEmpty) return _empty(l10n, drama);
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppColors.ink,
|
|
body: CustomScrollView(
|
|
key: const Key('detail-scroll'),
|
|
slivers: [
|
|
SliverToBoxAdapter(child: _hero(context, l10n, drama)),
|
|
SliverToBoxAdapter(child: _content(context, l10n, drama)),
|
|
const SliverToBoxAdapter(child: SizedBox(height: 28)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _loading(AppLocalizations l10n) => Scaffold(
|
|
backgroundColor: AppColors.ink,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.ink,
|
|
foregroundColor: AppColors.onPrimary,
|
|
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
|
),
|
|
body: const Center(child: CircularProgressIndicator()),
|
|
);
|
|
|
|
Widget _failed(AppLocalizations l10n) => Scaffold(
|
|
backgroundColor: AppColors.ink,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.ink,
|
|
foregroundColor: AppColors.onPrimary,
|
|
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
|
),
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
errorText(l10n, logic.failureMessage, l10n.detailError),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.muted),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold(
|
|
backgroundColor: AppColors.ink,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.ink,
|
|
foregroundColor: AppColors.onPrimary,
|
|
title: Text(localizedDramaTitle(l10n, drama)),
|
|
),
|
|
body: EmptyState(
|
|
key: const Key('drama-detail-empty'),
|
|
icon: Icons.video_library_outlined,
|
|
title: l10n.noEpisodesYet,
|
|
subtitle: l10n.emptyHint,
|
|
actionLabel: l10n.retry,
|
|
onAction: _load,
|
|
),
|
|
);
|
|
|
|
Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) {
|
|
return SizedBox(
|
|
key: const Key('detail-hero'),
|
|
height: 190,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
RemoteImage(
|
|
drama.posterUrl,
|
|
fit: BoxFit.cover,
|
|
showFallbackIcon: true,
|
|
),
|
|
const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Color(0x33000000), AppColors.onSurface],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: MediaQuery.paddingOf(context).top + 14,
|
|
left: 14,
|
|
child: _RoundAction(
|
|
key: const Key('detail-back'),
|
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
icon: Icons.chevron_left_rounded,
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: MediaQuery.paddingOf(context).top + 14,
|
|
right: 14,
|
|
child: _moreMenu(context, l10n),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
|
|
final continueIndex = logic.continueIndex;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.episodeCount(drama.episodeCount),
|
|
key: const Key('detail-episode-count'),
|
|
style: const TextStyle(
|
|
color: AppColors.secondary,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
const SizedBox(height: 7),
|
|
Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
key: const Key('detail-title'),
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
localizedDramaSynopsis(l10n, drama),
|
|
key: const Key('detail-synopsis'),
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 11,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 40,
|
|
child: FilledButton.icon(
|
|
key: const Key('detail-cta'),
|
|
onPressed: () => logic.openPlayer(context, continueIndex ?? 0),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.blue,
|
|
foregroundColor: AppColors.onPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
|
|
),
|
|
textStyle: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
|
label: Text(
|
|
continueIndex == null
|
|
? l10n.startWatching
|
|
: l10n.continueEpisode(continueIndex + 1),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
if (_continueFraction != null) ...[
|
|
const SizedBox(height: 8),
|
|
_ResumeBar(
|
|
fraction: _continueFraction!,
|
|
label: l10n.resumeProgress(
|
|
_clock(logic.progress!.position),
|
|
_clock(logic.progress!.duration),
|
|
),
|
|
),
|
|
],
|
|
if (logic.progressError != null) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
errorText(l10n, logic.progressError, l10n.progressError),
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => unawaited(logic.loadContinue()),
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
l10n.episodes,
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6;
|
|
final columns = sixColumnsWidth >= 40 ? 6 : 5;
|
|
final selectedIndex = continueIndex ?? 0;
|
|
final selectedNumber = drama.episodes.isEmpty
|
|
? null
|
|
: drama.episodes[selectedIndex].number;
|
|
final ordered = [
|
|
for (var index = 0; index < drama.episodes.length; index++)
|
|
(index: index, episode: drama.episodes[index]),
|
|
]..sort((a, b) => a.episode.number.compareTo(b.episode.number));
|
|
return GridView.builder(
|
|
key: const Key('detail-episode-chips'),
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
padding: EdgeInsets.zero,
|
|
itemCount: ordered.length,
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 1.35,
|
|
),
|
|
itemBuilder: (context, position) {
|
|
final item = ordered[position];
|
|
return _episodeChip(
|
|
context,
|
|
item.episode,
|
|
item.index,
|
|
selectedNumber,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _episodeChip(
|
|
BuildContext context,
|
|
Episode episode,
|
|
int index,
|
|
int? selectedNumber,
|
|
) {
|
|
final selected = episode.number == selectedNumber;
|
|
final locked = episode.locked || (!episode.isFree && index > 0);
|
|
return InkWell(
|
|
key: Key('detail-episode-chip-${episode.number}'),
|
|
onTap: () => logic.openPlayer(context, index),
|
|
borderRadius: BorderRadius.circular(9),
|
|
child: Container(
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? AppColors.blue
|
|
: locked
|
|
? AppColors.secondary.withValues(alpha: 0.16)
|
|
: AppColors.raised,
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
child: Text(
|
|
'${episode.number}',
|
|
style: TextStyle(
|
|
color: selected
|
|
? AppColors.onPrimary
|
|
: locked
|
|
? AppColors.secondary
|
|
: AppColors.muted,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _moreMenu(BuildContext context, AppLocalizations l10n) {
|
|
final saved = context.watch<FavoriteController>().isSaved(logic.drama.id);
|
|
return PopupMenuButton<String>(
|
|
key: const Key('detail-more'),
|
|
tooltip: l10n.more,
|
|
color: AppColors.card,
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case _menuFavorite:
|
|
unawaited(_toggleFavorite(l10n));
|
|
case _menuDownload:
|
|
_downloadCurrent();
|
|
case _menuShare:
|
|
unawaited(_shareDrama(l10n, logic.drama));
|
|
case _menuReport:
|
|
unawaited(_reportDrama(l10n));
|
|
}
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
key: const Key('detail-favorite'),
|
|
value: _menuFavorite,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
saved ? Icons.star_rounded : Icons.star_border_rounded,
|
|
color: saved ? AppColors.blue : AppColors.muted,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 9),
|
|
Text(l10n.favorite),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
key: const Key('detail-download'),
|
|
value: _menuDownload,
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.download_outlined,
|
|
color: AppColors.muted,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 9),
|
|
Text(l10n.downloads),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
key: const Key('detail-share'),
|
|
value: _menuShare,
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.share_outlined,
|
|
color: AppColors.muted,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 9),
|
|
Text(l10n.share),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
key: const Key('detail-report'),
|
|
value: _menuReport,
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18),
|
|
const SizedBox(width: 9),
|
|
Text(l10n.reportDrama),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card.withValues(alpha: 0.92),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.more_horiz_rounded,
|
|
size: 18,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _shareDrama(AppLocalizations l10n, Drama drama) async {
|
|
try {
|
|
await const PlatformShareService().shareDrama(
|
|
title: localizedDramaTitle(l10n, drama),
|
|
synopsis: localizedDramaSynopsis(l10n, drama),
|
|
footer: l10n.shareFooter,
|
|
);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.shareFailed)));
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleFavorite(AppLocalizations l10n) async {
|
|
if (!await ensureSignedIn(context)) return;
|
|
if (!mounted) return;
|
|
unawaited(context.read<Haptics>().lightImpact());
|
|
final ok = await logic.toggleFavorite();
|
|
if (ok || !mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
|
}
|
|
|
|
void _downloadCurrent() {
|
|
final episodes = logic.drama.episodes;
|
|
if (episodes.isEmpty) return;
|
|
final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1);
|
|
requestEpisodeDownload(context, episodes[index].id);
|
|
}
|
|
|
|
Future<void> _reportDrama(AppLocalizations l10n) async {
|
|
if (!await ensureSignedIn(context) || !mounted) return;
|
|
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
|
if (draft == null || !mounted) return;
|
|
unawaited(context.read<Haptics>().lightImpact());
|
|
final result = await context.read<InteractionController>().reportDrama(
|
|
logic.drama.id,
|
|
reason: draft.reason,
|
|
description: draft.description,
|
|
);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
result.isSuccess
|
|
? l10n.reportSubmitted
|
|
: result.errorMessage ?? l10n.reportFailed,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double? get _continueFraction {
|
|
final record = logic.progress;
|
|
if (record == null || !record.hasPosition) return null;
|
|
if (logic.continueIndex == null) return null;
|
|
return record.fraction;
|
|
}
|
|
|
|
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 duration.inHours > 0
|
|
? '${duration.inHours}:$minutes:$seconds'
|
|
: '$minutes:$seconds';
|
|
}
|
|
}
|
|
|
|
class _RoundAction extends StatelessWidget {
|
|
const _RoundAction({
|
|
super.key,
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final VoidCallback? onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card.withValues(alpha: 0.92),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
tooltip: tooltip.isEmpty ? null : tooltip,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(icon, size: 18, color: AppColors.onSurface),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ResumeBar extends StatelessWidget {
|
|
const _ResumeBar({required this.fraction, this.label});
|
|
|
|
final double fraction;
|
|
final String? label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(2),
|
|
child: LinearProgressIndicator(
|
|
value: fraction,
|
|
minHeight: 3,
|
|
backgroundColor: AppColors.border,
|
|
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
|
),
|
|
),
|
|
if (label != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label!,
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 10),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|