Skip to content

Commit

Permalink
feat: Add ability to edit personal and household information on the u…
Browse files Browse the repository at this point in the history
…ser profile (#172)

* chore: Add flutter form builder

* feat: Add profile section component

* feat: Allow to edit for personal and household sections

* build: add auth env variables for dev

* chore: Add new string catalog for user profile related strings

* chore: Replace hardcoded text with string catalog constants

---------

Co-authored-by: Don Setiawan <[email protected]>
  • Loading branch information
hinxcode and lsetiawan authored Oct 31, 2024
1 parent 50115bc commit b3fb9d4
Show file tree
Hide file tree
Showing 11 changed files with 413 additions and 90 deletions.
1 change: 1 addition & 0 deletions deployment/values.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ auth:
GOTRUE_SMTP_SENDER_NAME: "[email protected]"
GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true"
GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token"
GOTRUE_SMS_AUTOCONFIRM: "true"

rest:
imagePullSecrets:
Expand Down
26 changes: 26 additions & 0 deletions src/support_sphere/lib/constants/string_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class NormalAlertDialogStrings {
class LoginStrings {
static const String login = 'Login';
static const String loginIntoExisting = 'Login into an existing account';
static const String logout = 'Log Out';
static const String email = 'Email';
static const String password = 'Password';
static const String confirmPassword = 'Confirm Password';
Expand All @@ -37,13 +38,38 @@ class LoginStrings {
static const String familyName = 'Last Name';
}

/// User Profile related strings
class UserProfileStrings {
static const String userProfile = 'User Profile';
static const String personalInformation = 'Personal Information';
static const String householdInformation = 'Household Information';
static const String clusterInformation = 'Cluster Information';
static const String fullName = 'Name';
static const String phone = 'Phone';
static const String email = 'Email';
static const String givenName = 'Given Name';
static const String familyName = 'Family Name';
static const String householdMembers = 'Household Members';
static const String address = 'Address';
static const String pets = 'Pets';
static const String accessibilityNeeds = 'Accessibility Needs';
static const String accessibilityNeedsDefaultText = 'Not Applicable';
static const String notes = 'Notes';
static const String notesWithNote = 'Notes (visible to cluster captain(s))';
static const String clusterName = 'Name';
static const String meetingPlace = 'Meeting place';
static const String captains = 'Captain(s)';
static const String submit = 'Submit';
}

/// Error messages
class ErrorMessageStrings {
static const String invalidEmail = 'Invalid email';
static const String invalidPassword = 'Invalid password';
static const String invalidConfirmPassword = 'Passwords do not match';
static const String invalidSignUpCode = 'Invalid sign up code';
static const String mustNotContainSpecialCharacters = 'Must not contain any special characters';
static const String noUserIsSignedIn = 'No user is currently signed in, please try re-login';
}

/// App Modes Strings
Expand Down
9 changes: 9 additions & 0 deletions src/support_sphere/lib/data/repositories/authentication.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,13 @@ class AuthenticationRepository {
}
return defaultReturn;
}

Future<AuthUser> updateUserPhoneNumber({
String? phone,
}) async {
final response = await _authService.updateUserPhone(phone);
supabase_flutter.Session? session = _authService.getUserSession();

return _parseUser(response.user, _parseUserRole(session));
}
}
30 changes: 30 additions & 0 deletions src/support_sphere/lib/data/repositories/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import 'package:support_sphere/data/models/clusters.dart';
import 'package:support_sphere/data/models/households.dart';
import 'package:support_sphere/data/models/person.dart';
import 'package:support_sphere/data/services/cluster_service.dart';
import 'package:support_sphere/data/services/auth_service.dart';
import 'package:support_sphere/data/services/user_service.dart';

/// Repository for user interactions.
/// This class is responsible for handling user-related data operations.
class UserRepository {
final AuthService _authService = AuthService();
final UserService _userService = UserService();
final ClusterService _clusterService = ClusterService();

Expand Down Expand Up @@ -150,4 +152,32 @@ class UserRepository {
await _userService.createPerson(
userId: userId, givenName: givenName, familyName: familyName);
}

Future<void> updateUserName({
required String personId,
String? givenName,
String? familyName,
}) async {
await _userService.updatePerson(
id: personId,
givenName: givenName,
familyName: familyName,
);
}

Future<void> updateHousehold({
required String householdId,
String? address,
String? pets,
String? accessibilityNeeds,
String? notes,
}) async {
await _userService.updateHousehold(
id: householdId,
address: address,
pets: pets,
accessibilityNeeds: accessibilityNeeds,
notes: notes,
);
}
}
27 changes: 25 additions & 2 deletions src/support_sphere/lib/data/services/auth_service.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:support_sphere/utils/supabase.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'package:support_sphere/constants/string_catalog.dart';
// TODO: ADD API Handling in here for exceptions

List<String> _validSignupCodes = const [
Expand All @@ -11,7 +11,6 @@ List<String> _validSignupCodes = const [
];

class AuthService extends Equatable{

static final GoTrueClient _supabaseAuth = supabase.auth;

User? getSignedInUser() => _supabaseAuth.currentUser;
Expand All @@ -37,6 +36,30 @@ class AuthService extends Equatable{

Future<void> signOut() async => await _supabaseAuth.signOut();

Future<UserResponse> updateUserPhone(String? phone) async {
if (_supabaseAuth.currentUser == null) {
throw Exception(ErrorMessageStrings.noUserIsSignedIn);
}

if (phone == null || phone.isEmpty) {
// Currently, there is a bug in Supabase (see: https://github.com/supabase/supabase-js/issues/1008)
// where updateUser() does not clear the phone field correctly when the “new” phone value is empty.
// As a workaround, we can use Supabase RPC (see: https://www.restack.io/docs/supabase-knowledge-supabase-rpc-guide)
// or develop a separate API to implement this functionality.
// For now, I will ignore this issue, leaving the problem unresolved when a user has a phone number and wants to clear it.

// RPC Workaround:
// await _supabaseClient.rpc('clear_user_phone', params: { 'user_id': _supabaseAuth.currentUser?.id });
return Future.value(UserResponse.fromJson(_supabaseAuth.currentUser?.toJson() ?? {}));
} else {
return await _supabaseAuth.updateUser(
UserAttributes(
phone: phone,
),
);
}
}

@override
List<Object?> get props => [];
}
44 changes: 44 additions & 0 deletions src/support_sphere/lib/data/services/user_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,48 @@ class UserService {
'needs_help': false,
});
}

/// Updates a person's details in the people table.
Future<void> updatePerson({
required String id,
String? givenName,
String? familyName,
String? nickname,
bool? isSafe,
bool? needsHelp,
}) async {
final payload = <String, dynamic>{};

if (givenName != null) payload['given_name'] = givenName;
if (familyName != null) payload['family_name'] = familyName;
if (nickname != null) payload['nickname'] = nickname;
if (isSafe != null) payload['is_safe'] = isSafe;
if (needsHelp != null) payload['needs_help'] = needsHelp;

await _supabaseClient
.from('people')
.update(payload)
.eq('id', id);
}

/// Updates a household's details in the households table.
Future<void> updateHousehold({
required String id,
String? address,
String? pets,
String? accessibilityNeeds,
String? notes,
}) async {
final payload = <String, dynamic>{};

if (address != null) payload['address'] = address;
if (pets != null) payload['pets'] = pets;
if (accessibilityNeeds != null) payload['accessibility_needs'] = accessibilityNeeds;
if (notes != null) payload['notes'] = notes;

await _supabaseClient
.from('households')
.update(payload)
.eq('id', id);
}
}
47 changes: 47 additions & 0 deletions src/support_sphere/lib/logic/cubit/profile_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:support_sphere/data/models/auth_user.dart';
import 'package:support_sphere/data/models/clusters.dart';
import 'package:support_sphere/data/models/households.dart';
import 'package:support_sphere/data/models/person.dart';
import 'package:support_sphere/data/repositories/authentication.dart';
import 'package:support_sphere/data/repositories/user.dart';

