Improved sign up screen

This commit is contained in:
Marc Rejohn Castillano 2026-02-22 14:46:59 +08:00
parent 62a9544533
commit dd29d2f90f

View File

@ -85,166 +85,242 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
body: ResponsiveBody( body: ResponsiveBody(
maxWidth: 480, maxWidth: 480,
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
child: Form( child: SingleChildScrollView(
key: _formKey, child: Form(
child: Column( key: _formKey,
mainAxisAlignment: MainAxisAlignment.center, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
Center( children: [
child: Column( Center(
child: Column(
children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
const SizedBox(height: 12),
Text(
'TasQ',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
const SizedBox(height: 24),
TextFormField(
controller: _fullNameController,
decoration: const InputDecoration(labelText: 'Full name'),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
if (value.length < 6) {
return 'Use at least 6 characters.';
}
return null;
},
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
const SizedBox(height: 12),
Text( Text(
'TasQ', 'Password strength: $_passwordStrengthLabel',
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: _passwordStrength,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
color: _passwordStrengthColor,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
], ],
), ),
), const SizedBox(height: 12),
const SizedBox(height: 24), TextFormField(
TextFormField( controller: _confirmPasswordController,
controller: _fullNameController, decoration: const InputDecoration(
decoration: const InputDecoration(labelText: 'Full name'), labelText: 'Confirm password',
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required.';
}
if (value.length < 6) {
return 'Use at least 6 characters.';
}
return null;
},
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Password strength: $_passwordStrengthLabel',
style: Theme.of(context).textTheme.labelMedium,
), ),
const SizedBox(height: 6), obscureText: true,
LinearProgressIndicator( textInputAction: TextInputAction.done,
value: _passwordStrength, onFieldSubmitted: (_) {
minHeight: 8, if (!_isLoading) {
borderRadius: BorderRadius.circular(8), _handleSignUp();
color: _passwordStrengthColor, }
backgroundColor: Theme.of( },
context, validator: (value) {
).colorScheme.surfaceContainerHighest, if (value == null || value.isEmpty) {
), return 'Confirm your password.';
], }
), if (value != _passwordController.text) {
const SizedBox(height: 12), return 'Passwords do not match.';
TextFormField( }
controller: _confirmPasswordController, return null;
decoration: const InputDecoration( },
labelText: 'Confirm password',
), ),
obscureText: true, const SizedBox(height: 12),
textInputAction: TextInputAction.done, Text('Offices', style: Theme.of(context).textTheme.titleSmall),
onFieldSubmitted: (_) { const SizedBox(height: 8),
if (!_isLoading) { officesAsync.when(
_handleSignUp(); data: (offices) {
} if (offices.isEmpty) {
}, return const Text('No offices available.');
validator: (value) { }
if (value == null || value.isEmpty) {
return 'Confirm your password.'; final officeNameById = <String, String>{
} for (final o in offices) o.id: o.name,
if (value != _passwordController.text) { };
return 'Passwords do not match.';
} return Column(
return null; crossAxisAlignment: CrossAxisAlignment.start,
}, children: [
), ElevatedButton.icon(
const SizedBox(height: 16), onPressed: _isLoading
Text('Offices', style: Theme.of(context).textTheme.titleSmall), ? null
const SizedBox(height: 8), : () => _showOfficeSelectionDialog(offices),
officesAsync.when( icon: const Icon(Icons.place),
data: (offices) { label: const Text('Select Offices'),
if (offices.isEmpty) { ),
return const Text('No offices available.'); const SizedBox(height: 8),
} if (_selectedOfficeIds.isEmpty)
return Column( const Text('No office selected.')
children: offices else
.map( Wrap(
(office) => CheckboxListTile( spacing: 8,
value: _selectedOfficeIds.contains(office.id), runSpacing: 8,
onChanged: _isLoading children: _selectedOfficeIds.map((id) {
? null final name = officeNameById[id] ?? id;
: (selected) { return Chip(
setState(() { label: Text(name),
if (selected == true) { onDeleted: _isLoading
_selectedOfficeIds.add(office.id); ? null
} else { : () {
_selectedOfficeIds.remove(office.id); setState(
} () => _selectedOfficeIds.remove(id),
}); );
}, },
title: Text(office.name), );
controlAffinity: ListTileControlAffinity.leading, }).toList(),
contentPadding: EdgeInsets.zero,
), ),
],
);
},
loading: () => const LinearProgressIndicator(),
error: (error, _) => Text('Failed to load offices: $error'),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleSignUp,
child: _isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
) )
.toList(), : const Text('Create Account'),
); ),
}, const SizedBox(height: 12),
loading: () => const LinearProgressIndicator(), TextButton(
error: (error, _) => Text('Failed to load offices: $error'), onPressed: _isLoading ? null : () => context.go('/login'),
), child: const Text('Back to sign in'),
const SizedBox(height: 24), ),
FilledButton( ],
onPressed: _isLoading ? null : _handleSignUp, ),
child: _isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Account'),
),
const SizedBox(height: 12),
TextButton(
onPressed: _isLoading ? null : () => context.go('/login'),
child: const Text('Back to sign in'),
),
],
), ),
), ),
), ),
); );
} }
Future<void> _showOfficeSelectionDialog(List<dynamic> offices) async {
final tempSelected = Set<String>.from(_selectedOfficeIds);
await showDialog<void>(
context: context,
builder: (dialogCtx) => StatefulBuilder(
builder: (ctx2, setStateDialog) {
return AlertDialog(
title: const Text('Select Offices'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: offices.map<Widget>((office) {
final id = office.id;
final name = office.name;
return CheckboxListTile(
value: tempSelected.contains(id),
onChanged: (v) {
setStateDialog(() {
if (v == true) {
tempSelected.add(id);
} else {
tempSelected.remove(id);
}
});
},
title: Text(name),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
setState(() {
_selectedOfficeIds
..clear()
..addAll(tempSelected);
});
Navigator.of(dialogCtx).pop();
},
child: const Text('Save'),
),
],
);
},
),
);
}
void _updatePasswordStrength() { void _updatePasswordStrength() {
final text = _passwordController.text; final text = _passwordController.text;
var score = 0; var score = 0;
@ -253,18 +329,29 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
if (RegExp(r'[A-Z]').hasMatch(text)) score++; if (RegExp(r'[A-Z]').hasMatch(text)) score++;
if (RegExp(r'[a-z]').hasMatch(text)) score++; if (RegExp(r'[a-z]').hasMatch(text)) score++;
if (RegExp(r'\d').hasMatch(text)) score++; if (RegExp(r'\d').hasMatch(text)) score++;
if (RegExp(r'[!@#$%^&*(),.?":{}|<>\[\]\\/+=;_-]').hasMatch(text)) { if (RegExp(r'[!@#\$%\^&\*(),.?":{}|<>\[\]\\/+=;_-]').hasMatch(text)) {
score++; score++;
} }
final normalized = (score / 6).clamp(0.0, 1.0); final normalized = (score / 6).clamp(0.0, 1.0);
final (label, color) = switch (normalized) { String label;
<= 0.2 => ('Very weak', Colors.red), Color color;
<= 0.4 => ('Weak', Colors.deepOrange), if (normalized <= 0.2) {
<= 0.6 => ('Fair', Colors.orange), label = 'Very weak';
<= 0.8 => ('Strong', Colors.green), color = Colors.red;
_ => ('Excellent', Colors.teal), } else if (normalized <= 0.4) {
}; label = 'Weak';
color = Colors.deepOrange;
} else if (normalized <= 0.6) {
label = 'Fair';
color = Colors.orange;
} else if (normalized <= 0.8) {
label = 'Strong';
color = Colors.green;
} else {
label = 'Excellent';
color = Colors.teal;
}
setState(() { setState(() {
_passwordStrength = normalized; _passwordStrength = normalized;