Files
ReelShort/lib/pages/drama_detail/drama_detail_view.dart
T

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.dark(),
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), Color(0xCC0B0C10)],
),
),
),
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)
: const Color(0xFF29272B),
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: const BoxDecoration(
color: Color(0x80000000),
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: const BoxDecoration(
color: Color(0x80000000),
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: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
if (label != null) ...[
const SizedBox(height: 4),
Text(
label!,
style: const TextStyle(color: AppColors.muted, fontSize: 10),
),
],
],
);
}
}