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

485 lines
15 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/playback_position_cache.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_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
import 'widgets/drama_detail_bottom_bar.dart';
import 'widgets/drama_detail_header.dart';
import 'widgets/drama_detail_sections.dart';
import 'widgets/drama_episode_states.dart';
const String _menuFavorite = 'favorite';
const String _menuDownload = 'download';
const String _menuShare = 'share';
const String _menuReport = 'report';
const int _maxTags = 4;
const double _stateHeaderHeight = 52;
const double _stateHeaderHorizontal = 14;
const double _stateTitleGap = 12;
const double _stateTitleSize = 18;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 短剧详情页:暖色主信息、简介、标签、选集和固定播放入口。
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>(),
positionCache: context.read<PlaybackPositionCache>(),
);
_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 AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('detail-system-overlay'),
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.ink,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Theme(
data: AppTheme.dark(),
child: ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
),
),
);
}
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final drama = logic.drama;
final hasCardContent =
drama.title.trim().isNotEmpty || drama.posterUrl.trim().isNotEmpty;
if (!hasCardContent && logic.loading) return _loading(l10n);
if (!hasCardContent && logic.failed) return _failed(l10n);
if (drama.episodes.isEmpty && !logic.loading && !logic.failed) {
return _empty(l10n, drama);
}
final saved = context.watch<FavoriteController>().isSaved(drama.id);
final continueIndex = logic.continueIndex;
final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty;
final statsEpisode = episodesReady
? drama.episodes[continueIndex != null &&
continueIndex < drama.episodes.length
? continueIndex
: 0]
: null;
return Scaffold(
backgroundColor: AppColors.ink,
bottomNavigationBar: DramaDetailBottomBar(
l10n: l10n,
continueIndex: episodesReady ? continueIndex : null,
enabled: episodesReady,
onPlay: () => logic.openPlayer(context, continueIndex ?? 0),
resume: _resume(l10n),
),
body: CustomScrollView(
key: const Key('detail-scroll'),
slivers: [
SliverToBoxAdapter(
child: DramaDetailHeader(
l10n: l10n,
drama: drama,
saved: saved,
actions: DramaDetailHeaderActions(
onBack: () => Navigator.of(context).maybePop(),
onShare: () => unawaited(_shareDrama(l10n, drama)),
onFavorite: () => unawaited(_toggleFavorite(l10n)),
moreMenu: _moreMenu(context, l10n, saved),
),
),
),
SliverToBoxAdapter(
child: DramaStatsCard(
l10n: l10n,
drama: drama,
favoriteCount: logic.adjustedFavoriteCount,
likeCount: statsEpisode?.likeCount,
),
),
SliverToBoxAdapter(
child: DramaSynopsisSection(
l10n: l10n,
synopsis: localizedDramaSynopsis(l10n, drama),
),
),
if (_tagValues(context, drama).isNotEmpty)
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(context, drama))),
SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
);
}
Widget _episodes(AppLocalizations l10n, Drama drama, int? continueIndex) {
if (!drama.detailLoaded && logic.failed) {
return DramaEpisodesLoadError(
l10n: l10n,
message: logic.failureMessage,
onRetry: _load,
);
}
if (!drama.detailLoaded && logic.loading && drama.episodes.isEmpty) {
return DramaEpisodesLoading(l10n: l10n, episodeCount: drama.episodeCount);
}
return DramaEpisodeGrid(
l10n: l10n,
drama: drama,
continueIndex: continueIndex,
onOpen: (index) => logic.openPlayer(context, index),
);
}
Widget _loading(AppLocalizations l10n) => _statePage(
localizedDramaTitle(l10n, widget.drama),
const Center(child: CircularProgressIndicator()),
);
Widget _failed(AppLocalizations l10n) => _statePage(
localizedDramaTitle(l10n, widget.drama),
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) => _statePage(
localizedDramaTitle(l10n, drama),
EmptyState(
key: const Key('drama-detail-empty'),
icon: Icons.video_library_outlined,
title: l10n.noEpisodesYet,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: _load,
),
);
Widget _statePage(String title, Widget child) => Scaffold(
backgroundColor: AppColors.ink,
body: SafeArea(
child: Column(
children: [
SizedBox(
height: _stateHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _stateHeaderHorizontal,
),
child: Row(
children: [
const AppSubpageBackButton(
backKey: Key('detail-back'),
dark: true,
),
const SizedBox(width: _stateTitleGap),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: _stateTitleSize,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
),
Expanded(child: child),
],
),
),
);
Widget? _resume(AppLocalizations l10n) {
final fraction = _continueFraction;
final progressError = logic.progressError;
if (fraction == null && progressError == null) return null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (fraction != null)
DramaResumeBar(
fraction: fraction,
label: l10n.resumeProgress(
_clock(logic.progress!.position),
_clock(logic.progress!.duration),
),
),
if (progressError != null) ...[
if (fraction != null) const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
errorText(l10n, progressError, l10n.progressError),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 11),
),
),
TextButton(
onPressed: () => unawaited(logic.loadContinue()),
child: Text(l10n.retry),
),
],
),
],
],
);
}
Widget _moreMenu(BuildContext context, AppLocalizations l10n, bool saved) {
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-menu-favorite'),
value: _menuFavorite,
child: _menuItem(
saved ? Icons.star_rounded : Icons.star_border_rounded,
l10n.favorite,
saved ? AppColors.blue : AppColors.muted,
),
),
PopupMenuItem(
key: const Key('detail-download'),
value: _menuDownload,
child: _menuItem(
Icons.download_outlined,
l10n.downloads,
AppColors.muted,
),
),
PopupMenuItem(
key: const Key('detail-menu-share'),
value: _menuShare,
child: _menuItem(Icons.share_outlined, l10n.share, AppColors.muted),
),
PopupMenuItem(
key: const Key('detail-report'),
value: _menuReport,
child: _menuItem(
Icons.flag_outlined,
l10n.reportDrama,
AppColors.muted,
),
),
],
child: IgnorePointer(
child: AppSubpageCircleButton(
buttonKey: const Key('detail-more-visual'),
tooltip: l10n.more,
icon: Icons.more_horiz_rounded,
dark: true,
onPressed: () {},
),
),
);
}
Widget _menuItem(IconData icon, String label, Color color) {
return Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 9),
Text(label),
],
);
}
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;
}
List<String> _tagValues(BuildContext context, Drama drama) {
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final result = <String>[];
if (localized.isNotEmpty) {
result.addAll(localized.take(_maxTags));
} else {
for (final source in [drama.category, drama.genre]) {
for (final part in source.split(_tagSplitter)) {
final value = part.trim();
if (value.isNotEmpty && !result.contains(value)) result.add(value);
if (result.length == _maxTags) return result;
}
}
}
final language = drama.language.trim();
if (language.isNotEmpty) {
final label = _languageNames[language.toLowerCase()] ?? language;
if (!result.contains(label)) result.add(label);
}
return result.take(_maxTags).toList();
}
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';
}
}
const Map<String, String> _languageNames = {
'zh': '简体中文',
'zh-cn': '简体中文',
'zh-hans': '简体中文',
'en': 'English',
'en-us': 'English',
'id': 'Bahasa Indonesia',
'id-id': 'Bahasa Indonesia',
};