From af002edde3d6d6f9f3fa41f71016403c9fb38d52 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 10:34:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E7=8F=8A=E7=91=9A?= =?UTF-8?q?=E6=A9=99=E4=B8=BB=E9=A2=98=E4=BD=93=E7=B3=BB=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=AE=A4=E8=AF=81=E5=9F=BA=E7=A1=80=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=88ADR-005=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_theme.dart 重写为语义 token(AppColors/AppRadius),落地 primaryStrong/error 等 AA 对比度色 - 五个页面与公共组件的旧靛蓝硬编码色值全部替换为 token,仅换色不动布局 - 新增 BrandMark/AppTextField/PrimaryButton/InlineErrorBanner/AuthScaffold 及 6 个 widget 测试 - 门禁:dart format(0 changed)/ flutter analyze(0 issues)/ flutter test(7 passed) Co-Authored-By: Claude Fable 5 --- lib/core/theme/app_theme.dart | 126 +++++++++++++++++---- lib/core/widgets/app_text_field.dart | 88 ++++++++++++++ lib/core/widgets/auth_scaffold.dart | 32 ++++++ lib/core/widgets/brand_mark.dart | 50 ++++++++ lib/core/widgets/inline_error_banner.dart | 41 +++++++ lib/core/widgets/primary_button.dart | 47 ++++++++ lib/features/create/create_page.dart | 9 +- lib/features/home/home_page.dart | 34 +++--- lib/features/pets/pets_page.dart | 8 +- lib/features/profile/profile_page.dart | 8 +- lib/widgets/common.dart | 4 +- test/core/widgets/app_text_field_test.dart | 44 +++++++ test/core/widgets/primary_button_test.dart | 58 ++++++++++ 13 files changed, 500 insertions(+), 49 deletions(-) create mode 100644 lib/core/widgets/app_text_field.dart create mode 100644 lib/core/widgets/auth_scaffold.dart create mode 100644 lib/core/widgets/brand_mark.dart create mode 100644 lib/core/widgets/inline_error_banner.dart create mode 100644 lib/core/widgets/primary_button.dart create mode 100644 test/core/widgets/app_text_field_test.dart create mode 100644 test/core/widgets/primary_button_test.dart diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 573c62c..5699f67 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,21 +1,87 @@ import 'package:flutter/material.dart'; +/// 语义色 token(ADR-005:以 AI宠物_iOS_UI设计稿.html 珊瑚橙暖色系为品牌正典, +/// 详见 iteration-1-reports/04-ui-login-design-spec.md §1.1)。 abstract final class AppColors { - static const primary = Color(0xFF4F46E5); - static const canvas = Color(0xFFF8FAFC); - static const ink = Color(0xFF0F172A); - static const muted = Color(0xFF64748B); - static const border = Color(0xFFE2E8F0); - static const success = Color(0xFF10B981); + /// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。 + static const primary = Color(0xFFFF6F4C); + + /// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。 + static const primaryStrong = Color(0xFFD6431A); + + /// 浅色底上的强调文字、按钮反白替代。 + static const primaryDark = Color(0xFF7A2E12); + + /// amber:渐变终点、徽章、会员/促销 CTA。 + static const accent = Color(0xFFFFB648); + + /// amber 底上的文字。 + static const accentDark = Color(0xFF7A4B0A); + + /// 页面背景(奶油底)。 + static const canvas = Color(0xFFFFF7ED); + + /// 卡片、输入框背景。 + static const surface = Color(0xFFFFFFFF); + + /// peach:图标底、占位块、选中指示。 + static const surfaceTint = Color(0xFFFFE8D6); + + /// 主文字。 + static const ink = Color(0xFF3E2A1F); + + /// 次级文字、占位符。 + static const muted = Color(0xFF9C8977); + + /// 描边、分隔线。 + static const border = Color(0xFFF0DCC8); + + /// sage:成功提示的图标/描边。 + static const success = Color(0xFF7FA88A); + + /// 成功提示底上的文字。 + static const successInk = Color(0xFF3F5744); + + /// 成功提示底色。 + static const successSurface = Color(0xFFE8F0E8); + static const warning = Color(0xFFF59E0B); + + /// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。 + static const error = Color(0xFFD0342C); + + /// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。 + static const brandGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [primary, accent], + ); +} + +/// 圆角 token(设计规范 §1.3)。 +abstract final class AppRadius { + /// 小按钮、内嵌 CTA、错误横幅。 + static const sm = 12.0; + + /// 主按钮、SnackBar、菜单组。 + static const md = 16.0; + + /// 输入框、feed 卡。 + static const lg = 18.0; + + /// 大卡片。 + static const xl = 24.0; + + /// 芯片、徽章。 + static const pill = 999.0; } ThemeData buildAppTheme() { final scheme = ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.light, - surface: Colors.white, - ); + surface: AppColors.surface, + ).copyWith(error: AppColors.error, onError: Colors.white); return ThemeData( useMaterial3: true, @@ -48,40 +114,62 @@ ThemeData buildAppTheme() { bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4), ), cardTheme: const CardThemeData( - color: Colors.white, + color: AppColors.surface, elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), - side: BorderSide(color: Color(0xFFF1F5F9)), + borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)), + side: BorderSide(color: AppColors.border), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryStrong, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.ink.withAlpha(31), + disabledForegroundColor: AppColors.ink.withAlpha(97), + minimumSize: const Size(64, 52), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), + ), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), ), inputDecorationTheme: const InputDecorationTheme( filled: true, - fillColor: Colors.white, + fillColor: AppColors.surface, contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), + borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)), borderSide: BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), + borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)), borderSide: BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), + borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)), borderSide: BorderSide(color: AppColors.primary, width: 1.5), ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)), + borderSide: BorderSide(color: AppColors.error, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)), + borderSide: BorderSide(color: AppColors.error, width: 1.5), + ), + errorStyle: TextStyle(color: AppColors.error, fontSize: 12), ), snackBarTheme: const SnackBarThemeData( behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), ), ), navigationBarTheme: NavigationBarThemeData( - backgroundColor: Colors.white, - indicatorColor: const Color(0xFFE0E7FF), + backgroundColor: AppColors.surface, + indicatorColor: AppColors.surfaceTint, height: 72, labelTextStyle: WidgetStateProperty.resolveWith((states) { return TextStyle( @@ -90,7 +178,7 @@ ThemeData buildAppTheme() { ? FontWeight.w700 : FontWeight.w500, color: states.contains(WidgetState.selected) - ? AppColors.primary + ? AppColors.primaryStrong : AppColors.muted, ); }), diff --git a/lib/core/widgets/app_text_field.dart b/lib/core/widgets/app_text_field.dart new file mode 100644 index 0000000..50a46f2 --- /dev/null +++ b/lib/core/widgets/app_text_field.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 表单输入框封装(设计规范 §2.2 / §4.1)。 +/// +/// 密码模式([obscurable] 为 true)内置可见性切换按钮,点击目标 ≥44×44。 +/// 提交中禁用时整体 60% 不透明度。 +class AppTextField extends StatefulWidget { + const AppTextField({ + required this.label, + super.key, + this.controller, + this.prefixIcon, + this.errorText, + this.helperText, + this.enabled = true, + this.obscurable = false, + this.keyboardType, + this.textInputAction, + this.autofillHints, + this.onChanged, + this.onSubmitted, + }); + + final String label; + final TextEditingController? controller; + final IconData? prefixIcon; + final String? errorText; + final String? helperText; + final bool enabled; + + /// 是否为密码模式:默认遮蔽输入,并显示可见性切换按钮。 + final bool obscurable; + + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final Iterable? autofillHints; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + State createState() => _AppTextFieldState(); +} + +class _AppTextFieldState extends State { + bool obscured = true; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.enabled ? 1 : 0.6, + child: TextFormField( + controller: widget.controller, + enabled: widget.enabled, + obscureText: widget.obscurable && obscured, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + autofillHints: widget.autofillHints, + onChanged: widget.onChanged, + onFieldSubmitted: widget.onSubmitted, + decoration: InputDecoration( + labelText: widget.label, + errorText: widget.errorText, + helperText: widget.helperText, + prefixIcon: widget.prefixIcon == null + ? null + : Icon(widget.prefixIcon, color: AppColors.muted), + suffixIcon: widget.obscurable + ? IconButton( + tooltip: obscured ? '显示密码' : '隐藏密码', + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + onPressed: widget.enabled + ? () => setState(() => obscured = !obscured) + : null, + icon: Icon( + obscured ? Icons.visibility_off : Icons.visibility, + color: AppColors.muted, + ), + ) + : null, + ), + ), + ); + } +} diff --git a/lib/core/widgets/auth_scaffold.dart b/lib/core/widgets/auth_scaffold.dart new file mode 100644 index 0000000..7023bf0 --- /dev/null +++ b/lib/core/widgets/auth_scaffold.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// 认证页统一框架:SafeArea + 滚动 + 水平 padding 24 + 键盘避让(设计规范 §6.2)。 +/// +/// 登录 / 注册 / 找回密码共用。[appBar] 供注册页传入透明返回栏; +/// 键盘弹出时内容可滚动,防止溢出。 +class AuthScaffold extends StatelessWidget { + const AuthScaffold({required this.child, super.key, this.appBar}); + + final Widget child; + final PreferredSizeWidget? appBar; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: appBar, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: child, + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/core/widgets/brand_mark.dart b/lib/core/widgets/brand_mark.dart new file mode 100644 index 0000000..aec3250 --- /dev/null +++ b/lib/core/widgets/brand_mark.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 品牌区:logo + 字标 + slogan(设计规范 §2.2 / §5.2)。 +/// +/// Splash 与登录页共用,保证两处渲染一致,是淡入过渡成立的前提。 +class BrandMark extends StatelessWidget { + const BrandMark({super.key, this.size = 72, this.slogan = '和毛孩子在一起的每一天'}); + + /// logo 图形边长。 + final double size; + + /// 传 null 时不渲染 slogan 行。 + final String? slogan; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + decoration: const BoxDecoration( + gradient: AppColors.brandGradient, + borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)), + ), + child: Icon(Icons.pets, size: size * 0.5, color: Colors.white), + ), + const SizedBox(height: 12), + const Text( + 'Patbond', + style: TextStyle( + color: AppColors.primary, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + if (slogan != null) ...[ + const SizedBox(height: 4), + Text( + slogan!, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 14), + ), + ], + ], + ); + } +} diff --git a/lib/core/widgets/inline_error_banner.dart b/lib/core/widgets/inline_error_banner.dart new file mode 100644 index 0000000..3c83a54 --- /dev/null +++ b/lib/core/widgets/inline_error_banner.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 表单级错误横幅(设计规范 §4.2)。 +/// +/// 用于无法归属单一字段的业务错误(如 401「用户名或密码错误」), +/// 停留在表单上下文里供用户对照修改,不用 SnackBar。 +class InlineErrorBanner extends StatelessWidget { + const InlineErrorBanner({required this.message, super.key}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withAlpha(20), + borderRadius: const BorderRadius.all(Radius.circular(AppRadius.sm)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, size: 18, color: AppColors.error), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle( + color: AppColors.error, + fontSize: 13, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/primary_button.dart b/lib/core/widgets/primary_button.dart new file mode 100644 index 0000000..02fb66a --- /dev/null +++ b/lib/core/widgets/primary_button.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 主操作按钮:全宽 × 52 高,`primaryStrong` 填充(设计规范 §2.2 / §4.3 / §4.4)。 +/// +/// [isLoading] 为 true 时文字替换为 20×20 白色转圈,按钮尺寸不变、不可再点, +/// 填充保持 `primaryStrong`(loading 不是禁用态,不置灰)。 +class PrimaryButton extends StatelessWidget { + const PrimaryButton({ + required this.label, + super.key, + this.onPressed, + this.isLoading = false, + }); + + final String label; + + /// 传 null 进入禁用态(主题 disabled 置灰)。 + final VoidCallback? onPressed; + + final bool isLoading; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: isLoading ? null : onPressed, + style: isLoading + ? FilledButton.styleFrom( + disabledBackgroundColor: AppColors.primaryStrong, + ) + : null, + child: isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text(label), + ), + ); + } +} diff --git a/lib/features/create/create_page.dart b/lib/features/create/create_page.dart index c31fd4e..8dcfaf4 100644 --- a/lib/features/create/create_page.dart +++ b/lib/features/create/create_page.dart @@ -262,10 +262,13 @@ class _CreatePageState extends State { DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), - gradient: const LinearGradient( + gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, Color(0xCC0F172A)], + colors: [ + Colors.transparent, + AppColors.ink.withAlpha(204), + ], ), ), ), @@ -286,7 +289,7 @@ class _CreatePageState extends State { Text( style.subtitle, style: const TextStyle( - color: Color(0xFFE2E8F0), + color: Colors.white70, fontSize: 10, ), ), diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 1d18649..e18e7c0 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -314,15 +314,19 @@ class _PetGreetingCard extends StatelessWidget { gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFECFDF5), Color(0xFFFFF7ED)], + colors: [AppColors.surfaceTint, AppColors.canvas], ), ), child: Stack( children: [ - const Positioned( + Positioned( left: 160, top: 15, - child: Icon(Icons.pets, size: 28, color: Color(0x224F46E5)), + child: Icon( + Icons.pets, + size: 28, + color: AppColors.primary.withAlpha(34), + ), ), Positioned( right: -5, @@ -355,7 +359,7 @@ class _PetGreetingCard extends StatelessWidget { maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFF475569), + color: AppColors.primaryDark, fontSize: 12, height: 1.45, fontWeight: FontWeight.w600, @@ -497,7 +501,7 @@ class _WeatherDetailsSheet extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFEEF2FF), + color: AppColors.surfaceTint, borderRadius: BorderRadius.circular(18), ), child: Row( @@ -527,10 +531,10 @@ IconData _weatherIcon(WeatherCondition condition) { Color _weatherColor(WeatherCondition condition) { return switch (condition) { - WeatherCondition.sunny => const Color(0xFFF59E0B), - WeatherCondition.cloudy => const Color(0xFF64748B), + WeatherCondition.sunny => AppColors.warning, + WeatherCondition.cloudy => AppColors.muted, WeatherCondition.rainy => const Color(0xFF0284C7), - WeatherCondition.overcast => const Color(0xFF475569), + WeatherCondition.overcast => AppColors.ink, WeatherCondition.snow => const Color(0xFF0891B2), }; } @@ -577,9 +581,7 @@ class _StoryRow extends StatelessWidget { padding: const EdgeInsets.all(2), decoration: const BoxDecoration( shape: BoxShape.circle, - gradient: LinearGradient( - colors: [AppColors.primary, Color(0xFF8B5CF6)], - ), + gradient: AppColors.brandGradient, ), child: index == 0 ? const CircleAvatar( @@ -617,9 +619,7 @@ class _PromoCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF7C3AED)], - ), + gradient: AppColors.brandGradient, borderRadius: BorderRadius.circular(26), ), child: Row( @@ -639,7 +639,7 @@ class _PromoCard extends StatelessWidget { SizedBox(height: 5), Text( '遛狗 / 洗澡 / 寄养均可使用', - style: TextStyle(color: Color(0xFFE0E7FF), fontSize: 12), + style: TextStyle(color: Colors.white, fontSize: 12), ), ], ), @@ -647,7 +647,7 @@ class _PromoCard extends StatelessWidget { FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.white, - foregroundColor: AppColors.primary, + foregroundColor: AppColors.primaryStrong, ), onPressed: onTap, child: const Text('去使用'), @@ -789,7 +789,7 @@ class _CategoryGrid extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( - backgroundColor: const Color(0xFFEEF2FF), + backgroundColor: AppColors.surfaceTint, child: Icon(item.$2, color: AppColors.primary), ), const SizedBox(height: 7), diff --git a/lib/features/pets/pets_page.dart b/lib/features/pets/pets_page.dart index 147688b..c54d362 100644 --- a/lib/features/pets/pets_page.dart +++ b/lib/features/pets/pets_page.dart @@ -169,7 +169,7 @@ class PetsPage extends StatelessWidget { CircularProgressIndicator( value: progress, strokeWidth: 7, - backgroundColor: const Color(0xFFE2E8F0), + backgroundColor: AppColors.border, color: AppColors.success, ), Center( @@ -215,9 +215,9 @@ class PetsPage extends StatelessWidget { Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: const Color(0xFFECFDF5), + color: AppColors.successSurface, borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFFA7F3D0)), + border: Border.all(color: AppColors.success.withAlpha(140)), ), child: const Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -227,7 +227,7 @@ class PetsPage extends StatelessWidget { Expanded( child: Text( '健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。', - style: TextStyle(color: Color(0xFF047857), height: 1.5), + style: TextStyle(color: AppColors.successInk, height: 1.5), ), ), ], diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 9751640..ff5530e 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -82,10 +82,10 @@ class ProfilePage extends StatelessWidget { const SizedBox(height: 5), const Text( 'Patbond 社区创作达人', - style: TextStyle(color: Color(0xFFA5B4FC), fontSize: 12), + style: TextStyle(color: AppColors.accent, fontSize: 12), ), const SizedBox(height: 20), - const Divider(color: Color(0xFF334155)), + const Divider(color: Colors.white24), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -108,7 +108,7 @@ class ProfilePage extends StatelessWidget { children: menuItems.map((item) { return ListTile( leading: CircleAvatar( - backgroundColor: const Color(0xFFEEF2FF), + backgroundColor: AppColors.surfaceTint, child: Icon(item.$1, color: AppColors.primary), ), title: Text( @@ -162,7 +162,7 @@ class _ProfileStat extends StatelessWidget { const SizedBox(height: 3), Text( label, - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11), + style: const TextStyle(color: Colors.white70, fontSize: 11), ), ], ); diff --git a/lib/widgets/common.dart b/lib/widgets/common.dart index c3382f6..928b696 100644 --- a/lib/widgets/common.dart +++ b/lib/widgets/common.dart @@ -29,7 +29,7 @@ class RemoteImage extends StatelessWidget { loadingBuilder: (context, child, progress) { if (progress == null) return child; return ColoredBox( - color: const Color(0xFFF1F5F9), + color: AppColors.surfaceTint, child: SizedBox( width: width, height: height, @@ -41,7 +41,7 @@ class RemoteImage extends StatelessWidget { }, errorBuilder: (context, error, stackTrace) { return ColoredBox( - color: const Color(0xFFF1F5F9), + color: AppColors.surfaceTint, child: SizedBox( width: width, height: height, diff --git a/test/core/widgets/app_text_field_test.dart b/test/core/widgets/app_text_field_test.dart new file mode 100644 index 0000000..65c0c23 --- /dev/null +++ b/test/core/widgets/app_text_field_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; + +Widget wrap(Widget child) { + return MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + testWidgets('errorText 展示在输入框下方', (tester) async { + await tester.pumpWidget( + wrap(const AppTextField(label: '密码', errorText: '请输入密码')), + ); + + expect(find.text('请输入密码'), findsOneWidget); + }); + + testWidgets('errorText 为 null 时不显示错误', (tester) async { + await tester.pumpWidget(wrap(const AppTextField(label: '密码'))); + + expect(find.text('请输入密码'), findsNothing); + }); + + testWidgets('密码模式默认遮蔽,点击切换按钮后可见', (tester) async { + await tester.pumpWidget( + wrap(const AppTextField(label: '密码', obscurable: true)), + ); + + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + var editable = tester.widget(find.byType(EditableText)); + expect(editable.obscureText, isTrue); + + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); + + expect(find.byIcon(Icons.visibility), findsOneWidget); + editable = tester.widget(find.byType(EditableText)); + expect(editable.obscureText, isFalse); + }); +} diff --git a/test/core/widgets/primary_button_test.dart b/test/core/widgets/primary_button_test.dart new file mode 100644 index 0000000..6d3c3a8 --- /dev/null +++ b/test/core/widgets/primary_button_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; + +Widget wrap(Widget child) { + return MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + testWidgets('默认态显示文字,点击触发回调', (tester) async { + var tapped = 0; + await tester.pumpWidget( + wrap(PrimaryButton(label: '登录', onPressed: () => tapped++)), + ); + + expect(find.text('登录'), findsOneWidget); + await tester.tap(find.byType(PrimaryButton)); + expect(tapped, 1); + }); + + testWidgets('loading 态显示转圈、隐藏文字且不可点击', (tester) async { + var tapped = 0; + await tester.pumpWidget( + wrap( + PrimaryButton(label: '登录', isLoading: true, onPressed: () => tapped++), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('登录'), findsNothing); + + await tester.tap(find.byType(PrimaryButton), warnIfMissed: false); + await tester.pump(); + expect(tapped, 0); + + // loading 不是禁用态:填充保持 primaryStrong,尺寸保持 52 高。 + final button = tester.widget(find.byType(FilledButton)); + expect(button.onPressed, isNull); + expect( + button.style?.backgroundColor?.resolve({WidgetState.disabled}), + AppColors.primaryStrong, + ); + expect(tester.getSize(find.byType(PrimaryButton)).height, 52); + }); + + testWidgets('onPressed 为 null 时进入禁用态', (tester) async { + await tester.pumpWidget(wrap(const PrimaryButton(label: '登录'))); + + final button = tester.widget(find.byType(FilledButton)); + expect(button.onPressed, isNull); + expect(find.text('登录'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); +}