feat: 改用 Material Design 3 标准输入框

- 创建 MaterialInput 和 MaterialPasswordInput 组件
- 移除 shadcn_ui 依赖,使用原生 Material 组件
- 添加圆角边框 + Focus 效果
- 添加浮动标签动画
- 改进登录和注册页面视觉体验
- 统一设计语言(Material Design 3)
This commit is contained in:
2026-04-08 11:11:43 +08:00
parent 403a21361b
commit 97725cb768
3 changed files with 429 additions and 247 deletions

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:provider/provider.dart';
import '../../../core/theme/app_color_scheme.dart';
@@ -7,6 +6,7 @@ import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_theme_extension.dart';
import '../../../providers/auth_provider.dart';
import '../../components/material_input.dart';
import '../main/main_page.dart';
import 'register_page.dart';
@@ -18,14 +18,13 @@ class LoginPage extends StatefulWidget {
}
class _LoginPageState extends State<LoginPage> {
final formKey = GlobalKey<ShadFormState>();
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
static const _loadingIndicatorSize = 16.0;
static const _logoCircleSize = 80.0;
static const _inputHeight = 52.0;
static const _buttonHeight = 52.0;
@override
@@ -45,8 +44,8 @@ class _LoginPageState extends State<LoginPage> {
horizontal: AppSpacing.xl,
vertical: AppSpacing.xxl,
),
child: ShadForm(
key: formKey,
child: Form(
key: _formKey,
child: Column(
children: [
// 頂部品牌區域
@@ -135,63 +134,22 @@ class _LoginPageState extends State<LoginPage> {
}
Widget _buildUsernameField() {
return SizedBox(
height: _inputHeight,
child: ShadInputFormField(
id: 'username',
placeholder: const Text('請輸入用戶名'),
leading: Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: Icon(LucideIcons.user, size: 18, color: context.appColors.onSurfaceMuted),
),
validator: _validateUsername,
controller: _usernameController,
decoration: ShadDecoration(
border: ShadBorder.all(
color: context.colors.outlineVariant,
radius: AppRadius.radiusLg,
),
),
style: AppTextStyles.headlineMedium(context).copyWith(
color: context.colors.onSurface,
),
),
return MaterialInput(
controller: _usernameController,
labelText: '用戶名',
hintText: '請輸入用戶名',
prefixIcon: Icons.person_outline,
validator: _validateUsername,
);
}
Widget _buildPasswordField() {
final iconColor = context.appColors.onSurfaceMuted;
return SizedBox(
height: _inputHeight,
child: ShadInputFormField(
id: 'password',
placeholder: const Text('請輸入密碼'),
obscureText: _obscurePassword,
leading: Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: Icon(LucideIcons.lock, size: 18, color: iconColor),
),
trailing: GestureDetector(
onTap: () => setState(() => _obscurePassword = !_obscurePassword),
child: Icon(
_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye,
size: 18,
color: iconColor,
),
),
validator: _validatePassword,
controller: _passwordController,
decoration: ShadDecoration(
border: ShadBorder.all(
color: context.colors.outlineVariant,
radius: AppRadius.radiusLg,
),
),
style: AppTextStyles.headlineMedium(context).copyWith(
color: context.colors.onSurface,
),
),
return MaterialPasswordInput(
controller: _passwordController,
labelText: '密碼',
hintText: '請輸入密碼',
prefixIcon: Icons.lock_outline,
validator: _validatePassword,
);
}
@@ -204,13 +162,21 @@ class _LoginPageState extends State<LoginPage> {
builder: (context, auth, _) {
return SizedBox(
height: _buttonHeight,
child: ShadButton(
child: ElevatedButton(
onPressed: auth.isLoading ? null : () => _handleLogin(auth),
backgroundColor: buttonColor,
foregroundColor: textColor,
decoration: ShadDecoration(
border: ShadBorder.all(
radius: AppRadius.radiusLg,
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
foregroundColor: textColor,
disabledBackgroundColor: buttonColor.withValues(alpha: 0.5),
disabledForegroundColor: textColor.withValues(alpha: 0.5),
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,
),
),
child: auth.isLoading
@@ -299,12 +265,11 @@ class _LoginPageState extends State<LoginPage> {
// ============================================
Future<void> _handleLogin(AuthProvider auth) async {
if (!formKey.currentState!.saveAndValidate()) return;
if (!_formKey.currentState!.validate()) return;
final values = formKey.currentState!.value;
final response = await auth.login(
values['username'],
values['password'],
_usernameController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
@@ -331,15 +296,15 @@ class _LoginPageState extends State<LoginPage> {
}
void _showErrorDialog(String message) {
showShadDialog(
showDialog(
context: context,
builder: (context) => ShadDialog.alert(
builder: (context) => AlertDialog(
title: const Text('登錄失敗'),
description: Text(message),
content: Text(message),
actions: [
ShadButton(
child: const Text('確定'),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('確定'),
),
],
),

View File

@@ -1,26 +1,24 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/theme/app_color_scheme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../providers/auth_provider.dart';
import '../../components/glass_panel.dart';
import '../../components/neon_glow.dart';
import '../../components/material_input.dart';
import '../main/main_page.dart';
/// 註冊頁面(兩步註冊賬號信息 + 身份證上傳
/// 註冊頁面(兩步註冊:賬號信息 + 身份證上傳)
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
int _currentStep = 0; // 0: 賬號信息, 1: 身份證上傳
State<RegisterPage> createState() => _RegisterPageState>();
int _currentStep = 0; // 0: 賬號信息, 2: 身份證上傳
// 第一步
final _usernameController = TextEditingController();
@@ -84,7 +82,7 @@ class _RegisterPageState extends State<RegisterPage> {
backgroundColor: AppColorScheme.darkBackground.withValues(alpha: 0),
elevation: 0,
leading: IconButton(
icon: Icon(LucideIcons.chevronLeft, color: colorScheme.onSurface),
icon: Icon(Icons.chevron_left, color: colorScheme.onSurface),
onPressed: _currentStep == 1
? () => setState(() => _currentStep = 0)
: () => Navigator.pop(context),
@@ -170,7 +168,7 @@ class _RegisterPageState extends State<RegisterPage> {
),
child: Center(
child: isComplete
? Icon(LucideIcons.check, size: 16, color: textColor)
? Icon(Icons.check, size: 16, color: textColor)
: Text(
number,
style: AppTextStyles.headlineMedium(context).copyWith(
@@ -191,7 +189,7 @@ class _RegisterPageState extends State<RegisterPage> {
);
}
/// 第一步賬號信息
/// 第一步:賬號信息
Widget _buildStep1(ColorScheme colorScheme) {
return Form(
key: _formKey,
@@ -211,62 +209,40 @@ class _RegisterPageState extends State<RegisterPage> {
SizedBox(height: AppSpacing.xxl),
// 用戶名
TextFormField(
MaterialInput(
controller: _usernameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: '請輸入賬號(4-20位字母數字)',
prefixIcon: Icon(Icons.person_outline, color: colorScheme.onSurfaceVariant),
),
labelText: '賬號',
hintText: '請輸入賬號(4-20位字母數字)',
prefixIcon: Icons.person_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請輸入賬號';
if (value.length < 4) return '賬號至少4位';
if (value.length > 20) return '賬號最多20位';
if (value.length < 4) return '賬號過短';
if (value.length > 20) return '賬號過長';
return null;
},
),
SizedBox(height: AppSpacing.md),
// 密碼
TextFormField(
MaterialPasswordInput(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: '請輸入密碼(至少6位)',
prefixIcon: Icon(Icons.lock_outline, color: colorScheme.onSurfaceVariant),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
color: colorScheme.onSurfaceVariant,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
labelText: '密碼',
hintText: '請輸入密碼(至少6位)',
prefixIcon: Icons.lock_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請輸入密碼';
if (value.length < 6) return '密碼至少6位';
if (value.length < 6) return '密碼過短';
return null;
},
),
SizedBox(height: AppSpacing.md),
// 確認密碼
TextFormField(
MaterialPasswordInput(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: '請再次輸入密碼',
prefixIcon: Icon(Icons.lock_outline, color: colorScheme.onSurfaceVariant),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
color: colorScheme.onSurfaceVariant,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
),
labelText: '確認密碼',
hintText: '請再次輸入密碼',
prefixIcon: Icons.lock_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請再次輸入密碼';
if (value != _passwordController.text) return '兩次密碼不一致';
@@ -275,14 +251,12 @@ class _RegisterPageState extends State<RegisterPage> {
),
SizedBox(height: AppSpacing.md),
// 推廣碼(可選
TextFormField(
// 推廣碼(可選)
MaterialInput(
controller: _referralCodeController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: '推廣碼(選填)',
prefixIcon: Icon(Icons.card_giftcard, color: colorScheme.onSurfaceVariant),
),
labelText: '推廣碼',
hintText: '推廣碼(選填)',
prefixIcon: Icons.card_giftcard,
),
SizedBox(height: AppSpacing.xl),
@@ -307,15 +281,17 @@ class _RegisterPageState extends State<RegisterPage> {
Center(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text('已有賬號?立即登錄', style: AppTextStyles.headlineMedium(context)),
child: Text(
'已有賬號?立即登錄',
style: AppTextStyles.headlineMedium(context)),
),
),
),
],
),
);
}
/// 第二步身份證上傳
/// 第二步:身份證上傳
Widget _buildStep2(ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -332,10 +308,10 @@ class _RegisterPageState extends State<RegisterPage> {
padding: EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.md),
borderRadius: AppRadius.radiusLg,
),
child: Icon(
LucideIcons.shieldCheck,
Icons.shield,
color: colorScheme.primary,
size: 22,
),
@@ -360,98 +336,73 @@ class _RegisterPageState extends State<RegisterPage> {
),
],
),
],
),
SizedBox(height: AppSpacing.xl),
SizedBox(height: AppSpacing.xl),
// 身份證正面
Text(
'身份證正面人像面',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
// 身份證正面
Text(
'身份證正面(人像面)',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _frontFile,
imageBytes: _frontBytes,
label: '人像面',
onTap: () => _pickImage(true),
colorScheme: colorScheme,
),
),
),
SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _frontFile,
imageBytes: _frontBytes,
label: '人像面',
onTap: () => _pickImage(true),
colorScheme: colorScheme,
),
SizedBox(height: AppSpacing.lg),
// 身份證反面
Text(
'身份證反面國徽面',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
'身份證反面(國徽面)',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface
)
),
SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _backFile,
imageBytes: _backBytes,
label: '國徽面',
onTap: () => _pickImage(false),
colorScheme: colorScheme
)
),
),
SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _backFile,
imageBytes: _backBytes,
label: '國徽面',
onTap: () => _pickImage(false),
colorScheme: colorScheme,
),
SizedBox(height: AppSpacing.xl),
SizedBox(height: AppSpacing.lg),
// 註冊按鈕
Consumer<AuthProvider>(
builder: (context, auth, _) {
return SizedBox(
width: double.infinity,
child: NeonButton(
text: _isLoading ? '註冊中...' : '完成註冊',
type: NeonButtonType.primary,
onPressed: _canSubmit && !auth.isLoading ? _handleRegister : null,
height: 48,
showGlow: _canSubmit,
return SizedBox(
width: double.infinity,
child: NeonButton(
text: _isLoading ? '註冊中...' : '完成註冊',
type: NeonButtonType.primary,
onPressed: _canSubmit && !auth.isLoading ? _handleRegister : null,
height: 48,
showGlow: _canSubmit,
)
),
);
},
),
],
),
),
SizedBox(height: AppSpacing.lg),
// 安全提示
Container(
padding: EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColorScheme.up.withValues(alpha: 0.06),
borderRadius: AppRadius.radiusLg,
border: Border.all(color: AppColorScheme.up.withValues(alpha: 0.12)),
),
child: Row(
children: [
Icon(LucideIcons.lock, size: 16, color: AppColorScheme.up),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'您的身份信息將被加密存儲,僅用於身份驗證',
style: AppTextStyles.bodySmall(context).copyWith(
color: AppColorScheme.up.withValues(alpha: 0.8),
),
),
),
],
),
),
],
),
);
}
Widget _buildUploadZone({
required XFile? imageFile,
required XFile? imageFile;
required Uint8List? imageBytes,
required String label,
required VoidCallback onTap,
required String label;
required ColorScheme colorScheme,
}) {
final hasImage = imageFile != null && imageBytes != null;
@@ -491,8 +442,10 @@ class _RegisterPageState extends State<RegisterPage> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColorScheme.darkBackground.withValues(alpha: 0), AppColorScheme.darkSurfaceLowest.withValues(alpha: 0.6)],
),
colors: [
AppColorScheme.darkBackground.withValues(alpha: 0),
AppColorScheme.darkSurfaceLowest.withValues(alpha: 0.6),
],
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(AppRadius.xl),
bottomRight: Radius.circular(AppRadius.xl),
@@ -526,46 +479,77 @@ class _RegisterPageState extends State<RegisterPage> {
color: AppColorScheme.darkOnPrimary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(LucideIcons.x, size: 14, color: AppColorScheme.darkOnPrimary),
child: Icon(Icons.close, size: 14, color: AppColorScheme.darkOnPrimary),
),
),
],
),
: CustomPaint(
painter: _DashedBorderPainter(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
borderRadius: AppRadius.xl,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 28,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
SizedBox(height: AppSpacing.sm),
Text(
'點擊上傳$label',
style: AppTextStyles.bodyLarge(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
SizedBox(height: AppSpacing.xs),
Text(
'支持 JPG、PNG 格式',
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
],
),
),
),
);
],
),
);
)
: CustomPaint(
painter: _DashedBorderPainter(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
borderRadius: AppRadius.xl,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.camera,
size: 28,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
SizedBox(height: AppSpacing.sm),
Text(
'點擊上傳$label',
style: AppTextStyles.bodyLarge(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
SizedBox(height: AppSpacing.xs),
Text(
'支持 JPG、PNG 格式',
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
],
),
painter: _DashedBorderPainter(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
borderRadius: AppRadius.xl,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 28,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
SizedBox(height: AppSpacing.sm),
Text(
'点击上传$label',
style: AppTextStyles.bodyLarge(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
SizedBox(height: AppSpacing.xs),
Text(
'支持 JPG、PNG 格式',
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
],
),
),
),
),
),
);
}
@@ -579,12 +563,15 @@ class _RegisterPageState extends State<RegisterPage> {
final response = await auth.register(
_usernameController.text.trim(),
_passwordController.text,
referralCode: _referralCodeController.text.trim().isEmpty
? null
referralCode: _referralCodeController.text.trim().isEmpty ? null
: _referralCodeController.text.trim(),
: referralCode,
},
frontBytes: _frontBytes!,
backBytes: _backBytes!,
referralCode: _referralCodeController.text,
);
}
if (!mounted) return;
@@ -594,15 +581,15 @@ class _RegisterPageState extends State<RegisterPage> {
MaterialPageRoute(builder: (_) => const MainPage()),
);
} else {
showShadDialog(
showDialog(
context: context,
builder: (ctx) => ShadDialog.alert(
builder: (ctx) => AlertDialog(
title: const Text('註冊失敗'),
description: Text(response.message ?? '請稍後重試'),
content: Text(response.message ?? '請稍後重試'),
actions: [
ShadButton(
child: const Text('確定'),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('確定'),
),
],
),
@@ -610,15 +597,15 @@ class _RegisterPageState extends State<RegisterPage> {
}
} catch (e) {
if (mounted) {
showShadDialog(
showDialog(
context: context,
builder: (ctx) => ShadDialog.alert(
builder: (ctx) => AlertDialog(
title: const Text('註冊失敗'),
description: Text(e.toString()),
content: Text(e.toString()),
actions: [
ShadButton(
child: const Text('確定'),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('確定'),
),
],
),
@@ -630,7 +617,7 @@ class _RegisterPageState extends State<RegisterPage> {
}
}
/// 虛線邊框畫
/// 虚线边框画
class _DashedBorderPainter extends CustomPainter {
final Color color;
final double borderRadius;