Skip to content

Commit c5feded

Browse files
committed
Add forgot password page.
1 parent e62a7fb commit c5feded

8 files changed

Lines changed: 607 additions & 317 deletions

File tree

client/lib/config/app_router.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:papyrus/pages/dashboard_page.dart';
88
import 'package:papyrus/pages/developer_options_page.dart';
99
import 'package:papyrus/pages/goals_page.dart';
1010
import 'package:papyrus/pages/library_page.dart';
11+
import 'package:papyrus/pages/forgot_password_page.dart';
1112
import 'package:papyrus/pages/login_page.dart';
1213
import 'package:papyrus/pages/profile_page.dart';
1314
import 'package:papyrus/pages/register_page.dart';
@@ -47,6 +48,14 @@ class AppRouter {
4748
child: const RegisterPage(),
4849
),
4950
),
51+
GoRoute(
52+
name: 'FORGOT_PASSWORD',
53+
path: 'forgot-password',
54+
pageBuilder: (context, state) => NoTransitionPage(
55+
key: state.pageKey,
56+
child: const ForgotPasswordPage(),
57+
),
58+
),
5059
],
5160
),
5261
// Main app routes (with adaptive shell)
@@ -210,7 +219,8 @@ class AppRouter {
210219
redirect: (BuildContext context, GoRouterState state) {
211220
if (FirebaseAuth.instance.currentUser == null) {
212221
if (state.uri.toString().contains('/login') ||
213-
state.uri.toString().contains('/register')) {
222+
state.uri.toString().contains('/register') ||
223+
state.uri.toString().contains('/forgot-password')) {
214224
return null;
215225
}
216226

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
import 'package:firebase_auth/firebase_auth.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:go_router/go_router.dart';
4+
import 'package:provider/provider.dart';
5+
import 'package:papyrus/providers/display_mode_provider.dart';
6+
import 'package:papyrus/themes/design_tokens.dart';
7+
import 'package:papyrus/utils/responsive.dart';
8+
import 'package:papyrus/widgets/auth/auth_continue_button.dart';
9+
import 'package:papyrus/widgets/auth/auth_page_layouts.dart';
10+
import 'package:papyrus/widgets/auth/auth_switch_link.dart';
11+
import 'package:papyrus/widgets/input/email_input.dart';
12+
13+
/// Forgot password page for the Papyrus book management application.
14+
/// Provides responsive layouts for mobile, desktop, and e-ink displays.
15+
class ForgotPasswordPage extends StatefulWidget {
16+
const ForgotPasswordPage({super.key});
17+
18+
@override
19+
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
20+
}
21+
22+
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
23+
final _formKey = GlobalKey<FormState>();
24+
final _emailController = TextEditingController();
25+
final _emailFocusNode = FocusNode();
26+
27+
bool _isLoading = false;
28+
bool _emailSent = false;
29+
30+
@override
31+
void dispose() {
32+
_emailController.dispose();
33+
_emailFocusNode.dispose();
34+
super.dispose();
35+
}
36+
37+
Future<void> _handleResetPassword() async {
38+
if (_isLoading) return;
39+
40+
// Hide keyboard
41+
FocusScope.of(context).unfocus();
42+
43+
if (!_formKey.currentState!.validate()) return;
44+
45+
setState(() => _isLoading = true);
46+
ScaffoldMessenger.of(context).hideCurrentSnackBar();
47+
48+
try {
49+
await FirebaseAuth.instance.sendPasswordResetEmail(
50+
email: _emailController.text.trim(),
51+
);
52+
53+
if (!mounted) return;
54+
setState(() {
55+
_isLoading = false;
56+
_emailSent = true;
57+
});
58+
} on FirebaseAuthException catch (e) {
59+
if (!mounted) return;
60+
61+
String message;
62+
switch (e.code) {
63+
case 'user-not-found':
64+
message = 'No account found with this email.';
65+
break;
66+
case 'invalid-email':
67+
message = 'Invalid email address.';
68+
break;
69+
case 'too-many-requests':
70+
message = 'Too many attempts. Please try again later.';
71+
break;
72+
default:
73+
message = 'Failed to send reset email. Please try again.';
74+
}
75+
76+
setState(() => _isLoading = false);
77+
_showErrorSnackBar(message);
78+
} catch (e) {
79+
if (!mounted) return;
80+
setState(() => _isLoading = false);
81+
_showErrorSnackBar('An error occurred. Please try again.');
82+
}
83+
}
84+
85+
void _showErrorSnackBar(String message) {
86+
ScaffoldMessenger.of(context).showSnackBar(
87+
SnackBar(
88+
duration: const Duration(seconds: 5),
89+
content: Text(message),
90+
backgroundColor: Theme.of(context).colorScheme.error,
91+
),
92+
);
93+
}
94+
95+
void _navigateToLogin() {
96+
context.go('/login');
97+
}
98+
99+
void _navigateBack() {
100+
context.go('/');
101+
}
102+
103+
@override
104+
Widget build(BuildContext context) {
105+
final displayMode = context.watch<DisplayModeProvider>();
106+
final isEink = displayMode.isEinkMode;
107+
108+
if (isEink) {
109+
return EinkAuthLayout(
110+
headerTitle: 'RESET PASSWORD',
111+
onBack: _navigateBack,
112+
form: _emailSent
113+
? _EmailSentConfirmation(
114+
email: _emailController.text.trim(),
115+
isEink: true,
116+
isDesktop: false,
117+
)
118+
: _ForgotPasswordForm(
119+
formKey: _formKey,
120+
emailController: _emailController,
121+
emailFocusNode: _emailFocusNode,
122+
isLoading: _isLoading,
123+
onSubmit: _handleResetPassword,
124+
isEink: true,
125+
isDesktop: false,
126+
),
127+
footer: [
128+
const SizedBox(height: Spacing.xl),
129+
AuthSwitchLink(
130+
promptText: 'Remember your password?',
131+
actionText: 'Sign in',
132+
einkPromptText: 'REMEMBER YOUR PASSWORD?',
133+
einkActionText: 'SIGN IN',
134+
onPressed: _navigateToLogin,
135+
isEink: true,
136+
),
137+
],
138+
);
139+
}
140+
141+
return ResponsiveBuilder(
142+
mobile: (context) => MobileAuthLayout(
143+
heading: 'Reset password',
144+
subtitle: 'Enter your email to receive a password reset link',
145+
showHeader: !_emailSent,
146+
form: _emailSent
147+
? _EmailSentConfirmation(
148+
email: _emailController.text.trim(),
149+
isEink: false,
150+
isDesktop: false,
151+
)
152+
: _ForgotPasswordForm(
153+
formKey: _formKey,
154+
emailController: _emailController,
155+
emailFocusNode: _emailFocusNode,
156+
isLoading: _isLoading,
157+
onSubmit: _handleResetPassword,
158+
isEink: false,
159+
isDesktop: false,
160+
),
161+
footer: [
162+
const SizedBox(height: Spacing.md),
163+
AuthSwitchLink(
164+
promptText: 'Remember your password?',
165+
actionText: 'Sign in',
166+
einkPromptText: 'REMEMBER YOUR PASSWORD?',
167+
einkActionText: 'SIGN IN',
168+
onPressed: _navigateToLogin,
169+
isEink: false,
170+
),
171+
],
172+
),
173+
desktop: (context) => DesktopAuthLayout(
174+
heading: 'Reset password',
175+
subtitle: 'Enter your email to receive a password reset link',
176+
showHeader: !_emailSent,
177+
form: _emailSent
178+
? _EmailSentConfirmation(
179+
email: _emailController.text.trim(),
180+
isEink: false,
181+
isDesktop: true,
182+
)
183+
: _ForgotPasswordForm(
184+
formKey: _formKey,
185+
emailController: _emailController,
186+
emailFocusNode: _emailFocusNode,
187+
isLoading: _isLoading,
188+
onSubmit: _handleResetPassword,
189+
isEink: false,
190+
isDesktop: true,
191+
),
192+
footer: [
193+
const SizedBox(height: Spacing.md),
194+
AuthSwitchLink(
195+
promptText: 'Remember your password?',
196+
actionText: 'Sign in',
197+
einkPromptText: 'REMEMBER YOUR PASSWORD?',
198+
einkActionText: 'SIGN IN',
199+
onPressed: _navigateToLogin,
200+
isEink: false,
201+
),
202+
],
203+
),
204+
);
205+
}
206+
}
207+
208+
// =============================================================================
209+
// FORGOT PASSWORD FORM
210+
// =============================================================================
211+
212+
/// Forgot password form with a single email field.
213+
class _ForgotPasswordForm extends StatelessWidget {
214+
final GlobalKey<FormState> formKey;
215+
final TextEditingController emailController;
216+
final FocusNode emailFocusNode;
217+
final bool isLoading;
218+
final VoidCallback onSubmit;
219+
final bool isEink;
220+
final bool isDesktop;
221+
222+
const _ForgotPasswordForm({
223+
required this.formKey,
224+
required this.emailController,
225+
required this.emailFocusNode,
226+
required this.isLoading,
227+
required this.onSubmit,
228+
required this.isEink,
229+
required this.isDesktop,
230+
});
231+
232+
@override
233+
Widget build(BuildContext context) {
234+
return Form(
235+
key: formKey,
236+
child: Column(
237+
crossAxisAlignment: CrossAxisAlignment.stretch,
238+
mainAxisSize: MainAxisSize.min,
239+
children: [
240+
// Email field
241+
EmailInput(
242+
labelText: 'Email address',
243+
controller: emailController,
244+
focusNode: emailFocusNode,
245+
isEink: isEink,
246+
textInputAction: TextInputAction.done,
247+
onEditingComplete: onSubmit,
248+
),
249+
SizedBox(height: isEink ? Spacing.lg : Spacing.lg),
250+
// Continue button
251+
AuthContinueButton(
252+
isLoading: isLoading,
253+
onPressed: onSubmit,
254+
isEink: isEink,
255+
isDesktop: isDesktop,
256+
einkLoadingText: 'SENDING...',
257+
),
258+
],
259+
),
260+
);
261+
}
262+
}
263+
264+
// =============================================================================
265+
// EMAIL SENT CONFIRMATION
266+
// =============================================================================
267+
268+
/// Confirmation view shown after the reset email has been sent.
269+
class _EmailSentConfirmation extends StatelessWidget {
270+
final String email;
271+
final bool isEink;
272+
final bool isDesktop;
273+
274+
const _EmailSentConfirmation({
275+
required this.email,
276+
required this.isEink,
277+
required this.isDesktop,
278+
});
279+
280+
@override
281+
Widget build(BuildContext context) {
282+
final theme = Theme.of(context);
283+
284+
if (isEink) {
285+
return Column(
286+
crossAxisAlignment: CrossAxisAlignment.stretch,
287+
mainAxisSize: MainAxisSize.min,
288+
children: [
289+
const Text(
290+
'CHECK YOUR EMAIL',
291+
style: TextStyle(
292+
fontSize: 18,
293+
fontWeight: FontWeight.w700,
294+
letterSpacing: 0.5,
295+
),
296+
),
297+
const SizedBox(height: Spacing.md),
298+
Text(
299+
'We sent a password reset link to $email',
300+
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
301+
),
302+
const SizedBox(height: Spacing.sm),
303+
const Text(
304+
"If you don't see the email, check your spam folder.",
305+
style: TextStyle(fontSize: 14, color: Color(0xFF606060)),
306+
),
307+
],
308+
);
309+
}
310+
311+
return Column(
312+
crossAxisAlignment: CrossAxisAlignment.stretch,
313+
mainAxisSize: MainAxisSize.min,
314+
children: [
315+
Icon(
316+
Icons.mark_email_read_outlined,
317+
size: 72,
318+
color: theme.colorScheme.primary,
319+
),
320+
const SizedBox(height: Spacing.sm),
321+
Text(
322+
'Check your email',
323+
style: theme.textTheme.titleLarge?.copyWith(
324+
fontWeight: FontWeight.w600,
325+
color: theme.colorScheme.onSurface,
326+
),
327+
textAlign: TextAlign.center,
328+
),
329+
const SizedBox(height: Spacing.sm),
330+
Text(
331+
'We sent a password reset link to $email',
332+
style: theme.textTheme.bodyMedium?.copyWith(
333+
color: theme.colorScheme.onSurfaceVariant,
334+
),
335+
textAlign: TextAlign.center,
336+
),
337+
const SizedBox(height: Spacing.sm),
338+
Text(
339+
"If you don't see the email, check your spam folder.",
340+
style: theme.textTheme.bodyMedium?.copyWith(
341+
color: theme.colorScheme.onSurfaceVariant,
342+
),
343+
textAlign: TextAlign.center,
344+
),
345+
],
346+
);
347+
}
348+
}

0 commit comments

Comments
 (0)