// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; const double _ticketRadius = AppDimens.cardRadius; const double _notchSize = 24; const Color _ticketDash = AppColors.primaryBorder; /// 预约页:档期周历 + 电影票式空态 + 预约权益说明。 class ReservePage extends StatefulWidget { const ReservePage({super.key}); @override State createState() => _ReservePageState(); } class _ReservePageState extends State { late final List _week; late int _selected; @override void initState() { super.initState(); final today = DateTime.now(); final monday = today.subtract(Duration(days: today.weekday - 1)); _week = [ for (var index = 0; index < 7; index++) monday.add(Duration(days: index)), ]; _selected = today.weekday - 1; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( key: const Key('reserve-page'), backgroundColor: AppColors.ink, body: SafeArea( child: AppPullToRefresh( key: const Key('reserve-refresh'), onRefresh: _refresh, child: ListView( key: const Key('reserve-list'), physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), children: [ AppSubpageHeader( title: l10n.reserveTitle, subtitle: l10n.reserveSubtitle, backKey: const Key('reserve-back'), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _weekStrip(l10n), ), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: _ticketCard(context, l10n), ), Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), child: _steps(l10n), ), ], ), ), ), ); } Future _refresh() async { await context.read().refresh(); } Widget _weekStrip(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.reserveWeek, style: const TextStyle( color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), SizedBox( key: const Key('reserve-week-strip'), height: 56, child: Row( children: [ for (var index = 0; index < _week.length; index++) ...[ if (index > 0) const SizedBox(width: 6), Expanded(child: _dayCapsule(index)), ], ], ), ), ], ); } Widget _dayCapsule(int index) { final date = _week[index]; final selected = _selected == index; final weekdays = ['一', '二', '三', '四', '五', '六', '日']; return GestureDetector( key: Key('reserve-day-$index'), behavior: HitTestBehavior.opaque, onTap: () => setState(() => _selected = index), child: Container( decoration: BoxDecoration( color: selected ? AppColors.blue : AppColors.card, borderRadius: BorderRadius.circular(14), border: Border.all( color: selected ? AppColors.blue : AppColors.border, ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( weekdays[date.weekday - 1], style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: 11, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( '${date.day}', style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w800, ), ), ], ), ), ); } Widget _ticketCard(BuildContext context, AppLocalizations l10n) { return LayoutBuilder( builder: (context, constraints) => Stack( clipBehavior: Clip.none, children: [ Container( key: const Key('reserve-empty'), width: double.infinity, padding: const EdgeInsets.fromLTRB(28, 26, 28, 22), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_ticketRadius), border: Border.all(color: AppColors.border), ), child: Column( children: [ Container( width: 56, height: 56, decoration: const BoxDecoration( color: AppColors.raised, shape: BoxShape.circle, ), child: const Icon( Icons.notifications_active_rounded, color: AppColors.blue, size: 32, ), ), const SizedBox(height: 12), Text( l10n.reserveEmptyTitle, style: const TextStyle( color: AppColors.onSurface, fontSize: 17, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 6), Text( l10n.reserveEmptyHint, maxLines: 2, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 12), ), const SizedBox(height: 16), SizedBox( height: 40, child: FilledButton( key: const Key('reserve-go-new'), onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => const NewReleasesPage(), ), ), style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( l10n.goNewReleases, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), Positioned(left: -_notchSize / 2, top: 120, child: _notch()), Positioned(right: -_notchSize / 2, top: 120, child: _notch()), Positioned.fill( child: IgnorePointer( child: CustomPaint(painter: _DashedTicketPainter()), ), ), ], ), ); } Widget _notch() { return Container( width: _notchSize, height: _notchSize, decoration: const BoxDecoration( color: AppColors.ink, shape: BoxShape.circle, ), ); } Widget _steps(AppLocalizations l10n) { final steps = [l10n.reserveStep1, l10n.reserveStep2, l10n.reserveStep3]; return Column( key: const Key('reserve-steps'), crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.reserveStepsTitle, style: const TextStyle( color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 10), for (var index = 0; index < steps.length; index++) ...[ if (index > 0) const SizedBox(height: 8), Container( key: Key('reserve-step-${index + 1}'), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: Row( children: [ Container( width: 22, height: 22, alignment: Alignment.center, decoration: const BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, ), child: Text( '${index + 1}', style: const TextStyle( color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 10), Text( steps[index], style: const TextStyle( color: AppColors.onSurface, fontSize: 14, fontWeight: FontWeight.w600, ), ), ], ), ), ], ], ); } } class _DashedTicketPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = _ticketDash ..style = PaintingStyle.stroke ..strokeWidth = 1; final path = Path() ..addRRect( RRect.fromRectAndRadius( Offset.zero & size, const Radius.circular(_ticketRadius), ), ); for (final metric in path.computeMetrics()) { var distance = 0.0; while (distance < metric.length) { final next = (distance + 6).clamp(0.0, metric.length); canvas.drawPath(metric.extractPath(distance, next), paint); distance = next + 4; } } } @override bool shouldRepaint(covariant _DashedTicketPainter oldDelegate) => false; }