Files
monisuo/flutter_monisuo/lib/ui/pages/auth/register_page.dart

670 lines
21 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.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: 身份證上傳
// 第一步
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _referralCodeController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
// 第二步
XFile? _frontFile;
XFile? _backFile;
Uint8List? _frontBytes;
Uint8List? _backBytes;
final _picker = ImagePicker();
bool get _canSubmit =>
_frontFile != null && _backFile != null && !_isLoading;
bool _isLoading = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_referralCodeController.dispose();
super.dispose();
}
Future<void> _pickImage(bool isFront) async {
final picked = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (picked != null) {
final bytes = await picked.readAsBytes();
setState(() {
if (isFront) {
_frontFile = picked;
_frontBytes = bytes;
} else {
_backFile = picked;
_backBytes = bytes;
}
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: AppColorScheme.darkBackground.withValues(alpha: 0),
elevation: 0,
leading: IconButton(
icon: Icon(Icons.chevron_left, color: colorScheme.onSurface),
onPressed: _currentStep == 1
? () => setState(() => _currentStep = 0)
: () => Navigator.pop(context),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: AppSpacing.pagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 步驟指示器
_buildStepIndicator(colorScheme),
const SizedBox(height: AppSpacing.xl),
// 內容區
_currentStep == 0 ? _buildStep1(colorScheme) : _buildStep2(colorScheme),
],
),
),
),
);
}
Widget _buildStepIndicator(ColorScheme colorScheme) {
return Row(
children: [
_buildStepCircle(
number: '1',
label: '賬號信息',
isActive: true,
isComplete: _currentStep > 0,
colorScheme: colorScheme,
),
Expanded(
child: Container(
height: 2,
color: _currentStep > 0
? AppColorScheme.up
: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
_buildStepCircle(
number: '2',
label: '身份驗證',
isActive: _currentStep >= 1,
isComplete: false,
colorScheme: colorScheme,
),
],
);
}
Widget _buildStepCircle({
required String number,
required String label,
required bool isActive,
required bool isComplete,
required ColorScheme colorScheme,
}) {
final Color circleColor;
final Color textColor;
if (isComplete) {
circleColor = AppColorScheme.up;
textColor = AppColorScheme.darkOnPrimary;
} else if (isActive) {
circleColor = colorScheme.primary;
textColor = AppColorScheme.darkOnPrimary;
} else {
circleColor = colorScheme.surfaceContainerHigh;
textColor = colorScheme.onSurfaceVariant;
}
return Column(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: circleColor,
shape: BoxShape.circle,
),
child: Center(
child: isComplete
? Icon(Icons.check, size: 16, color: textColor)
: Text(
number,
style: AppTextStyles.headlineMedium(context).copyWith(
fontWeight: FontWeight.bold,
color: textColor,
),
),
),
),
const SizedBox(height: AppSpacing.xs),
Text(
label,
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
);
}
/// 第一步:賬號信息
Widget _buildStep1(ColorScheme colorScheme) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 標題
Center(
child: Text(
'創建賬號',
style: AppTextStyles.displaySmall(context).copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
const SizedBox(height: AppSpacing.xxl),
// 用戶名
MaterialInput(
controller: _usernameController,
labelText: '賬號',
hintText: '請輸入賬號(4-20位字母數字)',
prefixIcon: Icons.person_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請輸入賬號';
if (value.length < 4) return '賬號過短';
if (value.length > 20) return '賬號過長';
return null;
},
),
const SizedBox(height: AppSpacing.md),
// 密碼
MaterialPasswordInput(
controller: _passwordController,
labelText: '密碼',
hintText: '請輸入密碼(至少6位)',
prefixIcon: Icons.lock_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請輸入密碼';
if (value.length < 6) return '密碼過短';
return null;
},
),
const SizedBox(height: AppSpacing.md),
// 確認密碼
MaterialPasswordInput(
controller: _confirmPasswordController,
labelText: '確認密碼',
hintText: '請再次輸入密碼',
prefixIcon: Icons.lock_outline,
validator: (value) {
if (value == null || value.isEmpty) return '請再次輸入密碼';
if (value != _passwordController.text) return '兩次密碼不一致';
return null;
},
),
const SizedBox(height: AppSpacing.md),
// 推廣碼(可選)
MaterialInput(
controller: _referralCodeController,
labelText: '推廣碼',
hintText: '推廣碼(選填)',
prefixIcon: Icons.card_giftcard,
),
const SizedBox(height: AppSpacing.xl),
// 下一步按鈕
SizedBox(
width: double.infinity,
child: NeonButton(
text: '下一步',
type: NeonButtonType.primary,
onPressed: () {
if (_formKey.currentState!.validate()) {
setState(() => _currentStep = 1);
}
},
height: 48,
showGlow: true,
),
),
const SizedBox(height: AppSpacing.md),
// 登錄鏈接
Center(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'已有賬號?立即登錄',
style: AppTextStyles.headlineMedium(context),
),
),
),
],
),
);
}
/// 第二步:身份證上傳
Widget _buildStep2(ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 標題區
GlassPanel(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
Icons.shield,
color: colorScheme.primary,
size: 22,
),
),
const SizedBox(width: AppSpacing.md),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'身份驗證',
style: AppTextStyles.headlineLarge(context).copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
'上傳身份證正反面完成註冊',
style: AppTextStyles.bodyMedium(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
const SizedBox(height: AppSpacing.xl),
// 身份證正面
Text(
'身份證正面(人像面)',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _frontFile,
imageBytes: _frontBytes,
label: '人像面',
onTap: () => _pickImage(true),
colorScheme: colorScheme,
),
const SizedBox(height: AppSpacing.lg),
// 身份證反面
Text(
'身份證反面(國徽面)',
style: AppTextStyles.bodyLarge(context).copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
_buildUploadZone(
imageFile: _backFile,
imageBytes: _backBytes,
label: '國徽面',
onTap: () => _pickImage(false),
colorScheme: colorScheme,
),
const SizedBox(height: AppSpacing.xl),
// 註冊按鈕
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,
),
);
},
),
],
),
),
const SizedBox(height: AppSpacing.lg),
// 安全提示
Container(
padding: const 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(Icons.lock, size: 16, color: AppColorScheme.up),
const 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 Uint8List? imageBytes,
required String label,
required VoidCallback onTap,
required ColorScheme colorScheme,
}) {
final hasImage = imageFile != null && imageBytes != null;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: double.infinity,
height: 140,
decoration: BoxDecoration(
color: hasImage
? AppColorScheme.up.withValues(alpha: 0.06)
: colorScheme.surfaceContainerHigh.withValues(alpha: 0.3),
borderRadius: AppRadius.radiusXl,
border: Border.all(
color: hasImage
? AppColorScheme.up.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: hasImage
? ClipRRect(
borderRadius: AppRadius.radiusXl,
child: Stack(
fit: StackFit.passthrough,
children: [
Image.memory(imageBytes!, fit: BoxFit.cover),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
horizontal: AppSpacing.md,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColorScheme.darkBackground.withValues(alpha: 0),
AppColorScheme.darkSurfaceLowest
.withValues(alpha: 0.6),
],
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(AppRadius.xl),
bottomRight: Radius.circular(AppRadius.xl),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$label已選擇',
style: AppTextStyles.bodyMedium(context).copyWith(
fontWeight: FontWeight.w600,
color: AppColorScheme.darkOnPrimary,
),
),
GestureDetector(
onTap: () {
setState(() {
if (label == '人像面') {
_frontFile = null;
_frontBytes = null;
} else {
_backFile = null;
_backBytes = null;
}
});
},
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
decoration: BoxDecoration(
color: AppColorScheme.darkOnPrimary
.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
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),
),
const SizedBox(height: AppSpacing.sm),
Text(
'點擊上傳$label',
style: AppTextStyles.bodyLarge(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
const SizedBox(height: AppSpacing.xs),
Text(
'支持 JPG、PNG 格式',
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
],
),
),
),
);
}
Future<void> _handleRegister() async {
if (!_canSubmit) return;
setState(() => _isLoading = true);
try {
final auth = context.read<AuthProvider>();
final response = await auth.register(
_usernameController.text.trim(),
_passwordController.text,
referralCode: _referralCodeController.text.trim().isEmpty
? null
: _referralCodeController.text.trim(),
frontBytes: _frontBytes!,
backBytes: _backBytes!,
);
if (!mounted) return;
if (response.success) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const MainPage()),
);
} else {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('註冊失敗'),
content: Text(response.message ?? '請稍後重試'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('確定'),
),
],
),
);
}
} catch (e) {
if (mounted) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('註冊失敗'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('確定'),
),
],
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
/// 虚线边框画筆
class _DashedBorderPainter extends CustomPainter {
final Color color;
final double borderRadius;
_DashedBorderPainter({
required this.color,
required this.borderRadius,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
const dashWidth = 6.0;
const dashSpace = 4.0;
final path = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
Radius.circular(borderRadius),
));
final metrics = path.computeMetrics();
for (final metric in metrics) {
double distance = 0;
while (distance < metric.length) {
final end = (distance + dashWidth).clamp(0.0, metric.length);
canvas.drawPath(metric.extractPath(distance, end), paint);
distance += dashWidth + dashSpace;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.borderRadius != borderRadius;
}
}