From 0af6414fb7d1629ce18876c94aaeee92d8b31bd5 Mon Sep 17 00:00:00 2001 From: Amisha Date: Fri, 29 Mar 2024 18:52:59 +0530 Subject: [PATCH] Add expense repo and store --- Data/Data.xcodeproj/project.pbxproj | 6 +- Data/Data/DI/AppAssembly.swift | 12 +++ Data/Data/Model/Expense.swift | 14 ++-- Data/Data/Repository/ExpenseRepository.swift | 18 ++++- Data/Data/Repository/GroupRepository.swift | 2 +- Data/Data/Store/ExpenseStore.swift | 80 +++++++++++++++++++ Data/Data/Store/GroupStore.swift | 8 +- Data/Data/Store/ShareCodeStore.swift | 2 +- Splito/UI/Home/Expense/AddExpenseView.swift | 71 +++++++++------- .../UI/Home/Expense/AddExpenseViewModel.swift | 42 +++++++--- Splito/UI/Home/HomeRouteView.swift | 32 +------- 11 files changed, 208 insertions(+), 79 deletions(-) create mode 100644 Data/Data/Store/ExpenseStore.swift diff --git a/Data/Data.xcodeproj/project.pbxproj b/Data/Data.xcodeproj/project.pbxproj index 9412250c..81e51544 100644 --- a/Data/Data.xcodeproj/project.pbxproj +++ b/Data/Data.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ D85E86DE2BAB0292002EDF76 /* Expense.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86DD2BAB0292002EDF76 /* Expense.swift */; }; D85E86E52BAB088F002EDF76 /* ExpenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86E42BAB088F002EDF76 /* ExpenseRepository.swift */; }; D88721432B99F133009DC5BE /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88721422B99F133009DC5BE /* StorageManager.swift */; }; + D8910E382BB6D1D300877CE0 /* ExpenseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */; }; D89DBE1D2B872F0B00E5F1BD /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89DBE1C2B872F0B00E5F1BD /* NonceGenerator.swift */; }; D89DBE282B88802800E5F1BD /* Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89DBE272B88802800E5F1BD /* Country.swift */; }; D89DBE2B2B88817E00E5F1BD /* JSONUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89DBE2A2B88817E00E5F1BD /* JSONUtils.swift */; }; @@ -58,6 +59,7 @@ D85E86DD2BAB0292002EDF76 /* Expense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Expense.swift; sourceTree = ""; }; D85E86E42BAB088F002EDF76 /* ExpenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpenseRepository.swift; sourceTree = ""; }; D88721422B99F133009DC5BE /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; + D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpenseStore.swift; sourceTree = ""; }; D89DBE1C2B872F0B00E5F1BD /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; D89DBE272B88802800E5F1BD /* Country.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Country.swift; sourceTree = ""; }; D89DBE2A2B88817E00E5F1BD /* JSONUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONUtils.swift; sourceTree = ""; }; @@ -191,6 +193,7 @@ D8AC25BA2B7F327A00CEAAD3 /* SplitoPreference.swift */, D8A7CA742BA5AB670014EC67 /* UserStore.swift */, D8A7CA762BA5AB800014EC67 /* GroupStore.swift */, + D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */, D8A7CA7A2BA5B6AC0014EC67 /* ShareCodeStore.swift */, ); path = Store; @@ -259,8 +262,8 @@ D89DBE512B8DC4ED00E5F1BD /* Router */, D8AC25BF2B7F38C800CEAAD3 /* DI */, D89DBE292B88817200E5F1BD /* Utils */, - D8AC25B92B7F326B00CEAAD3 /* Store */, D89DBE262B88801F00E5F1BD /* Model */, + D8AC25B92B7F326B00CEAAD3 /* Store */, D83B15072B99976F004A5F4F /* Repository */, D8A7CA7E2BA867C80014EC67 /* Extension */, D89DBE4B2B8CBEB500E5F1BD /* Services */, @@ -470,6 +473,7 @@ D89DBE2B2B88817E00E5F1BD /* JSONUtils.swift in Sources */, D8A7CA802BA867F80014EC67 /* String+Extension.swift in Sources */, D89DBE282B88802800E5F1BD /* Country.swift in Sources */, + D8910E382BB6D1D300877CE0 /* ExpenseStore.swift in Sources */, D83B15092B999789004A5F4F /* GroupRepository.swift in Sources */, D89DBE532B8DC9F700E5F1BD /* AppRoute.swift in Sources */, D8A7CA752BA5AB670014EC67 /* UserStore.swift in Sources */, diff --git a/Data/Data/DI/AppAssembly.swift b/Data/Data/DI/AppAssembly.swift index b494b511..352ed977 100644 --- a/Data/Data/DI/AppAssembly.swift +++ b/Data/Data/DI/AppAssembly.swift @@ -31,6 +31,8 @@ public class AppAssembly: Assembly { StorageManager.init() }.inObjectScope(.container) + // MARK: - Stores + container.register(UserStore.self) { _ in UserStore.init() }.inObjectScope(.container) @@ -43,6 +45,12 @@ public class AppAssembly: Assembly { ShareCodeStore.init() }.inObjectScope(.container) + container.register(ExpenseStore.self) { _ in + ExpenseStore.init() + }.inObjectScope(.container) + + // MARK: - Repositories + container.register(UserRepository.self) { _ in UserRepository.init() }.inObjectScope(.container) @@ -54,5 +62,9 @@ public class AppAssembly: Assembly { container.register(ShareCodeRepository.self) { _ in ShareCodeRepository.init() }.inObjectScope(.container) + + container.register(ExpenseRepository.self) { _ in + ExpenseRepository.init() + }.inObjectScope(.container) } } diff --git a/Data/Data/Model/Expense.swift b/Data/Data/Model/Expense.swift index 1e5aaea3..1964f100 100644 --- a/Data/Data/Model/Expense.swift +++ b/Data/Data/Model/Expense.swift @@ -13,17 +13,20 @@ public struct Expense: Codable { let name: String let amount: Double - let date: Date - let paidBy: AppUser - let splitTo: [String] // Reference to users involved in the split + let date: Timestamp + let paidBy: String + let splitTo: [String] // Reference to user ids involved in the split + let groupId: String let splitType: SplitType - public init(name: String, amount: Double, date: Date, paidBy: AppUser, splitTo: [String], splitType: SplitType) { + public init(name: String, amount: Double, date: Timestamp, paidBy: String, + splitTo: [String], groupId: String, splitType: SplitType = .equally) { self.name = name self.amount = amount self.date = date self.paidBy = paidBy self.splitTo = splitTo + self.groupId = groupId self.splitType = splitType } @@ -31,8 +34,9 @@ public struct Expense: Codable { case name case amount case date - case paidBy = "paied_by" + case paidBy = "paid_by" case splitTo = "split_to" + case groupId = "group_id" case splitType = "split_type" } } diff --git a/Data/Data/Repository/ExpenseRepository.swift b/Data/Data/Repository/ExpenseRepository.swift index d8e9b788..083b9413 100644 --- a/Data/Data/Repository/ExpenseRepository.swift +++ b/Data/Data/Repository/ExpenseRepository.swift @@ -5,4 +5,20 @@ // Created by Amisha Italiya on 20/03/24. // -import Foundation +import Combine +import FirebaseFirestoreInternal + +public class ExpenseRepository: ObservableObject { + + @Inject private var store: ExpenseStore + + private var cancelable = Set() + + public func addExpense(expense: Expense, completion: @escaping (String?) -> Void) { + store.addExpense(expense: expense, completion: completion) + } + + public func deleteExpense(id: String) -> AnyPublisher { + store.deleteExpense(id: id) + } +} diff --git a/Data/Data/Repository/GroupRepository.swift b/Data/Data/Repository/GroupRepository.swift index 9489ec81..38ad670c 100644 --- a/Data/Data/Repository/GroupRepository.swift +++ b/Data/Data/Repository/GroupRepository.swift @@ -56,7 +56,7 @@ public class GroupRepository: ObservableObject { Future { [weak self] promise in guard let self else { return } - self.store.fetchGroups(userId: userId) + self.store.fetchGroups() .sink { completion in if case .failure(let error) = completion { promise(.failure(error)) diff --git a/Data/Data/Store/ExpenseStore.swift b/Data/Data/Store/ExpenseStore.swift new file mode 100644 index 00000000..b678b54a --- /dev/null +++ b/Data/Data/Store/ExpenseStore.swift @@ -0,0 +1,80 @@ +// +// ExpenseStore.swift +// Data +// +// Created by Amisha Italiya on 29/03/24. +// + +import Combine +import FirebaseFirestoreInternal + +public class ExpenseStore: ObservableObject { + + @Inject private var database: Firestore + + private let DATABASE_NAME: String = "expenses" + + public func addExpense(expense: Expense, completion: @escaping (String?) -> Void) { + do { + let code = try database.collection(DATABASE_NAME).addDocument(from: expense) + completion(code.documentID) + return + } catch { + LogE("ExpenseRepository :: \(#function) error: \(error.localizedDescription)") + } + completion(nil) + } + + public func fetchExpensesBy(groupId: String) -> AnyPublisher<[Expense], ServiceError> { + return Future { [weak self] promise in + guard let self = self else { + promise(.failure(.unexpectedError)) + return + } + + self.database.collection(DATABASE_NAME).whereField("group_id", isEqualTo: groupId).getDocuments { snapshot, error in + if let error = error { + LogE("ExpenseRepository :: \(#function) error: \(error.localizedDescription)") + promise(.failure(.networkError)) + return + } + + guard let snapshot, !snapshot.documents.isEmpty else { + LogD("ExpenseRepository :: \(#function) The document is not available.") + promise(.success([])) + return + } + + do { + let expenses = try snapshot.documents.compactMap { document in + try document.data(as: Expense.self) + } + promise(.success(expenses)) + } catch { + LogE("ExpenseRepository :: \(#function) Decode error: \(error.localizedDescription)") + promise(.failure(.decodingError)) + } + } + } + .eraseToAnyPublisher() + } + + public func deleteExpense(id: String) -> AnyPublisher { + Future { [weak self] promise in + guard let self else { + promise(.failure(.unexpectedError)) + return + } + + self.database.collection(self.DATABASE_NAME).document(id).delete { error in + if let error { + LogE("ExpenseRepository :: \(#function): Deleting collection failed with error: \(error.localizedDescription).") + promise(.failure(.databaseError)) + } else { + LogD("ExpenseRepository :: \(#function): expense deleted successfully.") + promise(.success(())) + } + } + }.eraseToAnyPublisher() + } +} diff --git a/Data/Data/Store/GroupStore.swift b/Data/Data/Store/GroupStore.swift index 6f0b01a2..8e4c7e9c 100644 --- a/Data/Data/Store/GroupStore.swift +++ b/Data/Data/Store/GroupStore.swift @@ -42,7 +42,7 @@ class GroupStore: ObservableObject { }.eraseToAnyPublisher() } - func fetchGroups(userId: String) -> AnyPublisher<[Groups], ServiceError> { + func fetchGroups() -> AnyPublisher<[Groups], ServiceError> { Future { [weak self] promise in guard let self else { promise(.failure(.unexpectedError)) @@ -56,9 +56,9 @@ class GroupStore: ObservableObject { return } - guard let snapshot else { - LogE("GroupStore :: \(#function) The document is not available.") - promise(.failure(.databaseError)) + guard let snapshot, !snapshot.documents.isEmpty else { + LogD("GroupStore :: \(#function) The document is not available.") + promise(.success([])) return } diff --git a/Data/Data/Store/ShareCodeStore.swift b/Data/Data/Store/ShareCodeStore.swift index b706f3b8..7ea24f84 100644 --- a/Data/Data/Store/ShareCodeStore.swift +++ b/Data/Data/Store/ShareCodeStore.swift @@ -57,7 +57,7 @@ public class ShareCodeStore: ObservableObject { } public func deleteSharedCode(documentId: String) -> AnyPublisher { - return Future { [weak self] promise in + Future { [weak self] promise in guard let self else { promise(.failure(.unexpectedError)) return diff --git a/Splito/UI/Home/Expense/AddExpenseView.swift b/Splito/UI/Home/Expense/AddExpenseView.swift index e46dff3c..9d156469 100644 --- a/Splito/UI/Home/Expense/AddExpenseView.swift +++ b/Splito/UI/Home/Expense/AddExpenseView.swift @@ -16,27 +16,33 @@ struct AddExpenseView: View { var body: some View { VStack(spacing: 25) { - GroupSelectionView(name: viewModel.selectedGroup?.name ?? "Group") { - viewModel.showGroupSelection = true - } + if case .loading = viewModel.currentViewState { + LoaderView(tintColor: primaryColor, scaleSize: 2) + } else { + GroupSelectionView(name: viewModel.selectedGroup?.name ?? "Group") { + viewModel.showGroupSelection = true + } - VStack(spacing: 16) { - ExpenseDetailRow(imageName: "note.text", placeholder: "Enter a description", - text: $viewModel.expenseName, date: $viewModel.expenseDate) - ExpenseDetailRow(imageName: "indianrupeesign.square", placeholder: "0.00", - text: $viewModel.expenseAmount, date: $viewModel.expenseDate, keyboardType: .numberPad) - ExpenseDetailRow(imageName: "calendar", placeholder: "Expense date", forDatePicker: true, - text: .constant(""), date: $viewModel.expenseDate) - } - .padding(.trailing, 20) + VStack(spacing: 16) { + ExpenseDetailRow(imageName: "note.text", placeholder: "Enter a description", + name: $viewModel.expenseName, amount: .constant(0), date: $viewModel.expenseDate) + ExpenseDetailRow(imageName: "indianrupeesign.square", placeholder: "0.00", + name: .constant(""), amount: $viewModel.expenseAmount, date: $viewModel.expenseDate, keyboardType: .numberPad) + ExpenseDetailRow(imageName: "calendar", placeholder: "Expense date", forDatePicker: true, + name: .constant(""), amount: .constant(0), date: $viewModel.expenseDate) + } + .padding(.trailing, 20) - PaidByView(payerName: viewModel.payerName) { - viewModel.showPayerSelection = viewModel.selectedGroup != nil + PaidByView(payerName: viewModel.payerName) { + viewModel.showPayerSelection = viewModel.selectedGroup != nil + } } } .padding(.horizontal, 20) .background(backgroundColor) .navigationBarTitle("Add an expense", displayMode: .inline) + .toastView(toast: $viewModel.toast) + .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .sheet(isPresented: $viewModel.showGroupSelection) { ChooseGroupView(viewModel: ChooseGroupViewModel(selectedGroup: viewModel.selectedGroup) { group in viewModel.selectedGroup = group @@ -50,17 +56,15 @@ struct AddExpenseView: View { } .toolbar { ToolbarItem(placement: .topBarLeading) { - Button { + Button("Cancel") { dismiss() - } label: { - Text("Cancel") } } ToolbarItem(placement: .topBarTrailing) { - Button { - dismiss() - } label: { - Text("Save") + Button("Add") { + viewModel.saveExpense { + dismiss() + } } .foregroundColor(primaryColor) } @@ -68,13 +72,19 @@ struct AddExpenseView: View { } } -struct ExpenseDetailRow: View { +private struct ExpenseDetail: Codable { + var name: String + var amount: Double +} + +private struct ExpenseDetailRow: View { var imageName: String var placeholder: String var forDatePicker: Bool = false - @Binding var text: String + @Binding var name: String + @Binding var amount: Double @Binding var date: Date var keyboardType: UIKeyboardType = .default @@ -96,9 +106,14 @@ struct ExpenseDetailRow: View { .font(.subTitle2()) } else { VStack { - TextField(placeholder, text: $text) - .font(.subTitle2()) - .keyboardType(keyboardType) + if keyboardType == .default { + TextField(placeholder, text: $name) + .font(.subTitle2()) + } else { + TextField("Amount", value: $amount, formatter: NumberFormatter()) + .font(.subTitle2()) + .keyboardType(keyboardType) + } Divider() .background(Color.gray) @@ -109,7 +124,7 @@ struct ExpenseDetailRow: View { } } -struct GroupSelectionView: View { +private struct GroupSelectionView: View { var name: String var onTap: () -> Void @@ -138,7 +153,7 @@ struct GroupSelectionView: View { } } -struct PaidByView: View { +private struct PaidByView: View { let payerName: String var onTap: () -> Void diff --git a/Splito/UI/Home/Expense/AddExpenseViewModel.swift b/Splito/UI/Home/Expense/AddExpenseViewModel.swift index dd2e7f5b..e14637c4 100644 --- a/Splito/UI/Home/Expense/AddExpenseViewModel.swift +++ b/Splito/UI/Home/Expense/AddExpenseViewModel.swift @@ -5,17 +5,24 @@ // Created by Amisha Italiya on 20/03/24. // -import Combine import Data +import Combine +import BaseStyle +import FirebaseFirestoreInternal class AddExpenseViewModel: BaseViewModel, ObservableObject { @Inject var preference: SplitoPreference + @Inject var expenseRepository: ExpenseRepository @Published var expenseName = "" - @Published var expenseAmount = "" + @Published var expenseAmount = 0.0 @Published var expenseDate = Date() + @Published var showGroupSelection = false + @Published var showPayerSelection = false + @Published var currentViewState: ViewState = .initial + @Published var payerName = "You" @Published var selectedGroup: Groups? @@ -25,17 +32,11 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { } } - @Published var showGroupSelection = false - @Published var showPayerSelection = false - - @Published var openForGroupSelection = false - private let router: Router init(router: Router) { self.router = router super.init() - updatePayerName() } @@ -47,8 +48,29 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { } } - // AddExpenseView Actions - func saveExpense() { + func saveExpense(completion: @escaping () -> Void) { + + if expenseName == "" || expenseAmount == 0 || selectedGroup == nil || selectedPayer == nil { + showToastFor(toast: ToastPrompt(type: .warning, title: "Warning", message: "Please fill all data to add expense.")) + return + } + + guard let selectedGroup, let selectedPayer, let groupId = selectedGroup.id else { return } + + currentViewState = .loading + let expense = Expense(name: expenseName, amount: expenseAmount, date: Timestamp(date: expenseDate), + paidBy: selectedPayer.id, splitTo: selectedGroup.members, groupId: groupId) + + expenseRepository.addExpense(expense: expense) { _ in + self.currentViewState = .initial + completion() + } + } +} +extension AddExpenseViewModel { + enum ViewState { + case initial + case loading } } diff --git a/Splito/UI/Home/HomeRouteView.swift b/Splito/UI/Home/HomeRouteView.swift index 276aa128..6566014c 100644 --- a/Splito/UI/Home/HomeRouteView.swift +++ b/Splito/UI/Home/HomeRouteView.swift @@ -16,32 +16,17 @@ struct HomeRouteView: View { var body: some View { ZStack { TabView { - HomeView() - .tabItem { - Label("Friends", systemImage: "person") - } - .tag(0) - GroupRouteView() .tabItem { Label("Groups", systemImage: "person.2") } - .tag(1) - - InvisibleView() - .hidden() - - HomeView() - .tabItem { - Label("Activity", systemImage: "chart.line.uptrend.xyaxis.circle") - } - .tag(3) + .tag(0) HomeView() .tabItem { Label("Account", systemImage: "person.crop.square") } - .tag(4) + .tag(1) } .tint(primaryColor) .overlay( @@ -56,18 +41,9 @@ struct HomeRouteView: View { } } -struct InvisibleView: View { - var body: some View { - Color.clear // Invisible color - .contentShape(Rectangle()) // Intercepts taps - .allowsHitTesting(false) // Disables hit testing - .disabled(true) - } -} - struct CenterFabButton: View { - var onclick: () -> Void + var onClick: () -> Void var body: some View { VStack { @@ -76,7 +52,7 @@ struct CenterFabButton: View { Spacer() Button { - onclick() + onClick() } label: { Image(systemName: "plus.circle.fill") .resizable()