From 372928d8e742f31b390c8ab61cc6616184507957 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 18 Feb 2026 21:42:48 +0800 Subject: [PATCH] Added User Profile Screen --- lib/providers/profile_provider.dart | 32 +++ lib/routing/app_router.dart | 5 + lib/screens/profile/profile_screen.dart | 347 ++++++++++++++++++++++++ lib/utils/supabase_response.dart | 1 + lib/widgets/app_shell.dart | 23 +- test/profile_screen_test.dart | 203 ++++++++++++++ 6 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 lib/screens/profile/profile_screen.dart create mode 100644 test/profile_screen_test.dart diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index a110e175..f45bed21 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/profile.dart'; import 'auth_provider.dart'; @@ -36,6 +37,37 @@ final profilesProvider = StreamProvider>((ref) { .map((rows) => rows.map(Profile.fromMap).toList()); }); +/// Controller for the current user's profile (update full name / password). +final profileControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return ProfileController(client); +}); + +class ProfileController { + ProfileController(this._client); + + final SupabaseClient _client; + + /// Update the `profiles.full_name` for the given user id. + Future updateFullName({ + required String userId, + required String fullName, + }) async { + await _client + .from('profiles') + .update({'full_name': fullName}) + .eq('id', userId); + } + + /// Update the current user's password (works for OAuth users too). + Future updatePassword(String password) async { + if (password.length < 8) { + throw Exception('Password must be at least 8 characters'); + } + await _client.auth.updateUser(UserAttributes(password: password)); + } +} + final isAdminProvider = Provider((ref) { final profileAsync = ref.watch(currentProfileProvider); return profileAsync.maybeWhen( diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index e9f17206..d81a3006 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -12,6 +12,7 @@ import '../screens/admin/offices_screen.dart'; import '../screens/admin/user_management_screen.dart'; import '../screens/dashboard/dashboard_screen.dart'; import '../screens/notifications/notifications_screen.dart'; +import '../screens/profile/profile_screen.dart'; import '../screens/shared/under_development_screen.dart'; import '../screens/tasks/task_detail_screen.dart'; import '../screens/tasks/tasks_list_screen.dart'; @@ -135,6 +136,10 @@ final appRouterProvider = Provider((ref) { path: '/notifications', builder: (context, state) => const NotificationsScreen(), ), + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileScreen(), + ), ], ), ], diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart new file mode 100644 index 00000000..5639ea85 --- /dev/null +++ b/lib/screens/profile/profile_screen.dart @@ -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 createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + final _detailsKey = GlobalKey(); + final _passwordKey = GlobalKey(); + final _fullNameController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + List _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.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( + 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 _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 _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 _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); + } + } +} diff --git a/lib/utils/supabase_response.dart b/lib/utils/supabase_response.dart index b2b9a44a..ddcc406e 100644 --- a/lib/utils/supabase_response.dart +++ b/lib/utils/supabase_response.dart @@ -4,6 +4,7 @@ /// PostgrestResponse-like objects (have `.error`, `.status`, `.statusText`). /// Helpers here provide a single place to extract an error message safely so /// callers don't accidentally call `[]` on non-Map objects. +library; String? extractSupabaseError(dynamic res) { if (res == null) return null; diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index dd882d09..9e5dd8af 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -57,11 +57,14 @@ class AppScaffold extends ConsumerWidget { tooltip: 'Account', onSelected: (value) { if (value == 0) { + context.go('/profile'); + } else if (value == 1) { ref.read(authControllerProvider).signOut(); } }, itemBuilder: (context) => const [ - PopupMenuItem(value: 0, child: Text('Sign out')), + PopupMenuItem(value: 0, child: Text('My profile')), + PopupMenuItem(value: 1, child: Text('Sign out')), ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -77,10 +80,20 @@ class AppScaffold extends ConsumerWidget { ), ) else - IconButton( - tooltip: 'Sign out', - onPressed: () => ref.read(authControllerProvider).signOut(), - icon: const Icon(Icons.logout), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Profile', + onPressed: () => context.go('/profile'), + icon: const Icon(Icons.account_circle), + ), + IconButton( + tooltip: 'Sign out', + onPressed: () => ref.read(authControllerProvider).signOut(), + icon: const Icon(Icons.logout), + ), + ], ), const _NotificationBell(), ], diff --git a/test/profile_screen_test.dart b/test/profile_screen_test.dart new file mode 100644 index 00000000..b67e753d --- /dev/null +++ b/test/profile_screen_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'package:tasq/models/office.dart'; +import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/user_office.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/providers/tickets_provider.dart'; +import 'package:tasq/providers/user_offices_provider.dart'; +import 'package:tasq/providers/auth_provider.dart'; +import 'package:tasq/providers/supabase_provider.dart'; +import 'package:tasq/screens/profile/profile_screen.dart'; +import 'package:tasq/widgets/multi_select_picker.dart'; + +class _FakeProfileController implements ProfileController { + String? lastFullName; + String? lastPassword; + + @override + Future updateFullName({ + required String userId, + required String fullName, + }) async { + lastFullName = fullName; + } + + @override + Future updatePassword(String password) async { + lastPassword = password; + } +} + +class _FakeUserOfficesController implements UserOfficesController { + final List> assigned = []; + + @override + Future assignUserOffice({ + required String userId, + required String officeId, + }) async { + assigned.add({'userId': userId, 'officeId': officeId}); + } + + @override + Future removeUserOffice({ + required String userId, + required String officeId, + }) async { + assigned.removeWhere( + (e) => e['userId'] == userId && e['officeId'] == officeId, + ); + } +} + +void main() { + final officeA = Office(id: 'office-1', name: 'HQ'); + final officeB = Office(id: 'office-2', name: 'Branch'); + final profile = Profile(id: 'user-1', role: 'standard', fullName: 'Paola'); + + ProviderScope buildApp({required List overrides}) { + return ProviderScope( + overrides: [ + currentUserIdProvider.overrideWithValue(profile.id), + sessionProvider.overrideWithValue(null), + ...overrides, + ], + child: const MaterialApp(home: Scaffold(body: ProfileScreen())), + ); + } + + testWidgets('renders fields and sections', (tester) async { + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + officesProvider.overrideWith( + (ref) => Stream.value([officeA, officeB]), + ), + userOfficesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('My Profile'), findsOneWidget); + expect(find.text('Account details'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Full name'), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is MultiSelectPicker && w.label == 'Offices', + ), + findsOneWidget, + ); + }); + + testWidgets('save details calls profile controller', (tester) async { + final fake = _FakeProfileController(); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + officesProvider.overrideWith((ref) => Stream.value([officeA])), + userOfficesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + profileControllerProvider.overrideWithValue(fake), + ], + ), + ); + + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Full name'), + 'New Name', + ); + await tester.tap(find.text('Save details')); + await tester.pumpAndSettle(); + + expect(fake.lastFullName, equals('New Name')); + }); + + testWidgets('save offices assigns selected office', (tester) async { + final fakeOffices = _FakeUserOfficesController(); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + officesProvider.overrideWith( + (ref) => Stream.value([officeA, officeB]), + ), + userOfficesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + userOfficesControllerProvider.overrideWithValue(fakeOffices), + ], + ), + ); + + await tester.pumpAndSettle(); + + // Open MultiSelect picker + final selectChip = find.byWidgetPredicate( + (w) => w is ActionChip && (w.label as Text).data == 'Select', + ); + expect(selectChip, findsOneWidget); + await tester.ensureVisible(selectChip); + await tester.tap(selectChip); + await tester.pumpAndSettle(); + + // In the dialog, tap the first office checkbox and press Done + await tester.tap(find.text('HQ')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + // Save offices + await tester.tap(find.text('Save offices')); + await tester.pumpAndSettle(); + + expect(fakeOffices.assigned, isNotEmpty); + expect(fakeOffices.assigned.first['officeId'], equals('office-1')); + }); + + testWidgets('change password calls controller', (tester) async { + final fake = _FakeProfileController(); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + officesProvider.overrideWith((ref) => Stream.value([officeA])), + userOfficesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + profileControllerProvider.overrideWithValue(fake), + ], + ), + ); + + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New password'), + 'new-pass-123', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Confirm password'), + 'new-pass-123', + ); + await tester.tap(find.text('Change password')); + await tester.pumpAndSettle(); + + expect(fake.lastPassword, equals('new-pass-123')); + }); +}