part 'profile_state.dart';
Expand All @@ -15,6 +16,7 @@ class ProfileCubit extends Cubit<ProfileState> {
}

final AuthUser authUser;
final AuthenticationRepository _authRepository = AuthenticationRepository();
final UserRepository _userRepository = UserRepository();

void profileChanged(Person? userProfile) {
Expand Down Expand Up @@ -83,4 +85,49 @@ class ProfileCubit extends Cubit<ProfileState> {
clusterChanged(null);
}
}

Future<void> savePersonalInfoModal({
required String personId,
String? givenName,
String? familyName,
String? phone,
}) async {
try {
await _userRepository.updateUserName(
personId: personId,
givenName: givenName,
familyName: familyName,
);
AuthUser updatedAuthUser = await _authRepository.updateUserPhoneNumber(
phone: phone,
);
authUserChanged(updatedAuthUser);
// TODO: Consider optimizing this to perform a partial update from the API result instead of fetching the entire profile
await fetchProfile();
} catch (error) {
// TODO: Handle error
}
}

Future<void> saveHouseholdInfoModal({
required String householdId,
String? address,
String? pets,
String? accessibilityNeeds,
String? notes,
}) async {
try {
await _userRepository.updateHousehold(
householdId: householdId,
address: address,
pets: pets,
accessibilityNeeds: accessibilityNeeds,
notes: notes,
);
// TODO: Consider optimizing this to perform a partial update from the API result instead of fetching the entire profile
await fetchProfile();
} catch (error) {
// TODO: Handle error
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:ionicons/ionicons.dart';
import 'package:support_sphere/logic/cubit/profile_cubit.dart';

class ProfileSection extends StatelessWidget {
const ProfileSection({
super.key,
this.title = "Section Header",
this.children = const [],
this.modalBody = const SizedBox(),
this.displayTitle = true,
this.readOnly = false,
this.state = const ProfileState(),
});

final String title;
final List<Widget> children;
final Widget modalBody;
final bool displayTitle;
final bool readOnly;
final ProfileState state;

@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10),
child: Column(
children: [
_getTitle(context) ?? const SizedBox(),
Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: children,
),
),
)
],
),
);
}

Widget? _getTitle(BuildContext context) {
if (displayTitle) {
return ListTile(
title: Text(title),
trailing: readOnly
? null
: GestureDetector(
onTap: () => _showModalBottomSheet(context),
child: const Icon(Ionicons.create_outline),
),
);
}
return null;
}

Future<dynamic> _showModalBottomSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
builder: (context) {
return Container(
padding: const EdgeInsets.all(16),
child: modalBody,
);
},
);
}
}
Loading

0 comments on commit b3fb9d4

Please sign in to comment.