// 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/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../discover/widgets/drama_grid.dart'; import '../player/open_player.dart'; const int _moreLimit = 12; const int _filmHoles = 8; const double _cardPadding = 8; const double _cardWidth = 124; const double _rowHeight = 204; const double _cardRadius = 12; const double _loadMoreThreshold = 200; /// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。 class NewReleasesPage extends StatefulWidget { const NewReleasesPage({super.key}); @override State createState() => _NewReleasesPageState(); } class _NewReleasesPageState extends State { final ScrollController _scroll = ScrollController(); PagedLocalListController? _pager; bool _refreshing = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_pager != null) return; _pager = PagedLocalListController( items: _newest(context.read().dramas), )..addListener(_rebuild); } @override void dispose() { _pager ?..removeListener(_rebuild) ..dispose(); _scroll.dispose(); super.dispose(); } void _rebuild() { if (mounted) setState(() {}); } List _newest(List source) { return [...source]..sort((a, b) => b.id.compareTo(a.id)); } Future _refresh() async { if (_refreshing) return; _refreshing = true; try { final catalog = context.read(); await catalog.refresh(); if (!mounted) return; _pager!.replaceAll(_newest(catalog.dramas)); if (_scroll.hasClients) { _scroll.jumpTo(_scroll.position.minScrollExtent); } } finally { _refreshing = false; } } bool _onScroll(ScrollNotification notification) { if (notification.depth != 0 || _refreshing) return false; final reachedBottom = notification.metrics.extentAfter <= _loadMoreThreshold; if (reachedBottom && (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification)) { unawaited(_pager!.loadMore()); } return false; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final pager = _pager!; final visible = pager.visibleItems; final hero = visible.isEmpty ? null : visible.first; final more = pager.items.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Scaffold( key: const Key('new-releases-page'), backgroundColor: AppColors.ink, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [AppColors.surfaceTop, AppColors.ink], ), ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppSubpageHeader( title: l10n.newReleasesTitle, subtitle: l10n.newReleasesSubtitle, backKey: const Key('new-releases-back'), actions: _countChip(l10n, pager.items.length), ), Expanded( child: NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( key: const Key('new-releases-refresh'), onRefresh: _refresh, child: ListView( key: const Key('new-releases-list'), controller: _scroll, physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(bottom: 24), children: hero == null ? [ SizedBox( height: 420, child: Center( child: Text( l10n.noStories, key: const Key('new-releases-empty'), style: const TextStyle( color: AppColors.muted, ), ), ), ), ] : [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, ), child: _heroCard(context, l10n, hero), ), const SizedBox(height: 14), const _FilmStrip(), const SizedBox(height: 14), if (more.isNotEmpty) ...[ _moreHeader(l10n), const SizedBox(height: 10), SizedBox( height: _rowHeight, child: ListView.separated( key: const Key('new-releases-row'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 16, ), itemCount: more.length, separatorBuilder: (_, _) => const SizedBox(width: 10), itemBuilder: (context, index) => _moreCard( context, l10n, more[index], index, ), ), ), ], const SizedBox(height: 20), _allHeader(l10n), const SizedBox(height: 10), DramaGrid( key: const Key('new-releases-all-grid'), l10n: l10n, dramas: visible, onOpen: (drama) => unawaited( playDramaFromCatalog(context, drama), ), ), PagedLoadFooter( loading: pager.loading, hasMore: pager.hasMore, hasItems: pager.items.isNotEmpty, loadMoreKey: const Key( 'new-releases-load-more', ), noMoreKey: const Key('new-releases-no-more'), noMoreLabel: l10n.noMore, ), ], ), ), ), ), ], ), ), ), ), ); } Widget _countChip(AppLocalizations l10n, int count) { return Container( key: const Key('new-releases-count'), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.badgeBeige, borderRadius: BorderRadius.circular(999), ), child: Text( l10n.newReleasesCount(count), style: const TextStyle( color: AppColors.blue, fontSize: 11, fontWeight: FontWeight.w700, ), ), ); } Widget _heroCard(BuildContext context, AppLocalizations l10n, Drama drama) { return GestureDetector( key: const Key('new-releases-hero'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 16 / 9, ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.transparent, AppColors.playerOverlayPill, ], ), ), ), Positioned( top: 10, left: 10, child: _newBadge( l10n, key: const Key('new-releases-hero-badge'), ), ), Positioned( left: 12, right: 64, bottom: 10, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( l10n.premiereToday, style: const TextStyle( color: AppColors.muted, fontSize: 10, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( drama.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, fontSize: 20, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), Row( children: [ const Icon( Icons.local_fire_department_rounded, color: AppColors.blue, size: 12, ), const SizedBox(width: 4), Flexible( child: Text( l10n.heatLabel( formatInteractionCount(drama.viewCount), ), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 11, ), ), ), ], ), ], ), ), Positioned( right: 12, bottom: 12, child: Container( key: const Key('new-releases-hero-play'), width: 40, height: 40, decoration: const BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow_rounded, color: AppColors.onPrimary, size: 22, ), ), ), ], ), ), ), ); } Widget _moreHeader(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Expanded( child: Text( l10n.rankingNew, style: const TextStyle( color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), ), ), Text( l10n.seeAll, style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), ); } Widget _allHeader(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( l10n.newReleasesAll, key: const Key('new-releases-all-header'), style: const TextStyle( color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), ), ); } Widget _moreCard( BuildContext context, AppLocalizations l10n, Drama drama, int index, ) { return GestureDetector( key: Key('new-release-card-${drama.id}'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Container( width: _cardWidth, padding: const EdgeInsets.all(_cardPadding), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), boxShadow: const [ BoxShadow( color: AppColors.cardShadow, blurRadius: 12, offset: Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: 108, height: 144, child: Stack( fit: StackFit.expand, children: [ RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), Positioned( top: 8, left: 8, child: _newBadge( l10n, key: Key('new-release-badge-$index'), compact: true, ), ), ], ), ), ), const SizedBox(height: 6), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( _releaseDate(index), style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), ), ); } /// 目录暂无上架时间字段,用倒序位次生成近几日的展示日期。 String _releaseDate(int index) { final date = DateTime.now().subtract(Duration(days: index + 1)); final month = date.month.toString().padLeft(2, '0'); final day = date.day.toString().padLeft(2, '0'); return '$month/$day'; } Widget _newBadge( AppLocalizations l10n, { required Key key, bool compact = false, }) { return Container( key: key, padding: EdgeInsets.symmetric( horizontal: compact ? 6 : 8, vertical: compact ? 2 : 3, ), decoration: BoxDecoration( color: AppColors.blue, borderRadius: BorderRadius.circular(999), ), child: Text( l10n.newBadge, style: TextStyle( color: AppColors.onPrimary, fontSize: compact ? 9 : 10, fontWeight: FontWeight.w800, ), ), ); } } class _FilmStrip extends StatelessWidget { const _FilmStrip(); @override Widget build(BuildContext context) { return Container( key: const Key('new-releases-film'), height: 22, decoration: const BoxDecoration( color: AppColors.raised, border: Border( top: BorderSide(color: AppColors.border), bottom: BorderSide(color: AppColors.border), ), ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_holes(), _holes()], ), ); } Widget _holes() { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (var index = 0; index < _filmHoles; index++) Container( width: 6, height: 8, decoration: BoxDecoration( color: AppColors.border, borderRadius: BorderRadius.circular(2), ), ), ], ); } }