From b3fb9d421146cc28ba027fdbafb5f58c0368f462 Mon Sep 17 00:00:00 2001 From: YH Huang Date: Fri, 1 Nov 2024 01:01:19 +0800 Subject: [PATCH] feat: Add ability to edit personal and household information on the user 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 --- deployment/values.dev.yaml | 1 + .../lib/constants/string_catalog.dart | 26 ++ .../lib/data/repositories/authentication.dart | 9 + .../lib/data/repositories/user.dart | 30 +++ .../lib/data/services/auth_service.dart | 27 +- .../lib/data/services/user_service.dart | 44 ++++ .../lib/logic/cubit/profile_cubit.dart | 47 ++++ .../components/profile_section.dart | 69 +++++ .../pages/main_app/profile/profile_body.dart | 239 +++++++++++------- src/support_sphere/pubspec.lock | 10 +- src/support_sphere/pubspec.yaml | 1 + 11 files changed, 413 insertions(+), 90 deletions(-) create mode 100644 src/support_sphere/lib/presentation/components/profile_section.dart diff --git a/deployment/values.dev.yaml b/deployment/values.dev.yaml index 1b5914b..a4faa6c 100644 --- a/deployment/values.dev.yaml +++ b/deployment/values.dev.yaml @@ -74,6 +74,7 @@ auth: GOTRUE_SMTP_SENDER_NAME: "your-mail@example.com" 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: diff --git a/src/support_sphere/lib/constants/string_catalog.dart b/src/support_sphere/lib/constants/string_catalog.dart index f013e71..4a47b53 100644 --- a/src/support_sphere/lib/constants/string_catalog.dart +++ b/src/support_sphere/lib/constants/string_catalog.dart @@ -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'; @@ -37,6 +38,30 @@ 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'; @@ -44,6 +69,7 @@ class ErrorMessageStrings { 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 diff --git a/src/support_sphere/lib/data/repositories/authentication.dart b/src/support_sphere/lib/data/repositories/authentication.dart index f38c1ec..d58c6ac 100644 --- a/src/support_sphere/lib/data/repositories/authentication.dart +++ b/src/support_sphere/lib/data/repositories/authentication.dart @@ -69,4 +69,13 @@ class AuthenticationRepository { } return defaultReturn; } + + Future updateUserPhoneNumber({ + String? phone, + }) async { + final response = await _authService.updateUserPhone(phone); + supabase_flutter.Session? session = _authService.getUserSession(); + + return _parseUser(response.user, _parseUserRole(session)); + } } diff --git a/src/support_sphere/lib/data/repositories/user.dart b/src/support_sphere/lib/data/repositories/user.dart index 9235334..22b61e5 100644 --- a/src/support_sphere/lib/data/repositories/user.dart +++ b/src/support_sphere/lib/data/repositories/user.dart @@ -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(); @@ -150,4 +152,32 @@ class UserRepository { await _userService.createPerson( userId: userId, givenName: givenName, familyName: familyName); } + + Future updateUserName({ + required String personId, + String? givenName, + String? familyName, + }) async { + await _userService.updatePerson( + id: personId, + givenName: givenName, + familyName: familyName, + ); + } + + Future 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, + ); + } } diff --git a/src/support_sphere/lib/data/services/auth_service.dart b/src/support_sphere/lib/data/services/auth_service.dart index 77aad54..5436f31 100644 --- a/src/support_sphere/lib/data/services/auth_service.dart +++ b/src/support_sphere/lib/data/services/auth_service.dart @@ -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 _validSignupCodes = const [ @@ -11,7 +11,6 @@ List _validSignupCodes = const [ ]; class AuthService extends Equatable{ - static final GoTrueClient _supabaseAuth = supabase.auth; User? getSignedInUser() => _supabaseAuth.currentUser; @@ -37,6 +36,30 @@ class AuthService extends Equatable{ Future signOut() async => await _supabaseAuth.signOut(); + Future 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 get props => []; } diff --git a/src/support_sphere/lib/data/services/user_service.dart b/src/support_sphere/lib/data/services/user_service.dart index a8c6f4a..da5d08a 100644 --- a/src/support_sphere/lib/data/services/user_service.dart +++ b/src/support_sphere/lib/data/services/user_service.dart @@ -82,4 +82,48 @@ class UserService { 'needs_help': false, }); } + + /// Updates a person's details in the people table. + Future updatePerson({ + required String id, + String? givenName, + String? familyName, + String? nickname, + bool? isSafe, + bool? needsHelp, + }) async { + final payload = {}; + + 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 updateHousehold({ + required String id, + String? address, + String? pets, + String? accessibilityNeeds, + String? notes, + }) async { + final payload = {}; + + 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); + } } \ No newline at end of file diff --git a/src/support_sphere/lib/logic/cubit/profile_cubit.dart b/src/support_sphere/lib/logic/cubit/profile_cubit.dart index b27e4bf..e6e539a 100644 --- a/src/support_sphere/lib/logic/cubit/profile_cubit.dart +++ b/src/support_sphere/lib/logic/cubit/profile_cubit.dart @@ -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'; @@ -15,6 +16,7 @@ class ProfileCubit extends Cubit { } final AuthUser authUser; + final AuthenticationRepository _authRepository = AuthenticationRepository(); final UserRepository _userRepository = UserRepository(); void profileChanged(Person? userProfile) { @@ -83,4 +85,49 @@ class ProfileCubit extends Cubit { clusterChanged(null); } } + + Future 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 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 + } + } } diff --git a/src/support_sphere/lib/presentation/components/profile_section.dart b/src/support_sphere/lib/presentation/components/profile_section.dart new file mode 100644 index 0000000..5ca5e7d --- /dev/null +++ b/src/support_sphere/lib/presentation/components/profile_section.dart @@ -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 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 _showModalBottomSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + builder: (context) { + return Container( + padding: const EdgeInsets.all(16), + child: modalBody, + ); + }, + ); + } +} diff --git a/src/support_sphere/lib/presentation/pages/main_app/profile/profile_body.dart b/src/support_sphere/lib/presentation/pages/main_app/profile/profile_body.dart index 4076d2a..310f438 100644 --- a/src/support_sphere/lib/presentation/pages/main_app/profile/profile_body.dart +++ b/src/support_sphere/lib/presentation/pages/main_app/profile/profile_body.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:support_sphere/data/models/auth_user.dart'; import 'package:support_sphere/data/models/clusters.dart'; @@ -8,6 +7,10 @@ import 'package:support_sphere/data/models/households.dart'; import 'package:support_sphere/data/models/person.dart'; import 'package:support_sphere/logic/bloc/auth/authentication_bloc.dart'; import 'package:support_sphere/logic/cubit/profile_cubit.dart'; +import 'package:support_sphere/presentation/components/profile_section.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:support_sphere/constants/string_catalog.dart'; /// Profile Body Widget class ProfileBody extends StatelessWidget { @@ -28,7 +31,7 @@ class ProfileBody extends StatelessWidget { height: 50, child: const Center( // TODO: Add profile picture - child: Text('User Profile', + child: Text(UserProfileStrings.userProfile, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ), @@ -76,7 +79,7 @@ class _LogOutButton extends StatelessWidget { onPressed: () => context.read().add(AuthOnLogoutRequested()), icon: const Icon(Ionicons.log_out_outline), - label: const Text('Log Out'), + label: const Text(LoginStrings.logout), ), ); }, @@ -84,78 +87,17 @@ class _LogOutButton extends StatelessWidget { } } -class _ProfileSection extends StatelessWidget { - const _ProfileSection( - {super.key, - this.title = "Section Header", - this.children = const [], - this.displayTitle = true, - this.readOnly = false}); - - final String title; - final List children; - final bool displayTitle; - final bool readOnly; - - @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.0), - child: Column( - children: children, - ), - ), - ) - ], - ), - ); - } - - Future _showModalBottomSheet(BuildContext context) { - return showCupertinoModalBottomSheet( - expand: true, - context: context, - - /// TODO: Implement Edit modal - builder: (context) => Container()); - } - - /// Get the title of the section - /// If the title is not displayed, return null - /// If the title is displayed, return a ListTile with the title and an edit icon - /// If the section is read only, don't show the edit icon - /// If the section is not read only, show the edit icon - Widget? _getTitle(BuildContext context) { - if (displayTitle) { - // return Center(child: Text(title)); - return ListTile( - title: Text(title), - trailing: readOnly - ? null - : GestureDetector( - onTap: () => _showModalBottomSheet(context), - child: const Icon(Ionicons.create_outline), - ), - ); - } - return null; - } -} - class _PersonalInformation extends StatelessWidget { const _PersonalInformation({super.key}); @override Widget build(BuildContext context) { + final formKey = GlobalKey(); + return BlocBuilder( buildWhen: (previous, current) => - previous.userProfile != current.userProfile, + previous.userProfile != current.userProfile || + previous.authUser != current.authUser, builder: (context, state) { Person? userProfile = state.userProfile; AuthUser? authUser = state.authUser; @@ -164,31 +106,87 @@ class _PersonalInformation extends StatelessWidget { String fullName = '$givenName $familyName'; String phoneNumber = authUser?.phone ?? ''; String email = authUser?.email ?? ''; - return _ProfileSection( - title: "Personal Information", + + return ProfileSection( + title: UserProfileStrings.personalInformation, + state: state, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Name"), + const Text(UserProfileStrings.fullName), Text(fullName), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Phone"), + const Text(UserProfileStrings.phone), Text(phoneNumber), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Email"), + const Text(UserProfileStrings.email), Text(email), ], ), ], + modalBody: FormBuilder( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FormBuilderTextField( + name: 'givenName', + decoration: const InputDecoration( + labelText: UserProfileStrings.givenName), + initialValue: givenName, + ), + const SizedBox(height: 4), + FormBuilderTextField( + name: 'familyName', + decoration: const InputDecoration( + labelText: UserProfileStrings.familyName), + initialValue: familyName, + ), + const SizedBox(height: 4), + FormBuilderTextField( + name: 'phone', + decoration: const InputDecoration( + labelText: UserProfileStrings.phone), + initialValue: phoneNumber, + validator: FormBuilderValidators.phoneNumber( + checkNullOrEmpty: false), + ), + const SizedBox(height: 32), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + onPressed: () { + if (formKey.currentState?.saveAndValidate() ?? false) { + final formData = formKey.currentState?.value; + + if (formData != null && userProfile != null) { + context.read().savePersonalInfoModal( + personId: userProfile.id, + givenName: formData['givenName'], + familyName: formData['familyName'], + phone: formData['phone'], + ); + Navigator.of(context).pop(); + } + } + }, + child: const Text(UserProfileStrings.submit), + ), + ], + ), + ), ); }, ); @@ -200,6 +198,8 @@ class _HouseholdInformation extends StatelessWidget { @override Widget build(BuildContext context) { + final formKey = GlobalKey(); + return BlocBuilder( buildWhen: (previous, current) => previous.household != current.household, builder: (context, state) { @@ -207,7 +207,7 @@ class _HouseholdInformation extends StatelessWidget { String address = household?.address ?? ''; String pets = household?.pets ?? ''; String notes = household?.notes ?? ''; - String accessibilityNeeds = household?.accessibility_needs ?? 'None'; + String accessibilityNeeds = household?.accessibility_needs ?? ''; List householdMembers = household?.houseHoldMembers?.members ?? []; List members = householdMembers.map((person) { @@ -216,13 +216,15 @@ class _HouseholdInformation extends StatelessWidget { String fullName = '$givenName $familyName'; return fullName; }).toList(); - return _ProfileSection( - title: "Household Information", + + return ProfileSection( + title: UserProfileStrings.householdInformation, + state: state, children: [ const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Household Members"), + const Text(UserProfileStrings.householdMembers), ], ), Container( @@ -240,28 +242,30 @@ class _HouseholdInformation extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Address"), + const Text(UserProfileStrings.address), Text(address), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Pets"), + const Text(UserProfileStrings.pets), Text(pets), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Accessiblity Needs"), - Text(accessibilityNeeds), + const Text(UserProfileStrings.accessibilityNeeds), + Text(accessibilityNeeds.isEmpty + ? UserProfileStrings.accessibilityNeedsDefaultText + : accessibilityNeeds), ], ), const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Notes (visible to cluster captain(s))"), + const Text(UserProfileStrings.notesWithNote), ], ), Container( @@ -276,6 +280,67 @@ class _HouseholdInformation extends StatelessWidget { ), ) ], + modalBody: FormBuilder( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FormBuilderTextField( + name: 'address', + decoration: const InputDecoration( + labelText: UserProfileStrings.address), + initialValue: address, + ), + const SizedBox(height: 4), + FormBuilderTextField( + name: 'pets', + decoration: + const InputDecoration(labelText: UserProfileStrings.pets), + initialValue: pets, + ), + const SizedBox(height: 4), + FormBuilderTextField( + name: 'accessibilityNeeds', + decoration: const InputDecoration( + labelText: UserProfileStrings.accessibilityNeeds), + initialValue: accessibilityNeeds, + ), + const SizedBox(height: 4), + FormBuilderTextField( + name: 'notes', + decoration: const InputDecoration( + labelText: UserProfileStrings.notes), + initialValue: notes, + ), + const SizedBox(height: 32), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + onPressed: () { + if (formKey.currentState?.saveAndValidate() ?? false) { + final formData = formKey.currentState?.value; + + if (formData != null && household != null) { + context.read().saveHouseholdInfoModal( + householdId: household.id, + address: formData['address'], + pets: formData['pets'], + accessibilityNeeds: + formData['accessibilityNeeds'], + notes: formData['notes'], + ); + Navigator.of(context).pop(); + } + } + }, + child: const Text(UserProfileStrings.submit), + ), + ], + ), + ), ); }, ); @@ -302,28 +367,28 @@ class _ClusterInformation extends StatelessWidget { String fullName = '$givenName $familyName'; return fullName; }).toList(); - return _ProfileSection( - title: "Cluster Information", + return ProfileSection( + title: UserProfileStrings.clusterInformation, readOnly: true, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Name"), + const Text(UserProfileStrings.clusterName), Text(name), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Meeting place"), + const Text(UserProfileStrings.meetingPlace), Text(meetingPlace), ], ), const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Captain(s)"), + const Text(UserProfileStrings.captains), ], ), Container( diff --git a/src/support_sphere/pubspec.lock b/src/support_sphere/pubspec.lock index 0e30453..127534a 100644 --- a/src/support_sphere/pubspec.lock +++ b/src/support_sphere/pubspec.lock @@ -283,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + sha256: c278ef69b08957d484f83413f0e77b656a39b7a7bb4eb8a295da3a820ecc6545 + url: "https://pub.dev" + source: hosted + version: "9.5.0" flutter_lints: dependency: "direct dev" description: @@ -1049,4 +1057,4 @@ packages: version: "2.0.2" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.24.0" diff --git a/src/support_sphere/pubspec.yaml b/src/support_sphere/pubspec.yaml index 7199017..13e074f 100644 --- a/src/support_sphere/pubspec.yaml +++ b/src/support_sphere/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: modal_bottom_sheet: ^3.0.0 form_builder_validators: ^11.0.0 font_awesome_flutter: ^10.7.0 + flutter_form_builder: ^9.5.0 dev_dependencies: flutter_test: