From a2383fc8a15a460cb6c0bb7dd07743b7f6996d80 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 10 Aug 2026 23:31:44 +0800 Subject: [PATCH] feat(apps): establish shared mobile design system --- apps/heqi_design_system/.gitignore | 2 + apps/heqi_design_system/README.md | 45 +++ apps/heqi_design_system/analysis_options.yaml | 1 + .../lib/heqi_design_system.dart | 5 + .../lib/src/components.dart | 204 +++++++++++++ apps/heqi_design_system/lib/src/theme.dart | 209 +++++++++++++ apps/heqi_design_system/lib/src/tokens.dart | 60 ++++ apps/heqi_design_system/pubspec.lock | 205 +++++++++++++ apps/heqi_design_system/pubspec.yaml | 19 ++ .../test/design_system_test.dart | 39 +++ apps/service_app/lib/app/app.dart | 2 + apps/service_app/lib/ui/core/app_theme.dart | 44 +-- apps/service_app/lib/ui/core/widgets.dart | 8 + .../lib/ui/features/auth/login_page.dart | 90 ++++-- .../ui/features/evidence/evidence_page.dart | 149 ++++++---- .../ui/features/preflight/preflight_page.dart | 193 +++++++----- .../lib/ui/features/profile/profile_page.dart | 130 ++++---- .../ui/features/work/work_detail_page.dart | 280 ++++++++++-------- .../lib/ui/features/work/work_list_page.dart | 133 ++++++--- apps/service_app/pubspec.lock | 7 + apps/service_app/pubspec.yaml | 2 + apps/user_app/lib/app/app.dart | 2 + .../lib/data/services/payment_jsapi_web.dart | 3 +- apps/user_app/lib/ui/core/app_theme.dart | 51 +--- apps/user_app/lib/ui/core/widgets.dart | 97 +++--- .../lib/ui/features/auth/login_page.dart | 91 ++++-- .../lib/ui/features/auth/register_page.dart | 141 ++++++--- .../lib/ui/features/home/home_page.dart | 56 ++-- .../lib/ui/features/orders/orders_page.dart | 106 ++++--- .../lib/ui/features/profile/profile_page.dart | 159 ++++++---- .../ui/features/shared/record_list_page.dart | 68 +++-- .../lib/ui/features/shop/shop_page.dart | 148 +++++---- apps/user_app/pubspec.lock | 7 + apps/user_app/pubspec.yaml | 2 + 34 files changed, 2007 insertions(+), 751 deletions(-) create mode 100644 apps/heqi_design_system/.gitignore create mode 100644 apps/heqi_design_system/README.md create mode 100644 apps/heqi_design_system/analysis_options.yaml create mode 100644 apps/heqi_design_system/lib/heqi_design_system.dart create mode 100644 apps/heqi_design_system/lib/src/components.dart create mode 100644 apps/heqi_design_system/lib/src/theme.dart create mode 100644 apps/heqi_design_system/lib/src/tokens.dart create mode 100644 apps/heqi_design_system/pubspec.lock create mode 100644 apps/heqi_design_system/pubspec.yaml create mode 100644 apps/heqi_design_system/test/design_system_test.dart create mode 100644 apps/service_app/lib/ui/core/widgets.dart diff --git a/apps/heqi_design_system/.gitignore b/apps/heqi_design_system/.gitignore new file mode 100644 index 0000000..a8c7d1d --- /dev/null +++ b/apps/heqi_design_system/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +build/ diff --git a/apps/heqi_design_system/README.md b/apps/heqi_design_system/README.md new file mode 100644 index 0000000..8f3580a --- /dev/null +++ b/apps/heqi_design_system/README.md @@ -0,0 +1,45 @@ +# Heqi Mobile Design System + +`heqi_design_system` 是用户端与服务人员端共用的 Flutter Design System。它只负责视觉 Token、Material 3 主题和无业务状态的基础组件,不包含 API、权限、金额、状态机或领域判断。 + +## 使用方式 + +业务 App 通过本地 path dependency 引用: + +```yaml +dependencies: + heqi_design_system: + path: ../heqi_design_system +``` + +```dart +import 'package:heqi_design_system/heqi_design_system.dart'; + +MaterialApp.router( + theme: HeqiTheme.light(HeqiBrand.consumer), + darkTheme: HeqiTheme.dark(HeqiBrand.consumer), + themeMode: ThemeMode.system, +); +``` + +## 公共 API + +- `HeqiColors`:品牌色与安全语义色。 +- `HeqiSpacing`:4dp 间距网格。 +- `HeqiRadius`:圆角层级。 +- `HeqiSize`:触控区、按钮、导航和内容宽度。 +- `HeqiMotion`:150/220/300ms 动效节奏。 +- `HeqiBreakpoints`:紧凑、中等、展开布局断点。 +- `HeqiTheme`:用户端与服务端的亮色/暗色 Material 3 主题。 +- `HeqiGutter`、`HeqiSurfaceSection`、`HeqiSectionHeader`:页面布局原语。 +- `HeqiStatusPill`、`HeqiMessageState`:语义状态与空/错状态。 + +## 变更规则 + +1. 新颜色、间距、圆角或动效必须先进入 Token,不得在业务页面直接写常量。 +2. 安全、资金、设备命令等业务状态仍由服务端决定;Design System 只负责展示映射。 +3. 公共组件不得依赖任一 App 的 Domain/Data 包。 +4. 破坏性 API 变更提升主版本;新增兼容 Token/组件提升次版本;视觉修复提升补丁版本。 +5. 更新后必须分别执行两个 App 的 `flutter analyze`、`flutter test` 和构建检查。 + +完整设计规范见 [`docs/13-移动端Design-System.md`](../../docs/13-移动端Design-System.md)。 diff --git a/apps/heqi_design_system/analysis_options.yaml b/apps/heqi_design_system/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/apps/heqi_design_system/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/apps/heqi_design_system/lib/heqi_design_system.dart b/apps/heqi_design_system/lib/heqi_design_system.dart new file mode 100644 index 0000000..bacde9a --- /dev/null +++ b/apps/heqi_design_system/lib/heqi_design_system.dart @@ -0,0 +1,5 @@ +library; + +export 'src/components.dart'; +export 'src/theme.dart'; +export 'src/tokens.dart'; diff --git a/apps/heqi_design_system/lib/src/components.dart b/apps/heqi_design_system/lib/src/components.dart new file mode 100644 index 0000000..535d21d --- /dev/null +++ b/apps/heqi_design_system/lib/src/components.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +import 'tokens.dart'; + +enum HeqiStatusTone { neutral, info, success, warning, danger } + +class HeqiGutter extends StatelessWidget { + const HeqiGutter({required this.child, super.key}); + final Widget child; + + @override + Widget build(BuildContext context) => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: HeqiSize.contentMaxWidth), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: + MediaQuery.sizeOf(context).width >= HeqiBreakpoints.compact + ? HeqiSpacing.x7 + : HeqiSpacing.x5, + ), + child: child, + ), + ), + ); +} + +class HeqiSurfaceSection extends StatelessWidget { + const HeqiSurfaceSection({ + required this.child, + this.padding = const EdgeInsets.all(HeqiSpacing.x5), + super.key, + }); + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(HeqiRadius.large), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Padding(padding: padding, child: child), + ); +} + +class HeqiSectionHeader extends StatelessWidget { + const HeqiSectionHeader({ + required this.title, + this.description, + this.trailing, + super.key, + }); + final String title; + final String? description; + final Widget? trailing; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + if (description != null) ...[ + const SizedBox(height: HeqiSpacing.x1), + Text( + description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ?trailing, + ], + ); +} + +class HeqiStatusPill extends StatelessWidget { + const HeqiStatusPill({ + required this.label, + this.tone = HeqiStatusTone.neutral, + this.icon, + super.key, + }); + final String label; + final HeqiStatusTone tone; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (foreground, background) = switch (tone) { + HeqiStatusTone.info => ( + scheme.onPrimaryContainer, + scheme.primaryContainer, + ), + HeqiStatusTone.success => ( + scheme.onSecondaryContainer, + scheme.secondaryContainer, + ), + HeqiStatusTone.warning => ( + scheme.onTertiaryContainer, + scheme.tertiaryContainer, + ), + HeqiStatusTone.danger => (scheme.onErrorContainer, scheme.errorContainer), + HeqiStatusTone.neutral => ( + scheme.onSurfaceVariant, + scheme.surfaceContainerHighest, + ), + }; + return Semantics( + label: '状态:$label', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(HeqiRadius.small), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: HeqiSize.iconSmall, color: foreground), + const SizedBox(width: HeqiSpacing.x1), + ], + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: foreground), + ), + ], + ), + ), + ); + } +} + +class HeqiMessageState extends StatelessWidget { + const HeqiMessageState({ + required this.title, + required this.description, + this.onRetry, + this.icon = Icons.info_outline_rounded, + super.key, + }); + final String title; + final String description; + final VoidCallback? onRetry; + final IconData icon; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(HeqiSpacing.x8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: HeqiSpacing.x5), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: HeqiSpacing.x2), + Text( + description, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: HeqiSpacing.x5), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('重新加载'), + ), + ], + ], + ), + ), + ); +} diff --git a/apps/heqi_design_system/lib/src/theme.dart b/apps/heqi_design_system/lib/src/theme.dart new file mode 100644 index 0000000..d86d349 --- /dev/null +++ b/apps/heqi_design_system/lib/src/theme.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +import 'tokens.dart'; + +/// 共享 Material 3 主题。业务 App 只能选择品牌,不应复制本主题后自行修改。 +abstract final class HeqiTheme { + static ThemeData light(HeqiBrand brand) => _theme(brand, Brightness.light); + + static ThemeData dark(HeqiBrand brand) => _theme(brand, Brightness.dark); + + static ThemeData _theme(HeqiBrand brand, Brightness brightness) { + final dark = brightness == Brightness.dark; + final seed = switch (brand) { + HeqiBrand.consumer => HeqiColors.consumerPrimary, + HeqiBrand.service => HeqiColors.servicePrimary, + }; + final darkPrimary = switch (brand) { + HeqiBrand.consumer => const Color(0xFFAFC6FF), + HeqiBrand.service => const Color(0xFFC5C0FF), + }; + final scheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: brightness, + primary: dark ? darkPrimary : seed, + secondary: dark ? const Color(0xFF86D7B5) : HeqiColors.success, + error: dark ? const Color(0xFFFFB4AB) : HeqiColors.danger, + surface: dark ? HeqiColors.darkSurface : Colors.white, + ); + final textTheme = _textTheme; + final radius = BorderRadius.circular(HeqiRadius.large); + + return ThemeData( + brightness: brightness, + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: dark + ? HeqiColors.darkBackground + : HeqiColors.lightBackground, + textTheme: textTheme, + visualDensity: VisualDensity.standard, + appBarTheme: AppBarTheme( + backgroundColor: Colors.transparent, + foregroundColor: scheme.onSurface, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: textTheme.titleLarge?.copyWith(color: scheme.onSurface), + ), + cardTheme: CardThemeData( + color: scheme.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: radius, + side: BorderSide(color: scheme.outlineVariant), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: dark ? scheme.surfaceContainerHigh : scheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: HeqiSpacing.x4, + vertical: HeqiSpacing.x4, + ), + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: scheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: scheme.error), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(HeqiSize.buttonHeight), + shape: RoundedRectangleBorder(borderRadius: radius), + textStyle: textTheme.labelLarge, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(HeqiSize.buttonHeight), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: radius), + textStyle: textTheme.labelLarge, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(HeqiSize.buttonHeight), + shape: RoundedRectangleBorder(borderRadius: radius), + textStyle: textTheme.labelLarge, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size.square(HeqiSize.minTouchTarget), + textStyle: textTheme.labelLarge, + ), + ), + navigationBarTheme: NavigationBarThemeData( + height: HeqiSize.navigationBarHeight, + elevation: 0, + backgroundColor: scheme.surface, + indicatorColor: scheme.primaryContainer, + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => textTheme.labelSmall?.copyWith( + color: states.contains(WidgetState.selected) + ? scheme.primary + : scheme.onSurfaceVariant, + fontWeight: states.contains(WidgetState.selected) + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + chipTheme: ChipThemeData( + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(HeqiRadius.small), + ), + labelStyle: textTheme.labelSmall, + ), + listTileTheme: const ListTileThemeData( + minTileHeight: 56, + contentPadding: EdgeInsets.symmetric( + horizontal: HeqiSpacing.x4, + vertical: HeqiSpacing.x1, + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: scheme.surface, + modalBackgroundColor: scheme.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(HeqiRadius.sheet), + ), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: scheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(HeqiRadius.sheet), + ), + ), + dividerTheme: DividerThemeData( + color: scheme.outlineVariant, + thickness: 1, + space: 1, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(HeqiRadius.medium), + ), + ), + ); + } + + static const _textTheme = TextTheme( + displaySmall: TextStyle( + fontSize: 32, + height: 1.25, + fontWeight: FontWeight.w700, + ), + headlineMedium: TextStyle( + fontSize: 28, + height: 1.28, + fontWeight: FontWeight.w700, + ), + headlineSmall: TextStyle( + fontSize: 24, + height: 1.33, + fontWeight: FontWeight.w700, + ), + titleLarge: TextStyle( + fontSize: 22, + height: 1.27, + fontWeight: FontWeight.w600, + ), + titleMedium: TextStyle( + fontSize: 16, + height: 1.5, + fontWeight: FontWeight.w600, + ), + bodyLarge: TextStyle(fontSize: 16, height: 1.5), + bodyMedium: TextStyle(fontSize: 14, height: 1.55), + labelLarge: TextStyle( + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w600, + ), + labelSmall: TextStyle( + fontSize: 12, + height: 1.35, + fontWeight: FontWeight.w500, + ), + ); +} diff --git a/apps/heqi_design_system/lib/src/tokens.dart b/apps/heqi_design_system/lib/src/tokens.dart new file mode 100644 index 0000000..2e6b95d --- /dev/null +++ b/apps/heqi_design_system/lib/src/tokens.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// 两端品牌主题。安全语义色始终共享,不能被品牌色覆盖。 +enum HeqiBrand { consumer, service } + +abstract final class HeqiColors { + static const consumerPrimary = Color(0xFF2563EB); + static const servicePrimary = Color(0xFF4F46E5); + static const success = Color(0xFF16875D); + static const warning = Color(0xFFB86400); + static const danger = Color(0xFFC7352A); + + static const lightBackground = Color(0xFFF7F8FA); + static const darkBackground = Color(0xFF0B1220); + static const darkSurface = Color(0xFF111827); +} + +/// 4dp 基础网格。页面不得新增不属于本表的随意间距。 +abstract final class HeqiSpacing { + static const x1 = 4.0; + static const x2 = 8.0; + static const x3 = 12.0; + static const x4 = 16.0; + static const x5 = 20.0; + static const x6 = 24.0; + static const x7 = 28.0; + static const x8 = 32.0; + static const x10 = 40.0; + static const x12 = 48.0; +} + +abstract final class HeqiRadius { + static const small = 10.0; + static const medium = 12.0; + static const large = 16.0; + static const extraLarge = 20.0; + static const sheet = 24.0; +} + +abstract final class HeqiSize { + static const minTouchTarget = 48.0; + static const buttonHeight = 52.0; + static const navigationBarHeight = 72.0; + static const contentMaxWidth = 720.0; + static const iconSmall = 16.0; + static const iconMedium = 24.0; + static const iconLarge = 32.0; +} + +abstract final class HeqiMotion { + static const fast = Duration(milliseconds: 150); + static const standard = Duration(milliseconds: 220); + static const emphasized = Duration(milliseconds: 300); +} + +abstract final class HeqiBreakpoints { + static const compact = 600.0; + static const medium = 840.0; + static const expanded = 1024.0; +} diff --git a/apps/heqi_design_system/pubspec.lock b/apps/heqi_design_system/pubspec.lock new file mode 100644 index 0000000..8357694 --- /dev/null +++ b/apps/heqi_design_system/pubspec.lock @@ -0,0 +1,205 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/apps/heqi_design_system/pubspec.yaml b/apps/heqi_design_system/pubspec.yaml new file mode 100644 index 0000000..103073b --- /dev/null +++ b/apps/heqi_design_system/pubspec.yaml @@ -0,0 +1,19 @@ +name: heqi_design_system +description: 和气物联网智能瓶阀移动端共享 Design System。 +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.12.2 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/apps/heqi_design_system/test/design_system_test.dart b/apps/heqi_design_system/test/design_system_test.dart new file mode 100644 index 0000000..4d0d31a --- /dev/null +++ b/apps/heqi_design_system/test/design_system_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:heqi_design_system/heqi_design_system.dart'; + +void main() { + test('two brands share safety semantics but use distinct primary colors', () { + final consumer = HeqiTheme.light(HeqiBrand.consumer).colorScheme; + final service = HeqiTheme.light(HeqiBrand.service).colorScheme; + + expect(consumer.primary, HeqiColors.consumerPrimary); + expect(service.primary, HeqiColors.servicePrimary); + expect(consumer.error, HeqiColors.danger); + expect(service.error, HeqiColors.danger); + }); + + testWidgets('status pill exposes a semantic status label', (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + theme: HeqiTheme.light(HeqiBrand.consumer), + home: const Scaffold( + body: HeqiStatusPill(label: '已通过', tone: HeqiStatusTone.success), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == '状态:已通过', + ), + findsOneWidget, + ); + semantics.dispose(); + }); + + test('touch target follows Material mobile minimum', () { + expect(HeqiSize.minTouchTarget, greaterThanOrEqualTo(48)); + }); +} diff --git a/apps/service_app/lib/app/app.dart b/apps/service_app/lib/app/app.dart index 97651a3..9611ba9 100644 --- a/apps/service_app/lib/app/app.dart +++ b/apps/service_app/lib/app/app.dart @@ -21,6 +21,8 @@ class _ServiceClientAppState extends State { title: '瓶安芯服务工作台', debugShowCheckedModeBanner: false, theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.system, routerConfig: _router, ); } diff --git a/apps/service_app/lib/ui/core/app_theme.dart b/apps/service_app/lib/ui/core/app_theme.dart index d2004b9..d2e616f 100644 --- a/apps/service_app/lib/ui/core/app_theme.dart +++ b/apps/service_app/lib/ui/core/app_theme.dart @@ -1,36 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:heqi_design_system/heqi_design_system.dart'; -class AppTheme { - static ThemeData light() { - final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF6C4CF1), - primary: const Color(0xFF6C4CF1), - secondary: const Color(0xFF16A66A), - surface: Colors.white, - ); - return ThemeData( - useMaterial3: true, - colorScheme: scheme, - scaffoldBackgroundColor: const Color(0xFFF5F4FA), - cardTheme: CardThemeData( - elevation: 0, - color: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ), - ); - } +/// 服务人员端主题适配层;安全语义色与用户端共享,品牌主色独立。 +abstract final class AppTheme { + static const primary = HeqiColors.servicePrimary; + static const success = HeqiColors.success; + static const warning = HeqiColors.warning; + static const danger = HeqiColors.danger; + + static ThemeData light() => HeqiTheme.light(HeqiBrand.service); + + static ThemeData dark() => HeqiTheme.dark(HeqiBrand.service); } diff --git a/apps/service_app/lib/ui/core/widgets.dart b/apps/service_app/lib/ui/core/widgets.dart new file mode 100644 index 0000000..2562e87 --- /dev/null +++ b/apps/service_app/lib/ui/core/widgets.dart @@ -0,0 +1,8 @@ +import 'package:heqi_design_system/heqi_design_system.dart'; + +typedef AppGutter = HeqiGutter; +typedef SurfaceSection = HeqiSurfaceSection; +typedef SectionHeader = HeqiSectionHeader; +typedef StatusPill = HeqiStatusPill; +typedef StatusTone = HeqiStatusTone; +typedef MessageState = HeqiMessageState; diff --git a/apps/service_app/lib/ui/features/auth/login_page.dart b/apps/service_app/lib/ui/features/auth/login_page.dart index 00f6011..153f982 100644 --- a/apps/service_app/lib/ui/features/auth/login_page.dart +++ b/apps/service_app/lib/ui/features/auth/login_page.dart @@ -16,6 +16,7 @@ class _LoginPageState extends State { final _password = TextEditingController(); bool _busy = false; String? _error; + bool _obscurePassword = true; Future _login() async { setState(() { @@ -43,28 +44,43 @@ class _LoginPageState extends State { body: SafeArea( child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon( - Icons.engineering_rounded, - size: 72, - color: Theme.of(context).colorScheme.primary, + Align( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.engineering_rounded, + size: 38, + color: Theme.of(context).colorScheme.primary, + semanticLabel: '服务工作台标识', + ), + ), ), - const SizedBox(height: 20), + const SizedBox(height: 24), Text( '瓶安芯服务工作台', textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900), + style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 8), - const Text('配送、安装维修与安检共用入口', textAlign: TextAlign.center), - const SizedBox(height: 34), + Text( + '配送、安装维修与安检共用入口', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 40), TextField( controller: _phone, keyboardType: TextInputType.phone, @@ -76,22 +92,50 @@ class _LoginPageState extends State { const SizedBox(height: 14), TextField( controller: _password, - obscureText: true, - decoration: const InputDecoration( + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( labelText: '登录密码', - prefixIcon: Icon(Icons.lock_outline), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + tooltip: _obscurePassword ? '显示密码' : '隐藏密码', + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), ), ), if (_error != null) Padding( padding: const EdgeInsets.only(top: 12), - child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), + child: Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), ), ), - const SizedBox(height: 20), - ElevatedButton( + const SizedBox(height: 24), + FilledButton( onPressed: _busy ? null : _login, child: _busy ? const SizedBox.square( @@ -100,11 +144,13 @@ class _LoginPageState extends State { ) : const Text('进入工作台'), ), - const SizedBox(height: 12), - const Text( + const SizedBox(height: 16), + Text( '账号与角色由平台审核分配,不提供自助注册', textAlign: TextAlign.center, - style: TextStyle(fontSize: 12), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), diff --git a/apps/service_app/lib/ui/features/evidence/evidence_page.dart b/apps/service_app/lib/ui/features/evidence/evidence_page.dart index 038a709..1ffa07f 100644 --- a/apps/service_app/lib/ui/features/evidence/evidence_page.dart +++ b/apps/service_app/lib/ui/features/evidence/evidence_page.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../../../app/dependencies.dart'; import '../../../data/offline/encrypted_draft_store.dart'; import '../../../data/repositories/service_repository.dart'; +import '../../core/widgets.dart'; class EvidencePage extends StatefulWidget { const EvidencePage({ @@ -160,66 +161,108 @@ class _EvidencePageState extends State { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('现场取证')), - body: ListView( - padding: const EdgeInsets.all(18), - children: [ - const Card( - child: Padding( - padding: EdgeInsets.all(18), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), child: Row( children: [ - Icon(Icons.lock_outline), - SizedBox(width: 12), - Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')), + Icon(Icons.lock_outline, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + const Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')), ], ), ), - ), - const SizedBox(height: 10), - ..._requiredStages.map( - (stage) => Card( - child: ListTile( - leading: Icon( - _evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined, - color: _evidence.containsKey(stage) ? Colors.green : null, - ), - title: Text(_stageName(stage)), - subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'), - trailing: TextButton( - onPressed: _busy ? null : () => _capture(stage), - child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'), - ), + const SizedBox(height: 28), + SectionHeader( + title: '取证进度', + description: '已完成 ${_evidence.length}/${_requiredStages.length} 项必需材料', + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: _requiredStages.isEmpty ? 0 : _evidence.length / _requiredStages.length, + minHeight: 6, + borderRadius: BorderRadius.circular(3), + ), + const SizedBox(height: 16), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < _requiredStages.length; index++) ...[ + ListTile( + minTileHeight: 68, + leading: Icon( + _evidence.containsKey(_requiredStages[index]) + ? Icons.check_circle_outline + : Icons.camera_alt_outlined, + color: _evidence.containsKey(_requiredStages[index]) + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text(_stageName(_requiredStages[index])), + subtitle: Text( + _evidence.containsKey(_requiredStages[index]) ? '已加密暂存' : '尚未采集', + ), + trailing: TextButton( + onPressed: _busy ? null : () => _capture(_requiredStages[index]), + child: Text(_evidence.containsKey(_requiredStages[index]) ? '重拍' : '拍摄'), + ), + ), + if (index < _requiredStages.length - 1) const Divider(indent: 56), + ], + ], ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _result, - maxLines: 4, - onChanged: (_) => _save(), - decoration: const InputDecoration(labelText: '现场结果说明'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _conclusion, - decoration: const InputDecoration(labelText: '结论'), - items: const [ - DropdownMenuItem(value: 'qualified', child: Text('合格')), - DropdownMenuItem(value: 'noncompliant', child: Text('不合格')), - DropdownMenuItem(value: 'high_risk', child: Text('高风险')), - ], - onChanged: (value) { - if (value == null) return; - setState(() => _conclusion = value); - _save(); - }, - ), - const SizedBox(height: 22), - ElevatedButton( - onPressed: _busy ? null : _submit, - child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'), - ), - ], + const SizedBox(height: 28), + const SectionHeader(title: '作业结论', description: '说明现场处理结果,并选择对应结论'), + const SizedBox(height: 12), + TextField( + controller: _result, + maxLines: 4, + minLines: 3, + onChanged: (_) => _save(), + decoration: const InputDecoration( + labelText: '现场结果说明', + helperText: '请填写处理过程、现场状态和后续建议', + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _conclusion, + decoration: const InputDecoration(labelText: '结论'), + items: const [ + DropdownMenuItem(value: 'qualified', child: Text('合格')), + DropdownMenuItem(value: 'noncompliant', child: Text('不合格')), + DropdownMenuItem(value: 'high_risk', child: Text('高风险')), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _conclusion = value); + _save(); + }, + ), + const SizedBox(height: 28), + FilledButton.icon( + onPressed: _busy ? null : _submit, + icon: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_upload_outlined), + label: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'), + ), + ], + ), ), ); diff --git a/apps/service_app/lib/ui/features/preflight/preflight_page.dart b/apps/service_app/lib/ui/features/preflight/preflight_page.dart index 9c35a02..8fff512 100644 --- a/apps/service_app/lib/ui/features/preflight/preflight_page.dart +++ b/apps/service_app/lib/ui/features/preflight/preflight_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../app/dependencies.dart'; import '../../../data/repositories/service_repository.dart'; import '../../../domain/models/service_models.dart'; +import '../../core/widgets.dart'; class PreflightPage extends StatefulWidget { const PreflightPage({required this.session, required this.repository, super.key}); @@ -46,84 +47,60 @@ class _PreflightPageState extends State { future: _future, builder: (context, snapshot) { if (!snapshot.hasData) { - if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + if (snapshot.hasError) { + return MessageState( + title: '作业检查加载失败', + description: '请检查网络后重新加载', + onRetry: () => setState(() => _future = widget.repository.preflight()), + ); + } return const Center(child: CircularProgressIndicator()); } final result = snapshot.data!; - return ListView( - padding: const EdgeInsets.all(18), - children: [ - Card( - color: Theme.of(context).colorScheme.primary, - child: Padding( - padding: const EdgeInsets.all(22), + final entries = result.checks.entries.toList(); + return SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + _PreflightBanner(result: result), + const SizedBox(height: 28), + SectionHeader( + title: '准入检查', + description: result.canWork ? '所有必需条件均已通过服务端校验' : '完成阻断项后才能进入工作台', + ), + const SizedBox(height: 12), + SurfaceSection( + padding: EdgeInsets.zero, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(roleName(result.roleCode), style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 6), - Text( - result.canWork ? '可以开始今日作业' : '仍有前置条件未完成', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 24, - ), - ), + for (var index = 0; index < entries.length; index++) ...[ + _CheckRow(entry: entries[index], label: _label(entries[index].key)), + if (index < entries.length - 1) const Divider(indent: 56), + ], ], ), ), - ), - const SizedBox(height: 12), - ...result.checks.entries.map((entry) { - final detail = entry.value is Map - ? Map.from(entry.value as Map) - : const {}; - final status = detail['status']?.toString() ?? 'blocked'; - final passed = status == 'passed'; - return Card( - child: ListTile( - leading: Icon( - passed - ? Icons.check_circle - : status == 'not_configured' - ? Icons.info_outline - : Icons.cancel, - color: passed - ? Colors.green - : status == 'not_configured' - ? Colors.orange - : Colors.red, - ), - title: Text(_label(entry.key)), - subtitle: Text( - status == 'not_configured' - ? '平台暂未启用,不冒充校验通过' - : passed - ? '已通过服务端校验' - : '未通过', - ), + const SizedBox(height: 28), + if (result.workStatus != 'on_duty') + FilledButton.icon( + onPressed: _busy ? null : () => _attendance('clock_in'), + icon: const Icon(Icons.location_on_outlined), + label: Text(_busy ? '正在定位并打卡…' : '定位并上班打卡'), + ) + else ...[ + FilledButton( + onPressed: result.canWork ? () => context.go('/work') : null, + child: const Text('进入工作台'), ), - ); - }), - const SizedBox(height: 18), - if (result.workStatus != 'on_duty') - ElevatedButton.icon( - onPressed: _busy ? null : () => _attendance('clock_in'), - icon: const Icon(Icons.location_on), - label: const Text('定位并上班打卡'), - ) - else ...[ - ElevatedButton( - onPressed: result.canWork ? () => context.go('/work') : null, - child: const Text('进入工作台'), - ), - TextButton( - onPressed: _busy ? null : () => _attendance('clock_out'), - child: const Text('下班打卡'), - ), + const SizedBox(height: 8), + TextButton( + onPressed: _busy ? null : () => _attendance('clock_out'), + child: const Text('下班打卡'), + ), + ], ], - ], + ), ); }, ), @@ -141,3 +118,83 @@ class _PreflightPageState extends State { _ => value, }; } + +class _PreflightBanner extends StatelessWidget { + const _PreflightBanner({required this.result}); + final PreflightResult result; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final background = result.canWork ? scheme.secondaryContainer : scheme.tertiaryContainer; + final foreground = result.canWork ? scheme.onSecondaryContainer : scheme.onTertiaryContainer; + return Semantics( + liveRegion: true, + label: result.canWork ? '作业检查通过,可以开始今日作业' : '作业检查未通过,仍有前置条件未完成', + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + result.canWork ? Icons.verified_rounded : Icons.warning_amber_rounded, + color: foreground, + size: 32, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + roleName(result.roleCode), + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: foreground), + ), + const SizedBox(height: 6), + Text( + result.canWork ? '可以开始今日作业' : '仍有前置条件未完成', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: foreground), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _CheckRow extends StatelessWidget { + const _CheckRow({required this.entry, required this.label}); + final MapEntry entry; + final String label; + + @override + Widget build(BuildContext context) { + final detail = entry.value is Map + ? Map.from(entry.value as Map) + : const {}; + final status = detail['status']?.toString() ?? 'blocked'; + final passed = status == 'passed'; + final tone = passed + ? StatusTone.success + : status == 'not_configured' + ? StatusTone.warning + : StatusTone.danger; + final text = status == 'not_configured' + ? '平台暂未启用' + : passed + ? '已通过' + : '未通过'; + return ListTile( + leading: Icon(passed ? Icons.check_circle_outline : Icons.info_outline_rounded), + title: Text(label), + trailing: StatusPill(label: text, tone: tone), + ); + } +} diff --git a/apps/service_app/lib/ui/features/profile/profile_page.dart b/apps/service_app/lib/ui/features/profile/profile_page.dart index f335eca..e979a2c 100644 --- a/apps/service_app/lib/ui/features/profile/profile_page.dart +++ b/apps/service_app/lib/ui/features/profile/profile_page.dart @@ -5,6 +5,7 @@ import '../../../app/dependencies.dart'; import '../../../data/offline/encrypted_draft_store.dart'; import '../../../data/repositories/service_repository.dart'; import '../../../domain/models/service_models.dart'; +import '../../core/widgets.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({ @@ -64,76 +65,101 @@ class _ProfilePageState extends State { future: _future, builder: (context, snapshot) { if (!snapshot.hasData) { - if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + if (snapshot.hasError) { + return MessageState( + title: '工作台信息加载失败', + description: '请检查网络后重新加载', + onRetry: () => setState(() => _future = _load()), + ); + } return const Center(child: CircularProgressIndicator()); } final (profile, wallet, drafts) = snapshot.data!; final balance = (wallet['balance'] as num?)?.toInt() ?? 0; return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - CircleAvatar( - radius: 30, - child: Text(profile.name.isEmpty ? '工' : profile.name.substring(0, 1)), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.name, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), + SurfaceSection( + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.primary, + child: Text(profile.name.isEmpty ? '工' : profile.name.substring(0, 1)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(profile.name, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text( + '${roleName(profile.roleCode)} · ${profile.phone}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - Text('${roleName(profile.roleCode)} · ${profile.phone}'), - ], + ), + ], + ), + ), + StatusPill( + label: profile.workStatus == 'on_duty' ? '在岗' : '离岗', + tone: profile.workStatus == 'on_duty' ? StatusTone.success : StatusTone.neutral, + ), + ], + ), + ), + const SizedBox(height: 28), + const SectionHeader(title: '工作概览'), + const SizedBox(height: 12), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.account_balance_wallet_outlined), + title: const Text('钱包余额'), + trailing: Text( + '¥${(balance / 100).toStringAsFixed(2)}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, ), ), - Chip(label: Text(profile.workStatus == 'on_duty' ? '在岗' : '离岗')), - ], - ), + ), + const Divider(indent: 56), + ListTile( + leading: const Icon(Icons.lock_outline), + title: const Text('加密现场草稿'), + subtitle: const Text('仅当前账号重新认证后可恢复'), + trailing: StatusPill( + label: '$drafts 份', + tone: drafts > 0 ? StatusTone.warning : StatusTone.neutral, + ), + ), + ], ), ), - Card( - child: ListTile( - leading: const Icon(Icons.account_balance_wallet_outlined), - title: const Text('钱包余额'), - trailing: Text( - '¥${(balance / 100).toStringAsFixed(2)}', - style: const TextStyle(fontWeight: FontWeight.w900), - ), - ), - ), - Card( - child: ListTile( - leading: const Icon(Icons.lock_outline), - title: const Text('加密现场草稿'), - subtitle: const Text('仅当前账号重新认证后可恢复'), - trailing: Text('$drafts 份'), - ), - ), - Card( + const SizedBox(height: 28), + const SectionHeader(title: '账户操作'), + const SizedBox(height: 12), + SurfaceSection( + padding: EdgeInsets.zero, child: ListTile( leading: const Icon(Icons.verified_user_outlined), title: const Text('重新执行作业前检查'), + subtitle: const Text('检查岗位、资质、在岗与设备授权状态'), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/preflight'), ), ), - Card( - child: ListTile( - leading: const Icon(Icons.logout), - title: const Text('退出登录'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _logout(drafts), - ), + const SizedBox(height: 24), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + onPressed: () => _logout(drafts), + icon: const Icon(Icons.logout), + label: const Text('退出登录'), ), ], ); diff --git a/apps/service_app/lib/ui/features/work/work_detail_page.dart b/apps/service_app/lib/ui/features/work/work_detail_page.dart index e7949f2..04cc9f1 100644 --- a/apps/service_app/lib/ui/features/work/work_detail_page.dart +++ b/apps/service_app/lib/ui/features/work/work_detail_page.dart @@ -7,6 +7,7 @@ import '../../../app/dependencies.dart'; import '../../../data/offline/encrypted_draft_store.dart'; import '../../../data/repositories/service_repository.dart'; import '../../../domain/models/service_models.dart'; +import '../../core/widgets.dart'; class WorkDetailPage extends StatefulWidget { const WorkDetailPage({ @@ -173,150 +174,183 @@ class _WorkDetailPageState extends State { future: _future, builder: (context, snapshot) { if (!snapshot.hasData) { - if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + if (snapshot.hasError) { + return MessageState( + title: '任务详情加载失败', + description: '请检查网络后重新加载', + onRetry: () => setState(() => _future = _load()), + ); + } return const Center(child: CircularProgressIndicator()); } final item = snapshot.data!; - return ListView( - padding: const EdgeInsets.all(18), - children: [ - Card( - color: Theme.of(context).colorScheme.primary, - child: Padding( - padding: const EdgeInsets.all(22), + return SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.number, - style: const TextStyle(color: Colors.white70), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), ), const SizedBox(height: 8), Text( item.title, - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w900, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), - const SizedBox(height: 12), - Chip(label: Text(statusName(item.status))), + const SizedBox(height: 16), + StatusPill(label: statusName(item.status), tone: StatusTone.info), ], ), ), - ), - Card( - child: ListTile( - leading: const Icon(Icons.location_on_outlined), - title: const Text('服务地址'), - subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address), + const SizedBox(height: 28), + const SectionHeader(title: '服务信息'), + const SizedBox(height: 12), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.location_on_outlined), + title: const Text('服务地址'), + subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address), + ), + const Divider(indent: 56), + ListTile( + leading: const Icon(Icons.person_outline), + title: Text(item.raw['contact_name'] as String? ?? '服务用户'), + subtitle: Text(item.raw['contact_phone'] as String? ?? '未提供联系电话'), + ), + ], + ), ), - ), - Card( - child: ListTile( - leading: const Icon(Icons.person_outline), - title: Text(item.raw['contact_name'] as String? ?? '服务用户'), - subtitle: Text(item.raw['contact_phone'] as String? ?? ''), - ), - ), - const SizedBox(height: 18), - if (item.allowedActions.contains('start')) - ElevatedButton( - onPressed: _busy - ? null - : () => _run( - (value) => widget.repository.start( - value, - widget.session.roleCode, + const SizedBox(height: 28), + const SectionHeader(title: '任务操作', description: '操作结果以服务端状态与现场凭证为准'), + const SizedBox(height: 12), + if (item.allowedActions.isEmpty) const SurfaceSection(child: Text('当前状态没有可执行操作')), + if (item.allowedActions.contains('start')) + FilledButton.icon( + onPressed: _busy + ? null + : () => _run( + (value) => widget.repository.start(value, widget.session.roleCode), + item, ), - item, - ), - child: const Text('开始处理'), - ), - if (item.allowedActions.contains('append_tracks')) - OutlinedButton( - onPressed: _busy - ? null - : () => _run( - (value) => widget.repository.appendCurrentTrack( - value.identity, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('开始处理'), + ), + if (item.allowedActions.contains('append_tracks')) ...[ + if (item.allowedActions.contains('start')) const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => _run( + (value) => widget.repository.appendCurrentTrack(value.identity), + item, ), - item, - ), - child: const Text('上报当前位置'), - ), - if (item.allowedActions.contains('arrive')) - ElevatedButton( - onPressed: _busy - ? null - : () => _run( - (value) => widget.repository.arrive(value.identity), - item, - ), - child: const Text('到达并校验围栏'), - ), - if (item.allowedActions.contains('submit_receipt')) - ElevatedButton( - onPressed: _busy ? null : () => _receipt(item), - child: const Text('拍摄签收凭证并提交'), - ), - if (item.allowedActions.contains('submit_result')) - ElevatedButton( - onPressed: _busy - ? null - : () async { - final changed = await context.push( - '/tasks/${item.identity}/evidence', - ); - if (changed == true) setState(() => _future = _load()); - }, - child: const Text('现场取证与提交'), - ), - if (item.allowedActions.contains('exception')) - TextButton( - onPressed: _busy - ? null - : () async { - final reason = await _reason(); - if (reason != null && reason.isNotEmpty) { - await _run( - (value) => widget.repository.exception( - value, - widget.session.roleCode, - reason, - ), - item, + icon: const Icon(Icons.my_location_outlined), + label: const Text('上报当前位置'), + ), + ], + if (item.allowedActions.contains('arrive')) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _busy + ? null + : () => _run((value) => widget.repository.arrive(value.identity), item), + icon: const Icon(Icons.location_on_outlined), + label: const Text('到达并校验围栏'), + ), + ], + if (item.allowedActions.contains('submit_receipt')) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _busy ? null : () => _receipt(item), + icon: const Icon(Icons.photo_camera_outlined), + label: const Text('拍摄签收凭证并提交'), + ), + ], + if (item.allowedActions.contains('submit_result')) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _busy + ? null + : () async { + final changed = await context.push( + '/tasks/${item.identity}/evidence', ); - } - }, - child: const Text('标记异常'), - ), - if (item.allowedActions.contains('recover')) - ElevatedButton( - onPressed: _busy - ? null - : () async { - final reason = await _reason(); - if (reason != null && reason.isNotEmpty) { - await _run( - (value) => widget.repository.recover( - value, - widget.session.roleCode, - reason, - ), - item, - ); - } - }, - child: const Text('恢复任务'), - ), - if (_busy) - const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ], + if (changed == true) setState(() => _future = _load()); + }, + icon: const Icon(Icons.fact_check_outlined), + label: const Text('现场取证与提交'), + ), + ], + if (item.allowedActions.contains('recover')) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _busy + ? null + : () async { + final reason = await _reason(); + if (reason != null && reason.isNotEmpty) { + await _run( + (value) => + widget.repository.recover(value, widget.session.roleCode, reason), + item, + ); + } + }, + icon: const Icon(Icons.restart_alt_rounded), + label: const Text('恢复任务'), + ), + ], + if (item.allowedActions.contains('exception')) ...[ + const SizedBox(height: 20), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + onPressed: _busy + ? null + : () async { + final reason = await _reason(); + if (reason != null && reason.isNotEmpty) { + await _run( + (value) => widget.repository.exception( + value, + widget.session.roleCode, + reason, + ), + item, + ); + } + }, + icon: const Icon(Icons.report_problem_outlined), + label: const Text('标记异常'), + ), + ], + if (_busy) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + label: '正在处理任务操作', + child: const LinearProgressIndicator(), + ), + ), + ], + ), ); }, ), diff --git a/apps/service_app/lib/ui/features/work/work_list_page.dart b/apps/service_app/lib/ui/features/work/work_list_page.dart index 70eb5eb..574f71a 100644 --- a/apps/service_app/lib/ui/features/work/work_list_page.dart +++ b/apps/service_app/lib/ui/features/work/work_list_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../app/dependencies.dart'; import '../../../data/repositories/service_repository.dart'; import '../../../domain/models/service_models.dart'; +import '../../core/widgets.dart'; import 'work_list_view_model.dart'; class WorkListPage extends StatefulWidget { @@ -53,59 +54,88 @@ class _WorkListPageState extends State { return const Center(child: CircularProgressIndicator()); } if (_viewModel.error != null && _viewModel.items.isEmpty) { - return Center(child: Text(_viewModel.error.toString())); + return MessageState( + title: '任务加载失败', + description: '请检查网络后重新加载', + onRetry: _viewModel.load, + ); } return RefreshIndicator( onRefresh: _viewModel.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ - Card( - color: widget.session.roleCode == 'operations' - ? const Color(0xFFE9F8F0) - : const Color(0xFFEEEAFE), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Icon( + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( widget.session.roleCode == 'delivery' - ? Icons.local_shipping + ? Icons.local_shipping_outlined : widget.session.roleCode == 'installer' - ? Icons.handyman - : Icons.health_and_safety, - size: 38, + ? Icons.handyman_outlined + : Icons.health_and_safety_outlined, + color: Theme.of(context).colorScheme.primary, ), - const SizedBox(width: 14), - Expanded( - child: Text( - widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务', - style: const TextStyle(fontWeight: FontWeight.w800), - ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.completed ? '作业历史' : '${roleName(widget.session.roleCode)}任务', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], - ), + ), + ], ), ), + const SizedBox(height: 28), + SectionHeader( + title: widget.completed ? '全部记录' : '待处理任务', + description: _viewModel.items.isEmpty + ? '当前没有需要展示的任务' + : '共 ${_viewModel.items.length} 项', + ), + const SizedBox(height: 12), if (_viewModel.items.isEmpty) - const Padding( - padding: EdgeInsets.all(42), - child: Center(child: Text('暂无任务')), - ) + const MessageState(title: '暂无任务', description: '新的服务端分派任务会显示在这里') else - ..._viewModel.items.map( - (item) => Card( - child: ListTile( - contentPadding: const EdgeInsets.all(16), - title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.w800)), - subtitle: Padding( - padding: const EdgeInsets.only(top: 8), - child: Text('${item.number}\n${item.address}'), - ), - trailing: Chip(label: Text(statusName(item.status))), - onTap: () => context.push('/tasks/${item.identity}'), - ), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < _viewModel.items.length; index++) ...[ + _TaskRow( + item: _viewModel.items[index], + onTap: () => context.push('/tasks/${_viewModel.items[index].identity}'), + ), + if (index < _viewModel.items.length - 1) + const Divider(indent: 16, endIndent: 16), + ], + ], ), ), ], @@ -115,3 +145,28 @@ class _WorkListPageState extends State { ), ); } + +class _TaskRow extends StatelessWidget { + const _TaskRow({required this.item, required this.onTap}); + final WorkItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => ListTile( + minTileHeight: 88, + title: Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis), + titleTextStyle: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '${item.number}${item.address.isEmpty ? '' : ' · ${item.address}'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + trailing: StatusPill(label: statusName(item.status), tone: StatusTone.info), + onTap: onTap, + ); +} diff --git a/apps/service_app/pubspec.lock b/apps/service_app/pubspec.lock index 2a3756c..3cdb1ce 100644 --- a/apps/service_app/pubspec.lock +++ b/apps/service_app/pubspec.lock @@ -336,6 +336,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.8" + heqi_design_system: + dependency: "direct main" + description: + path: "../heqi_design_system" + relative: true + source: path + version: "0.1.0" hooks: dependency: transitive description: diff --git a/apps/service_app/pubspec.yaml b/apps/service_app/pubspec.yaml index bf44aa2..127437f 100644 --- a/apps/service_app/pubspec.yaml +++ b/apps/service_app/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + heqi_design_system: + path: ../heqi_design_system # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/apps/user_app/lib/app/app.dart b/apps/user_app/lib/app/app.dart index 2ef1d8e..0133b2d 100644 --- a/apps/user_app/lib/app/app.dart +++ b/apps/user_app/lib/app/app.dart @@ -21,6 +21,8 @@ class _UserClientAppState extends State { title: '瓶安芯', debugShowCheckedModeBanner: false, theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.system, routerConfig: _router, ); } diff --git a/apps/user_app/lib/data/services/payment_jsapi_web.dart b/apps/user_app/lib/data/services/payment_jsapi_web.dart index 8e1343e..745cfb7 100644 --- a/apps/user_app/lib/data/services/payment_jsapi_web.dart +++ b/apps/user_app/lib/data/services/payment_jsapi_web.dart @@ -7,7 +7,8 @@ external void _invoke(String name, JSAny? arguments, JSFunction callback); Future invokeWechatJsapi(String clientArgs) { final completer = Completer(); - final arguments = jsonDecode(clientArgs).jsify(); + final Object? decoded = jsonDecode(clientArgs); + final JSAny? arguments = decoded.jsify(); _invoke( 'getBrandWCPayRequest', arguments, diff --git a/apps/user_app/lib/ui/core/app_theme.dart b/apps/user_app/lib/ui/core/app_theme.dart index 6feba5d..4eda0db 100644 --- a/apps/user_app/lib/ui/core/app_theme.dart +++ b/apps/user_app/lib/ui/core/app_theme.dart @@ -1,45 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:heqi_design_system/heqi_design_system.dart'; -class AppTheme { - static const safetyBlue = Color(0xFF246BFD); - static const ink = Color(0xFF14213D); - static const canvas = Color(0xFFF4F7FB); +/// 用户端主题适配层;Token 与组件实现统一来自 heqi_design_system。 +abstract final class AppTheme { + static const primary = HeqiColors.consumerPrimary; + static const success = HeqiColors.success; + static const warning = HeqiColors.warning; + static const danger = HeqiColors.danger; - static ThemeData light() { - final scheme = ColorScheme.fromSeed( - seedColor: safetyBlue, - primary: safetyBlue, - surface: Colors.white, - error: const Color(0xFFD92D20), - ); - return ThemeData( - colorScheme: scheme, - scaffoldBackgroundColor: canvas, - useMaterial3: true, - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, - foregroundColor: ink, - centerTitle: false, - ), - cardTheme: CardThemeData( - color: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ), - ); - } + static ThemeData light() => HeqiTheme.light(HeqiBrand.consumer); + + static ThemeData dark() => HeqiTheme.dark(HeqiBrand.consumer); } diff --git a/apps/user_app/lib/ui/core/widgets.dart b/apps/user_app/lib/ui/core/widgets.dart index 7f4a31e..f2a45b7 100644 --- a/apps/user_app/lib/ui/core/widgets.dart +++ b/apps/user_app/lib/ui/core/widgets.dart @@ -1,7 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:heqi_design_system/heqi_design_system.dart'; import '../../domain/models/client_models.dart'; +typedef AppGutter = HeqiGutter; +typedef SurfaceSection = HeqiSurfaceSection; +typedef SectionHeader = HeqiSectionHeader; +typedef StatusPill = HeqiStatusPill; +typedef StatusTone = HeqiStatusTone; + class PageIntro extends StatelessWidget { const PageIntro({required this.eyebrow, required this.title, this.description, super.key}); @@ -10,29 +17,31 @@ class PageIntro extends StatelessWidget { final String? description; @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - eyebrow, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w700, - letterSpacing: 1, + Widget build(BuildContext context) => AppGutter( + child: Padding( + padding: const EdgeInsets.only(top: HeqiSpacing.x4, bottom: HeqiSpacing.x6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), ), - ), - const SizedBox(height: 6), - Text( - title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w800), - ), - if (description != null) ...[ - const SizedBox(height: 6), - Text(description!, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: HeqiSpacing.x2), + Text(title, style: Theme.of(context).textTheme.headlineMedium), + if (description != null) ...[ + const SizedBox(height: HeqiSpacing.x2), + Text( + description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ], - ], + ), ), ); } @@ -44,20 +53,18 @@ class RecordCard extends StatelessWidget { final VoidCallback? onTap; @override - Widget build(BuildContext context) => Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + Widget build(BuildContext context) => Material( + color: Theme.of(context).colorScheme.surface, child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - title: Text( - record.title.isEmpty ? '未命名记录' : record.title, - style: const TextStyle(fontWeight: FontWeight.w700), + minTileHeight: 72, + title: Text(record.title.isEmpty ? '未命名记录' : record.title), + titleTextStyle: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, ), - subtitle: record.subtitle.isEmpty - ? null - : Padding(padding: const EdgeInsets.only(top: 6), child: Text(record.subtitle)), + subtitle: record.subtitle.isEmpty ? null : Text(record.subtitle), trailing: record.status == null - ? const Icon(Icons.chevron_right) - : Chip(label: Text('状态 ${record.status}'), visualDensity: VisualDensity.compact), + ? const Icon(Icons.chevron_right_rounded) + : StatusPill(label: '状态 ${record.status}', tone: StatusTone.info), onTap: onTap, ), ); @@ -71,26 +78,10 @@ class EmptyState extends StatelessWidget { final VoidCallback? onRetry; @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.inbox_outlined, size: 52, color: Theme.of(context).colorScheme.outline), - const SizedBox(height: 16), - Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - Text(description, textAlign: TextAlign.center), - if (onRetry != null) ...[ - const SizedBox(height: 18), - OutlinedButton(onPressed: onRetry, child: const Text('重新加载')), - ], - ], - ), - ), + Widget build(BuildContext context) => HeqiMessageState( + title: title, + description: description, + onRetry: onRetry, + icon: Icons.inbox_outlined, ); } diff --git a/apps/user_app/lib/ui/features/auth/login_page.dart b/apps/user_app/lib/ui/features/auth/login_page.dart index 3f31531..6771657 100644 --- a/apps/user_app/lib/ui/features/auth/login_page.dart +++ b/apps/user_app/lib/ui/features/auth/login_page.dart @@ -17,6 +17,7 @@ class _LoginPageState extends State { final _password = TextEditingController(); bool _submitting = false; String? _error; + bool _obscurePassword = true; @override void dispose() { @@ -44,28 +45,43 @@ class _LoginPageState extends State { body: SafeArea( child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon( - Icons.health_and_safety_rounded, - size: 68, - color: Theme.of(context).colorScheme.primary, + Align( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.health_and_safety_rounded, + size: 38, + color: Theme.of(context).colorScheme.primary, + semanticLabel: '瓶安芯安全标识', + ), + ), ), - const SizedBox(height: 20), + const SizedBox(height: 24), Text( '瓶安芯', textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w900), + style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 8), - const Text('安全服务与生活采购', textAlign: TextAlign.center), - const SizedBox(height: 36), + Text( + '安全服务与生活采购', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 40), TextField( controller: _phone, keyboardType: TextInputType.phone, @@ -78,19 +94,50 @@ class _LoginPageState extends State { const SizedBox(height: 14), TextField( controller: _password, - obscureText: true, + obscureText: _obscurePassword, autofillHints: const [AutofillHints.password], - decoration: const InputDecoration( + onSubmitted: (_) => _submitting ? null : _login(), + decoration: InputDecoration( labelText: '密码', - prefixIcon: Icon(Icons.lock_outline), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + tooltip: _obscurePassword ? '显示密码' : '隐藏密码', + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), ), ), if (_error != null) ...[ const SizedBox(height: 12), - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ), ], - const SizedBox(height: 20), - ElevatedButton( + const SizedBox(height: 24), + FilledButton( onPressed: _submitting ? null : _login, child: _submitting ? const SizedBox.square( @@ -103,11 +150,13 @@ class _LoginPageState extends State { onPressed: () => context.push('/register'), child: const Text('首次使用?注册账号'), ), - const SizedBox(height: 12), - const Text( - '登录即表示同意用户协议和隐私政策', + const SizedBox(height: 8), + Text( + '登录即表示你已阅读并同意《用户协议》和《隐私政策》', textAlign: TextAlign.center, - style: TextStyle(fontSize: 12), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), diff --git a/apps/user_app/lib/ui/features/auth/register_page.dart b/apps/user_app/lib/ui/features/auth/register_page.dart index d66c740..4a15b2a 100644 --- a/apps/user_app/lib/ui/features/auth/register_page.dart +++ b/apps/user_app/lib/ui/features/auth/register_page.dart @@ -21,6 +21,7 @@ class _RegisterPageState extends State { String _requestIdentity = ''; bool _busy = false; String? _message; + bool _obscurePassword = true; Future _sendCode() async { try { @@ -70,53 +71,103 @@ class _RegisterPageState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('注册用户')), - body: ListView( - padding: const EdgeInsets.all(20), - children: [ - TextField( - controller: _phone, - keyboardType: TextInputType.phone, - decoration: const InputDecoration(labelText: '手机号'), - ), - const SizedBox(height: 12), - TextField( - controller: _name, - decoration: const InputDecoration(labelText: '姓名'), - ), - const SizedBox(height: 12), - TextField( - controller: _address, - decoration: const InputDecoration(labelText: '服务地址'), - ), - const SizedBox(height: 12), - TextField( - controller: _password, - obscureText: true, - decoration: const InputDecoration(labelText: '登录密码'), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: TextField( - controller: _code, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '验证码'), + appBar: AppBar(title: const Text('创建账号')), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + Text('开始使用瓶安芯', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + '填写真实服务信息,便于校验所属服务区域。', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + autofillHints: const [AutofillHints.telephoneNumber], + decoration: const InputDecoration( + labelText: '手机号', + prefixIcon: Icon(Icons.phone_outlined), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _name, + autofillHints: const [AutofillHints.name], + decoration: const InputDecoration( + labelText: '姓名', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _address, + autofillHints: const [AutofillHints.fullStreetAddress], + decoration: const InputDecoration( + labelText: '服务地址', + helperText: '用于匹配气站与配送服务范围', + prefixIcon: Icon(Icons.location_on_outlined), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _password, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + labelText: '登录密码', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + tooltip: _obscurePassword ? '显示密码' : '隐藏密码', + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + icon: Icon( + _obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, + ), ), ), - const SizedBox(width: 10), - OutlinedButton(onPressed: _sendCode, child: const Text('获取验证码')), - ], - ), - if (_message != null) - Padding(padding: const EdgeInsets.only(top: 12), child: Text(_message!)), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _busy || _requestIdentity.isEmpty ? null : _register, - child: const Text('创建账号'), - ), - ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _code, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '验证码'), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 120, + child: OutlinedButton( + onPressed: _busy ? null : _sendCode, + child: const Text('获取验证码'), + ), + ), + ], + ), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics(liveRegion: true, child: Text(_message!)), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: _busy || _requestIdentity.isEmpty ? null : _register, + child: _busy + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('创建账号'), + ), + ], + ), ), ); } diff --git a/apps/user_app/lib/ui/features/home/home_page.dart b/apps/user_app/lib/ui/features/home/home_page.dart index 9aed9b8..afdc0fd 100644 --- a/apps/user_app/lib/ui/features/home/home_page.dart +++ b/apps/user_app/lib/ui/features/home/home_page.dart @@ -58,28 +58,37 @@ class _HomePageState extends State { title: '今天也要安心用气', description: '设备控制能力尚未开放,本页只展示真实服务与安全内容。', ), - Card( - margin: const EdgeInsets.symmetric(horizontal: 16), - child: Padding( - padding: const EdgeInsets.all(20), + AppGutter( + child: SurfaceSection( child: Row( children: [ - Icon( - Icons.store_mall_directory_outlined, - size: 38, - color: Theme.of(context).colorScheme.primary, + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.store_mall_directory_outlined, + color: Theme.of(context).colorScheme.primary, + semanticLabel: '服务归属', + ), ), - const SizedBox(width: 14), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('当前服务归属', style: TextStyle(fontWeight: FontWeight.w800)), + Text('当前服务归属', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text( relation == null ? '尚未建立服务关系' : '${relation['gas_name'] ?? ''} ${relation['delivery_name'] ?? ''}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), @@ -88,17 +97,30 @@ class _HomePageState extends State { ), ), ), - const Padding( - padding: EdgeInsets.fromLTRB(20, 26, 20, 8), - child: Text('安全公告', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18)), + const SizedBox(height: 32), + const AppGutter( + child: SectionHeader(title: '安全公告', description: '来自平台的最新安全提醒与服务信息'), ), + const SizedBox(height: 12), if (contents.isEmpty) - const Padding( - padding: EdgeInsets.all(20), - child: Text('暂无已发布内容'), + const AppGutter( + child: SurfaceSection(child: Text('暂无已发布内容')), ) else - ...contents.take(6).map((item) => RecordCard(record: item)), + AppGutter( + child: SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < contents.take(6).length; index++) ...[ + RecordCard(record: contents[index]), + if (index < contents.take(6).length - 1) + const Divider(indent: 16, endIndent: 16), + ], + ], + ), + ), + ), ], ), ); diff --git a/apps/user_app/lib/ui/features/orders/orders_page.dart b/apps/user_app/lib/ui/features/orders/orders_page.dart index 6b5954e..55af161 100644 --- a/apps/user_app/lib/ui/features/orders/orders_page.dart +++ b/apps/user_app/lib/ui/features/orders/orders_page.dart @@ -22,9 +22,17 @@ class OrdersPage extends StatelessWidget { final action = await showModalBottomSheet( context: context, builder: (context) => SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text('订单操作', style: Theme.of(context).textTheme.titleLarge), + ), + ), if (record.status == 16 || record.status == 18) ...[ ListTile( title: const Text('支付宝支付'), @@ -75,12 +83,8 @@ class OrdersPage extends StatelessWidget { identity: record.identity, requestNo: const Uuid().v7(), channel: action, - payType: kIsWeb - ? (action == 'alipay' ? 'wap' : 'jsapi') - : 'app', - openid: kIsWeb && action == 'wechat' - ? Uri.base.queryParameters['openid'] ?? '' - : '', + payType: kIsWeb ? (action == 'alipay' ? 'wap' : 'jsapi') : 'app', + openid: kIsWeb && action == 'wechat' ? Uri.base.queryParameters['openid'] ?? '' : '', ); await _launcher.launch(payment); if (context.mounted) { @@ -126,47 +130,53 @@ class OrdersPage extends StatelessWidget { @override Widget build(BuildContext context) => DefaultTabController( - length: 4, - child: Scaffold( - appBar: AppBar( - title: const Text('我的订单'), - bottom: const TabBar( - tabs: [ - Tab(text: '商城'), - Tab(text: '供气'), - Tab(text: '退款'), - Tab(text: '工单'), - ], - ), - ), - body: TabBarView( - children: [ - RecordListPage( - title: '商城订单', - eyebrow: '交易', - viewModel: RecordListViewModel(repository.shopOrders), - onRecordTap: (record) => - _openActions(context, record, 'shop'), - ), - RecordListPage( - title: '供气订单', - eyebrow: '履约', - viewModel: RecordListViewModel(repository.gasOrders), - onRecordTap: (record) => - _openActions(context, record, 'gas'), - ), - RecordListPage( - title: '退款记录', - eyebrow: '资金', - viewModel: RecordListViewModel(repository.refunds), - ), - RecordListPage( - title: '服务工单', - eyebrow: '服务', - viewModel: RecordListViewModel(repository.tickets), - ), - ], - ), + length: 4, + child: Scaffold( + appBar: AppBar( + title: const Text('我的订单'), + bottom: const TabBar( + tabs: [ + Tab(text: '商城'), + Tab(text: '供气'), + Tab(text: '退款'), + Tab(text: '工单'), + ], ), - ); + ), + body: TabBarView( + children: [ + RecordListPage( + title: '商城订单', + eyebrow: '交易', + description: '查看商城订单并处理待支付或退款事项', + embedded: true, + viewModel: RecordListViewModel(repository.shopOrders), + onRecordTap: (record) => _openActions(context, record, 'shop'), + ), + RecordListPage( + title: '供气订单', + eyebrow: '履约', + description: '查看供气订单与当前履约状态', + embedded: true, + viewModel: RecordListViewModel(repository.gasOrders), + onRecordTap: (record) => _openActions(context, record, 'gas'), + ), + RecordListPage( + title: '退款记录', + eyebrow: '资金', + description: '退款结果以服务端审核和资金流水为准', + embedded: true, + viewModel: RecordListViewModel(repository.refunds), + ), + RecordListPage( + title: '服务工单', + eyebrow: '服务', + description: '查看维修、安装与其他服务工单', + embedded: true, + viewModel: RecordListViewModel(repository.tickets), + ), + ], + ), + ), + ); } diff --git a/apps/user_app/lib/ui/features/profile/profile_page.dart b/apps/user_app/lib/ui/features/profile/profile_page.dart index 1d8549e..507d202 100644 --- a/apps/user_app/lib/ui/features/profile/profile_page.dart +++ b/apps/user_app/lib/ui/features/profile/profile_page.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../../../app/dependencies.dart'; import '../../../data/repositories/client_repository.dart'; import '../../../domain/models/client_models.dart'; +import '../../core/widgets.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({required this.session, required this.repository, super.key}); @@ -90,86 +91,118 @@ class _ProfilePageState extends State { future: _future, builder: (context, snapshot) { if (!snapshot.hasData) { - if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + if (snapshot.hasError) { + return EmptyState( + title: '个人信息加载失败', + description: '请检查网络后重新进入本页', + onRetry: () => setState(() => _future = _load()), + ); + } return const Center(child: CircularProgressIndicator()); } final (profile, wallet) = snapshot.data!; return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - CircleAvatar( - radius: 30, - child: Text(profile.name.isEmpty ? '用' : profile.name.substring(0, 1)), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.name, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), - ), - Text(profile.phone), - ], - ), - ), - ], - ), - ), - ), - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Column( + SurfaceSection( + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.primary, + child: Text(profile.name.isEmpty ? '用' : profile.name.substring(0, 1)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('钱包余额'), - SizedBox(height: 4), - Text('充值结果以服务端确认为准', style: TextStyle(fontSize: 12)), + Text(profile.name, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text( + profile.phone, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ], ), - Text( - moneyText(wallet.balance), - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w900), - ), - ], - ), + ), + ], ), ), - _item(Icons.location_on_outlined, '地址管理', _addAddress), - _item(Icons.description_outlined, '供气合同', () => context.push('/records/contracts')), - _item( - Icons.account_balance_wallet_outlined, - '钱包流水', - () => context.push('/records/wallet'), + const SizedBox(height: 28), + Text('资产概览', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + SurfaceSection( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('钱包余额', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + '以服务端资金流水为准', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + Text( + moneyText(wallet.balance), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + const SizedBox(height: 28), + Text('账户与服务', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + _item(Icons.location_on_outlined, '地址管理', _addAddress), + const Divider(indent: 56), + _item( + Icons.description_outlined, + '供气合同', + () => context.push('/records/contracts'), + ), + const Divider(indent: 56), + _item( + Icons.account_balance_wallet_outlined, + '钱包流水', + () => context.push('/records/wallet'), + ), + const Divider(indent: 56), + _item(Icons.build_outlined, '申请维修', _createTicket), + ], + ), + ), + const SizedBox(height: 24), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + onPressed: widget.session.logout, + icon: const Icon(Icons.logout), + label: const Text('退出登录'), ), - _item(Icons.build_outlined, '申请维修', _createTicket), - _item(Icons.logout, '退出登录', widget.session.logout), ], ); }, ), ); - Widget _item(IconData icon, String title, VoidCallback onTap) => Card( - child: ListTile( - leading: Icon(icon), - title: Text(title), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ), + Widget _item(IconData icon, String title, VoidCallback onTap) => ListTile( + leading: Icon(icon), + title: Text(title), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, ); } diff --git a/apps/user_app/lib/ui/features/shared/record_list_page.dart b/apps/user_app/lib/ui/features/shared/record_list_page.dart index 5aa5fed..6b40477 100644 --- a/apps/user_app/lib/ui/features/shared/record_list_page.dart +++ b/apps/user_app/lib/ui/features/shared/record_list_page.dart @@ -12,6 +12,7 @@ class RecordListPage extends StatefulWidget { this.description, this.floatingActionButton, this.onRecordTap, + this.embedded = false, super.key, }); @@ -21,6 +22,7 @@ class RecordListPage extends StatefulWidget { final RecordListViewModel viewModel; final Widget? floatingActionButton; final ValueChanged? onRecordTap; + final bool embedded; @override State createState() => _RecordListPageState(); @@ -40,10 +42,8 @@ class _RecordListPageState extends State { } @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: Text(widget.title)), - floatingActionButton: widget.floatingActionButton, - body: ListenableBuilder( + Widget build(BuildContext context) { + final content = ListenableBuilder( listenable: widget.viewModel, builder: (context, _) { final state = widget.viewModel; @@ -62,24 +62,52 @@ class _RecordListPageState extends State { child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: PageIntro( - eyebrow: widget.eyebrow, - title: widget.title, - description: widget.description, + if (!widget.embedded) + SliverToBoxAdapter( + child: PageIntro( + eyebrow: widget.eyebrow, + title: widget.title, + description: widget.description, + ), + ) + else + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Text( + widget.description ?? widget.title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ), - ), if (state.records.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: EmptyState(title: '暂无记录', description: '服务端还没有可展示的数据'), ) else - SliverList.builder( - itemCount: state.records.length, - itemBuilder: (context, index) => RecordCard( - record: state.records[index], - onTap: widget.onRecordTap == null ? null : () => widget.onRecordTap!(state.records[index]), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < state.records.length; index++) ...[ + RecordCard( + record: state.records[index], + onTap: widget.onRecordTap == null + ? null + : () => widget.onRecordTap!(state.records[index]), + ), + if (index < state.records.length - 1) + const Divider(indent: 16, endIndent: 16), + ], + ], + ), + ), ), ), const SliverPadding(padding: EdgeInsets.only(bottom: 24)), @@ -87,6 +115,12 @@ class _RecordListPageState extends State { ), ); }, - ), - ); + ); + if (widget.embedded) return content; + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + floatingActionButton: widget.floatingActionButton, + body: content, + ); + } } diff --git a/apps/user_app/lib/ui/features/shop/shop_page.dart b/apps/user_app/lib/ui/features/shop/shop_page.dart index dca737e..1092ab1 100644 --- a/apps/user_app/lib/ui/features/shop/shop_page.dart +++ b/apps/user_app/lib/ui/features/shop/shop_page.dart @@ -33,26 +33,37 @@ class _ShopPageState extends State { } final confirmed = await showModalBottomSheet( context: context, + isScrollControlled: true, showDragHandle: true, - builder: (context) => Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 30), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '确认下单', - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 12), - Text(product.title), - Text(addresses.first.title), - const SizedBox(height: 18), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('提交订单'), - ), - ], + builder: (context) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('确认下单', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 20), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.shopping_bag_outlined), + title: Text(product.title), + subtitle: Text(product.subtitle), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.location_on_outlined), + title: const Text('配送地址'), + subtitle: Text(addresses.first.title), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('提交订单'), + ), + ], + ), ), ), ); @@ -89,48 +100,22 @@ class _ShopPageState extends State { } final products = snapshot.data ?? const []; return ListView( - padding: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 32), children: [ const PageIntro(eyebrow: '品质保障', title: '燃气安全商城', description: '价格和库存以服务端结算为准'), if (products.isEmpty) const EmptyState(title: '暂无商品', description: '目前没有上架且有库存的商品') else - ...products.map( - (product) => Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), - child: Padding( - padding: const EdgeInsets.all(18), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: const Color(0xFFEAF1FF), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon(Icons.local_fire_department_outlined), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product.title, - style: const TextStyle(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 6), - Text(product.subtitle), - ], - ), - ), - IconButton.filled( - onPressed: () => _buy(product), - icon: const Icon(Icons.add_shopping_cart), - ), + AppGutter( + child: SurfaceSection( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < products.length; index++) ...[ + _ProductRow(product: products[index], onBuy: () => _buy(products[index])), + if (index < products.length - 1) const Divider(indent: 92, endIndent: 16), ], - ), + ], ), ), ), @@ -140,3 +125,56 @@ class _ShopPageState extends State { ), ); } + +class _ProductRow extends StatelessWidget { + const _ProductRow({required this.product, required this.onBuy}); + + final ClientRecord product; + final VoidCallback onBuy; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 12, 16), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.local_fire_department_outlined, + color: Theme.of(context).colorScheme.primary, + semanticLabel: '燃气安全商品', + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(product.title, style: Theme.of(context).textTheme.titleMedium), + if (product.subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + product.subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: '购买 ${product.title}', + onPressed: onBuy, + icon: const Icon(Icons.add_shopping_cart_outlined), + ), + ], + ), + ); +} diff --git a/apps/user_app/pubspec.lock b/apps/user_app/pubspec.lock index da17542..8776797 100644 --- a/apps/user_app/pubspec.lock +++ b/apps/user_app/pubspec.lock @@ -192,6 +192,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "17.3.0" + heqi_design_system: + dependency: "direct main" + description: + path: "../heqi_design_system" + relative: true + source: path + version: "0.1.0" hooks: dependency: transitive description: diff --git a/apps/user_app/pubspec.yaml b/apps/user_app/pubspec.yaml index 32162c8..01f5be4 100644 --- a/apps/user_app/pubspec.yaml +++ b/apps/user_app/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + heqi_design_system: + path: ../heqi_design_system # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.