fix(flutter): 修复详情页首帧闪屏
This commit is contained in:
1 parent
a11ba50b13
commit
bb92996db5
4 files changed
+390
-13
No files matched your search
@@ -28,6 +28,7 @@ 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';
|
||||
@@ -93,17 +94,23 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
|
||||
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);
|
||||
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;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
bottomNavigationBar: DramaDetailBottomBar(
|
||||
l10n: l10n,
|
||||
continueIndex: continueIndex,
|
||||
continueIndex: episodesReady ? continueIndex : null,
|
||||
enabled: episodesReady,
|
||||
onPlay: () => logic.openPlayer(context, continueIndex ?? 0),
|
||||
resume: _resume(l10n),
|
||||
),
|
||||
@@ -134,20 +141,32 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
if (_tagValues(drama).isNotEmpty)
|
||||
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))),
|
||||
SliverToBoxAdapter(
|
||||
child: DramaEpisodeGrid(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
continueIndex: continueIndex,
|
||||
onOpen: (index) => logic.openPlayer(context, index),
|
||||
),
|
||||
),
|
||||
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()),
|
||||
|
||||
@@ -16,12 +16,14 @@ class DramaDetailBottomBar extends StatelessWidget {
|
||||
required this.l10n,
|
||||
required this.continueIndex,
|
||||
required this.onPlay,
|
||||
required this.enabled,
|
||||
this.resume,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final int? continueIndex;
|
||||
final VoidCallback onPlay;
|
||||
final bool enabled;
|
||||
final Widget? resume;
|
||||
|
||||
@override
|
||||
@@ -44,10 +46,12 @@ class DramaDetailBottomBar extends StatelessWidget {
|
||||
height: _ctaHeight,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('detail-cta'),
|
||||
onPressed: onPlay,
|
||||
onPressed: enabled ? onPlay : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
disabledForegroundColor: AppColors.muted,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_ctaRadius),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/formatting/error_text.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _skeletonCount = 6;
|
||||
const int _gridColumns = 6;
|
||||
const double _gridSpacing = 8;
|
||||
const double _radius = 10;
|
||||
const double _horizontal = 14;
|
||||
const double _sectionTop = 16;
|
||||
const double _minOpacity = 0.45;
|
||||
const double _maxOpacity = 0.8;
|
||||
const Duration _breathDuration = Duration(milliseconds: 900);
|
||||
|
||||
/// 详情接口返回前在选集区展示六个呼吸骨架格。
|
||||
class DramaEpisodesLoading extends StatefulWidget {
|
||||
const DramaEpisodesLoading({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.episodeCount,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final int episodeCount;
|
||||
|
||||
@override
|
||||
State<DramaEpisodesLoading> createState() => _DramaEpisodesLoadingState();
|
||||
}
|
||||
|
||||
class _DramaEpisodesLoadingState extends State<DramaEpisodesLoading>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: _breathDuration)
|
||||
..repeat(reverse: true);
|
||||
_opacity = Tween<double>(
|
||||
begin: _minOpacity,
|
||||
end: _maxOpacity,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('detail-episodes-section'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_horizontal,
|
||||
_sectionTop,
|
||||
_horizontal,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_SectionTitle(title: widget.l10n.episodes),
|
||||
const Spacer(),
|
||||
if (widget.episodeCount > 0)
|
||||
Text(
|
||||
widget.l10n.episodeCountLabel(widget.episodeCount),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GridView.builder(
|
||||
key: const Key('detail-episodes-loading'),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _skeletonCount,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _gridColumns,
|
||||
mainAxisSpacing: _gridSpacing,
|
||||
crossAxisSpacing: _gridSpacing,
|
||||
),
|
||||
itemBuilder: (context, index) => FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(_radius),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情接口失败时只替换选集区,保留上方卡片与简介。
|
||||
class DramaEpisodesLoadError extends StatelessWidget {
|
||||
const DramaEpisodesLoadError({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final String? message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('detail-episodes-section'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_horizontal,
|
||||
_sectionTop,
|
||||
_horizontal,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(title: l10n.episodes),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, message, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
key: const Key('detail-episodes-retry'),
|
||||
onPressed: onRetry,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user