Compare commits
2 Commits
2601da395c
...
ee77361b92
| Author | SHA1 | Date | |
|---|---|---|---|
| ee77361b92 | |||
| af002edde3 |
@@ -17,6 +17,7 @@ AI 创作、预约、账号和部分菜单目前仍为本地演示交互。
|
|||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
+107
-19
@@ -1,21 +1,87 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
abstract final class AppColors {
|
||||||
static const primary = Color(0xFF4F46E5);
|
/// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。
|
||||||
static const canvas = Color(0xFFF8FAFC);
|
static const primary = Color(0xFFFF6F4C);
|
||||||
static const ink = Color(0xFF0F172A);
|
|
||||||
static const muted = Color(0xFF64748B);
|
/// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。
|
||||||
static const border = Color(0xFFE2E8F0);
|
static const primaryStrong = Color(0xFFD6431A);
|
||||||
static const success = Color(0xFF10B981);
|
|
||||||
|
/// 浅色底上的强调文字、按钮反白替代。
|
||||||
|
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);
|
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() {
|
ThemeData buildAppTheme() {
|
||||||
final scheme = ColorScheme.fromSeed(
|
final scheme = ColorScheme.fromSeed(
|
||||||
seedColor: AppColors.primary,
|
seedColor: AppColors.primary,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
surface: Colors.white,
|
surface: AppColors.surface,
|
||||||
);
|
).copyWith(error: AppColors.error, onError: Colors.white);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
@@ -48,40 +114,62 @@ ThemeData buildAppTheme() {
|
|||||||
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
|
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
|
||||||
),
|
),
|
||||||
cardTheme: const CardThemeData(
|
cardTheme: const CardThemeData(
|
||||||
color: Colors.white,
|
color: AppColors.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
|
||||||
side: BorderSide(color: Color(0xFFF1F5F9)),
|
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(
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: AppColors.surface,
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||||
borderSide: BorderSide(color: AppColors.border),
|
borderSide: BorderSide(color: AppColors.border),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||||
borderSide: BorderSide(color: AppColors.border),
|
borderSide: BorderSide(color: AppColors.border),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(18)),
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
|
||||||
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
|
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(
|
snackBarTheme: const SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: AppColors.surface,
|
||||||
indicatorColor: const Color(0xFFE0E7FF),
|
indicatorColor: AppColors.surfaceTint,
|
||||||
height: 72,
|
height: 72,
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
return TextStyle(
|
return TextStyle(
|
||||||
@@ -90,7 +178,7 @@ ThemeData buildAppTheme() {
|
|||||||
? FontWeight.w700
|
? FontWeight.w700
|
||||||
: FontWeight.w500,
|
: FontWeight.w500,
|
||||||
color: states.contains(WidgetState.selected)
|
color: states.contains(WidgetState.selected)
|
||||||
? AppColors.primary
|
? AppColors.primaryStrong
|
||||||
: AppColors.muted,
|
: 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(
|
DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
gradient: const LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
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(
|
Text(
|
||||||
style.subtitle,
|
style.subtitle,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFFE2E8F0),
|
color: Colors.white70,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -314,15 +314,19 @@ class _PetGreetingCard extends StatelessWidget {
|
|||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFECFDF5), Color(0xFFFFF7ED)],
|
colors: [AppColors.surfaceTint, AppColors.canvas],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Positioned(
|
Positioned(
|
||||||
left: 160,
|
left: 160,
|
||||||
top: 15,
|
top: 15,
|
||||||
child: Icon(Icons.pets, size: 28, color: Color(0x224F46E5)),
|
child: Icon(
|
||||||
|
Icons.pets,
|
||||||
|
size: 28,
|
||||||
|
color: AppColors.primary.withAlpha(34),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -5,
|
right: -5,
|
||||||
@@ -355,7 +359,7 @@ class _PetGreetingCard extends StatelessWidget {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF475569),
|
color: AppColors.primaryDark,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -497,7 +501,7 @@ class _WeatherDetailsSheet extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEEF2FF),
|
color: AppColors.surfaceTint,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -527,10 +531,10 @@ IconData _weatherIcon(WeatherCondition condition) {
|
|||||||
|
|
||||||
Color _weatherColor(WeatherCondition condition) {
|
Color _weatherColor(WeatherCondition condition) {
|
||||||
return switch (condition) {
|
return switch (condition) {
|
||||||
WeatherCondition.sunny => const Color(0xFFF59E0B),
|
WeatherCondition.sunny => AppColors.warning,
|
||||||
WeatherCondition.cloudy => const Color(0xFF64748B),
|
WeatherCondition.cloudy => AppColors.muted,
|
||||||
WeatherCondition.rainy => const Color(0xFF0284C7),
|
WeatherCondition.rainy => const Color(0xFF0284C7),
|
||||||
WeatherCondition.overcast => const Color(0xFF475569),
|
WeatherCondition.overcast => AppColors.ink,
|
||||||
WeatherCondition.snow => const Color(0xFF0891B2),
|
WeatherCondition.snow => const Color(0xFF0891B2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -577,9 +581,7 @@ class _StoryRow extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
gradient: AppColors.brandGradient,
|
||||||
colors: [AppColors.primary, Color(0xFF8B5CF6)],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: index == 0
|
child: index == 0
|
||||||
? const CircleAvatar(
|
? const CircleAvatar(
|
||||||
@@ -617,9 +619,7 @@ class _PromoCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: AppColors.brandGradient,
|
||||||
colors: [AppColors.primary, Color(0xFF7C3AED)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(26),
|
borderRadius: BorderRadius.circular(26),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -639,7 +639,7 @@ class _PromoCard extends StatelessWidget {
|
|||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
Text(
|
Text(
|
||||||
'遛狗 / 洗澡 / 寄养均可使用',
|
'遛狗 / 洗澡 / 寄养均可使用',
|
||||||
style: TextStyle(color: Color(0xFFE0E7FF), fontSize: 12),
|
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -647,7 +647,7 @@ class _PromoCard extends StatelessWidget {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: AppColors.primary,
|
foregroundColor: AppColors.primaryStrong,
|
||||||
),
|
),
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
child: const Text('去使用'),
|
child: const Text('去使用'),
|
||||||
@@ -789,7 +789,7 @@ class _CategoryGrid extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
backgroundColor: const Color(0xFFEEF2FF),
|
backgroundColor: AppColors.surfaceTint,
|
||||||
child: Icon(item.$2, color: AppColors.primary),
|
child: Icon(item.$2, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 7),
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class PetsPage extends StatelessWidget {
|
|||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
value: progress,
|
value: progress,
|
||||||
strokeWidth: 7,
|
strokeWidth: 7,
|
||||||
backgroundColor: const Color(0xFFE2E8F0),
|
backgroundColor: AppColors.border,
|
||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
@@ -215,9 +215,9 @@ class PetsPage extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFECFDF5),
|
color: AppColors.successSurface,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(color: const Color(0xFFA7F3D0)),
|
border: Border.all(color: AppColors.success.withAlpha(140)),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -227,7 +227,7 @@ class PetsPage extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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 SizedBox(height: 5),
|
||||||
const Text(
|
const Text(
|
||||||
'Patbond 社区创作达人',
|
'Patbond 社区创作达人',
|
||||||
style: TextStyle(color: Color(0xFFA5B4FC), fontSize: 12),
|
style: TextStyle(color: AppColors.accent, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Divider(color: Color(0xFF334155)),
|
const Divider(color: Colors.white24),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
@@ -108,7 +108,7 @@ class ProfilePage extends StatelessWidget {
|
|||||||
children: menuItems.map((item) {
|
children: menuItems.map((item) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: const Color(0xFFEEF2FF),
|
backgroundColor: AppColors.surfaceTint,
|
||||||
child: Icon(item.$1, color: AppColors.primary),
|
child: Icon(item.$1, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -162,7 +162,7 @@ class _ProfileStat extends StatelessWidget {
|
|||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
label,
|
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) {
|
loadingBuilder: (context, child, progress) {
|
||||||
if (progress == null) return child;
|
if (progress == null) return child;
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: const Color(0xFFF1F5F9),
|
color: AppColors.surfaceTint,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -41,7 +41,7 @@ class RemoteImage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: const Color(0xFFF1F5F9),
|
color: AppColors.surfaceTint,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
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