670 lines
21 KiB
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;
|
|
}
|
|
}
|