Files
ReelShort/lib/pages/player/player_view.dart
T

991 lines
33 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 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.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 '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_featured_comment.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
/// The home feed has no overlay play button, so it keeps its own center hint.
/// Detail/series playback uses `_PlaybackOverlay.player-play-toggle` instead.
bool shouldShowFeedPauseIndicator({
required bool homeMode,
required bool selected,
required bool ready,
required bool playing,
required bool pip,
}) => homeMode && selected && ready && !playing && !pip;
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
required this.drama,
required this.initialIndex,
this.homeMode = false,
this.embedded = false,
this.active = true,
this.resumeRequest = 0,
this.backRequest = 0,
this.onDiscover,
this.onMy,
this.onSearch,
this.onMenu,
this.feedDramas,
this.onDramaChanged,
this.onImmersiveChanged,
});
final Drama drama;
final int initialIndex;
final bool homeMode;
final bool embedded;
final bool active;
final int resumeRequest;
final int backRequest;
final VoidCallback? onDiscover;
final VoidCallback? onMy;
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
@override
void initState() {
super.initState();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
logic.onPageUpdated(oldWidget);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
logic.onLifecycle(state);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _buildPlayer(context),
);
}
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: widget.embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
),
);
}
Widget _buildEpisode(
BuildContext context,
int index,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
: entry.episode;
final selected = index == logic.session.currentIndex;
// Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder.
final port =
(selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.pip || logic.pipPreparing;
Widget? video;
if (ready) {
video = GestureDetector(
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox(
width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height,
child: VideoPlayer(
videoPort.controller,
key: ValueKey(videoPort.controller),
),
),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: AppColors.ink),
?video,
if (!logic.chromeHidden)
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x00000000),
Color(0x33000000),
Color(0xE6000000),
],
stops: [0, 0.16, 0.55, 1],
),
),
),
),
// 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading)
const Center(
child: CircularProgressIndicator(color: AppColors.blueSoft),
),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(
child: CircularProgressIndicator(color: AppColors.blueSoft),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !ready && logic.blockedEpisodeId == episode.id)
_BlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
),
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
if (selected &&
!ready &&
logic.blockedEpisodeId != episode.id &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
if (shouldShowFeedPauseIndicator(
homeMode: widget.homeMode,
selected: selected,
ready: ready,
playing: logic.session.userWantsPlay,
pip: logic.pip,
))
Center(
child: PlayerCenterToggle(
key: const Key('home-feed-center-play'),
playing: false,
onTap: () => logic.session.setUserWantsPlay(true),
),
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (widget.homeMode)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
episode: episode,
liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onEpisodes: () => logic.showEpisodes(context),
onDownload: () => requestEpisodeDownload(context, episode.id),
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
)
else
_PlaybackOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onEpisodes: () => logic.showEpisodes(context),
onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
playing: logic.session.userWantsPlay,
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
),
],
);
}
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
class _BlockedNotice extends StatelessWidget {
const _BlockedNotice({
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
});
final AppLocalizations l10n;
final Episode episode;
final String? accessType;
final int unlockCoin;
@override
Widget build(BuildContext context) {
final needsUnlock =
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
final title = needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia
? l10n.noMediaNotice
: episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: _UnlockCard(
locked: needsUnlock,
title: title,
coinLabel: needsUnlock && unlockCoin > 0
? '$unlockCoin ${l10n.coins}'
: null,
footnote: needsUnlock ? l10n.unlockComingSoon : null,
),
),
);
}
}
/// Coin lock presented as a reward card: gold chip, chili only on the icon.
class _UnlockCard extends StatelessWidget {
const _UnlockCard({
required this.locked,
required this.title,
required this.coinLabel,
required this.footnote,
});
final bool locked;
final String title;
final String? coinLabel;
final String? footnote;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined,
color: locked ? AppColors.gold : AppColors.cream,
size: 32,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.cream,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (coinLabel != null) ...[
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
color: AppColors.gold,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Text(
coinLabel!,
style: const TextStyle(
color: AppColors.onGold,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
),
],
if (footnote != null) ...[
const SizedBox(height: 8),
Text(
footnote!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
],
),
),
);
}
}
class _PlaybackOverlay extends StatefulWidget {
const _PlaybackOverlay({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.playing,
required this.onTogglePlay,
required this.onBack,
required this.onEpisodes,
required this.onComments,
required this.onLike,
required this.onSave,
required this.onDownload,
required this.onShare,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
this.onNext,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final bool playing;
final VoidCallback onTogglePlay;
final VoidCallback? onBack;
final VoidCallback onEpisodes;
final VoidCallback onComments;
final VoidCallback? onNext;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onDownload;
final VoidCallback onShare;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
}
class _PlaybackOverlayState extends State<_PlaybackOverlay> {
Timer? _hideTimer;
bool _controlsVisible = true;
@override
void initState() {
super.initState();
_scheduleHide();
}
@override
void dispose() {
_hideTimer?.cancel();
super.dispose();
}
void _scheduleHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _controlsVisible = false);
});
}
void _reveal() {
setState(() => _controlsVisible = true);
_scheduleHide();
}
void _togglePlay() {
widget.onTogglePlay();
_reveal();
}
void _onVideoTap() {
if (!_controlsVisible) {
_reveal();
} else {
_togglePlay();
}
}
void _run(VoidCallback action) {
action();
_reveal();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
key: const Key('player-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: _onVideoTap,
),
),
if (_controlsVisible)
Column(
key: const Key('player-controls-visible'),
children: [
_topBar(context),
Expanded(
child: Stack(
children: [
Center(
child: PlayerCenterToggle(
key: const Key('player-play-toggle'),
playing: widget.playing,
onTap: _togglePlay,
),
),
const Positioned(
right: 10,
bottom: 14,
child: Text(
'1.0x',
style: TextStyle(
color: Color(0xFFDDDDDD),
fontSize: 9,
),
),
),
],
),
),
Padding(
key: const Key('player-controls-bottom'),
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
child: Column(
children: [
SeekProgressBar(
key: const Key('player-progress'),
valueListenable: widget.controller,
semanticsLabel: widget.l10n.seekVideo,
showTimes: false,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _PlayerAction(
key: const Key('player-episodes'),
icon: Icons.view_list_rounded,
label: widget.l10n.episodes,
onTap: () => _run(widget.onEpisodes),
),
),
const SizedBox(width: 8),
Expanded(
child: _PlayerAction(
key: const Key('player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: widget.l10n.comments,
onTap: () => _run(widget.onComments),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 32,
child: FilledButton(
key: const Key('player-next'),
onPressed: widget.onNext == null
? null
: () => _run(widget.onNext!),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised,
disabledForegroundColor: AppColors.subtle,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.l10n.nextEpisode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 2),
const Icon(
Icons.skip_next_rounded,
size: 14,
),
],
),
),
),
),
],
),
const SizedBox(height: 12),
PlayerFeaturedComment(
drama: widget.drama,
onViewAll: () => _run(widget.onComments),
),
],
),
),
],
),
],
),
);
}
Widget _topBar(BuildContext context) {
return SizedBox(
key: const Key('player-controls-top'),
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
if (widget.onBack != null)
_TopAction(
key: const Key('player-back'),
tooltip: widget.l10n.backToSeries,
icon: Icons.chevron_left_rounded,
onPressed: () => _run(widget.onBack!),
)
else
const SizedBox(width: 28),
const SizedBox(width: 8),
Expanded(
child: Text(
'${localizedDramaTitle(widget.l10n, widget.drama)} · '
'${widget.l10n.episodeLabel(widget.episode.number)}',
key: const Key('player-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
_TopAction(
key: const Key('player-more'),
tooltip: widget.l10n.more,
icon: Icons.more_horiz_rounded,
onPressed: () => _openMore(context),
),
],
),
),
);
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('player-more-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(widget.l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
widget.onDownload();
},
),
if (widget.onPip != null)
ListTile(
key: const Key('player-more-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(widget.l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
widget.onPip!();
},
),
ListTile(
key: const Key('player-more-like'),
leading: Icon(
widget.liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: widget.liked ? AppColors.blue : AppColors.muted,
),
title: Text(widget.l10n.like),
onTap: () {
Navigator.pop(sheetContext);
widget.onLike();
},
),
ListTile(
key: const Key('player-more-favorite'),
leading: Icon(
widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
color: widget.saved ? AppColors.blue : AppColors.muted,
),
title: Text(widget.l10n.favorite),
onTap: () {
Navigator.pop(sheetContext);
widget.onSave();
},
),
ListTile(
key: const Key('player-more-share'),
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
title: Text(widget.l10n.share),
onTap: () {
Navigator.pop(sheetContext);
widget.onShare();
},
),
],
),
),
);
if (mounted) _reveal();
}
}
class _TopAction extends StatelessWidget {
const _TopAction({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 15),
),
);
}
}
class _PlayerAction extends StatelessWidget {
const _PlayerAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF2A2A30),
borderRadius: BorderRadius.circular(9),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 32,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 14, color: AppColors.secondary),
const SizedBox(width: 3),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
}