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:
ssbg04 committed 2026-09-25 21:18:02 +08:00
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';
}
}