adding packages

This commit is contained in:
2026-01-15 14:38:46 -08:00
parent ef86e3ab6a
commit 6869cf47e5
5253 changed files with 726695 additions and 34 deletions
@@ -0,0 +1,19 @@
export 'package:pocketbase/pocketbase.dart';
export 'src/auth_service.dart';
export 'src/auth_interceptor.dart';
export 'src/persistent_auth_store.dart';
export 'src/ui/login_screen.dart';
export 'src/ui/register_screen.dart';
export 'src/ui/forgot_password_screen.dart';
export 'src/ui/update_profile_screen.dart';
export 'src/ui/change_email_screen.dart';
export 'src/ui/verify_email_screen.dart';
export 'src/ui/delete_account_screen.dart';
export 'src/view_models/login_view_model.dart';
export 'src/view_models/register_view_model.dart';
export 'src/view_models/forgot_password_view_model.dart';
export 'src/view_models/update_profile_view_model.dart';
export 'src/view_models/change_email_view_model.dart';
export 'src/view_models/verify_email_view_model.dart';
export 'src/view_models/delete_account_view_model.dart';
@@ -0,0 +1,50 @@
import 'package:http/http.dart' as http;
import 'package:http_interceptor/http_interceptor.dart';
import 'package:pocketbase/pocketbase.dart';
class TokenRetryPolicy extends RetryPolicy {
final PocketBase _pb;
TokenRetryPolicy(this._pb);
@override
// Limit retries to 1 to prevent infinite loops if the refresh itself fails.
int get maxRetryAttempts => 1;
@override
Future<bool> shouldAttemptRetryOnResponse(BaseResponse response) async {
// Check for 401 Unauthorized status
if (response.statusCode == 401) {
// Check if we even have a token to refresh.
// If we are anonymous, a 401 is valid and shouldn't trigger a refresh.
if (!_pb.authStore.isValid) return false;
// print('[Auth] Token expired (401). Attempting refresh...');
try {
// PERFORMS THE AUTO REFRESH
// The SDK's authRefresh method automatically rotates the token
// and updates the AuthStore via the save() callback we defined earlier.
await _pb.collection('users').authRefresh();
return true; // Signal to retry the request
} catch (e) {
// print('[Auth] Refresh failed: $e. Logging out.');
// If refresh fails (e.g., user banned, password changed),
// we must clear the store to trigger a logout in the UI.
_pb.authStore.clear();
return false; // Do not retry
}
}
return false;
}
}
http.Client clientFactory(PocketBase pb) {
return InterceptedClient.build(
interceptors: [
// Optional: Add logging interceptor here for debugging
],
retryPolicy: TokenRetryPolicy(pb),
);
}
@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
class AuthService {
final PocketBase _pb;
// Broadcast controller allows multiple listeners (UI, Router, Analytics)
final StreamController<RecordModel?> _userController =
StreamController<RecordModel?>.broadcast();
AuthService(this._pb) {
// 1. Emit Initial State
// We synchronously check the store. If valid, emit the model.
_emitCurrent();
// 2. Listen to PocketBase AuthStore changes
// The onChange stream fires whenever authStore.save() or authStore.clear() is called.
_pb.authStore.onChange.listen((event) {
_emitCurrent();
});
}
void _emitCurrent() {
final model = _pb.authStore.record;
if (_pb.authStore.isValid && model is RecordModel) {
_userController.add(model);
} else {
_userController.add(null);
}
}
// Expose the stream for the UI to consume.
// We use .distinct() to ensure the UI only rebuilds if the user object actually changes.
Stream<RecordModel?> get authStateChanges =>
_userController.stream.distinct();
// Current value accessor for synchronous checks (e.g. inside guards)
RecordModel? get currentUser => _pb.authStore.record;
// --- Actions ---
Future<void> login(String email, String password) async {
await _pb.collection('users').authWithPassword(email, password);
}
Future<void> signup(String email, String password) async {
await _pb
.collection('users')
.create(
body: {
'email': email,
'password': password,
'passwordConfirm': password,
'name': email.split('@').first,
},
);
// Auto-login after signup
await login(email, password);
}
Future<void> logout() async {
_pb.authStore.clear();
}
Future<void> resetPassword(String email) async {
await _pb.collection('users').requestPasswordReset(email);
}
Future<RecordModel> updateProfile(Map<String, dynamic> body) async {
final user = currentUser;
if (user == null) {
throw ClientException(
url: Uri(),
response: {'message': 'User not logged in'},
);
}
return await _pb.collection('users').update(user.id, body: body);
}
Future<void> requestEmailChange(String newEmail) async {
final user = currentUser;
if (user == null) {
throw ClientException(
url: Uri(),
response: {'message': 'User not logged in'},
);
}
await _pb.collection('users').requestEmailChange(newEmail);
}
Future<void> requestVerification() async {
final user = currentUser;
if (user == null) {
throw ClientException(
url: Uri(),
response: {'message': 'User not logged in'},
);
}
await _pb
.collection('users')
.requestVerification(user.getStringValue('email'));
}
Future<void> deleteAccount() async {
final user = currentUser;
if (user == null) {
throw ClientException(
url: Uri(),
response: {'message': 'User not logged in'},
);
}
await _pb.collection('users').delete(user.id);
_pb.authStore.clear();
}
}
@@ -0,0 +1,42 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pocketbase/pocketbase.dart';
/// Abstract contract for authentication persistence.
/// Implement this to use different storage solutions (e.g., Hive, SecureStorage).
abstract class AuthPersistence {
Future<void> saveAuthData(String data);
Future<String?> loadAuthData();
Future<void> clearAuthData();
}
/// SharedPreferences implementation of [AuthPersistence].
class SharedPrefsAuthPersistence implements AuthPersistence {
final SharedPreferences _prefs;
static const String _kAuthKey = 'pb_auth_token';
SharedPrefsAuthPersistence(this._prefs);
@override
Future<void> saveAuthData(String data) async {
await _prefs.setString(_kAuthKey, data);
}
@override
Future<String?> loadAuthData() async {
return _prefs.getString(_kAuthKey);
}
@override
Future<void> clearAuthData() async {
await _prefs.remove(_kAuthKey);
}
}
/// The custom AuthStore that connects PocketBase to any [AuthPersistence] implementation.
class PersistentAuthStore extends AsyncAuthStore {
PersistentAuthStore({required AuthPersistence persistence, super.initial})
: super(
save: (String data) => persistence.saveAuthData(data),
clear: () => persistence.clearAuthData(),
);
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/change_email_view_model.dart';
class PocketBaseChangeEmailScreen extends StatefulWidget {
final AuthService authService;
const PocketBaseChangeEmailScreen({super.key, required this.authService});
@override
State<PocketBaseChangeEmailScreen> createState() =>
_PocketBaseChangeEmailScreenState();
}
class _PocketBaseChangeEmailScreenState
extends State<PocketBaseChangeEmailScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
late final ChangeEmailViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = ChangeEmailViewModel(widget.authService);
}
Future<void> _requestEmailChange() async {
if (!_formKey.currentState!.validate()) return;
try {
await _viewModel.requestEmailChange(_emailController.text.trim());
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
if (_viewModel.success) {
return Scaffold(
appBar: AppBar(title: const Text('Change Email')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
const SizedBox(height: 16),
const Text(
'Verification email sent!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Check your new email address for instructions to verify the change.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Go Back'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Change Email')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Enter your new email address.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'New Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the new email';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading
? null
: _requestEmailChange,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Request Change'),
),
],
),
),
),
),
);
},
);
}
@override
void dispose() {
_emailController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/delete_account_view_model.dart';
class PocketBaseDeleteAccountScreen extends StatefulWidget {
final AuthService authService;
const PocketBaseDeleteAccountScreen({super.key, required this.authService});
@override
State<PocketBaseDeleteAccountScreen> createState() =>
_PocketBaseDeleteAccountScreenState();
}
class _PocketBaseDeleteAccountScreenState
extends State<PocketBaseDeleteAccountScreen> {
final _confirmController = TextEditingController();
late final DeleteAccountViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = DeleteAccountViewModel(widget.authService);
}
Future<void> _deleteAccount() async {
if (_confirmController.text != 'DELETE') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please type DELETE to confirm.')),
);
return;
}
try {
await _viewModel.deleteAccount();
// Navigation handled by auth state change
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: const Text('Delete Account'),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.warning_amber_rounded,
color: Colors.red,
size: 80,
),
const SizedBox(height: 24),
const Text(
'Are you sure you want to delete your account?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'This action is irreversible. All your data will be permanently removed.',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextField(
controller: _confirmController,
decoration: const InputDecoration(
labelText: 'Type DELETE to confirm',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading ? null : _deleteAccount,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Permanently Delete Account'),
),
],
),
),
),
);
},
);
}
@override
void dispose() {
_confirmController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/forgot_password_view_model.dart';
class PocketBaseForgotPasswordScreen extends StatefulWidget {
final AuthService authService;
final VoidCallback? onBackToLogin;
const PocketBaseForgotPasswordScreen({
super.key,
required this.authService,
this.onBackToLogin,
});
@override
State<PocketBaseForgotPasswordScreen> createState() =>
_PocketBaseForgotPasswordScreenState();
}
class _PocketBaseForgotPasswordScreenState
extends State<PocketBaseForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
late final ForgotPasswordViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = ForgotPasswordViewModel(widget.authService);
}
Future<void> _resetPassword() async {
if (!_formKey.currentState!.validate()) return;
try {
await _viewModel.resetPassword(_emailController.text.trim());
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
if (_viewModel.success) {
return Scaffold(
appBar: AppBar(title: const Text('Forgot Password')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
const SizedBox(height: 16),
const Text(
'Password reset link sent!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Check your email for instructions to reset your password.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: widget.onBackToLogin,
child: const Text('Back to Login'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Forgot Password')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Enter your email address to receive a password reset link.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading ? null : _resetPassword,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Send Reset Link'),
),
if (widget.onBackToLogin != null) ...[
const SizedBox(height: 16),
TextButton(
onPressed: _viewModel.isLoading
? null
: widget.onBackToLogin,
child: const Text('Back to Login'),
),
],
],
),
),
),
),
);
},
);
}
@override
void dispose() {
_emailController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import '../../pocketbase_auth.dart';
import '../auth_service.dart';
import '../view_models/login_view_model.dart';
class PocketBaseLoginScreen extends StatefulWidget {
final AuthService authService;
final VoidCallback? onSignup;
final VoidCallback? onForgotPassword;
const PocketBaseLoginScreen({
super.key,
required this.authService,
this.onSignup,
this.onForgotPassword,
});
@override
State<PocketBaseLoginScreen> createState() => _PocketBaseLoginScreenState();
}
class _PocketBaseLoginScreenState extends State<PocketBaseLoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
late final LoginViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = LoginViewModel(widget.authService);
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
try {
await _viewModel.login(
_emailController.text.trim(),
_passwordController.text.trim(),
);
// Navigation is handled by auth state stream
} catch (_) {
// Error is handled in ViewModel and UI updates via listener
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading ? null : _login,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
if (widget.onForgotPassword != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: _viewModel.isLoading
? null
: widget.onForgotPassword,
child: const Text('Forgot Password?'),
),
],
if (widget.onSignup != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: _viewModel.isLoading
? null
: widget.onSignup,
child: const Text('Create an account'),
),
],
],
),
),
),
),
);
},
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/register_view_model.dart';
class PocketBaseRegisterScreen extends StatefulWidget {
final AuthService authService;
final VoidCallback? onLogin;
const PocketBaseRegisterScreen({
super.key,
required this.authService,
this.onLogin,
});
@override
State<PocketBaseRegisterScreen> createState() =>
_PocketBaseRegisterScreenState();
}
class _PocketBaseRegisterScreenState extends State<PocketBaseRegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
late final RegisterViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = RegisterViewModel(widget.authService);
}
Future<void> _signup() async {
if (!_formKey.currentState!.validate()) return;
try {
await _viewModel.signup(
_emailController.text.trim(),
_passwordController.text.trim(),
_confirmPasswordController.text.trim(),
);
// Navigation is handled by auth state stream
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading ? null : _signup,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Register'),
),
if (widget.onLogin != null) ...[
const SizedBox(height: 16),
TextButton(
onPressed: _viewModel.isLoading ? null : widget.onLogin,
child: const Text('Already have an account? Login'),
),
],
],
),
),
),
),
);
},
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/update_profile_view_model.dart';
class PocketBaseUpdateProfileScreen extends StatefulWidget {
final AuthService authService;
const PocketBaseUpdateProfileScreen({super.key, required this.authService});
@override
State<PocketBaseUpdateProfileScreen> createState() =>
_PocketBaseUpdateProfileScreenState();
}
class _PocketBaseUpdateProfileScreenState
extends State<PocketBaseUpdateProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
late final UpdateProfileViewModel _viewModel;
bool _isInit = true;
@override
void initState() {
super.initState();
_viewModel = UpdateProfileViewModel(widget.authService);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_isInit) {
final user = widget.authService.currentUser;
if (user != null) {
_nameController.text = user.getStringValue('name');
}
_isInit = false;
}
}
Future<void> _updateProfile() async {
if (!_formKey.currentState!.validate()) return;
try {
await _viewModel.updateProfile(name: _nameController.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile updated successfully')),
);
}
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
return Scaffold(
appBar: AppBar(title: const Text('Update Profile')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your name';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _viewModel.isLoading ? null : _updateProfile,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Update Profile'),
),
],
),
),
),
);
},
);
}
@override
void dispose() {
_nameController.dispose();
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import '../auth_service.dart';
import '../view_models/verify_email_view_model.dart';
class PocketBaseVerifyEmailScreen extends StatefulWidget {
final AuthService authService;
const PocketBaseVerifyEmailScreen({super.key, required this.authService});
@override
State<PocketBaseVerifyEmailScreen> createState() =>
_PocketBaseVerifyEmailScreenState();
}
class _PocketBaseVerifyEmailScreenState
extends State<PocketBaseVerifyEmailScreen> {
late final VerifyEmailViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = VerifyEmailViewModel(widget.authService);
}
Future<void> _requestVerification() async {
try {
await _viewModel.requestVerification();
} catch (_) {
// Error handled by view model
}
}
@override
Widget build(BuildContext context) {
// We need to re-fetch the user to check verification status, or rely on auth state updates.
// However, for this simple screen, we might just use the current user from authService.
// Ideally, the ViewModel should also expose the user or verification status if it changes.
// For now, let's keep using widget.authService.currentUser but maybe we should move that to VM too?
// Let's stick to the screen accessing authService for user info as it was doing.
final user = widget.authService.currentUser;
final isVerified = user?.getBoolValue('verified') ?? false;
return ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
if (_viewModel.success) {
return Scaffold(
appBar: AppBar(title: const Text('Verify Email')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
const SizedBox(height: 16),
const Text(
'Verification email sent!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Check your email for instructions to verify your account.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Go Back'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Verify Email')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isVerified) ...[
const Icon(Icons.verified, color: Colors.green, size: 64),
const SizedBox(height: 16),
const Text(
'Your email is verified!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
] else ...[
const Icon(
Icons.warning_amber,
color: Colors.orange,
size: 64,
),
const SizedBox(height: 16),
const Text(
'Your email is not verified.',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Email: ${user?.getStringValue('email')}',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (_viewModel.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
_viewModel.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: _viewModel.isLoading
? null
: _requestVerification,
child: _viewModel.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Send Verification Email'),
),
],
],
),
),
),
);
},
);
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
}
@@ -0,0 +1,11 @@
import 'package:pocketbase/pocketbase.dart';
String parseErrorMessage(dynamic error) {
if (error is ClientException) {
final response = error.response;
if (response['message'] != null) {
return response['message'] as String;
}
}
return error.toString();
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class ChangeEmailViewModel extends ChangeNotifier {
final AuthService _authService;
ChangeEmailViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool _success = false;
bool get success => _success;
Future<void> requestEmailChange(String newEmail) async {
_isLoading = true;
_errorMessage = null;
_success = false;
notifyListeners();
try {
await _authService.requestEmailChange(newEmail);
_success = true;
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class DeleteAccountViewModel extends ChangeNotifier {
final AuthService _authService;
DeleteAccountViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
Future<void> deleteAccount() async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _authService.deleteAccount();
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class ForgotPasswordViewModel extends ChangeNotifier {
final AuthService _authService;
ForgotPasswordViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool _success = false;
bool get success => _success;
Future<void> resetPassword(String email) async {
_isLoading = true;
_errorMessage = null;
_success = false;
notifyListeners();
try {
await _authService.resetPassword(email);
_success = true;
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class LoginViewModel extends ChangeNotifier {
final AuthService _authService;
LoginViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
Future<void> login(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _authService.login(email, password);
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class RegisterViewModel extends ChangeNotifier {
final AuthService _authService;
RegisterViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
Future<void> signup(
String email,
String password,
String confirmPassword,
) async {
if (password != confirmPassword) {
_errorMessage = "Passwords do not match";
notifyListeners();
return; // Or throw custom error
}
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _authService.signup(email, password);
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class UpdateProfileViewModel extends ChangeNotifier {
final AuthService _authService;
UpdateProfileViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool _success = false;
bool get success => _success;
Future<void> updateProfile({String? name, String? avatar}) async {
_isLoading = true;
_errorMessage = null;
_success = false;
notifyListeners();
try {
final body = <String, dynamic>{};
if (name != null) body['name'] = name;
if (avatar != null) body['avatar'] = avatar;
await _authService.updateProfile(body);
_success = true;
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import '../auth_service.dart';
import '../utils.dart';
class VerifyEmailViewModel extends ChangeNotifier {
final AuthService _authService;
VerifyEmailViewModel(this._authService);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool _success = false;
bool get success => _success;
Future<void> requestVerification() async {
_isLoading = true;
_errorMessage = null;
_success = false;
notifyListeners();
try {
await _authService.requestVerification();
_success = true;
} catch (e) {
_errorMessage = parseErrorMessage(e);
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
}