// 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/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/app_glow_panel.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import '../../../core/data/services/catalog_controller.dart'; import '../../../core/data/services/favorite_controller.dart'; import 'episode_picker_cell.dart'; const int _columns = 6; const double _sheetHeightFactor = 0.62; const double _cellAspect = 1.05; const double _sheetRadius = 20; const double _pageHorizontal = 20; const double _tabGap = 20; const double _posterWidth = 86; const double _posterRadius = AppDimens.coverRadius; const double _relatedWidth = 104; const double _relatedGap = 10; const int _synopsisLines = 3; const int _maxTags = 4; const int _maxRelated = 8; final RegExp _tagSplitter = RegExp(r'[,/、·]'); const TextStyle _synopsisStyle = TextStyle( color: AppColors.textPrimary, fontSize: 14, height: 1.55, ); /// 弹窗页签:简介 / 选集。 enum DramaInfoTab { detail, episodes } /// 播放页的剧名弹窗:简介与选集在同一个面板内切换,不再跳转详情页。 /// /// 布局:拖拽条 → 页签 + 关闭 → 内容(海报信息块 / 选集网格)。 class DramaInfoSheet extends StatefulWidget { const DramaInfoSheet({ super.key, required this.drama, required this.currentNumber, required this.onSelectEpisode, this.onOpenRelated, this.initialTab = DramaInfoTab.detail, }); final Drama drama; /// 当前播放集号,用于选集网格高亮。 final int currentNumber; /// 点击某集,参数为该集在原始列表中的索引。 final ValueChanged onSelectEpisode; /// 点相似推荐里的短剧,交给外层决定怎么打开。 final ValueChanged? onOpenRelated; final DramaInfoTab initialTab; @override State createState() => _DramaInfoSheetState(); } class _DramaInfoSheetState extends State { late DramaInfoTab _tab = widget.initialTab; bool _synopsisExpanded = false; @override Widget build(BuildContext context) { return AnnotatedRegion( key: const Key('drama-info-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: AppColors.transparent, systemNavigationBarColor: AppColors.card, systemNavigationBarDividerColor: AppColors.transparent, systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), border: false, child: Container( key: const Key('drama-info-sheet'), height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, decoration: const BoxDecoration(color: AppColors.transparent), child: SafeArea( top: false, bottom: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _grabber(), _headerRow(AppLocalizations.of(context)!), Expanded( child: _tab == DramaInfoTab.detail ? _detailPane() : _episodePane(), ), ], ), ), ), ), ); } Widget _grabber() { return Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(top: 8), decoration: BoxDecoration( color: AppColors.border, borderRadius: BorderRadius.circular(2), ), ), ); } Widget _headerRow(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(_pageHorizontal, 6, 8, 2), child: Row( children: [ _tabButton( key: const Key('drama-info-tab-detail'), label: l10n.synopsisTitle, tab: DramaInfoTab.detail, ), const SizedBox(width: _tabGap), _tabButton( key: const Key('drama-info-tab-episodes'), label: l10n.episodes, tab: DramaInfoTab.episodes, ), const Spacer(), _closeButton(l10n), ], ), ); } /// 视觉上是个小圆钮,点按区域为 44pt。 Widget _closeButton(AppLocalizations l10n) { return SizedBox( key: const Key('drama-info-close'), width: AppDimens.tapTarget, height: AppDimens.tapTarget, child: Center( child: Semantics( button: true, label: l10n.close, child: Material( color: AppColors.raised, shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => Navigator.pop(context), child: const SizedBox( width: 24, height: 24, child: Icon( Icons.close_rounded, size: 14, color: AppColors.onPrimary, ), ), ), ), ), ), ); } Widget _tabButton({ required Key key, required String label, required DramaInfoTab tab, }) { final selected = _tab == tab; return GestureDetector( key: key, behavior: HitTestBehavior.opaque, onTap: () { if (_tab != tab) setState(() => _tab = tab); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Text( label, style: TextStyle( color: selected ? AppColors.onSurface : AppColors.muted, fontSize: 18, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, ), ), ), ); } Widget _detailPane() { final l10n = AppLocalizations.of(context)!; final drama = widget.drama; final tags = _tagValues( drama, Localizations.localeOf(context).languageCode, ); final related = _relatedDramas(); return ListView( key: const Key('drama-info-detail'), padding: const EdgeInsets.fromLTRB( _pageHorizontal, 12, _pageHorizontal, 28, ), children: [ _headline(l10n, drama), const SizedBox(height: 16), _synopsis(l10n, drama), if (tags.isNotEmpty) ...[const SizedBox(height: 14), _tagRow(tags)], const SizedBox(height: 20), _favoriteButton(l10n, drama), if (related.isNotEmpty) ...[ const SizedBox(height: 26), _relatedSection(l10n, related), ], ], ); } /// 海报 + 剧名 + 热度。 Widget _headline(AppLocalizations l10n, Drama drama) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(_posterRadius), child: SizedBox( width: _posterWidth, child: AspectRatio( aspectRatio: 3 / 4, child: RemoteImage(drama.posterUrl, fit: BoxFit.cover), ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( localizedDramaTitle(l10n, drama), key: const Key('drama-info-title'), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 20, fontWeight: FontWeight.w800, height: 1.25, ), ), const SizedBox(height: 8), Row( children: [ const Icon( Icons.local_fire_department_rounded, size: 16, color: AppColors.blue, ), const SizedBox(width: 4), Text( formatInteractionCount(drama.viewCount), style: const TextStyle( color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 12), Text( l10n.episodeCountLabel(drama.episodeCount), style: const TextStyle( color: AppColors.muted, fontSize: 13, ), ), ], ), ], ), ), ], ); } Widget _synopsis(AppLocalizations l10n, Drama drama) { final synopsis = localizedDramaSynopsis(l10n, drama).trim(); if (synopsis.isEmpty) return const SizedBox.shrink(); return LayoutBuilder( builder: (context, constraints) { final painter = TextPainter( text: TextSpan(text: synopsis, style: _synopsisStyle), maxLines: _synopsisLines, textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth); final overflows = painter.didExceedMaxLines; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( synopsis, key: const Key('drama-info-synopsis'), maxLines: _synopsisExpanded ? null : _synopsisLines, overflow: _synopsisExpanded ? TextOverflow.visible : TextOverflow.ellipsis, style: _synopsisStyle, ), if (overflows) GestureDetector( key: const Key('drama-info-synopsis-toggle'), onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), child: Padding( padding: const EdgeInsets.only(top: 4), child: Text( _synopsisExpanded ? l10n.showLess : l10n.expandSynopsis, style: const TextStyle( color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w700, ), ), ), ), ], ); }, ); } Widget _tagRow(List tags) { return Wrap( key: const Key('drama-info-tags'), spacing: 8, runSpacing: 8, children: [ for (final tag in tags) Container( padding: const EdgeInsets.fromLTRB(10, 5, 4, 5), decoration: BoxDecoration( color: AppColors.raised, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( tag, style: const TextStyle( color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w500, ), ), const Icon( Icons.chevron_right_rounded, size: 14, color: AppColors.subtle, ), ], ), ), ], ); } Widget _favoriteButton(AppLocalizations l10n, Drama drama) { final favorites = context.watch(); final saved = favorites.isSaved(drama.id); return SizedBox( height: 48, child: Material( color: AppColors.raised, borderRadius: BorderRadius.circular(12), child: InkWell( key: const Key('drama-info-favorite'), borderRadius: BorderRadius.circular(12), onTap: () => unawaited(_toggleFavorite(l10n, drama)), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, size: 20, color: AppColors.onPrimary, ), const SizedBox(width: 8), Text( l10n.favorite, style: const TextStyle( color: AppColors.onPrimary, fontSize: 15, fontWeight: FontWeight.w700, ), ), ], ), ), ), ); } Widget _relatedSection(AppLocalizations l10n, List related) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.similarDramas, style: const TextStyle( color: AppColors.onSurface, fontSize: 17, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 12), SizedBox( height: _relatedWidth / 3 * 4 + 8, child: ListView.separated( key: const Key('drama-info-related'), scrollDirection: Axis.horizontal, itemCount: related.length, separatorBuilder: (_, _) => const SizedBox(width: _relatedGap), itemBuilder: (context, index) { final drama = related[index]; return GestureDetector( key: Key('drama-info-related-${drama.id}'), onTap: () { Navigator.pop(context); widget.onOpenRelated?.call(drama); }, child: ClipRRect( borderRadius: BorderRadius.circular(AppDimens.coverRadius), child: SizedBox( width: _relatedWidth, child: AspectRatio( aspectRatio: 3 / 4, child: RemoteImage(drama.posterUrl, fit: BoxFit.cover), ), ), ), ); }, ), ), ], ); } Widget _episodePane() { final l10n = AppLocalizations.of(context)!; final episodes = widget.drama.episodes; if (episodes.isEmpty) { return Center( child: Text( l10n.noEpisodesYet, key: const Key('drama-info-no-episodes'), style: const TextStyle(color: AppColors.muted, fontSize: 13), ), ); } return GridView.builder( key: const Key('drama-info-episodes'), padding: const EdgeInsets.fromLTRB( _pageHorizontal, 8, _pageHorizontal, 12, ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: _columns, mainAxisSpacing: AppDimens.spaceSm, crossAxisSpacing: AppDimens.spaceSm, childAspectRatio: _cellAspect, ), itemCount: episodes.length, itemBuilder: (context, index) { final episode = episodes[index]; return EpisodePickerCell( key: Key('player-episode-${episode.number}'), episode: episode, selected: episode.number == widget.currentNumber, lockLabel: l10n.locked, onTap: () { Navigator.pop(context); widget.onSelectEpisode(index); }, ); }, ); } Future _toggleFavorite(AppLocalizations l10n, Drama drama) async { final favorites = context.read(); final ok = await favorites.toggle(drama.id, card: drama); if (ok || !mounted) return; if (favorites.lastErrorCode == 200007) { await ensureSignedIn(context); return; } ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); } /// 相似推荐先用目录里的其它剧兜底,没有目录来源就不显示这一块。 List _relatedDramas() { final CatalogController catalog; try { catalog = context.read(); } on ProviderNotFoundException { return const []; } return catalog.dramas .where((item) => item.id != widget.drama.id) .take(_maxRelated) .toList(growable: false); } List _tagValues(Drama drama, String languageCode) { final localized = drama.localizedTypeLabels(languageCode); if (localized.isNotEmpty) { return localized.take(_maxTags).toList(growable: false); } final result = []; for (final source in [drama.genre, drama.category]) { for (final part in source.split(_tagSplitter)) { final tag = part.trim(); if (tag.isEmpty || result.contains(tag)) continue; result.add(tag); if (result.length == _maxTags) return result; } } return result; } }