feat(auth,capstone): validate email on password reset, convert images to webp and add team carousel
- Add email deliverability verification (MEV) to forgot password flow (lookupResetOptions & sendEmailOtp) - Validate recipient email format and verify deliverability before sending reset password link in user management - Convert all backdrops, group photos, and solo avatars to WebP format, drastically reducing asset footprint from >60MB to ~4.6MB - Add auto-advancing slide preview carousel with 2s cooldown, navigation buttons, and wrapping to Capstone Members screen - Update Capstone member emails and add individual solo avatar photos
This commit is contained in:
1 parent
99be1e8d79
commit
2376e99217
34 files changed
+415
-72
No files matched your search
@@ -1,24 +1,122 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_sizes.dart';
|
||||
import '../../../core/utils/theme_extension.dart';
|
||||
|
||||
class CapstoneMembersScreen extends StatelessWidget {
|
||||
class CapstoneMembersScreen extends StatefulWidget {
|
||||
const CapstoneMembersScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CapstoneMembersScreen> createState() => _CapstoneMembersScreenState();
|
||||
}
|
||||
|
||||
class _CapstoneMembersScreenState extends State<CapstoneMembersScreen> {
|
||||
static const List<String> _groupImages = [
|
||||
'assets/images/group/1.webp',
|
||||
'assets/images/group/2.webp',
|
||||
'assets/images/group/3.webp',
|
||||
'assets/images/group/4.webp',
|
||||
'assets/images/group/5.webp',
|
||||
'assets/images/group/6.webp',
|
||||
'assets/images/group/7.webp',
|
||||
'assets/images/group/8.webp',
|
||||
'assets/images/group/9.webp',
|
||||
'assets/images/group/10.webp',
|
||||
'assets/images/group/11.webp',
|
||||
'assets/images/group/12.webp',
|
||||
'assets/images/group/13.webp',
|
||||
];
|
||||
|
||||
static const List<_MemberInfo> _members = [
|
||||
_MemberInfo(
|
||||
name: 'Alibutod, Rhina Mhay C.',
|
||||
role: 'Technical Writer',
|
||||
email: 'alibutodrhinamhay@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/rhinamae.webp',
|
||||
),
|
||||
_MemberInfo(
|
||||
name: 'Antonio, Clara Maris B.',
|
||||
role: 'UI/UX Designer',
|
||||
email: 'antonioclaramaris.21@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/claramaris.webp',
|
||||
),
|
||||
_MemberInfo(
|
||||
name: 'De Vera, Ermhar A.',
|
||||
role: 'System Analyst',
|
||||
email: 'ermhardevera11@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/ermhar.webp',
|
||||
),
|
||||
_MemberInfo(
|
||||
name: 'Ellio, James Young G.',
|
||||
role: 'Tester',
|
||||
email: 'elliojames01@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/jamesyoung.webp',
|
||||
),
|
||||
_MemberInfo(
|
||||
name: 'Garcia, Cris Charles V.',
|
||||
role: 'Programmer',
|
||||
email: 'crischarlesgarcia345@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/crischarles.webp',
|
||||
),
|
||||
_MemberInfo(
|
||||
name: 'Pasigan, Chinee R.',
|
||||
role: 'Project Manager',
|
||||
email: 'chineepasigan@gmail.com',
|
||||
avatarAsset: 'assets/images/solo/chinee.webp',
|
||||
),
|
||||
];
|
||||
|
||||
late final PageController _pageController;
|
||||
int _currentIndex = 0;
|
||||
Timer? _cooldownTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_startCooldownTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCooldownTimer() {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
if (!mounted) return;
|
||||
_goToNext();
|
||||
});
|
||||
}
|
||||
|
||||
void _goToNext() {
|
||||
if (!_pageController.hasClients) return;
|
||||
final nextIndex = (_currentIndex + 1) % _groupImages.length;
|
||||
_pageController.animateToPage(
|
||||
nextIndex,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _goToPrevious() {
|
||||
if (!_pageController.hasClients) return;
|
||||
final prevIndex = (_currentIndex - 1 + _groupImages.length) % _groupImages.length;
|
||||
_pageController.animateToPage(
|
||||
prevIndex,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = context.isDark;
|
||||
|
||||
const members = [
|
||||
{'name': 'Alibutod, Rhina Mhay C.', 'role': 'Technical Writer', 'email': 'rhina@example.com'},
|
||||
{'name': 'Antonio, Clara Maris B.', 'role': 'UI/UX Designer', 'email': 'clara@example.com'},
|
||||
{'name': 'De Vera, Ermhar A.', 'role': 'System Analyst', 'email': 'ermhar@example.com'},
|
||||
{'name': 'Ellio, James Young G.', 'role': 'Tester', 'email': 'james@example.com'},
|
||||
{'name': 'Garcia, Cris Charles V.', 'role': 'Programmer', 'email': 'cris@example.com'},
|
||||
{'name': 'Pasigan, Chinee R.', 'role': 'Project Manager', 'email': 'chinee@example.com'},
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkPageBackground : Colors.grey.shade50,
|
||||
appBar: AppBar(
|
||||
@@ -52,15 +150,134 @@ class CapstoneMembersScreen extends StatelessWidget {
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset(
|
||||
'assets/images/capstone_members.jpg',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Group Pictures Slide Preview (Single image with 2s cooldown) ──
|
||||
MouseRegion(
|
||||
onEnter: (_) => _cooldownTimer?.cancel(),
|
||||
onExit: (_) => _startCooldownTimer(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 10,
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _groupImages.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
_startCooldownTimer();
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return Image.asset(
|
||||
_groupImages[index],
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: isDark ? Colors.grey.shade900 : Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image, color: Colors.grey, size: 48),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Previous Button
|
||||
Positioned(
|
||||
left: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.chevron_left_rounded, color: Colors.white, size: 28),
|
||||
tooltip: 'Previous photo',
|
||||
onPressed: () {
|
||||
_goToPrevious();
|
||||
_startCooldownTimer();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Next Button
|
||||
Positioned(
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 28),
|
||||
tooltip: 'Next photo',
|
||||
onPressed: () {
|
||||
_goToNext();
|
||||
_startCooldownTimer();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Indicator Pill (Current Index / Total)
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_currentIndex + 1} / ${_groupImages.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
@@ -76,56 +293,89 @@ class CapstoneMembersScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...members.map(
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Members List ──
|
||||
..._members.map(
|
||||
(member) => Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 1,
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.1),
|
||||
child: const Icon(Icons.person, color: AppColors.primaryGreen),
|
||||
),
|
||||
title: Text(
|
||||
member['name']!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
member['role']!,
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextMuted : Colors.grey,
|
||||
// Solo Avatar Picture
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.4),
|
||||
width: 2,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
member['email']!,
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.blue.shade300 : Colors.blue,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.asset(
|
||||
member.avatarAsset,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => CircleAvatar(
|
||||
backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.1),
|
||||
child: const Icon(Icons.person, color: AppColors.primaryGreen),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Name, Role & Email
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
member.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
member.role,
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (member.email.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email_outlined,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextMuted : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SelectableText(
|
||||
member.email,
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.blue.shade300 : Colors.blue.shade700,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -140,3 +390,17 @@ class CapstoneMembersScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberInfo {
|
||||
final String name;
|
||||
final String role;
|
||||
final String email;
|
||||
final String avatarAsset;
|
||||
|
||||
const _MemberInfo({
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.email,
|
||||
required this.avatarAsset,
|
||||
});
|
||||
}
|
||||
@@ -172,6 +172,17 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
}
|
||||
|
||||
Future<void> _confirmResetPassword(SystemUser user) async {
|
||||
final email = user.email?.trim() ?? '';
|
||||
final emailFormatError = AppValidators.validateEmail(email);
|
||||
if (email.isEmpty || emailFormatError != null) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Cannot Send Reset Link',
|
||||
'User @${user.username} does not have a valid registered email address (${email.isEmpty ? "no email registered" : email}).\n\nPlease edit this user and provide a valid, active email address before sending a reset link.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (ctx) => _ResetPasswordConfirmationDialog(user: user),
|
||||
@@ -179,6 +190,22 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
try {
|
||||
// 1. Verify recipient email deliverability before dispatching reset link
|
||||
final validation =
|
||||
await ref.read(usersProvider.notifier).validateEmail(email);
|
||||
if (validation['valid'] == false) {
|
||||
final reason = validation['reason']?.toString() ??
|
||||
'Invalid or undeliverable email address';
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Email Verification Failed',
|
||||
'Cannot send password reset link: the recipient email address "$email" failed verification ($reason).\n\nPlease update the user profile with a valid email address first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Dispatch reset link
|
||||
final message = await ref
|
||||
.read(usersProvider.notifier)
|
||||
.resetPassword(
|
||||
@@ -2400,6 +2427,17 @@ class _ResetPasswordConfirmationDialogState
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final email = widget.user.email?.trim() ?? '';
|
||||
final emailError = AppValidators.validateEmail(email);
|
||||
if (email.isEmpty || emailError != null) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Invalid Email Address',
|
||||
'User @${widget.user.username} does not have a valid registered email address. Please update the user profile before sending a reset link.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final password = _passwordCtrl.text.trim();
|
||||
|
||||
if (password.isEmpty) {
|
||||
@@ -2420,7 +2458,9 @@ class _ResetPasswordConfirmationDialogState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final hasEmail = widget.user.email != null && widget.user.email!.trim().isNotEmpty;
|
||||
final email = widget.user.email?.trim() ?? '';
|
||||
final hasValidEmail =
|
||||
email.isNotEmpty && AppValidators.validateEmail(email) == null;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -2538,7 +2578,7 @@ class _ResetPasswordConfirmationDialogState
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Recipient Email Card / Warning
|
||||
if (hasEmail)
|
||||
if (hasValidEmail)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -2583,7 +2623,9 @@ class _ResetPasswordConfirmationDialogState
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No email registered. Please edit this user and provide a valid email before sending a reset link.',
|
||||
email.isNotEmpty
|
||||
? 'The registered email "$email" has an invalid email format. Please edit this user and provide a valid email before sending a reset link.'
|
||||
: 'No email registered. Please edit this user and provide a valid email before sending a reset link.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? const Color(0xFFFCA5A5) : Colors.red.shade900,
|
||||
@@ -2594,7 +2636,7 @@ class _ResetPasswordConfirmationDialogState
|
||||
),
|
||||
),
|
||||
|
||||
if (hasEmail) ...[
|
||||
if (hasValidEmail) ...[
|
||||
const SizedBox(height: 16),
|
||||
// Expiration selector
|
||||
DropdownButtonFormField<int>(
|
||||
@@ -2641,7 +2683,7 @@ class _ResetPasswordConfirmationDialogState
|
||||
Flexible(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: hasEmail ? AppColors.primaryGreen : Colors.grey,
|
||||
backgroundColor: hasValidEmail ? AppColors.primaryGreen : Colors.grey,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
@@ -2653,7 +2695,7 @@ class _ResetPasswordConfirmationDialogState
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: hasEmail ? _submit : null,
|
||||
onPressed: hasValidEmail ? _submit : null,
|
||||
icon: const Icon(Icons.send_rounded, size: 16),
|
||||
label: const Text(
|
||||
'SEND LINK',
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:flutter/material.dart';
|
||||
/// cropped backdrop based on the current theme (Light / Dark) and orientation (Landscape / Portrait).
|
||||
///
|
||||
/// Source: `assets/images/backdrop.png` cropped into:
|
||||
/// - Light Landscape: `assets/images/backdrop_light_landscape.png`
|
||||
/// - Light Portrait: `assets/images/backdrop_light_portrait.png`
|
||||
/// - Dark Landscape: `assets/images/backdrop_dark_landscape.png`
|
||||
/// - Dark Portrait: `assets/images/backdrop_dark_portrait.png`
|
||||
/// - Light Landscape: `assets/images/backdrop_light_landscape.webp`
|
||||
/// - Light Portrait: `assets/images/backdrop_light_portrait.webp`
|
||||
/// - Dark Landscape: `assets/images/backdrop_dark_landscape.webp`
|
||||
/// - Dark Portrait: `assets/images/backdrop_dark_portrait.webp`
|
||||
class AbstractBackground extends StatelessWidget {
|
||||
final Widget child;
|
||||
final bool withOverlay;
|
||||
@@ -30,12 +30,12 @@ class AbstractBackground extends StatelessWidget {
|
||||
}) {
|
||||
if (isDark) {
|
||||
return isLandscape
|
||||
? 'assets/images/backdrop_dark_landscape.png'
|
||||
: 'assets/images/backdrop_dark_portrait.png';
|
||||
? 'assets/images/backdrop_dark_landscape.webp'
|
||||
: 'assets/images/backdrop_dark_portrait.webp';
|
||||
} else {
|
||||
return isLandscape
|
||||
? 'assets/images/backdrop_light_landscape.png'
|
||||
: 'assets/images/backdrop_light_portrait.png';
|
||||
? 'assets/images/backdrop_light_landscape.webp'
|
||||
: 'assets/images/backdrop_light_portrait.webp';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user