// 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/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/formatting/error_text.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/empty_state.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'favorites_binding.dart'; const double _pageHorizontal = 14; const double _cardRadius = 14; const double _cardPadding = 10; const double _coverWidth = 80; const double _coverHeight = 106; const double _coverRadius = 10; const double _removeSize = 28; const double _removeIconSize = 20; const double _statsFontSize = 12; const double _metaFontSize = 11; const double _titleFontSize = 15; const int _maxTags = 2; const double _emptyListHeight = 360; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 独立我的收藏页。 class FavoritesPage extends StatefulWidget { const FavoritesPage({super.key}); @override State createState() => _FavoritesPageState(); } class _FavoritesPageState extends State { bool _requested = false; @override void didChangeDependencies() { super.didChangeDependencies(); final signedIn = context.watch().isSignedIn; if (!signedIn) { _requested = false; return; } if (_requested) return; _requested = true; final favorites = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(FavoritesBinding().load(favorites)); }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), child: Scaffold( key: const Key('favorites-page'), backgroundColor: AppColors.ink, body: SafeArea( child: Column( children: [ AppSubpageHeader( title: l10n.myFavorites, backKey: const Key('favorites-back'), ), Expanded( child: signedIn ? _FavoritesBody( favorites: context.watch(), ) : const AuthGatePanel(), ), ], ), ), ), ); } } class _FavoritesBody extends StatelessWidget { const _FavoritesBody({required this.favorites}); final FavoriteController favorites; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; if (favorites.loading && favorites.cards.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('favorites-loading')), ); } if (favorites.error != null && favorites.cards.isEmpty) { return _refreshable( favorites, children: [ SizedBox( height: _emptyListHeight, child: EmptyState( key: const Key('favorites-error'), icon: Icons.cloud_off_rounded, title: errorText(l10n, favorites.error, l10n.favoritesError), actionLabel: l10n.retry, actionKey: const Key('favorites-retry'), onAction: () => unawaited(favorites.load()), ), ), ], ); } if (favorites.cards.isEmpty) { return _refreshable( favorites, children: [ SizedBox( height: _emptyListHeight, child: EmptyState( key: const Key('favorites-empty'), icon: Icons.star_border_rounded, title: l10n.noFavorites, subtitle: l10n.emptyHint, ), ), ], ); } return _refreshable( favorites, children: [ _summary(l10n), for (final (index, drama) in favorites.cards.indexed) ...[ if (index > 0) const SizedBox(height: 10), _FavoriteCard( drama: drama, isFavorite: favorites.isSaved(drama.id), onOpen: () => _open(context, drama), onRemove: () => _remove(context, drama.id), ), ], ], ); } Widget _refreshable( FavoriteController favorites, { required List children, }) { return RefreshIndicator( color: AppColors.blue, backgroundColor: AppColors.card, strokeWidth: 2, onRefresh: favorites.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( _pageHorizontal, 4, _pageHorizontal, 28, ), children: children, ), ); } Widget _summary(AppLocalizations l10n) { return Align( alignment: Alignment.centerLeft, child: Text( l10n.favoritesCount(favorites.cards.length), key: const Key('favorites-count'), style: const TextStyle( color: AppColors.muted, fontSize: _statsFontSize, ), ), ); } Future _open(BuildContext context, Drama drama) async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), ); } Future _remove(BuildContext context, String dramaId) async { unawaited(context.read().lightImpact()); final ok = await favorites.remove(dramaId); if (ok || !context.mounted) return; final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); } } class _FavoriteCard extends StatelessWidget { const _FavoriteCard({ required this.drama, required this.isFavorite, required this.onOpen, required this.onRemove, }); final Drama drama; final bool isFavorite; final VoidCallback onOpen; final VoidCallback onRemove; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final tags = _tags(); return Container( key: ValueKey('favorite-card-${drama.id}'), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), boxShadow: const [AppTheme.cardShadow], ), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(_cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, child: Padding( padding: const EdgeInsets.all(_cardPadding), child: Stack( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(_coverRadius), child: RemoteImage( drama.posterUrl, key: ValueKey('favorite-cover-${drama.id}'), width: _coverWidth, height: _coverHeight, ), ), const SizedBox(width: 12), Expanded( child: Padding( padding: const EdgeInsets.only( right: _removeSize, top: 2, ), child: SizedBox( height: _coverHeight, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( drama.title, key: ValueKey('favorite-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: _titleFontSize, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), if (tags.isNotEmpty) SizedBox( height: 20, child: SingleChildScrollView( scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), child: Row( children: [ for (final tag in tags) Padding( padding: const EdgeInsets.only( right: 5, ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: AppColors.raised, borderRadius: BorderRadius.circular(6), ), child: Text( tag, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: _metaFontSize, ), ), ), ), ], ), ), ), const Spacer(), Row( children: [ const Icon( Icons.local_fire_department_rounded, color: AppColors.blue, size: 13, ), const SizedBox(width: 3), Text( formatInteractionCount(drama.viewCount), style: const TextStyle( color: AppColors.muted, fontSize: _metaFontSize, ), ), const Spacer(), Text( l10n.episodeCountLabel(drama.episodeCount), style: const TextStyle( color: AppColors.muted, fontSize: _metaFontSize, ), ), ], ), ], ), ), ), ), ], ), Positioned( top: 0, right: 0, child: SizedBox.square( dimension: _removeSize, child: Material( color: AppColors.raised, shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: IconButton( key: ValueKey('favorite-remove-${drama.id}'), tooltip: l10n.remove, padding: EdgeInsets.zero, onPressed: onRemove, icon: Icon( isFavorite ? Icons.star_rounded : Icons.star_border_rounded, color: isFavorite ? AppColors.blue : AppColors.muted, size: _removeIconSize, ), ), ), ), ), ], ), ), ), ), ); } List _tags() { final values = []; for (final source in [drama.genre, drama.category]) { for (final part in source.split(_tagSplitter)) { final value = part.trim(); if (value.isNotEmpty && !values.contains(value)) values.add(value); if (values.length == _maxTags) return values; } } return values; } }