Added User Profile Screen
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../providers/auth_provider.dart' show sessionProvider;
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/user_offices_provider.dart';
|
||||
import '../../widgets/multi_select_picker.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
final _detailsKey = GlobalKey<FormState>();
|
||||
final _passwordKey = GlobalKey<FormState>();
|
||||
final _fullNameController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
List<String> _selectedOfficeIds = [];
|
||||
bool _savingDetails = false;
|
||||
bool _changingPassword = false;
|
||||
bool _savingOffices = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fullNameController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
final userOfficesAsync = ref.watch(userOfficesProvider);
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
final session = ref.watch(sessionProvider);
|
||||
|
||||
// Populate controllers from profile stream (if not editing)
|
||||
profileAsync.whenData((p) {
|
||||
final name = p?.fullName ?? '';
|
||||
if (_fullNameController.text != name) {
|
||||
_fullNameController.text = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Populate selected offices from userOfficesProvider
|
||||
final assignedOfficeIds =
|
||||
userOfficesAsync.valueOrNull
|
||||
?.where((u) => u.userId == userId)
|
||||
.map((u) => u.officeId)
|
||||
.toList() ??
|
||||
[];
|
||||
if (_selectedOfficeIds.isEmpty) {
|
||||
_selectedOfficeIds = List<String>.from(assignedOfficeIds);
|
||||
}
|
||||
|
||||
return ResponsiveBody(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('My Profile', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Details Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _detailsKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Email (read-only)
|
||||
TextFormField(
|
||||
initialValue: session?.user.email ?? '',
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Full name',
|
||||
),
|
||||
validator: (v) => (v ?? '').trim().isEmpty
|
||||
? 'Full name is required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _savingDetails ? null : _onSaveDetails,
|
||||
child: Text(
|
||||
_savingDetails ? 'Saving...' : 'Save details',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Change password Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _passwordKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Password',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Set or change your password. OAuth users (Google/Meta) can set a password here.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _newPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
return null; // allow empty to skip
|
||||
}
|
||||
if ((v).length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm password',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
final pw = _newPasswordController.text;
|
||||
if (pw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (v != pw) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _changingPassword
|
||||
? null
|
||||
: _onChangePassword,
|
||||
child: Text(
|
||||
_changingPassword
|
||||
? 'Updating...'
|
||||
: 'Change password',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Offices Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Offices',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
return Column(
|
||||
children: [
|
||||
MultiSelectPicker<Office>(
|
||||
label: 'Offices',
|
||||
items: offices,
|
||||
selectedIds: _selectedOfficeIds,
|
||||
getId: (o) => o.id,
|
||||
getLabel: (o) => o.name,
|
||||
onChanged: (ids) =>
|
||||
setState(() => _selectedOfficeIds = ids),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _savingOffices
|
||||
? null
|
||||
: _onSaveOffices,
|
||||
child: Text(
|
||||
_savingOffices
|
||||
? 'Saving...'
|
||||
: 'Save offices',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Text('Failed to load offices: $e'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSaveDetails() async {
|
||||
if (!_detailsKey.currentState!.validate()) return;
|
||||
final id = ref.read(currentUserIdProvider);
|
||||
if (id == null) return;
|
||||
setState(() => _savingDetails = true);
|
||||
try {
|
||||
await ref
|
||||
.read(profileControllerProvider)
|
||||
.updateFullName(
|
||||
userId: id,
|
||||
fullName: _fullNameController.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Profile updated.')));
|
||||
// Refresh providers so other UI picks up the change immediately
|
||||
ref.invalidate(currentProfileProvider);
|
||||
ref.invalidate(profilesProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Update failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingDetails = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onChangePassword() async {
|
||||
if (!_passwordKey.currentState!.validate()) return;
|
||||
final pw = _newPasswordController.text;
|
||||
if (pw.isEmpty) {
|
||||
// nothing to do
|
||||
return;
|
||||
}
|
||||
setState(() => _changingPassword = true);
|
||||
try {
|
||||
await ref.read(profileControllerProvider).updatePassword(pw);
|
||||
_newPasswordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Password updated.')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Password update failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _changingPassword = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSaveOffices() async {
|
||||
final id = ref.read(currentUserIdProvider);
|
||||
if (id == null) return;
|
||||
setState(() => _savingOffices = true);
|
||||
try {
|
||||
final assignments = ref.read(userOfficesProvider).valueOrNull ?? [];
|
||||
final assigned = assignments
|
||||
.where((a) => a.userId == id)
|
||||
.map((a) => a.officeId)
|
||||
.toSet();
|
||||
final selected = _selectedOfficeIds.toSet();
|
||||
|
||||
final toAdd = selected.difference(assigned);
|
||||
final toRemove = assigned.difference(selected);
|
||||
|
||||
final ctrl = ref.read(userOfficesControllerProvider);
|
||||
for (final officeId in toAdd) {
|
||||
await ctrl.assignUserOffice(userId: id, officeId: officeId);
|
||||
}
|
||||
for (final officeId in toRemove) {
|
||||
await ctrl.removeUserOffice(userId: id, officeId: officeId);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Offices updated.')));
|
||||
ref.invalidate(userOfficesProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to save offices: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingOffices = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user