diff --git a/recipients_app/lib/core/cubits/auth/auth_cubit.dart b/recipients_app/lib/core/cubits/auth/auth_cubit.dart index 2894c2a65..7fe1d6acb 100644 --- a/recipients_app/lib/core/cubits/auth/auth_cubit.dart +++ b/recipients_app/lib/core/cubits/auth/auth_cubit.dart @@ -23,12 +23,14 @@ class AuthCubit extends Cubit { if (user != null) { try { final recipient = await userRepository.fetchRecipient(user); + final Organization? organization = await _fetchOrganization(recipient); emit( AuthState( status: AuthStatus.authenticated, firebaseUser: user, recipient: recipient, + organization: organization, ), ); } on Exception catch (ex, stackTrace) { @@ -56,12 +58,7 @@ class AuthCubit extends Cubit { if (user != null) { final recipient = await userRepository.fetchRecipient(user); - Organization? organization; - - if (recipient?.organizationRef != null) { - organization = await organizationRepository - .fetchOrganization(recipient!.organizationRef!); - } + final Organization? organization = await _fetchOrganization(recipient); emit( AuthState( @@ -104,4 +101,11 @@ class AuthCubit extends Cubit { await userRepository.signOut(); emit(const AuthState()); } + + Future _fetchOrganization(Recipient? recipient) async { + if (recipient?.organizationRef != null) { + return await organizationRepository.fetchOrganization(recipient!.organizationRef!); + } + return null; + } } diff --git a/recipients_app/lib/core/cubits/auth/auth_state.dart b/recipients_app/lib/core/cubits/auth/auth_state.dart index ae3db5a60..04e1e570f 100644 --- a/recipients_app/lib/core/cubits/auth/auth_state.dart +++ b/recipients_app/lib/core/cubits/auth/auth_state.dart @@ -26,19 +26,21 @@ class AuthState extends Equatable { }); @override - List get props => [status, firebaseUser, recipient, exception]; + List get props => [status, firebaseUser, recipient, exception, organization]; AuthState copyWith({ AuthStatus? status, User? firebaseUser, Recipient? recipient, Exception? exception, + Organization? organization, }) { return AuthState( status: status ?? this.status, firebaseUser: firebaseUser ?? this.firebaseUser, recipient: recipient ?? this.recipient, exception: exception ?? this.exception, + organization: organization ?? this.organization, ); } } diff --git a/recipients_app/lib/core/cubits/dashboard_card_manager/dashboard_card_manager_cubit.dart b/recipients_app/lib/core/cubits/dashboard_card_manager/dashboard_card_manager_cubit.dart index 1cb0f6d8e..1bc72afc4 100644 --- a/recipients_app/lib/core/cubits/dashboard_card_manager/dashboard_card_manager_cubit.dart +++ b/recipients_app/lib/core/cubits/dashboard_card_manager/dashboard_card_manager_cubit.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:app/core/cubits/auth/auth_cubit.dart"; import "package:app/data/repositories/repositories.dart"; import "package:app/view/widgets/account/dashboard_card.dart"; @@ -9,16 +11,23 @@ part "dashboard_card_manager_state.dart"; class DashboardCardManagerCubit extends Cubit { final AuthCubit authCubit; final CrashReportingRepository crashReportingRepository; + late StreamSubscription authSubscription; DashboardCardManagerCubit({ required this.authCubit, required this.crashReportingRepository, }) : super(const DashboardCardManagerState()) { - authCubit.stream.listen((event) { + authSubscription = authCubit.stream.listen((event) { fetchCards(); }); } + @override + Future close() async { + authSubscription.cancel(); + super.close(); + } + Future fetchCards() async { emit(state.copyWith(status: DashboardCardManagerStatus.loading)); final recipient = authCubit.state.recipient; @@ -29,7 +38,6 @@ class DashboardCardManagerCubit extends Cubit { try { // TODO: currently payment phone number is used for login, we need to switch that - final paymentPhoneNumber = recipient.mobileMoneyPhone; final contactPhoneNumber = recipient.communicationMobilePhone; diff --git a/recipients_app/lib/data/datasource/demo/demo_user.dart b/recipients_app/lib/data/datasource/demo/demo_user.dart new file mode 100644 index 000000000..825f56a94 --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/demo_user.dart @@ -0,0 +1,149 @@ +import "package:firebase_auth/firebase_auth.dart"; + +class DemoUser implements User { + @override + String? get displayName => "demo user"; + + @override + String? get email => null; + + @override + bool get emailVerified => true; + + @override + Future getIdToken([bool forceRefresh = false]) { + throw UnimplementedError(); + } + + @override + Future delete() { + throw UnimplementedError(); + } + + @override + Future getIdTokenResult([bool forceRefresh = false]) { + throw UnimplementedError(); + } + + @override + bool get isAnonymous => throw UnimplementedError(); + + @override + Future linkWithCredential(AuthCredential credential) { + throw UnimplementedError(); + } + + @override + Future linkWithPhoneNumber(String phoneNumber, [RecaptchaVerifier? verifier]) { + throw UnimplementedError(); + } + + @override + Future linkWithPopup(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + Future linkWithProvider(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + Future linkWithRedirect(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + UserMetadata get metadata => throw UnimplementedError(); + + @override + MultiFactor get multiFactor => throw UnimplementedError(); + + @override + String? get phoneNumber => throw UnimplementedError(); + + @override + String? get photoURL => throw UnimplementedError(); + + @override + List get providerData => throw UnimplementedError(); + + @override + Future reauthenticateWithCredential(AuthCredential credential) { + throw UnimplementedError(); + } + + @override + Future reauthenticateWithPopup(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + Future reauthenticateWithProvider(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + Future reauthenticateWithRedirect(AuthProvider provider) { + throw UnimplementedError(); + } + + @override + String? get refreshToken => throw UnimplementedError(); + + @override + Future reload() { + throw UnimplementedError(); + } + + @override + Future sendEmailVerification([ActionCodeSettings? actionCodeSettings]) { + throw UnimplementedError(); + } + + @override + String? get tenantId => throw UnimplementedError(); + + @override + String get uid => throw UnimplementedError(); + + @override + Future unlink(String providerId) { + throw UnimplementedError(); + } + + @override + Future updateDisplayName(String? displayName) { + throw UnimplementedError(); + } + + @override + Future updateEmail(String newEmail) { + throw UnimplementedError(); + } + + @override + Future updatePassword(String newPassword) { + throw UnimplementedError(); + } + + @override + Future updatePhoneNumber(PhoneAuthCredential phoneCredential) { + throw UnimplementedError(); + } + + @override + Future updatePhotoURL(String? photoURL) { + throw UnimplementedError(); + } + + @override + Future updateProfile({String? displayName, String? photoURL}) { + throw UnimplementedError(); + } + + @override + Future verifyBeforeUpdateEmail(String newEmail, [ActionCodeSettings? actionCodeSettings]) { + throw UnimplementedError(); + } +} diff --git a/recipients_app/lib/data/datasource/demo/no_op_document_reference.dart b/recipients_app/lib/data/datasource/demo/no_op_document_reference.dart new file mode 100644 index 000000000..8006c327c --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/no_op_document_reference.dart @@ -0,0 +1,58 @@ +import "package:cloud_firestore/cloud_firestore.dart"; + +// We are using DocumentReference in repository / data source. That's why we need to get +// no-op implementation for it for demo data source. + +// ignore: subtype_of_sealed_class +class NoOpDocumentReference implements DocumentReference> { + const NoOpDocumentReference(); + + @override + CollectionReference> collection(String collectionPath) { + throw UnimplementedError(); + } + + @override + Future delete() { + throw UnimplementedError(); + } + + @override + FirebaseFirestore get firestore => throw UnimplementedError(); + + @override + Future>> get([GetOptions? options]) { + throw UnimplementedError(); + } + + @override + String get id => throw UnimplementedError(); + + @override + CollectionReference> get parent => throw UnimplementedError(); + + @override + String get path => throw UnimplementedError(); + + @override + Future set(Map data, [SetOptions? options]) { + throw UnimplementedError(); + } + + @override + Stream>> snapshots( + {bool includeMetadataChanges = false, ListenSource source = ListenSource.defaultSource,}) { + throw UnimplementedError(); + } + + @override + Future update(Map data) { + throw UnimplementedError(); + } + + @override + DocumentReference withConverter( + {required FromFirestore fromFirestore, required ToFirestore toFirestore,}) { + throw UnimplementedError(); + } +} diff --git a/recipients_app/lib/data/datasource/demo/organization_demo_data_source.dart b/recipients_app/lib/data/datasource/demo/organization_demo_data_source.dart new file mode 100644 index 000000000..0ba0d6b0a --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/organization_demo_data_source.dart @@ -0,0 +1,16 @@ +import "package:app/data/datasource/organization_data_source.dart"; +import "package:app/data/models/models.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +class OrganizationDemoDataSource implements OrganizationDataSource { + final Organization _organization = _generateOrganization(); + + static Organization _generateOrganization() { + return const Organization(name: "Demo organization", contactName: "Demo manager", contactNumber: "+232 123456789"); + } + + @override + Future fetchOrganization(DocumentReference organizationRef) async { + return _organization; + } +} diff --git a/recipients_app/lib/data/datasource/demo/payment_demo_data_source.dart b/recipients_app/lib/data/datasource/demo/payment_demo_data_source.dart new file mode 100644 index 000000000..853c7dca6 --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/payment_demo_data_source.dart @@ -0,0 +1,99 @@ +import "dart:math"; + +import "package:app/data/datasource/payment_data_source.dart"; +import "package:app/data/models/models.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +const String paymentCollection = "payments"; + +class PaymentDemoDataSource implements PaymentDataSource { + List payments = initData(); + + static List initData() { + final List payments = []; + + final nowDate = DateTime.now(); + final random = Random(); + + final confirmedPaymentsCount = random.nextInt(12) + 1; + final notConfirmedPaymentsCount = random.nextInt(2) + 1; + + for (int i = 0; i < confirmedPaymentsCount; i++) { + final currentDateTime = DateTime( + nowDate.year, + nowDate.month - confirmedPaymentsCount - notConfirmedPaymentsCount + i, + 15, + ); + payments.add( + SocialIncomePayment( + id: "${currentDateTime.year}-${currentDateTime.month}", + paymentAt: Timestamp.fromDate(currentDateTime), + currency: "SLE", + amount: 700, + status: PaymentStatus.confirmed, + ), + ); + } + + for (int i = 0; i < notConfirmedPaymentsCount; i++) { + final currentDateTime = DateTime( + nowDate.year, + nowDate.month - notConfirmedPaymentsCount + i, + 15, + ); + payments.add( + SocialIncomePayment( + id: "${currentDateTime.year}-${currentDateTime.month}", + paymentAt: Timestamp.fromDate(currentDateTime), + currency: "SLE", + amount: 700, + status: PaymentStatus.paid, + ), + ); + } + + payments.sort((a, b) => a.id.compareTo(b.id)); + + return payments; + } + + @override + Future> fetchPayments({ + required String recipientId, + }) async { + return payments; + } + + /// This updates the payment status to confirmed + /// and also sets lastUpdatedAt and lastUpdatedBy to the + /// current time and recipient + @override + Future confirmPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + }) async { + final updatedPayment = payment.copyWith( + status: PaymentStatus.confirmed, + updatedBy: recipient.userId, + ); + + final indexWhere = payments.indexWhere((element) => element.id == updatedPayment.id); + payments[indexWhere] = updatedPayment; + } + + @override + Future contestPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + required String contestReason, + }) async { + final updatedPayment = payment.copyWith( + status: PaymentStatus.contested, + comments: contestReason, + updatedBy: recipient.userId, + ); + + final indexWhere = payments.indexWhere((element) => element.id == updatedPayment.id); + payments[indexWhere] = updatedPayment; + } +} diff --git a/recipients_app/lib/data/datasource/demo/survey_demo_data_source.dart b/recipients_app/lib/data/datasource/demo/survey_demo_data_source.dart new file mode 100644 index 000000000..0bbefc443 --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/survey_demo_data_source.dart @@ -0,0 +1,47 @@ +import "package:app/data/datasource/survey_data_source.dart"; +import "package:app/data/models/models.dart"; +import "package:app/data/models/survey/survey.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +class SurveyDemoDataSource implements SurveyDataSource { + final List _surveys = _generateDemoSurveys(); + + static List _generateDemoSurveys() { + final now = DateTime.now(); + final surveys = [ + Survey( + id: "onboarding", + status: SurveyServerStatus.scheduled, + dueDateAt: Timestamp.fromDate(now.subtract(const Duration(days: 10))), + ), + Survey( + id: "checkin", + status: SurveyServerStatus.scheduled, + dueDateAt: Timestamp.fromDate(now), + ), + Survey( + id: "offboarding", + status: SurveyServerStatus.scheduled, + dueDateAt: Timestamp.fromDate( + now.add(const Duration(days: 11)), + ), + ), + Survey( + id: "followup", + status: SurveyServerStatus.scheduled, + dueDateAt: Timestamp.fromDate( + now.add(const Duration(days: 16)), + ), + ), + ]; + + surveys.sort((a, b) => a.id.compareTo(b.id)); + + return surveys; + } + + @override + Future> fetchSurveys({required String recipientId}) async { + return _surveys; + } +} diff --git a/recipients_app/lib/data/datasource/demo/user_demo_data_source.dart b/recipients_app/lib/data/datasource/demo/user_demo_data_source.dart new file mode 100644 index 000000000..e4468a4f7 --- /dev/null +++ b/recipients_app/lib/data/datasource/demo/user_demo_data_source.dart @@ -0,0 +1,67 @@ +import "dart:async"; + +import "package:app/data/datasource/demo/demo_user.dart"; +import "package:app/data/datasource/demo/no_op_document_reference.dart"; +import "package:app/data/datasource/user_data_source.dart"; +import "package:app/data/models/models.dart"; +import "package:firebase_auth/firebase_auth.dart"; + +class UserDemoDataSource implements UserDataSource { + Recipient? _recipient = const Recipient( + userId: "demo", + firstName: "Demo", + lastName: "SocialIncome", + mobileMoneyPhone: Phone(23271118897), + communicationMobilePhone: Phone(23271118897), + organizationRef: NoOpDocumentReference(), + ); + final _userStreamController = StreamController(); + late final _userBroadcastStreamController = _getBroadcastStream(); + final _user = DemoUser(); + + @override + Stream authStateChanges() { + _userStreamController.add(_user); + return _userBroadcastStreamController; + } + + Stream _getBroadcastStream() { + return _userStreamController.stream.asBroadcastStream(); + } + + @override + User? get currentUser { + return _user; + } + + @override + Future fetchRecipient(User firebaseUser) async { + return _recipient; + } + + @override + Future verifyPhoneNumber({ + required String phoneNumber, + required Function(String, int?) onCodeSend, + required Function(FirebaseAuthException) onVerificationFailed, + required Function(PhoneAuthCredential) onVerificationCompleted, + required int? forceResendingToken, + }) async { + // TODO do the auth flow demo as well + } + + @override + Future signOut() async { + _userStreamController.add(null); + } + + @override + Future signInWithCredential(PhoneAuthCredential credentials) async { + // TODO do the auth flow demo as well + } + + @override + Future updateRecipient(Recipient recipient) async { + _recipient = recipient; + } +} diff --git a/recipients_app/lib/data/datasource/organization_data_source.dart b/recipients_app/lib/data/datasource/organization_data_source.dart new file mode 100644 index 000000000..cfbb715ae --- /dev/null +++ b/recipients_app/lib/data/datasource/organization_data_source.dart @@ -0,0 +1,8 @@ +import "package:app/data/models/organization.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +abstract class OrganizationDataSource { + Future fetchOrganization( + DocumentReference organizationRef, + ); +} diff --git a/recipients_app/lib/data/datasource/payment_data_source.dart b/recipients_app/lib/data/datasource/payment_data_source.dart new file mode 100644 index 000000000..04c75cf1e --- /dev/null +++ b/recipients_app/lib/data/datasource/payment_data_source.dart @@ -0,0 +1,19 @@ +import "package:app/data/models/payment/social_income_payment.dart"; +import "package:app/data/models/recipient.dart"; + +abstract class PaymentDataSource { + Future> fetchPayments({ + required String recipientId, + }); + + Future confirmPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + }); + + Future contestPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + required String contestReason, + }); +} diff --git a/recipients_app/lib/data/datasource/remote/organization_remote_data_source.dart b/recipients_app/lib/data/datasource/remote/organization_remote_data_source.dart new file mode 100644 index 000000000..fd2d0c5b6 --- /dev/null +++ b/recipients_app/lib/data/datasource/remote/organization_remote_data_source.dart @@ -0,0 +1,28 @@ +import "package:app/data/datasource/organization_data_source.dart"; +import "package:app/data/models/organization.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +const String surveyCollection = "surveys"; + +class OrganizationRemoteDataSource implements OrganizationDataSource { + final FirebaseFirestore firestore; + + const OrganizationRemoteDataSource({ + required this.firestore, + }); + + @override + Future fetchOrganization( + DocumentReference organizationRef, + ) async { + final organization = organizationRef.withConverter( + fromFirestore: (snapshot, _) { + final data = snapshot.data()!; + return Organization.fromJson(data); + }, + toFirestore: (organization, _) => organization.toJson(), + ); + + return (await organization.get()).data(); + } +} diff --git a/recipients_app/lib/data/datasource/remote/payment_remote_data_source.dart b/recipients_app/lib/data/datasource/remote/payment_remote_data_source.dart new file mode 100644 index 000000000..95eb607be --- /dev/null +++ b/recipients_app/lib/data/datasource/remote/payment_remote_data_source.dart @@ -0,0 +1,77 @@ +import "package:app/data/datasource/payment_data_source.dart"; +import "package:app/data/datasource/remote/user_remote_data_source.dart"; +import "package:app/data/models/models.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +const String paymentCollection = "payments"; + +class PaymentRemoteDataSource implements PaymentDataSource { + final FirebaseFirestore firestore; + + const PaymentRemoteDataSource({ + required this.firestore, + }); + + @override + Future> fetchPayments({ + required String recipientId, + }) async { + final List payments = []; + + final paymentsDocs = + await firestore.collection(recipientCollection).doc(recipientId).collection(paymentCollection).get(); + + for (final paymentDoc in paymentsDocs.docs) { + final payment = SocialIncomePayment.fromJson( + paymentDoc.data(), + ); + + payments.add(payment.copyWith(id: paymentDoc.id)); + } + + payments.sort((a, b) => a.id.compareTo(b.id)); + + return payments; + } + + /// This updates the payment status to confirmed + /// and also sets lastUpdatedAt and lastUpdatedBy to the + /// current time and recipient + @override + Future confirmPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + }) async { + final updatedPayment = payment.copyWith( + status: PaymentStatus.confirmed, + updatedBy: recipient.userId, + ); + + await firestore + .collection(recipientCollection) + .doc(recipient.userId) + .collection(paymentCollection) + .doc(payment.id) + .update(updatedPayment.toJson()); + } + + @override + Future contestPayment({ + required Recipient recipient, + required SocialIncomePayment payment, + required String contestReason, + }) async { + final updatedPayment = payment.copyWith( + status: PaymentStatus.contested, + comments: contestReason, + updatedBy: recipient.userId, + ); + + await firestore + .collection(recipientCollection) + .doc(recipient.userId) + .collection(paymentCollection) + .doc(payment.id) + .update(updatedPayment.toJson()); + } +} diff --git a/recipients_app/lib/data/datasource/remote/survey_remote_data_source.dart b/recipients_app/lib/data/datasource/remote/survey_remote_data_source.dart new file mode 100644 index 000000000..7b835fa7c --- /dev/null +++ b/recipients_app/lib/data/datasource/remote/survey_remote_data_source.dart @@ -0,0 +1,39 @@ +import "package:app/data/datasource/remote/user_remote_data_source.dart"; +import "package:app/data/datasource/survey_data_source.dart"; +import "package:app/data/models/survey/survey.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; + +const String surveyCollection = "surveys"; + +class SurveyRemoteDataSource implements SurveyDataSource { + final FirebaseFirestore firestore; + + const SurveyRemoteDataSource({ + required this.firestore, + }); + + @override + Future> fetchSurveys({ + required String recipientId, + }) async { + final surveys = []; + + final surveysDocs = await firestore + .collection(recipientCollection) + .doc(recipientId) + .collection(surveyCollection) + .get(); + + for (final surveyDoc in surveysDocs.docs) { + final survey = Survey.fromJson(surveyDoc.data()); + + surveys.add( + survey.copyWith(id: surveyDoc.id), + ); + } + + surveys.sort((a, b) => a.id.compareTo(b.id)); + + return surveys; + } +} diff --git a/recipients_app/lib/data/datasource/remote/user_remote_data_source.dart b/recipients_app/lib/data/datasource/remote/user_remote_data_source.dart new file mode 100644 index 000000000..2c1b913b1 --- /dev/null +++ b/recipients_app/lib/data/datasource/remote/user_remote_data_source.dart @@ -0,0 +1,92 @@ +import "dart:developer"; +import "package:app/data/datasource/user_data_source.dart"; +import "package:app/data/models/recipient.dart"; +import "package:cloud_firestore/cloud_firestore.dart"; +import "package:firebase_auth/firebase_auth.dart"; + +const String recipientCollection = "/recipients"; + +class UserRemoteDataSource implements UserDataSource { + final FirebaseFirestore firestore; + final FirebaseAuth firebaseAuth; + + const UserRemoteDataSource({ + required this.firestore, + required this.firebaseAuth, + }); + + @override + Stream authStateChanges() => firebaseAuth.authStateChanges(); + + @override + User? get currentUser => firebaseAuth.currentUser; + + + /// Fetches the user data by userId from firestore and maps it to a recipient object + /// Returns null if the user does not exist. + @override + Future fetchRecipient(User firebaseUser) async { + final phoneNumber = firebaseUser.phoneNumber ?? ""; + + final matchingUsers = await firestore + .collection(recipientCollection) + .where( + "mobile_money_phone.phone", + isEqualTo: int.parse(phoneNumber.substring(1)), + ) + .get(); + + if (matchingUsers.docs.isEmpty) { + return null; + } + + final userSnapshot = matchingUsers.docs.firstOrNull; + + // This doesnt work because user id from firebaseAuth is not related to user id from firestore + // Needs to be discussed if changes should be made or not + // final userSnapshot = + // await firestore.collection("/recipients").doc(firebaseUser.uid).get(); + + if (userSnapshot != null && userSnapshot.exists) { + return Recipient.fromMap(userSnapshot.data()).copyWith( + userId: userSnapshot.id, + ); + } else { + return null; + } + } + + @override + Future verifyPhoneNumber({ + required String phoneNumber, + required Function(String, int?) onCodeSend, + required Function(FirebaseAuthException) onVerificationFailed, + required Function(PhoneAuthCredential) onVerificationCompleted, + required int? forceResendingToken, + }) async { + await firebaseAuth.verifyPhoneNumber( + phoneNumber: phoneNumber, + forceResendingToken: forceResendingToken, + timeout: const Duration(seconds: 60), + verificationCompleted: (credential) => onVerificationCompleted(credential), + verificationFailed: (ex) => onVerificationFailed(ex), + codeSent: (verificationId, forceResendingToken) => onCodeSend(verificationId, forceResendingToken), + codeAutoRetrievalTimeout: (verificationId) { + log("auto-retrieval timeout"); + }, + ); + } + + @override + Future signOut() => firebaseAuth.signOut(); + + @override + Future signInWithCredential(PhoneAuthCredential credentials) => firebaseAuth.signInWithCredential(credentials); + + @override + Future updateRecipient(Recipient recipient) async { + final updatedRecipient = recipient.copyWith(updatedBy: recipient.userId); + + return firestore.collection(recipientCollection).doc(recipient.userId).update(updatedRecipient.toJson()); + } +} diff --git a/recipients_app/lib/data/datasource/survey_data_source.dart b/recipients_app/lib/data/datasource/survey_data_source.dart new file mode 100644 index 000000000..ab10bbe2e --- /dev/null +++ b/recipients_app/lib/data/datasource/survey_data_source.dart @@ -0,0 +1,7 @@ +import "package:app/data/models/survey/survey.dart"; + +abstract class SurveyDataSource { + Future> fetchSurveys({ + required String recipientId, + }); +} diff --git a/recipients_app/lib/data/datasource/user_data_source.dart b/recipients_app/lib/data/datasource/user_data_source.dart new file mode 100644 index 000000000..647d8168e --- /dev/null +++ b/recipients_app/lib/data/datasource/user_data_source.dart @@ -0,0 +1,18 @@ +import "package:app/data/models/recipient.dart"; +import "package:firebase_auth/firebase_auth.dart"; + +abstract class UserDataSource { + Stream authStateChanges(); + User? get currentUser; + Future fetchRecipient(User firebaseUser); + Future verifyPhoneNumber({ + required String phoneNumber, + required Function(String, int?) onCodeSend, + required Function(FirebaseAuthException) onVerificationFailed, + required Function(PhoneAuthCredential) onVerificationCompleted, + required int? forceResendingToken, + }); + Future signOut(); + Future signInWithCredential(PhoneAuthCredential credentials); + Future updateRecipient(Recipient recipient); +} diff --git a/recipients_app/lib/data/models/recipient.dart b/recipients_app/lib/data/models/recipient.dart index 96bbe3641..ed054cba2 100644 --- a/recipients_app/lib/data/models/recipient.dart +++ b/recipients_app/lib/data/models/recipient.dart @@ -78,10 +78,6 @@ class Recipient extends Equatable { @JsonKey(name: "successor") final String? successorName; - // this should be got from `/recipients//payments` collection - @JsonKey(includeFromJson: false, includeToJson: false) - final List? payments; - const Recipient({ required this.userId, required this.communicationMobilePhone, @@ -102,7 +98,6 @@ class Recipient extends Equatable { this.imLinkRegular, this.nextSurvey, this.organizationRef, - this.payments = const [], this.updatedBy, this.successorName, }); @@ -129,7 +124,6 @@ class Recipient extends Equatable { imLinkRegular, nextSurvey, organizationRef, - payments, updatedBy, successorName, ]; @@ -155,7 +149,6 @@ class Recipient extends Equatable { String? imLinkRegular, Timestamp? nextSurvey, DocumentReference? organizationRef, - List? payments, String? updatedBy, String? successorName, }) { @@ -180,7 +173,6 @@ class Recipient extends Equatable { imLinkRegular: imLinkRegular ?? this.imLinkRegular, nextSurvey: nextSurvey ?? this.nextSurvey, organizationRef: organizationRef ?? this.organizationRef, - payments: payments ?? this.payments, updatedBy: updatedBy ?? this.updatedBy, successorName: successorName ?? this.successorName, ); diff --git a/recipients_app/lib/data/repositories/organization_repository.dart b/recipients_app/lib/data/repositories/organization_repository.dart index d1aad7833..b95b94655 100644 --- a/recipients_app/lib/data/repositories/organization_repository.dart +++ b/recipients_app/lib/data/repositories/organization_repository.dart @@ -1,24 +1,27 @@ +import "package:app/data/datasource/demo/organization_demo_data_source.dart"; +import "package:app/data/datasource/organization_data_source.dart"; +import "package:app/data/datasource/remote/organization_remote_data_source.dart"; import "package:app/data/models/organization.dart"; +import "package:app/demo_manager.dart"; import "package:cloud_firestore/cloud_firestore.dart"; class OrganizationRepository { + late OrganizationDataSource remoteDataSource = OrganizationRemoteDataSource(firestore: firestore); + late OrganizationDataSource demoDataSource = OrganizationDemoDataSource(); + + final DemoManager demoManager; final FirebaseFirestore firestore; - const OrganizationRepository({ + OrganizationRepository({ required this.firestore, + required this.demoManager, }); + OrganizationDataSource get _activeDataSource => demoManager.isDemoEnabled ? demoDataSource : remoteDataSource; + Future fetchOrganization( DocumentReference organizationRef, ) async { - final organization = organizationRef.withConverter( - fromFirestore: (snapshot, _) { - final data = snapshot.data()!; - return Organization.fromJson(data); - }, - toFirestore: (organization, _) => organization.toJson(), - ); - - return (await organization.get()).data(); + return _activeDataSource.fetchOrganization(organizationRef); } } diff --git a/recipients_app/lib/data/repositories/payment_repository.dart b/recipients_app/lib/data/repositories/payment_repository.dart index bf5b67f28..78893d503 100644 --- a/recipients_app/lib/data/repositories/payment_repository.dart +++ b/recipients_app/lib/data/repositories/payment_repository.dart @@ -1,58 +1,38 @@ +import "package:app/data/datasource/demo/payment_demo_data_source.dart"; +import "package:app/data/datasource/payment_data_source.dart"; +import "package:app/data/datasource/remote/payment_remote_data_source.dart"; import "package:app/data/models/models.dart"; -import "package:app/data/repositories/repositories.dart"; +import "package:app/demo_manager.dart"; import "package:cloud_firestore/cloud_firestore.dart"; -const String paymentCollection = "payments"; - class PaymentRepository { + late PaymentDataSource remoteDataSource = PaymentRemoteDataSource(firestore: firestore); + late PaymentDataSource demoDataSource = PaymentDemoDataSource(); + + final DemoManager demoManager; final FirebaseFirestore firestore; - const PaymentRepository({ + PaymentRepository({ required this.firestore, + required this.demoManager, }); + PaymentDataSource get _activeDataSource => demoManager.isDemoEnabled ? demoDataSource : remoteDataSource; + Future> fetchPayments({ required String recipientId, }) async { - final List payments = []; - - final paymentsDocs = await firestore - .collection(recipientCollection) - .doc(recipientId) - .collection(paymentCollection) - .get(); - - for (final paymentDoc in paymentsDocs.docs) { - final payment = SocialIncomePayment.fromJson( - paymentDoc.data(), - ); - - payments.add(payment.copyWith(id: paymentDoc.id)); - } - - payments.sort((a, b) => a.id.compareTo(b.id)); - - return payments; + return _activeDataSource.fetchPayments(recipientId: recipientId); } - /// This updates the payment status to confirmed - /// and also sets lastUpdatedAt and lastUpdatedBy to the - /// current time and recipient Future confirmPayment({ required Recipient recipient, required SocialIncomePayment payment, }) async { - final updatedPayment = payment.copyWith( - status: PaymentStatus.confirmed, - updatedBy: recipient.userId, + await _activeDataSource.confirmPayment( + recipient: recipient, + payment: payment, ); - - await firestore - .collection(recipientCollection) - .doc(recipient.userId) - .collection(paymentCollection) - .doc(payment.id) - .update(updatedPayment.toJson()); } Future contestPayment({ @@ -60,17 +40,10 @@ class PaymentRepository { required SocialIncomePayment payment, required String contestReason, }) async { - final updatedPayment = payment.copyWith( - status: PaymentStatus.contested, - comments: contestReason, - updatedBy: recipient.userId, + await _activeDataSource.contestPayment( + recipient: recipient, + payment: payment, + contestReason: contestReason, ); - - await firestore - .collection(recipientCollection) - .doc(recipient.userId) - .collection(paymentCollection) - .doc(payment.id) - .update(updatedPayment.toJson()); } } diff --git a/recipients_app/lib/data/repositories/survey_repository.dart b/recipients_app/lib/data/repositories/survey_repository.dart index 5031038f7..5c6e73fbd 100644 --- a/recipients_app/lib/data/repositories/survey_repository.dart +++ b/recipients_app/lib/data/repositories/survey_repository.dart @@ -1,38 +1,28 @@ +import "package:app/data/datasource/demo/survey_demo_data_source.dart"; +import "package:app/data/datasource/remote/survey_remote_data_source.dart"; +import "package:app/data/datasource/survey_data_source.dart"; import "package:app/data/models/models.dart"; import "package:app/data/models/survey/survey.dart"; -import "package:app/data/repositories/repositories.dart"; +import "package:app/demo_manager.dart"; import "package:cloud_firestore/cloud_firestore.dart"; -const String surveyCollection = "surveys"; - class SurveyRepository { + late SurveyDataSource remoteDataSource = SurveyRemoteDataSource(firestore: firestore); + late SurveyDataSource demoDataSource = SurveyDemoDataSource(); + + final DemoManager demoManager; final FirebaseFirestore firestore; - const SurveyRepository({ + SurveyRepository({ required this.firestore, + required this.demoManager, }); + SurveyDataSource get _activeDataSource => demoManager.isDemoEnabled ? demoDataSource : remoteDataSource; + Future> fetchSurveys({ required String recipientId, }) async { - final surveys = []; - - final surveysDocs = await firestore - .collection(recipientCollection) - .doc(recipientId) - .collection(surveyCollection) - .get(); - - for (final surveyDoc in surveysDocs.docs) { - final survey = Survey.fromJson(surveyDoc.data()); - - surveys.add( - survey.copyWith(id: surveyDoc.id), - ); - } - - surveys.sort((a, b) => a.id.compareTo(b.id)); - - return surveys; + return _activeDataSource.fetchSurveys(recipientId: recipientId); } } diff --git a/recipients_app/lib/data/repositories/user_repository.dart b/recipients_app/lib/data/repositories/user_repository.dart index 102bd9cc1..21b6e52fb 100644 --- a/recipients_app/lib/data/repositories/user_repository.dart +++ b/recipients_app/lib/data/repositories/user_repository.dart @@ -1,94 +1,80 @@ -import "dart:developer"; +import "dart:async"; +import "package:app/data/datasource/demo/user_demo_data_source.dart"; +import "package:app/data/datasource/remote/user_remote_data_source.dart"; +import "package:app/data/datasource/user_data_source.dart"; import "package:app/data/models/models.dart"; -import "package:app/data/repositories/repositories.dart"; +import "package:app/demo_manager.dart"; import "package:cloud_firestore/cloud_firestore.dart"; import "package:firebase_auth/firebase_auth.dart"; -const String recipientCollection = "/recipients"; - class UserRepository { + late UserDataSource remoteDataSource = UserRemoteDataSource(firestore: firestore, firebaseAuth: firebaseAuth); + late UserDataSource demoDataSource = UserDemoDataSource(); + + final DemoManager demoManager; final FirebaseFirestore firestore; final FirebaseAuth firebaseAuth; - const UserRepository({ + UserRepository({ required this.firestore, required this.firebaseAuth, + required this.demoManager, }); - Stream authStateChanges() => firebaseAuth.authStateChanges(); - User? get currentUser => firebaseAuth.currentUser; - - /// Fetches the user data by userId from firestore and maps it to a recipient object - /// Returns null if the user does not exist. - Future fetchRecipient(User firebaseUser) async { - final phoneNumber = firebaseUser.phoneNumber ?? ""; - - final matchingUsers = await firestore - .collection(recipientCollection) - .where( - "mobile_money_phone.phone", - isEqualTo: int.parse(phoneNumber.substring(1)), - ) - .get(); - - if (matchingUsers.docs.isEmpty) { - return null; - } - - final userSnapshot = matchingUsers.docs.firstOrNull; - - // This doesnt work because user id from firebaseAuth is not related to user id from firestore - // Needs to be discussed if changes should be made or not - // final userSnapshot = - // await firestore.collection("/recipients").doc(firebaseUser.uid).get(); - - if (userSnapshot != null && userSnapshot.exists) { - // TODO: decide if we should keep it in user object in the app at all - final payments = - await PaymentRepository(firestore: firestore).fetchPayments(recipientId: userSnapshot.id); - - return Recipient.fromMap(userSnapshot.data()).copyWith( - payments: payments, - userId: userSnapshot.id, - ); - } else { - return null; - } + UserDataSource get _activeDataSource => demoManager.isDemoEnabled ? demoDataSource : remoteDataSource; + + Stream authStateChanges() { + final StreamController authStateController = StreamController(); + StreamSubscription? authStateSubscription; + + authStateSubscription = _activeDataSource.authStateChanges().listen((authState) { + authStateController.add(authState); + }); + + // Start listen on demo mode changes, so we can update the authStateSubscription if the active data source is changing. + demoManager.isDemoEnabledStream.listen((isDemoMode) { + authStateSubscription?.cancel(); + + authStateSubscription = _activeDataSource.authStateChanges().listen((authState) { + authStateController.add(authState); + }); + + authStateController.onCancel = () { + authStateSubscription?.cancel(); + }; + }); + + return authStateController.stream; } + User? get currentUser => _activeDataSource.currentUser; + + Future fetchRecipient(User firebaseUser) => _activeDataSource.fetchRecipient(firebaseUser); + Future verifyPhoneNumber({ required String phoneNumber, required Function(String, int?) onCodeSend, required Function(FirebaseAuthException) onVerificationFailed, required Function(PhoneAuthCredential) onVerificationCompleted, required int? forceResendingToken, - }) async { - await firebaseAuth.verifyPhoneNumber( - phoneNumber: phoneNumber, - forceResendingToken: forceResendingToken, - timeout: const Duration(seconds: 60), - verificationCompleted: (credential) => onVerificationCompleted(credential), - verificationFailed: (ex) => onVerificationFailed(ex), - codeSent: (verificationId, forceResendingToken) => - onCodeSend(verificationId, forceResendingToken), - codeAutoRetrievalTimeout: (e) { - log("auto-retrieval timeout"); - }, - ); - } + }) => + _activeDataSource.verifyPhoneNumber( + phoneNumber: phoneNumber, + onCodeSend: onCodeSend, + onVerificationFailed: onVerificationFailed, + onVerificationCompleted: onVerificationCompleted, + forceResendingToken: forceResendingToken, + ); - Future signOut() => firebaseAuth.signOut(); + Future signOut() { + return _activeDataSource.signOut().whenComplete(() { + demoManager.isDemoEnabled = false; + }); + } Future signInWithCredential(PhoneAuthCredential credentials) => - firebaseAuth.signInWithCredential(credentials); + _activeDataSource.signInWithCredential(credentials); - Future updateRecipient(Recipient recipient) async { - final updatedRecipient = recipient.copyWith(updatedBy: recipient.userId); - - return firestore - .collection(recipientCollection) - .doc(recipient.userId) - .update(updatedRecipient.toJson()); - } + Future updateRecipient(Recipient recipient) => _activeDataSource.updateRecipient(recipient); } diff --git a/recipients_app/lib/demo_manager.dart b/recipients_app/lib/demo_manager.dart new file mode 100644 index 000000000..24a6870ed --- /dev/null +++ b/recipients_app/lib/demo_manager.dart @@ -0,0 +1,26 @@ +import "dart:async"; + +class DemoManager { + factory DemoManager() { + return _instance; + } + + DemoManager._privateConstructor() { + _isDemoEnabled = false; + _controller.add(false); + } + + static final DemoManager _instance = DemoManager._privateConstructor(); + + bool _isDemoEnabled = false; + + final _controller = StreamController.broadcast(); + Stream get isDemoEnabledStream => _controller.stream; + + set isDemoEnabled(bool value) { + _isDemoEnabled = value; + _controller.add(_isDemoEnabled); + } + + bool get isDemoEnabled => _isDemoEnabled; +} diff --git a/recipients_app/lib/l10n/app_en.arb b/recipients_app/lib/l10n/app_en.arb index e5da358be..a6d415fd4 100644 --- a/recipients_app/lib/l10n/app_en.arb +++ b/recipients_app/lib/l10n/app_en.arb @@ -276,5 +276,7 @@ "missingRecaptchaVersion": "Missing reCAPTCHA version", "invalidRecaptchaVersion": "Invalid reCAPTCHA version", "invalidReqType": "Invalid request type", - "errorEmailInvalid": "Invalid email address" + "errorEmailInvalid": "Invalid email address", + "demoCta": "Demo", + "createAccountDemo": "Enter demo" } diff --git a/recipients_app/lib/l10n/app_kri.arb b/recipients_app/lib/l10n/app_kri.arb index bedc1eb70..dfd9fb81c 100644 --- a/recipients_app/lib/l10n/app_kri.arb +++ b/recipients_app/lib/l10n/app_kri.arb @@ -176,5 +176,7 @@ "invalidVerificationCodeError": "Di spɛshal kod we wi sɛn yu nɔ kɔrɛkt. Duya chɛk di SMS kod ɛn tray bak.", "userDisabledError": "Wi dɔn lɔk yu akawnt. Rich awt to wi if yu want fɔ no mɔ.", "invalidCredentialError": " Di fon nɔmba ɔ di spɛshal kod we wi sɛn to yu in tɛm dɔn pas fɔ yuz. Duya tray bak.", - "errorEmailInvalid": "Imel nɔ kɔrɛkt" + "errorEmailInvalid": "Imel nɔ kɔrɛkt", + "demoCta": "Demo", + "createAccountDemo": "Enta di demo" } diff --git a/recipients_app/lib/main.dart b/recipients_app/lib/main.dart index 9447f5890..6fa31ab22 100644 --- a/recipients_app/lib/main.dart +++ b/recipients_app/lib/main.dart @@ -1,4 +1,5 @@ import "package:app/core/helpers/custom_bloc_observer.dart"; +import "package:app/demo_manager.dart"; import "package:app/my_app.dart"; import "package:cloud_firestore/cloud_firestore.dart"; import "package:firebase_app_check/firebase_app_check.dart"; @@ -31,6 +32,7 @@ Future main() async { final firestore = FirebaseFirestore.instance; final firebaseAuth = FirebaseAuth.instance; final messaging = FirebaseMessaging.instance; + final demoManager = DemoManager(); if (appFlavor == "dev") { firestore.useFirestoreEmulator("localhost", 8080); @@ -52,6 +54,7 @@ Future main() async { firebaseAuth: firebaseAuth, firestore: firestore, messaging: messaging, + demoManager: demoManager, ), ), ); diff --git a/recipients_app/lib/my_app.dart b/recipients_app/lib/my_app.dart index 278ad5ad9..ab2c0988f 100644 --- a/recipients_app/lib/my_app.dart +++ b/recipients_app/lib/my_app.dart @@ -1,6 +1,7 @@ import "package:app/core/cubits/auth/auth_cubit.dart"; import "package:app/core/cubits/settings/settings_cubit.dart"; import "package:app/data/repositories/repositories.dart"; +import "package:app/demo_manager.dart"; import "package:app/kri_intl.dart"; import "package:app/ui/configs/configs.dart"; import "package:app/view/pages/main_app_page.dart"; @@ -19,12 +20,14 @@ class MyApp extends StatelessWidget { final FirebaseAuth firebaseAuth; final FirebaseFirestore firestore; final FirebaseMessaging messaging; + final DemoManager demoManager; const MyApp({ super.key, required this.firebaseAuth, required this.firestore, required this.messaging, + required this.demoManager, }); // This widget is the root of your application. @@ -32,6 +35,9 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiRepositoryProvider( providers: [ + RepositoryProvider( + create: (context) => demoManager, + ), RepositoryProvider( create: (context) => MessagingRepository( messaging: messaging, @@ -41,6 +47,7 @@ class MyApp extends StatelessWidget { create: (context) => UserRepository( firebaseAuth: firebaseAuth, firestore: firestore, + demoManager: demoManager, ), ), RepositoryProvider( @@ -49,16 +56,19 @@ class MyApp extends StatelessWidget { RepositoryProvider( create: (context) => PaymentRepository( firestore: firestore, + demoManager: demoManager, ), ), RepositoryProvider( create: (context) => SurveyRepository( firestore: firestore, + demoManager: demoManager, ), ), RepositoryProvider( create: (context) => OrganizationRepository( firestore: firestore, + demoManager: demoManager, ), ), ], diff --git a/recipients_app/lib/view/pages/terms_and_conditions_page.dart b/recipients_app/lib/view/pages/terms_and_conditions_page.dart index 6e669c2c0..ed4fb74de 100644 --- a/recipients_app/lib/view/pages/terms_and_conditions_page.dart +++ b/recipients_app/lib/view/pages/terms_and_conditions_page.dart @@ -1,5 +1,6 @@ import "package:app/core/cubits/auth/auth_cubit.dart"; import "package:app/core/helpers/flushbar_helper.dart"; +import "package:app/demo_manager.dart"; import "package:app/ui/buttons/buttons.dart"; import "package:app/ui/configs/configs.dart"; import "package:flutter/gestures.dart"; @@ -15,6 +16,7 @@ class TermsAndConditionsPage extends StatelessWidget { @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; + final demoManager = RepositoryProvider.of(context); return Scaffold( appBar: AppBar( @@ -95,7 +97,7 @@ class TermsAndConditionsPage extends StatelessWidget { context.read().updateRecipient(updated); } }, - label: localizations.createAccount, + label: demoManager.isDemoEnabled ? localizations.createAccountDemo : localizations.createAccount, ), ], ), diff --git a/recipients_app/lib/view/widgets/dialogs/social_income_contact_dialog.dart b/recipients_app/lib/view/widgets/dialogs/social_income_contact_dialog.dart index 98a04e7a8..a4abd377c 100644 --- a/recipients_app/lib/view/widgets/dialogs/social_income_contact_dialog.dart +++ b/recipients_app/lib/view/widgets/dialogs/social_income_contact_dialog.dart @@ -31,7 +31,7 @@ class SocialIncomeContactDialog extends StatelessWidget { children: [ Image( image: const AssetImage("assets/team.png"), - width: MediaQuery.of(context).size.width * 0.6, + width: MediaQuery.sizeOf(context).width * 0.6, ), Text( localizations.supportTeam, diff --git a/recipients_app/lib/view/widgets/welcome/phone_input_page.dart b/recipients_app/lib/view/widgets/welcome/phone_input_page.dart index e70c834b1..78d100045 100644 --- a/recipients_app/lib/view/widgets/welcome/phone_input_page.dart +++ b/recipients_app/lib/view/widgets/welcome/phone_input_page.dart @@ -1,4 +1,5 @@ import "package:app/core/cubits/signup/signup_cubit.dart"; +import "package:app/demo_manager.dart"; import "package:app/ui/buttons/buttons.dart"; import "package:app/ui/configs/app_colors.dart"; import "package:flutter/material.dart"; @@ -36,7 +37,7 @@ class _PhoneInputPageState extends State { @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; - + final demoManager = RepositoryProvider.of(context); final isLoading = context.watch().state.status == SignupStatus.loadingPhoneNumber; return Scaffold( @@ -45,6 +46,18 @@ class _PhoneInputPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + Align( + alignment: Alignment.topRight, + child: SafeArea( + child: ButtonSmall( + onPressed: () { + demoManager.isDemoEnabled = true; + }, + label: localizations.demoCta, + buttonType: ButtonSmallType.outlined, + ), + ), + ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -52,7 +65,7 @@ class _PhoneInputPageState extends State { children: [ Image( image: const AssetImage("assets/earth_animation.gif"), - height: MediaQuery.of(context).size.height * 0.3, + height: MediaQuery.sizeOf(context).height * 0.3, ), const SizedBox(height: 16), Text( @@ -97,10 +110,8 @@ class _PhoneInputPageState extends State { ), inputDecoration: InputDecoration( labelText: localizations.phoneNumber, - labelStyle: Theme.of(context) - .textTheme - .headlineMedium! - .copyWith(color: AppColors.primaryColor), + labelStyle: + Theme.of(context).textTheme.headlineMedium!.copyWith(color: AppColors.primaryColor), enabledBorder: const OutlineInputBorder( borderSide: BorderSide(color: AppColors.primaryColor), borderRadius: BorderRadius.all(Radius.circular(10)),