diff --git a/assets/images/image_app_bar_bg.png b/assets/images/image_app_bar_bg.png new file mode 100644 index 0000000..f6a25e3 Binary files /dev/null and b/assets/images/image_app_bar_bg.png differ diff --git a/assets/images/image_ok.png b/assets/images/image_ok.png new file mode 100644 index 0000000..f2006f8 Binary files /dev/null and b/assets/images/image_ok.png differ diff --git a/assets/images/image_scan.png b/assets/images/image_scan.png new file mode 100644 index 0000000..57e1d27 Binary files /dev/null and b/assets/images/image_scan.png differ diff --git a/assets/images/image_vs.png b/assets/images/image_vs.png index 6848454..5dfd8b6 100644 Binary files a/assets/images/image_vs.png and b/assets/images/image_vs.png differ diff --git a/lib/features/events/pages/page_event_info.dart b/lib/features/events/pages/page_event_info.dart index d8d5540..5bf577f 100644 --- a/lib/features/events/pages/page_event_info.dart +++ b/lib/features/events/pages/page_event_info.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:recording_tool/app/config/app_config.dart'; @@ -67,42 +68,41 @@ class _EventInfoPageState extends ConsumerState { final state = ref.watch(eventInfoProvider); final profile = state.profile; - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: const Color(0xFFF5F6F8), + ), + child: Scaffold( + backgroundColor: const Color(0xFFF5F6F8), + appBar: myAppBar(context: context), + body: Stack( children: [ - _Header(onBack: () => AppNavigator.pop(context: context)), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(62.w, 8.h, 62.w, 40.h), - child: Column( - children: [ - _ProfileSection(profile: profile), - SizedBox(height: 20.h), - Text( - state.eventTitle, - style: TextStyle( - fontSize: 20.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), - fontWeight: FontWeight.w500, - ), + const _TopGradientHeader(), + SafeArea( + bottom: false, + child: Column( + children: [ + // _Header(onBack: () => AppNavigator.pop(context: context)), + SizedBox(height: 10.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: _ProfileSection( + profile: profile, + eventTitle: state.eventTitle, ), - SizedBox(height: 24.h), - SizedBox( - height: 400.h, - child: _ScheduleList( - state: state, - onRetry: () => ref - .read(eventInfoProvider.notifier) - .loadRegistrationList(), - onItemTap: onItemTap, - ), + ), + SizedBox(height: 8.h), + Expanded( + child: _ScheduleList( + state: state, + onRetry: () => ref + .read(eventInfoProvider.notifier) + .loadRegistrationList(), + onItemTap: onItemTap, ), - ], - ), + ), + ], ), ), ], @@ -112,6 +112,31 @@ class _EventInfoPageState extends ConsumerState { } } +class _TopGradientHeader extends StatelessWidget { + const _TopGradientHeader(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 219.h, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(36.r), + bottomRight: Radius.circular(36.r), + ), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF5EC9FF), Color(0xFF178BFF)], + ), + ), + ), + ); + } +} + Widget buildEventRegistrationDestination({ required EventRegistrationItem item, required String playerId, @@ -135,16 +160,16 @@ class _Header extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 52.h, + height: 44.h, child: Align( alignment: Alignment.centerLeft, child: IconButton( onPressed: onBack, - icon: Icon(Icons.arrow_back_ios_new, size: 32.r), - color: Colors.black, + icon: Icon(Icons.chevron_left_rounded, size: 28.r), + color: Colors.white, tooltip: '返回', - padding: EdgeInsets.only(left: 16.w), - constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h), + padding: EdgeInsets.only(left: 10.w), + constraints: BoxConstraints(minWidth: 44.w, minHeight: 44.h), ), ), ); @@ -152,52 +177,143 @@ class _Header extends StatelessWidget { } class _ProfileSection extends StatelessWidget { - const _ProfileSection({required this.profile}); + const _ProfileSection({required this.profile, required this.eventTitle}); final EventProfile profile; + final String eventTitle; @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - profile.avatarUrl.isEmpty - ? AppAvatar(size: 50.r) - : AppAvatar(size: 50.r, imageUrl: profile.avatarUrl), - - SizedBox(width: 28.w), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: 30.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), + final title = eventTitle.trim().isEmpty ? '赛事信息' : eventTitle.trim(); + return Container( + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6.r), + ), + child: Stack( + children: [ + // Positioned( + // top: -28.h, + // right: -14.w, + // child: Transform.rotate( + // angle: 0.68, + // child: Container( + // width: 110.w, + // height: 138.h, + // decoration: BoxDecoration( + // gradient: LinearGradient( + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // colors: [ + // const Color(0xFF53D7FF).withValues(alpha: 0.12), + // const Color(0xFFFF85C8).withValues(alpha: 0.13), + // ], + // ), + // ), + // ), + // ), + // ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + profile.avatarUrl.isEmpty + ? AppAvatar( + size: 52.r, + initials: profile.name.isEmpty ? 'S' : profile.name, + ) + : AppAvatar(size: 52.r, imageUrl: profile.avatarUrl), + SizedBox(width: 12.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name.isEmpty ? '参赛选手' : profile.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.sp, + height: 1.2, + color: const Color(0xFF33363B), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 5.h), + Text( + profile.phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.sp, + height: 1.2, + color: const Color(0xFF969CA6), + ), + ), + ], + ), ), - ), - SizedBox(height: 36.h), - Text( - profile.phone, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), - ), - ), - ], - ), + ], + ), + SizedBox(height: 10.h), + Divider( + height: 1.h, + thickness: 1.h, + color: const Color(0xFFE8EAED), + ), + SizedBox(height: 10.h), + _EventTitleHighlight(title: title), + ], ), + ], + ), + ); + } +} + +class _EventTitleHighlight extends StatelessWidget { + const _EventTitleHighlight({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: IntrinsicWidth( + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + Positioned( + left: 0, + right: 0, + bottom: 2.h, + child: Container( + height: 8.h, + decoration: BoxDecoration( + color: const Color(0xFFB8F5E8), + borderRadius: BorderRadius.circular(4.r), + ), + ), + ), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, + height: 1.25, + color: const Color(0xFF33363B), + fontWeight: FontWeight.w600, + ), + ), + ], ), - ], + ), ); } } @@ -223,97 +339,68 @@ class _ScheduleList extends StatelessWidget { Text( '暂无赛事报名信息', textAlign: TextAlign.center, - style: TextStyle(fontSize: 18.sp, color: const Color(0xFF2F2F2F)), + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF3A3D42)), ), - SizedBox(height: 18.h), + SizedBox(height: 14.h), TextButton(onPressed: onRetry, child: const Text('重新加载')), ], ), ); } - return ListView.builder( + return ListView.separated( + padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 24.h), itemCount: state.items.length, + separatorBuilder: (_, _) => SizedBox(height: 8.h), itemBuilder: (context, index) { final item = state.items[index]; - return _ScheduleCard(item: item, onTap: () => onItemTap(item)); + return _ScheduleCard( + item: item, + scheduleIndex: index + 1, + onTap: () => onItemTap(item), + ); }, ); } } class _ScheduleCard extends StatelessWidget { - const _ScheduleCard({required this.item, required this.onTap}); + const _ScheduleCard({ + required this.item, + required this.scheduleIndex, + required this.onTap, + }); final EventRegistrationItem item; + final int scheduleIndex; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: Colors.white, + borderRadius: BorderRadius.circular(6.r), child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(6.r), child: Container( - constraints: BoxConstraints(minHeight: 138.h), - padding: EdgeInsets.fromLTRB(10.w, 14.h, 14.w, 0), + constraints: BoxConstraints(minHeight: 86.h), + padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h), decoration: BoxDecoration( - border: Border.all(color: const Color(0xFF7A7A7A)), + color: Colors.white, + borderRadius: BorderRadius.circular(6.r), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.groupName.isEmpty - ? item.itemName - : '${item.itemName} (${item.groupName})', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 20.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 26.h), - Text( - item.matchPlace, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 20.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), - ), - ), - SizedBox(height: 26.h), - Text( - item.scheduleTime, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 20.sp, - height: 1.2, - color: const Color(0xFF2F2F2F), - ), - ), - ], + child: _ScheduleDetails( + item: item, + scheduleIndex: scheduleIndex, ), ), SizedBox(width: 12.w), - SizedBox( - width: 148.w, - height: 120.h, - child: Align( - alignment: Alignment.center, - child: _ScheduleBadge(item: item), - ), - ), + _ScheduleStatus(item: item), ], ), ), @@ -322,72 +409,148 @@ class _ScheduleCard extends StatelessWidget { } } -class _ScheduleBadge extends StatelessWidget { - const _ScheduleBadge({required this.item}); +class _ScheduleDetails extends StatelessWidget { + const _ScheduleDetails({required this.item, required this.scheduleIndex}); + + final EventRegistrationItem item; + final int scheduleIndex; + + @override + Widget build(BuildContext context) { + final memberLine = _formatMemberLine(item); + final opponentLine = _formatOpponentLine(item); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatTitle(item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + height: 1.2, + color: const Color(0xFF30343A), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + _MetaText(_formatVenue(item, scheduleIndex)), + SizedBox(height: 3.h), + _MetaText(_formatScheduleTime(item)), + if (memberLine.isNotEmpty) ...[ + SizedBox(height: 3.h), + _MetaText(memberLine), + ], + if (opponentLine.isNotEmpty) ...[ + SizedBox(height: 3.h), + _MetaText(opponentLine), + ], + ], + ); + } +} + +class _MetaText extends StatelessWidget { + const _MetaText(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.sp, + height: 1.2, + color: const Color(0xFF6E747D), + fontWeight: FontWeight.w400, + ), + ); + } +} + +class _ScheduleStatus extends StatelessWidget { + const _ScheduleStatus({required this.item}); final EventRegistrationItem item; @override Widget build(BuildContext context) { - if (item.statusLabel != null) { - return CustomPaint( - painter: _CutCornerBorderPainter(color: const Color(0xFF7A7A7A)), - child: SizedBox( - width: 148.w, - height: 90.h, - child: Center( - child: Text( - item.statusLabel!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 20.sp, color: const Color(0xFF2F2F2F)), - ), - ), + final status = item.statusLabel; + if (status != null) { + return Text( + status, + style: TextStyle( + fontSize: 10.sp, + height: 1.2, + color: const Color(0xFF1DBF73), + fontWeight: FontWeight.w500, ), ); } return Text( - item.matchPlace, + _formatNumberBadge(item), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 34.sp, + fontSize: 20.sp, height: 1, - color: const Color(0xFF2F2F2F), + color: const Color(0xFFF27D69), fontWeight: FontWeight.w700, ), ); } } -class _CutCornerBorderPainter extends CustomPainter { - const _CutCornerBorderPainter({required this.color}); +String _formatTitle(EventRegistrationItem item) { + if (item.groupName.isEmpty) return item.itemName; + return '${item.itemName} (${item.groupName})'; +} - final Color color; +String _formatVenue(EventRegistrationItem item, int scheduleIndex) { + final place = item.matchPlace.trim(); + if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区'; + return '$place号场馆$scheduleIndex区'; +} - @override - void paint(Canvas canvas, Size size) { - final side = 12.r; - final path = Path() - ..moveTo(side, 0) - ..lineTo(size.width - side, 0) - ..lineTo(size.width, side) - ..lineTo(size.width, size.height - side) - ..lineTo(size.width - side, size.height) - ..lineTo(side, size.height) - ..lineTo(0, size.height - side) - ..lineTo(0, side) - ..close(); - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..color = color; - canvas.drawPath(path, paint); +String _formatScheduleTime(EventRegistrationItem item) { + final start = DateTime.tryParse(item.matchStartTime); + final end = DateTime.tryParse(item.matchEndTime); + if (start == null && end == null) return item.scheduleTime; + if (start != null && end != null) { + return '${start.month}月${start.day}日 ${_formatClock(start)}-${_formatClock(end)}'; } + final value = start ?? end!; + return '${value.month}月${value.day}日 ${_formatClock(value)}'; +} - @override - bool shouldRepaint(covariant _CutCornerBorderPainter oldDelegate) { - return oldDelegate.color != color; - } +String _formatClock(DateTime value) { + return '${value.hour}:${value.minute.toString().padLeft(2, '0')}'; +} + +String _formatMemberLine(EventRegistrationItem item) { + final names = item.teamMembers + .map((member) => member.name.trim()) + .where((name) => name.isNotEmpty) + .toList(growable: false); + return names.join('、'); +} + +String _formatOpponentLine(EventRegistrationItem item) { + final opponent = item.opponentName.trim(); + if (opponent.isEmpty) return ''; + return '对手:$opponent'; +} + +String _formatNumberBadge(EventRegistrationItem item) { + final place = item.matchPlace.trim(); + if (place.isEmpty) return ''; + final match = RegExp(r'\d+').firstMatch(place); + if (match != null) return '${match.group(0)}号'; + return place; } diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 176be07..2e6d63c 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -24,6 +24,10 @@ class $AssetsHtmlGen { class $AssetsImagesGen { const $AssetsImagesGen(); + /// File path: assets/images/image_app_bar_bg.png + AssetGenImage get imageAppBarBg => + const AssetGenImage('assets/images/image_app_bar_bg.png'); + /// File path: assets/images/image_app_icon.png AssetGenImage get imageAppIcon => const AssetGenImage('assets/images/image_app_icon.png'); @@ -40,10 +44,18 @@ class $AssetsImagesGen { AssetGenImage get imageLogo => const AssetGenImage('assets/images/image_logo.png'); + /// File path: assets/images/image_ok.png + AssetGenImage get imageOk => + const AssetGenImage('assets/images/image_ok.png'); + /// File path: assets/images/image_page_bg.png AssetGenImage get imagePageBg => const AssetGenImage('assets/images/image_page_bg.png'); + /// File path: assets/images/image_scan.png + AssetGenImage get imageScan => + const AssetGenImage('assets/images/image_scan.png'); + /// File path: assets/images/image_scan_qrcode.png AssetGenImage get imageScanQrcode => const AssetGenImage('assets/images/image_scan_qrcode.png'); @@ -54,11 +66,14 @@ class $AssetsImagesGen { /// List of all assets List get values => [ + imageAppBarBg, imageAppIcon, imageAppNameText, imageDialogBg, imageLogo, + imageOk, imagePageBg, + imageScan, imageScanQrcode, imageVs, ]; diff --git a/lib/shared/widgets/app_bar.dart b/lib/shared/widgets/app_bar.dart index 3e7904a..a04eed1 100644 --- a/lib/shared/widgets/app_bar.dart +++ b/lib/shared/widgets/app_bar.dart @@ -1,31 +1,77 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/app/router/app_navigator.dart'; +import 'package:recording_tool/gen/assets.gen.dart'; -AppBar myAppBar({ +/// 全局页面标题栏:背景图撑满 + 居中标题 + 返回按钮。 +/// 可直接赋给 [Scaffold.appBar]。 +class AppPageBar extends StatelessWidget implements PreferredSizeWidget { + const AppPageBar({ + super.key, + this.title = '', + this.titleWidget, + this.backgroundImage, + this.onBack, + this.actions, + }); + + final String title; + final Widget? titleWidget; + final String? backgroundImage; + final VoidCallback? onBack; + final List? actions; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final imagePath = backgroundImage ?? Assets.images.imageAppBarBg.path; + + return AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle.light, + flexibleSpace: SizedBox.expand( + child: Image.asset( + imagePath, + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + leading: IconButton( + icon: Icon( + Icons.chevron_left_rounded, + color: Colors.white, + size: 28.sp, + ), + onPressed: onBack ?? () => AppNavigator.pop(context: context), + ), + title: + titleWidget ?? + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: 20.sp, + fontWeight: FontWeight.w600, + fontFamily: 'PingFang SC', + ), + ), + actions: actions, + ); + } +} + +/// 兼容旧调用;内部委托 [AppPageBar]。 +PreferredSizeWidget myAppBar({ required BuildContext context, String title = '', Widget? titleWidget, -}) => AppBar( - leadingWidth: 56.w, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - Navigator.of(context).maybePop(); - }, - ), - title: - titleWidget ?? - Text( - title, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 20.sp, - fontFamily: 'PingFang SC', - fontWeight: FontWeight.w400, - ), - ), - centerTitle: true, - backgroundColor: Colors.transparent, - elevation: 0, -); + List? actions, +}) { + return AppPageBar(title: title, titleWidget: titleWidget, actions: actions); +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index c5baed8..523b59a 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,4 +1,5 @@ export 'app_avatar.dart'; +export 'app_bar.dart'; export 'app_button.dart'; export 'app_card.dart'; export 'app_dialog.dart';