377 lines
12 KiB
Dart
377 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:inou_app/design/inou_theme.dart';
|
|
import 'package:inou_app/design/inou_text.dart';
|
|
import 'package:inou_app/design/widgets/widgets.dart';
|
|
|
|
/// Signup page with step-by-step flow
|
|
class SignupPage extends StatefulWidget {
|
|
const SignupPage({super.key});
|
|
|
|
@override
|
|
State<SignupPage> createState() => _SignupPageState();
|
|
}
|
|
|
|
class _SignupPageState extends State<SignupPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _confirmPasswordController = TextEditingController();
|
|
|
|
bool _isLoading = false;
|
|
bool _obscurePassword = true;
|
|
bool _obscureConfirm = true;
|
|
bool _acceptedTerms = false;
|
|
|
|
// Additional profile info
|
|
DateTime? _dateOfBirth;
|
|
String? _sex;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_confirmPasswordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InouAuthFlowPage(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'Create your account',
|
|
style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Start understanding your health better',
|
|
style: InouText.body.copyWith(
|
|
color: InouTheme.textMuted,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Name field
|
|
InouTextField(
|
|
label: 'Full name',
|
|
controller: _nameController,
|
|
placeholder: 'Your name',
|
|
autofillHints: const [AutofillHints.name],
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Please enter your name';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Email field
|
|
InouTextField(
|
|
label: 'Email',
|
|
controller: _emailController,
|
|
placeholder: 'you@example.com',
|
|
keyboardType: TextInputType.emailAddress,
|
|
autofillHints: const [AutofillHints.email],
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Please enter your email';
|
|
}
|
|
if (!value.contains('@')) {
|
|
return 'Please enter a valid email';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Date of birth
|
|
_buildDateOfBirthField(),
|
|
const SizedBox(height: 16),
|
|
|
|
// Sex selection
|
|
InouRadioGroup<String>(
|
|
label: 'Biological sex',
|
|
hint: 'Used for accurate medical context',
|
|
value: _sex,
|
|
options: const [
|
|
InouRadioOption(value: 'male', label: 'Male'),
|
|
InouRadioOption(value: 'female', label: 'Female'),
|
|
],
|
|
onChanged: (value) {
|
|
setState(() => _sex = value);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Password field
|
|
InouTextField(
|
|
label: 'Password',
|
|
controller: _passwordController,
|
|
placeholder: 'At least 8 characters',
|
|
obscureText: _obscurePassword,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
|
color: InouTheme.textMuted,
|
|
size: 20,
|
|
),
|
|
onPressed: () {
|
|
setState(() => _obscurePassword = !_obscurePassword);
|
|
},
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Please enter a password';
|
|
}
|
|
if (value.length < 8) {
|
|
return 'Password must be at least 8 characters';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Confirm password field
|
|
InouTextField(
|
|
label: 'Confirm password',
|
|
controller: _confirmPasswordController,
|
|
placeholder: 'Re-enter your password',
|
|
obscureText: _obscureConfirm,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscureConfirm ? Icons.visibility_off : Icons.visibility,
|
|
color: InouTheme.textMuted,
|
|
size: 20,
|
|
),
|
|
onPressed: () {
|
|
setState(() => _obscureConfirm = !_obscureConfirm);
|
|
},
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Please confirm your password';
|
|
}
|
|
if (value != _passwordController.text) {
|
|
return 'Passwords do not match';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Terms acceptance
|
|
InouCheckbox(
|
|
value: _acceptedTerms,
|
|
onChanged: (value) {
|
|
setState(() => _acceptedTerms = value ?? false);
|
|
},
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: InouText.bodySmall.copyWith(color: InouTheme.text),
|
|
children: [
|
|
const TextSpan(text: 'I agree to the '),
|
|
TextSpan(
|
|
text: 'Terms of Service',
|
|
style: TextStyle(color: InouTheme.accent),
|
|
// TODO: Make tappable
|
|
),
|
|
const TextSpan(text: ' and '),
|
|
TextSpan(
|
|
text: 'Privacy Policy',
|
|
style: TextStyle(color: InouTheme.accent),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Sign up button
|
|
InouButton(
|
|
text: _isLoading ? 'Creating account...' : 'Create account',
|
|
onPressed: (_isLoading || !_acceptedTerms) ? null : _handleSignup,
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Login link
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Already have an account? ',
|
|
style: InouText.body.copyWith(
|
|
color: InouTheme.textMuted,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pushReplacementNamed(context, '/login'),
|
|
style: TextButton.styleFrom(
|
|
padding: EdgeInsets.zero,
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
child: Text(
|
|
'Sign in',
|
|
style: InouText.body.copyWith(
|
|
color: InouTheme.accent,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDateOfBirthField() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Date of birth',
|
|
style: InouText.label,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Used for accurate medical context',
|
|
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
|
|
),
|
|
const SizedBox(height: 8),
|
|
InkWell(
|
|
onTap: _selectDateOfBirth,
|
|
borderRadius: InouTheme.borderRadiusMd,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: InouTheme.bgCard,
|
|
borderRadius: InouTheme.borderRadiusMd,
|
|
border: Border.all(color: InouTheme.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_dateOfBirth != null
|
|
? '${_dateOfBirth!.month}/${_dateOfBirth!.day}/${_dateOfBirth!.year}'
|
|
: 'Select date',
|
|
style: InouText.body.copyWith(
|
|
color: _dateOfBirth != null
|
|
? InouTheme.text
|
|
: InouTheme.textMuted,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.calendar_today,
|
|
size: 20,
|
|
color: InouTheme.textMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _selectDateOfBirth() async {
|
|
final now = DateTime.now();
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateOfBirth ?? DateTime(now.year - 30),
|
|
firstDate: DateTime(1900),
|
|
lastDate: now,
|
|
builder: (context, child) {
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(
|
|
colorScheme: ColorScheme.light(
|
|
primary: InouTheme.accent,
|
|
onPrimary: Colors.white,
|
|
surface: InouTheme.bgCard,
|
|
onSurface: InouTheme.text,
|
|
),
|
|
),
|
|
child: child!,
|
|
);
|
|
},
|
|
);
|
|
|
|
if (picked != null) {
|
|
setState(() => _dateOfBirth = picked);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSignup() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
if (_dateOfBirth == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Please select your date of birth'),
|
|
backgroundColor: InouTheme.danger,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_sex == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Please select your biological sex'),
|
|
backgroundColor: InouTheme.danger,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// TODO: Implement actual signup
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
|
|
if (mounted) {
|
|
// Navigate to email verification or dashboard
|
|
Navigator.pushNamedAndRemoveUntil(
|
|
context,
|
|
'/dashboard',
|
|
(route) => false,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Signup failed: ${e.toString()}'),
|
|
backgroundColor: InouTheme.danger,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
}
|