Compare commits
2 Commits
2601da395c
...
ee77361b92
| Author | SHA1 | Date | |
|---|---|---|---|
| ee77361b92 | |||
| af002edde3 |
@@ -17,6 +17,7 @@ AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
+107
-19
@@ -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,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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<String>? autofillHints;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
@override
|
||||
State<AppTextField> createState() => _AppTextFieldState();
|
||||
}
|
||||
|
||||
class _AppTextFieldState extends State<AppTextField> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,10 +262,13 @@ class _CreatePageState extends State<CreatePage> {
|
||||
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<CreatePage> {
|
||||
Text(
|
||||
style.subtitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE2E8F0),
|
||||
color: Colors.white70,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EditableText>(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<EditableText>(find.byType(EditableText));
|
||||
expect(editable.obscureText, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -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<FilledButton>(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<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
expect(find.text('登录'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user