337 lines
12 KiB
Dart
337 lines
12 KiB
Dart
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';
|
|
import '../../utils/snackbar.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;
|
|
showSuccessSnackBar(context, 'Profile updated.');
|
|
// Refresh providers so other UI picks up the change immediately
|
|
ref.invalidate(currentProfileProvider);
|
|
ref.invalidate(profilesProvider);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorSnackBar(context, '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;
|
|
showSuccessSnackBar(context, 'Password updated.');
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorSnackBar(context, '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;
|
|
showSuccessSnackBar(context, 'Offices updated.');
|
|
ref.invalidate(userOfficesProvider);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorSnackBar(context, 'Failed to save offices: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _savingOffices = false);
|
|
}
|
|
}
|
|
}
|