diff --git a/BaseStyle/BaseStyle.xcodeproj/project.pbxproj b/BaseStyle/BaseStyle.xcodeproj/project.pbxproj index 50eaec79..5ba2124d 100644 --- a/BaseStyle/BaseStyle.xcodeproj/project.pbxproj +++ b/BaseStyle/BaseStyle.xcodeproj/project.pbxproj @@ -603,7 +603,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -663,7 +663,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -693,7 +693,7 @@ INFOPLIST_FILE = BaseStyle/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -731,7 +731,7 @@ INFOPLIST_FILE = BaseStyle/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Data/Data.xcodeproj/project.pbxproj b/Data/Data.xcodeproj/project.pbxproj index e95cd49a..81e51544 100644 --- a/Data/Data.xcodeproj/project.pbxproj +++ b/Data/Data.xcodeproj/project.pbxproj @@ -11,7 +11,10 @@ 7EF3A291581F7EA20CB1042D /* Pods_Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E91B3E23688435064A60C0C4 /* Pods_Data.framework */; }; D83B15052B9996C0004A5F4F /* Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83B15042B9996C0004A5F4F /* Groups.swift */; }; D83B15092B999789004A5F4F /* GroupRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83B15082B999789004A5F4F /* GroupRepository.swift */; }; + 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 */; }; @@ -22,7 +25,6 @@ D89DBE532B8DC9F700E5F1BD /* AppRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89DBE522B8DC9F700E5F1BD /* AppRoute.swift */; }; D8A7CA752BA5AB670014EC67 /* UserStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A7CA742BA5AB670014EC67 /* UserStore.swift */; }; D8A7CA772BA5AB800014EC67 /* GroupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A7CA762BA5AB800014EC67 /* GroupStore.swift */; }; - D8A7CA792BA5AB920014EC67 /* MemberStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A7CA782BA5AB920014EC67 /* MemberStore.swift */; }; D8A7CA7B2BA5B6AC0014EC67 /* ShareCodeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A7CA7A2BA5B6AC0014EC67 /* ShareCodeStore.swift */; }; D8A7CA802BA867F80014EC67 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A7CA7F2BA867F80014EC67 /* String+Extension.swift */; }; D8AC25BB2B7F327A00CEAAD3 /* SplitoPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC25BA2B7F327A00CEAAD3 /* SplitoPreference.swift */; }; @@ -31,8 +33,6 @@ D8AC25C32B7F390B00CEAAD3 /* AppAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC25C22B7F390B00CEAAD3 /* AppAssembly.swift */; }; D8D14A522BA0917D00F45FF2 /* SharedCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D14A512BA0917D00F45FF2 /* SharedCode.swift */; }; D8D14A542BA092F500F45FF2 /* ShareCodeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D14A532BA092F400F45FF2 /* ShareCodeRepository.swift */; }; - D8D14A5A2BA1E6E400F45FF2 /* MemberRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D14A592BA1E6E400F45FF2 /* MemberRepository.swift */; }; - D8D14A5C2BA1E73600F45FF2 /* Member.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D14A5B2BA1E73600F45FF2 /* Member.swift */; }; D8D42AA92B872726009B345D /* DDLogProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D42AA82B872726009B345D /* DDLogProvider.swift */; }; D8DF8BBF2B7A315100165138 /* Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8DF8BB62B7A315000165138 /* Data.framework */; }; D8DF8BC42B7A315100165138 /* DataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DF8BC32B7A315100165138 /* DataTests.swift */; }; @@ -56,7 +56,10 @@ BED6C37AA3F8FD2A6350DE1C /* Pods-Data.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Data.debug.xcconfig"; path = "Target Support Files/Pods-Data/Pods-Data.debug.xcconfig"; sourceTree = ""; }; D83B15042B9996C0004A5F4F /* Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Groups.swift; sourceTree = ""; }; D83B15082B999789004A5F4F /* GroupRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupRepository.swift; sourceTree = ""; }; + 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 = ""; }; @@ -67,7 +70,6 @@ D89DBE522B8DC9F700E5F1BD /* AppRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoute.swift; sourceTree = ""; }; D8A7CA742BA5AB670014EC67 /* UserStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStore.swift; sourceTree = ""; }; D8A7CA762BA5AB800014EC67 /* GroupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStore.swift; sourceTree = ""; }; - D8A7CA782BA5AB920014EC67 /* MemberStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberStore.swift; sourceTree = ""; }; D8A7CA7A2BA5B6AC0014EC67 /* ShareCodeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCodeStore.swift; sourceTree = ""; }; D8A7CA7F2BA867F80014EC67 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; D8AC25BA2B7F327A00CEAAD3 /* SplitoPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitoPreference.swift; sourceTree = ""; }; @@ -76,8 +78,6 @@ D8AC25C22B7F390B00CEAAD3 /* AppAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembly.swift; sourceTree = ""; }; D8D14A512BA0917D00F45FF2 /* SharedCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedCode.swift; sourceTree = ""; }; D8D14A532BA092F400F45FF2 /* ShareCodeRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCodeRepository.swift; sourceTree = ""; }; - D8D14A592BA1E6E400F45FF2 /* MemberRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberRepository.swift; sourceTree = ""; }; - D8D14A5B2BA1E73600F45FF2 /* Member.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Member.swift; sourceTree = ""; }; D8D42A9E2B870BBA009B345D /* BaseStyle.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BaseStyle.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D8D42AA82B872726009B345D /* DDLogProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDLogProvider.swift; sourceTree = ""; }; D8DF8BB62B7A315000165138 /* Data.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Data.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -136,7 +136,7 @@ children = ( D89DBE452B8CBE0F00E5F1BD /* UserRepository.swift */, D83B15082B999789004A5F4F /* GroupRepository.swift */, - D8D14A592BA1E6E400F45FF2 /* MemberRepository.swift */, + D85E86E42BAB088F002EDF76 /* ExpenseRepository.swift */, D8D14A532BA092F400F45FF2 /* ShareCodeRepository.swift */, ); path = Repository; @@ -148,7 +148,7 @@ D89DBE272B88802800E5F1BD /* Country.swift */, D89DBE472B8CBE4C00E5F1BD /* AppUser.swift */, D83B15042B9996C0004A5F4F /* Groups.swift */, - D8D14A5B2BA1E73600F45FF2 /* Member.swift */, + D85E86DD2BAB0292002EDF76 /* Expense.swift */, D8D14A512BA0917D00F45FF2 /* SharedCode.swift */, ); path = Model; @@ -193,7 +193,7 @@ D8AC25BA2B7F327A00CEAAD3 /* SplitoPreference.swift */, D8A7CA742BA5AB670014EC67 /* UserStore.swift */, D8A7CA762BA5AB800014EC67 /* GroupStore.swift */, - D8A7CA782BA5AB920014EC67 /* MemberStore.swift */, + D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */, D8A7CA7A2BA5B6AC0014EC67 /* ShareCodeStore.swift */, ); path = Store; @@ -262,8 +262,8 @@ D89DBE512B8DC4ED00E5F1BD /* Router */, D8AC25BF2B7F38C800CEAAD3 /* DI */, D89DBE292B88817200E5F1BD /* Utils */, - D8AC25B92B7F326B00CEAAD3 /* Store */, D89DBE262B88801F00E5F1BD /* Model */, + D8AC25B92B7F326B00CEAAD3 /* Store */, D83B15072B99976F004A5F4F /* Repository */, D8A7CA7E2BA867C80014EC67 /* Extension */, D89DBE4B2B8CBEB500E5F1BD /* Services */, @@ -473,16 +473,16 @@ 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 */, - D8D14A5A2BA1E6E400F45FF2 /* MemberRepository.swift in Sources */, D89DBE532B8DC9F700E5F1BD /* AppRoute.swift in Sources */, D8A7CA752BA5AB670014EC67 /* UserStore.swift in Sources */, D89DBE402B8C9E7400E5F1BD /* RouterView.swift in Sources */, + D85E86E52BAB088F002EDF76 /* ExpenseRepository.swift in Sources */, D8A7CA772BA5AB800014EC67 /* GroupStore.swift in Sources */, D8AC25BE2B7F359B00CEAAD3 /* FirebaseProvider.swift in Sources */, D89DBE1D2B872F0B00E5F1BD /* NonceGenerator.swift in Sources */, - D8D14A5C2BA1E73600F45FF2 /* Member.swift in Sources */, - D8A7CA792BA5AB920014EC67 /* MemberStore.swift in Sources */, + D85E86DE2BAB0292002EDF76 /* Expense.swift in Sources */, D8D42AA92B872726009B345D /* DDLogProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -559,7 +559,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -619,7 +619,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -648,7 +648,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -746,7 +746,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Data/Data/DI/AppAssembly.swift b/Data/Data/DI/AppAssembly.swift index 98e8aca2..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) @@ -39,14 +41,16 @@ public class AppAssembly: Assembly { GroupStore.init() }.inObjectScope(.container) - container.register(MemberStore.self) { _ in - MemberStore.init() - }.inObjectScope(.container) - container.register(ShareCodeStore.self) { _ in ShareCodeStore.init() }.inObjectScope(.container) + container.register(ExpenseStore.self) { _ in + ExpenseStore.init() + }.inObjectScope(.container) + + // MARK: - Repositories + container.register(UserRepository.self) { _ in UserRepository.init() }.inObjectScope(.container) @@ -55,12 +59,12 @@ public class AppAssembly: Assembly { GroupRepository.init() }.inObjectScope(.container) - container.register(MemberRepository.self) { _ in - MemberRepository.init() - }.inObjectScope(.container) - 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 new file mode 100644 index 00000000..1964f100 --- /dev/null +++ b/Data/Data/Model/Expense.swift @@ -0,0 +1,46 @@ +// +// Expense.swift +// Data +// +// Created by Amisha Italiya on 20/03/24. +// + +import FirebaseFirestore + +public struct Expense: Codable { + + @DocumentID public var id: String? // Automatically generated ID by Firestore + + let name: String + let amount: Double + 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: 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 + } + + enum CodingKeys: String, CodingKey { + case name + case amount + case date + case paidBy = "paid_by" + case splitTo = "split_to" + case groupId = "group_id" + case splitType = "split_type" + } +} + +public enum SplitType: String, Codable { + case equally +} diff --git a/Data/Data/Model/Groups.swift b/Data/Data/Model/Groups.swift index 18028f5f..5b8c4e86 100644 --- a/Data/Data/Model/Groups.swift +++ b/Data/Data/Model/Groups.swift @@ -13,11 +13,11 @@ public struct Groups: Codable, Identifiable { public var name: String public var createdBy: String - public var members: [Member] + public var members: [String] public var imageUrl: String? public var createdAt: Timestamp - public init(name: String, createdBy: String, members: [Member], imageUrl: String? = nil, createdAt: Timestamp) { + public init(name: String, createdBy: String, members: [String], imageUrl: String? = nil, createdAt: Timestamp) { self.name = name self.createdBy = createdBy self.members = members diff --git a/Data/Data/Model/Member.swift b/Data/Data/Model/Member.swift deleted file mode 100644 index 1e594386..00000000 --- a/Data/Data/Model/Member.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Member.swift -// Data -// -// Created by Amisha Italiya on 13/03/24. -// - -import FirebaseFirestore - -public struct Member: Codable { - - @DocumentID public var id: String? // Automatically generated ID by Firestore - - public var userId: String - public var groupId: String - public var totalBalance: Double? - public var owesToOthers: Double? - public var owedByOthers: Double? - - public init(userId: String, groupId: String, totalBalance: Double? = nil, owesToOthers: Double? = nil, owedByOthers: Double? = nil) { - self.userId = userId - self.groupId = groupId - self.totalBalance = totalBalance - self.owesToOthers = owesToOthers - self.owedByOthers = owedByOthers - } - - enum CodingKeys: String, CodingKey { - case id - case userId = "user_id" - case groupId = "group_id" - case totalBalance = "total_balance" - case owesToOthers = "owes_to_others" - case owedByOthers = "owed_by_others" - } -} diff --git a/Data/Data/Repository/ExpenseRepository.swift b/Data/Data/Repository/ExpenseRepository.swift new file mode 100644 index 00000000..083b9413 --- /dev/null +++ b/Data/Data/Repository/ExpenseRepository.swift @@ -0,0 +1,24 @@ +// +// ExpenseRepository.swift +// Data +// +// Created by Amisha Italiya on 20/03/24. +// + +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 a3d33f10..38ad670c 100644 --- a/Data/Data/Repository/GroupRepository.swift +++ b/Data/Data/Repository/GroupRepository.swift @@ -12,113 +12,109 @@ public class GroupRepository: ObservableObject { @Inject private var store: GroupStore @Inject private var preference: SplitoPreference + @Inject private var userRepository: UserRepository @Inject private var storageManager: StorageManager - @Inject private var memberRepository: MemberRepository - private var cancelables = Set() + private var cancelable = Set() public func createGroup(group: Groups, imageData: Data?) -> AnyPublisher { Future { [weak self] promise in + guard let self else { promise(.failure(.unexpectedError)); return } - guard let self else { return } - - self.createGroupInStore(group: group) - .flatMap { docId -> AnyPublisher<(String, Member), ServiceError> in - return self.addCreatorToMembers(groupId: docId) - .map { (docId, $0) } - .eraseToAnyPublisher() + self.store.createGroup(group: group) { docId in + guard let docId else { + promise(.failure(.databaseError)) + return } - .flatMap { docId, member -> AnyPublisher in + + if let imageData { var newGroup = group newGroup.id = docId - newGroup.members.append(member) - return self.finalizeGroupCreation(group: newGroup, imageData: imageData) + self.uploadImage(imageData: imageData, group: newGroup) + .sink { completion in + switch completion { + case .finished: + return + case .failure(let error): + promise(.failure(error)) + } + } receiveValue: { _ in + promise(.success(docId)) + }.store(in: &self.cancelable) + } else { + promise(.success(docId)) } + } + }.eraseToAnyPublisher() + } + + public func fetchGroupBy(id: String) -> AnyPublisher { + store.fetchGroupBy(id: id) + } + + public func fetchGroups(userId: String) -> AnyPublisher<[Groups], ServiceError> { + Future { [weak self] promise in + guard let self else { return } + + self.store.fetchGroups() .sink { completion in - if case let .failure(error) = completion { + if case .failure(let error) = completion { promise(.failure(error)) } - } receiveValue: { docId in - promise(.success(docId)) - } - .store(in: &self.cancelables) - } - .eraseToAnyPublisher() + } receiveValue: { groups in + // Show only those groups in which the user is part of + let filteredGroups = groups.filter { $0.createdBy == userId || $0.members.contains { $0 == userId } } + promise(.success(filteredGroups)) + }.store(in: &self.cancelable) + }.eraseToAnyPublisher() } - private func createGroupInStore(group: Groups) -> AnyPublisher { - Future { [weak self] promise in + public func addMemberToGroup(memberId: String, groupId: String) -> AnyPublisher { + return fetchGroupBy(id: groupId) + .flatMap { group -> AnyPublisher in + guard let group else { return Fail(error: .dataNotFound).eraseToAnyPublisher() } - guard let self else { - promise(.failure(.unexpectedError)) - return - } + var newGroup = group + newGroup.members.append(memberId) - self.store.createGroup(group: group) { docId in - guard let docId else { - promise(.failure(.databaseError)) - return - } - promise(.success(docId)) + return self.updateGroup(group: newGroup) } - }.eraseToAnyPublisher() + .eraseToAnyPublisher() } - private func addCreatorToMembers(groupId: String) -> AnyPublisher { - Future { [weak self] promise in - - guard let self, let userId = self.preference.user?.id else { return } - var member = Member(userId: userId, groupId: groupId) + public func fetchMembersBy(groupId: String) -> AnyPublisher<[AppUser], ServiceError> { + fetchGroupBy(id: groupId) + .flatMap { group -> AnyPublisher<[AppUser], ServiceError> in + guard let group else { + return Fail(error: .dataNotFound).eraseToAnyPublisher() + } - self.memberRepository.addMemberToMembers(member: member) { memberId in - if let memberId { - member.id = memberId - promise(.success(member)) - } else { - promise(.failure(.databaseError)) + // Create a publisher for each member ID and fetch user data + let memberPublishers = group.members.map { (userId: String) -> AnyPublisher in + return self.fetchMemberBy(userId: userId) } + + return Publishers.MergeMany(memberPublishers) + .compactMap { $0 } + .collect() + .mapError { $0 } + .eraseToAnyPublisher() } - }.eraseToAnyPublisher() + .eraseToAnyPublisher() } - private func finalizeGroupCreation(group: Groups, imageData: Data?) -> AnyPublisher { - Future { [weak self] promise in - guard let self, let groupId = group.id else { return } - if let imageData { - self.uploadImage(imageData: imageData, group: group) - .sink { completion in - switch completion { - case .finished: - return - case .failure(let error): - promise(.failure(error)) - } - } receiveValue: { _ in - promise(.success(groupId)) - }.store(in: &self.cancelables) - } else { - self.updateGroup(group: group) - .sink { completion in - switch completion { - case .finished: - return - case .failure(let error): - promise(.failure(error)) - } - } receiveValue: { _ in - promise(.success(groupId)) - }.store(in: &self.cancelables) - } - }.eraseToAnyPublisher() + public func fetchMemberBy(userId: String) -> AnyPublisher { + userRepository.fetchUserBy(userID: userId) + } + + public func updateGroup(group: Groups) -> AnyPublisher { + store.updateGroup(group: group) } private func uploadImage(imageData: Data, group: Groups) -> AnyPublisher { Future { [weak self] promise in - guard let self, let groupId = group.id else { - promise(.failure(.unexpectedError)) - return - } + guard let self, let groupId = group.id else { promise(.failure(.unexpectedError)); return } self.storageManager.uploadImage(for: .group, id: groupId, imageData: imageData) { url in guard let url else { @@ -139,68 +135,8 @@ public class GroupRepository: ObservableObject { } } receiveValue: { _ in promise(.success(())) - }.store(in: &self.cancelables) - } - }.eraseToAnyPublisher() - } - - public func addMemberToGroup(groupId: String, memberId: String) -> AnyPublisher { - Future { [weak self] promise in - guard let self else { - promise(.failure(.unexpectedError)) - return + }.store(in: &self.cancelable) } - - self.fetchGroupBy(id: groupId) - .flatMap { group -> AnyPublisher<(Groups?, Member?), ServiceError> in - return self.fetchMemberWith(id: memberId) - .map { (group, $0) } - .eraseToAnyPublisher() - } - .flatMap { group, member -> AnyPublisher in - guard var group, let member else { - return Fail(error: .dataNotFound).eraseToAnyPublisher() - } - group.members.append(member) - return self.updateGroup(group: group) - } - .sink { completion in - if case let .failure(error) = completion { - promise(.failure(error)) - } - } receiveValue: { _ in - promise(.success(groupId)) - } - .store(in: &self.cancelables) - }.eraseToAnyPublisher() - } - - public func fetchMemberWith(id: String) -> AnyPublisher { - Future { [weak self] promise in - guard let self else { return } - self.memberRepository.fetchMemberBy(id: id) - .sink { completion in - switch completion { - case .failure(let error): - promise(.failure(error)) - case .finished: - break - } - } receiveValue: { member in - promise(.success(member)) - }.store(in: &self.cancelables) }.eraseToAnyPublisher() } - - public func updateGroup(group: Groups) -> AnyPublisher { - store.updateGroup(group: group) - } - - public func fetchGroups(userId: String) -> AnyPublisher<[Groups], ServiceError> { - store.fetchGroups(userId: userId) - } - - public func fetchGroupBy(id: String) -> AnyPublisher { - store.fetchGroupBy(id: id) - } } diff --git a/Data/Data/Repository/MemberRepository.swift b/Data/Data/Repository/MemberRepository.swift deleted file mode 100644 index f8ea1141..00000000 --- a/Data/Data/Repository/MemberRepository.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// MemberRepository.swift -// Data -// -// Created by Amisha Italiya on 13/03/24. -// - -import Combine - -public class MemberRepository: ObservableObject { - - @Inject private var store: MemberStore - - @Inject var preference: SplitoPreference - @Inject var codeRepository: ShareCodeRepository - - private var cancelables = Set() - - public func addMemberToMembers(member: Member, completion: @escaping (String?) -> Void) { - store.addMember(member: member, completion: completion) - } - - public func updateMember(member: Member) -> AnyPublisher { - store.updateMember(member: member) - } - - public func fetchMemberBy(id: String) -> AnyPublisher { - store.fetchMemberBy(id: id) - } -} diff --git a/Data/Data/Repository/ShareCodeRepository.swift b/Data/Data/Repository/ShareCodeRepository.swift index 66d67b92..ec161e22 100644 --- a/Data/Data/Repository/ShareCodeRepository.swift +++ b/Data/Data/Repository/ShareCodeRepository.swift @@ -9,9 +9,11 @@ import Combine public class ShareCodeRepository: ObservableObject { + public let CODE_EXPIRATION_LIMIT = 2 /// Limit for code expiration, in days. + @Inject private var store: ShareCodeStore - private var cancelables = Set() + private var cancelable = Set() public func addSharedCode(sharedCode: SharedCode, completion: @escaping (String?) -> Void) { store.addSharedCode(sharedCode: sharedCode, completion: completion) @@ -36,7 +38,6 @@ public class ShareCodeRepository: ObservableObject { } } receiveValue: { code in completion(code == nil) - }.store(in: &cancelables) - + }.store(in: &cancelable) } } diff --git a/Data/Data/Repository/UserRepository.swift b/Data/Data/Repository/UserRepository.swift index b41be6d4..32bedee4 100644 --- a/Data/Data/Repository/UserRepository.swift +++ b/Data/Data/Repository/UserRepository.swift @@ -11,48 +11,41 @@ public class UserRepository: ObservableObject { @Inject private var store: UserStore - private var cancelables = Set() - - public func storeUser(user: AppUser) -> AnyPublisher { - Future { [weak self] promise in - - guard let self else { - promise(.failure(.unexpectedError)) - return + private var cancelable = Set() + + public func storeUser(user: AppUser) -> AnyPublisher { + return self.store.fetchUsers() + .flatMap { [weak self] users -> AnyPublisher in + guard let self else { + return Fail(error: .unexpectedError).eraseToAnyPublisher() + } + + if let searchedUser = users.first(where: { $0.id == user.id }) { + return Just(searchedUser).setFailureType(to: ServiceError.self).eraseToAnyPublisher() + } else { + return self.store.addUser(user: user) + .mapError { error in + LogE("UserRepository :: \(#function) addUser failed, error: \(error.localizedDescription).") + return .databaseError + } + .map { _ in user } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } } + .eraseToAnyPublisher() + } - self.store.fetchUsers() - .sink { completion in - switch completion { - case .finished: - LogE("UserRepository :: \(#function) storeUser finished.") - case .failure(let error): - LogE("UserRepository :: \(#function) storeUser failed, error: \(error.localizedDescription).") - promise(.failure(error)) - } - } receiveValue: { [weak self] users in - guard let self else { return } - let searchedUser = users.first(where: { $0.id == user.id }) - - if searchedUser != nil { - promise(.success(())) - } else { - self.store.addUser(user: user) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - LogE("UserRepository :: \(#function) addUser failed, error: \(error.localizedDescription).") - promise(.failure(error)) - case .finished: - LogE("UserRepository :: \(#function) addUser finished.") - } - } receiveValue: { _ in - promise(.success(())) - }.store(in: &cancelables) - } - }.store(in: &cancelables) - }.eraseToAnyPublisher() + public func fetchUserBy(userID: String) -> AnyPublisher { + return self.store.fetchUsers() + .map { users -> AppUser? in + return users.first(where: { $0.id == userID }) + } + .mapError { error -> ServiceError in + LogE("UserRepository :: \(#function) fetchUserByID failed, error: \(error.localizedDescription).") + return .databaseError + } + .eraseToAnyPublisher() } public func updateUser(user: AppUser) -> AnyPublisher { diff --git a/Data/Data/Router/AppRoute.swift b/Data/Data/Router/AppRoute.swift index 341e7dd3..b1df37f6 100644 --- a/Data/Data/Router/AppRoute.swift +++ b/Data/Data/Router/AppRoute.swift @@ -30,6 +30,9 @@ public enum AppRoute: Hashable { case InviteMemberView(groupId: String) case JoinMemberView + // MARK: - Expense Button + case AddExpenseView + // MARK: - Activity Tab case ActivityHomeView @@ -68,6 +71,9 @@ public enum AppRoute: Hashable { case .ProfileView: "userProfileView" + case .AddExpenseView: + "addExpenseView" + case .AccountHomeView: "accountHomeView" } 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/MemberStore.swift b/Data/Data/Store/MemberStore.swift deleted file mode 100644 index adde9fa2..00000000 --- a/Data/Data/Store/MemberStore.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// MemberStore.swift -// Data -// -// Created by Amisha Italiya on 16/03/24. -// - -import Combine -import FirebaseFirestoreInternal - -class MemberStore: ObservableObject { - - private let DATABASE_NAME: String = "members" - - @Inject private var database: Firestore - - func addMember(member: Member, completion: @escaping (String?) -> Void) { - do { - let member = try database.collection(DATABASE_NAME).addDocument(from: member) - completion(member.documentID) - return - } catch { - LogE("MemberStore :: \(#function) error: \(error.localizedDescription)") - } - completion(nil) - } - - func updateMember(member: Member) -> AnyPublisher { - Future { [weak self] promise in - guard let self, let docID = member.id else { - promise(.failure(.unexpectedError)) - return - } - do { - try self.database.collection(self.DATABASE_NAME).document(docID).setData(from: member, merge: true) - promise(.success(())) - } catch { - LogE("MemberStore :: \(#function) error: \(error.localizedDescription)") - promise(.failure(.databaseError)) - } - }.eraseToAnyPublisher() - } - - func fetchMemberBy(id: String) -> AnyPublisher { - Future { [weak self] promise in - guard let self else { - promise(.failure(.unexpectedError)) - return - } - - self.database.collection(DATABASE_NAME).document(id).getDocument { snapshot, error in - if let error { - LogE("MemberStore :: \(#function) error: \(error.localizedDescription)") - promise(.failure(.unexpectedError)) - return - } - - guard let snapshot else { - LogE("MemberStore :: \(#function) The document is not available.") - promise(.failure(.databaseError)) - return - } - - do { - let member = try snapshot.data(as: Member.self) - promise(.success(member)) - } catch { - LogE("MemberStore :: \(#function) Decode error: \(error.localizedDescription)") - promise(.failure(.decodingError)) - } - } - }.eraseToAnyPublisher() - } -} 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/Podfile b/Podfile index 3a41e4df..41ee1923 100644 --- a/Podfile +++ b/Podfile @@ -1,5 +1,5 @@ # Uncomment the next line to define a global platform for your project -platform :ios, '16' +platform :ios, '16.4' workspace 'Splito.xcworkspace' project 'Splito.xcodeproj' diff --git a/Podfile.lock b/Podfile.lock index 16e27e82..fcea6245 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -862,6 +862,6 @@ SPEC CHECKSUMS: SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 577bc6b2afbbbf58b4a4b52c9694cbacdb9101c6 +PODFILE CHECKSUM: b966f24309b6f1584571edcb03ef8deb0c810bc2 COCOAPODS: 1.15.0 diff --git a/Splito.xcodeproj/project.pbxproj b/Splito.xcodeproj/project.pbxproj index 0c02e9d7..5b59f2db 100644 --- a/Splito.xcodeproj/project.pbxproj +++ b/Splito.xcodeproj/project.pbxproj @@ -12,6 +12,13 @@ 1AA0888605B3C007D521672B /* Pods_Splito_SplitoUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F43B44CC4843658DFC543F9 /* Pods_Splito_SplitoUITests.framework */; }; D8302DA02B9F282F005ACA13 /* InviteMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8302D9F2B9F282F005ACA13 /* InviteMemberView.swift */; }; D8302DA22B9F284D005ACA13 /* InviteMemberViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8302DA12B9F284D005ACA13 /* InviteMemberViewModel.swift */; }; + D85E86E02BAB06A3002EDF76 /* AddExpenseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86DF2BAB06A3002EDF76 /* AddExpenseView.swift */; }; + D85E86E32BAB06D9002EDF76 /* AddExpenseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86E22BAB06D9002EDF76 /* AddExpenseViewModel.swift */; }; + D85E86E72BB2E189002EDF76 /* ExpenseRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86E62BB2E189002EDF76 /* ExpenseRouteView.swift */; }; + D85E86E92BB3FD49002EDF76 /* ChoosePayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86E82BB3FD49002EDF76 /* ChoosePayerView.swift */; }; + D85E86EB2BB3FD59002EDF76 /* ChoosePayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86EA2BB3FD59002EDF76 /* ChoosePayerViewModel.swift */; }; + D85E86ED2BB41B87002EDF76 /* ChooseGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86EC2BB41B87002EDF76 /* ChooseGroupView.swift */; }; + D85E86EF2BB41B9F002EDF76 /* ChooseGroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85E86EE2BB41B9F002EDF76 /* ChooseGroupViewModel.swift */; }; D863857B2B908AB600A8C6EB /* VERSION in Resources */ = {isa = PBXBuildFile; fileRef = D863857A2B908AB600A8C6EB /* VERSION */; }; D88721452B9B2C78009DC5BE /* GroupListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88721442B9B2C78009DC5BE /* GroupListView.swift */; }; D88721472B9B2C97009DC5BE /* GroupListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88721462B9B2C97009DC5BE /* GroupListViewModel.swift */; }; @@ -106,6 +113,13 @@ D8015C082B7A47D80002886A /* UI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = UI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D8302D9F2B9F282F005ACA13 /* InviteMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteMemberView.swift; sourceTree = ""; }; D8302DA12B9F284D005ACA13 /* InviteMemberViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteMemberViewModel.swift; sourceTree = ""; }; + D85E86DF2BAB06A3002EDF76 /* AddExpenseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExpenseView.swift; sourceTree = ""; }; + D85E86E22BAB06D9002EDF76 /* AddExpenseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExpenseViewModel.swift; sourceTree = ""; }; + D85E86E62BB2E189002EDF76 /* ExpenseRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpenseRouteView.swift; sourceTree = ""; }; + D85E86E82BB3FD49002EDF76 /* ChoosePayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChoosePayerView.swift; sourceTree = ""; }; + D85E86EA2BB3FD59002EDF76 /* ChoosePayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChoosePayerViewModel.swift; sourceTree = ""; }; + D85E86EC2BB41B87002EDF76 /* ChooseGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseGroupView.swift; sourceTree = ""; }; + D85E86EE2BB41B9F002EDF76 /* ChooseGroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseGroupViewModel.swift; sourceTree = ""; }; D863857A2B908AB600A8C6EB /* VERSION */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = VERSION; sourceTree = ""; }; D863857E2B909A5A00A8C6EB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D88721442B9B2C78009DC5BE /* GroupListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupListView.swift; sourceTree = ""; }; @@ -240,6 +254,28 @@ path = "Add Member"; sourceTree = ""; }; + D85E86E12BAB06BC002EDF76 /* Expense */ = { + isa = PBXGroup; + children = ( + D85E86E62BB2E189002EDF76 /* ExpenseRouteView.swift */, + D85E86DF2BAB06A3002EDF76 /* AddExpenseView.swift */, + D85E86E22BAB06D9002EDF76 /* AddExpenseViewModel.swift */, + D85E86F02BB41CBA002EDF76 /* Detail Selection */, + ); + path = Expense; + sourceTree = ""; + }; + D85E86F02BB41CBA002EDF76 /* Detail Selection */ = { + isa = PBXGroup; + children = ( + D85E86EC2BB41B87002EDF76 /* ChooseGroupView.swift */, + D85E86EE2BB41B9F002EDF76 /* ChooseGroupViewModel.swift */, + D85E86E82BB3FD49002EDF76 /* ChoosePayerView.swift */, + D85E86EA2BB3FD59002EDF76 /* ChoosePayerViewModel.swift */, + ); + path = "Detail Selection"; + sourceTree = ""; + }; D89684382B722D3400D5F721 = { isa = PBXGroup; children = ( @@ -414,10 +450,11 @@ children = ( D8AC26E42B84B12800CEAAD3 /* HomeView.swift */, D89DBE412B8CA72700E5F1BD /* HomeRouteView.swift */, - D89DBE542B8DE8B200E5F1BD /* Friends */, D89DBE552B8DE8C600E5F1BD /* Groups */, - D89DBE562B8DE8CF00E5F1BD /* Activity */, + D85E86E12BAB06BC002EDF76 /* Expense */, D89DBE572B8DE8D400E5F1BD /* Account */, + D89DBE562B8DE8CF00E5F1BD /* Activity */, + D89DBE542B8DE8B200E5F1BD /* Friends */, ); path = Home; sourceTree = ""; @@ -719,12 +756,15 @@ files = ( D8D14A692BA3133500F45FF2 /* OnboardRouteView.swift in Sources */, D89DBE1F2B87327400E5F1BD /* SignInWithAppleDelegate.swift in Sources */, + D85E86EF2BB41B9F002EDF76 /* ChooseGroupViewModel.swift in Sources */, D8E244B72B972BE700C6C82A /* BaseViewModel.swift in Sources */, D8AC26F22B84B12800CEAAD3 /* MainRouteView.swift in Sources */, D89DBE592B8DE91D00E5F1BD /* FriendRouteView.swift in Sources */, + D85E86E02BAB06A3002EDF76 /* AddExpenseView.swift in Sources */, D88721472B9B2C97009DC5BE /* GroupListViewModel.swift in Sources */, D8D14A622BA2DCE700F45FF2 /* UserProfileViewModel.swift in Sources */, D8A7CA6E2BA483F60014EC67 /* GroupHomeView.swift in Sources */, + D85E86ED2BB41B87002EDF76 /* ChooseGroupView.swift in Sources */, D8302DA22B9F284D005ACA13 /* InviteMemberViewModel.swift in Sources */, D89DBE5D2B8DE97B00E5F1BD /* ActivityRouteView.swift in Sources */, D8E244BF2B98592C00C6C82A /* GroupHomeViewModel.swift in Sources */, @@ -738,12 +778,15 @@ D89DBE5F2B8DE98600E5F1BD /* AccountRouteView.swift in Sources */, D8D14A652BA2DD7300F45FF2 /* ProfileImageView.swift in Sources */, D8D14A672BA2E25100F45FF2 /* UserProfileList.swift in Sources */, + D85E86E72BB2E189002EDF76 /* ExpenseRouteView.swift in Sources */, D8302DA02B9F282F005ACA13 /* InviteMemberView.swift in Sources */, D89684452B722D3400D5F721 /* SplitoApp.swift in Sources */, D8AC26F72B84B12800CEAAD3 /* LoginView.swift in Sources */, + D85E86E32BAB06D9002EDF76 /* AddExpenseViewModel.swift in Sources */, D8A7CA702BA484370014EC67 /* GroupSettingView.swift in Sources */, D8A7CA722BA486250014EC67 /* GroupSettingViewModel.swift in Sources */, D8D14A4E2BA0688A00F45FF2 /* FloatingGroupMenu.swift in Sources */, + D85E86EB2BB3FD59002EDF76 /* ChoosePayerViewModel.swift in Sources */, D89DBE5B2B8DE97000E5F1BD /* GroupRouteView.swift in Sources */, D8AC27152B84B73000CEAAD3 /* PageControl.swift in Sources */, D8E244BB2B9843A100C6C82A /* CreateGroupView.swift in Sources */, @@ -754,6 +797,7 @@ D88721452B9B2C78009DC5BE /* GroupListView.swift in Sources */, D8D14A582BA189F800F45FF2 /* JoinMemberViewModel.swift in Sources */, D89DBE232B875C2200E5F1BD /* PhoneLoginView.swift in Sources */, + D85E86E92BB3FD49002EDF76 /* ChoosePayerView.swift in Sources */, D8AC26F62B84B12800CEAAD3 /* LoginViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -843,7 +887,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -900,7 +944,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -934,7 +978,7 @@ INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1038,7 +1082,7 @@ INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1129,7 +1173,7 @@ DEVELOPMENT_TEAM = S985H2T7J8; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.project.SplitoTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1151,7 +1195,7 @@ DEVELOPMENT_TEAM = S985H2T7J8; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.project.SplitoTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/CheckMark.png b/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/CheckMark.png new file mode 100644 index 00000000..a1076398 Binary files /dev/null and b/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/CheckMark.png differ diff --git a/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/Contents.json b/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/Contents.json new file mode 100644 index 00000000..9036ca7c --- /dev/null +++ b/Splito/Resource/Assets.xcassets/Images/Setting/CheckMarkTick.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "CheckMark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Splito/UI/BaseViewModel.swift b/Splito/UI/BaseViewModel.swift index 6b1613ad..e283b26e 100644 --- a/Splito/UI/BaseViewModel.swift +++ b/Splito/UI/BaseViewModel.swift @@ -18,7 +18,7 @@ open class BaseViewModel { @Published public var alert: AlertPrompt = .init(message: "") @Published public var showAlert: Bool = false - public var cancelables = Set() + public var cancelable = Set() public init() { } diff --git a/Splito/UI/Home/Expense/AddExpenseView.swift b/Splito/UI/Home/Expense/AddExpenseView.swift new file mode 100644 index 00000000..3db8b6bd --- /dev/null +++ b/Splito/UI/Home/Expense/AddExpenseView.swift @@ -0,0 +1,186 @@ +// +// AddExpenseView.swift +// Splito +// +// Created by Amisha Italiya on 20/03/24. +// + +import SwiftUI +import BaseStyle + +struct AddExpenseView: View { + + @ObservedObject var viewModel: AddExpenseViewModel + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 25) { + 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", + 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 + } + } + } + .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 + viewModel.selectedPayer = nil + }) + } + .sheet(isPresented: $viewModel.showPayerSelection) { + ChoosePayerView(viewModel: ChoosePayerViewModel(groupId: viewModel.selectedGroup?.id ?? "", selectedPayer: viewModel.selectedPayer) { payer in + viewModel.selectedPayer = payer + }) + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Add") { + viewModel.saveExpense { + dismiss() + } + } + .foregroundColor(primaryColor) + } + } + } +} + +private struct ExpenseDetailRow: View { + + var imageName: String + var placeholder: String + var forDatePicker: Bool = false + + @Binding var name: String + @Binding var amount: Double + @Binding var date: Date + + var keyboardType: UIKeyboardType = .default + + var body: some View { + HStack(spacing: 16) { + Image(systemName: imageName) + .resizable() + .foregroundColor(primaryText) + .frame(width: 32, height: 32) + .padding(12) + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 8).stroke(outlineColor, lineWidth: 1) + ) + + if forDatePicker { + DatePicker(placeholder, selection: $date, displayedComponents: .date) + .font(.subTitle2()) + } else { + VStack { + if keyboardType == .default { + TextField(placeholder, text: $name) + .font(.subTitle2()) + } else { + TextField("Amount", value: $amount, formatter: NumberFormatter()) + .font(.subTitle2()) + .keyboardType(keyboardType) + } + + Divider() + .background(Color.gray) + .frame(height: 1) + } + } + } + } +} + +private struct GroupSelectionView: View { + + var name: String + var onTap: () -> Void + + var body: some View { + HStack(spacing: 10) { + Text("You and: ") + .foregroundColor(primaryText) + + Button { + onTap() + } label: { + Text(name) + .font(.subTitle2()) + .foregroundColor(secondaryText) + } + .buttonStyle(.scale) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 20).stroke(outlineColor, lineWidth: 1) + ) + + Spacer() + } + } +} + +private struct PaidByView: View { + + let payerName: String + var onTap: () -> Void + + var body: some View { + HStack(spacing: 10) { + Text("Paid by") + .font(.subTitle2()) + .foregroundColor(primaryText) + + Button { + onTap() + } label: { + Text(payerName) + .font(.subTitle2()) + .foregroundColor(secondaryText) + } + .buttonStyle(.scale) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 8).stroke(outlineColor, lineWidth: 1) + ) + + Text("and split equally") + .font(.subTitle2()) + .foregroundColor(primaryText) + } + } +} + +#Preview { + AddExpenseView(viewModel: AddExpenseViewModel(router: .init(root: .AddExpenseView))) +} diff --git a/Splito/UI/Home/Expense/AddExpenseViewModel.swift b/Splito/UI/Home/Expense/AddExpenseViewModel.swift new file mode 100644 index 00000000..e14637c4 --- /dev/null +++ b/Splito/UI/Home/Expense/AddExpenseViewModel.swift @@ -0,0 +1,76 @@ +// +// AddExpenseViewModel.swift +// Splito +// +// Created by Amisha Italiya on 20/03/24. +// + +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 = 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? + + @Published var selectedPayer: AppUser? { + didSet { + updatePayerName() + } + } + + private let router: Router + + init(router: Router) { + self.router = router + super.init() + updatePayerName() + } + + func updatePayerName() { + if let user = preference.user, let selectedPayer, selectedPayer.id == user.id { + self.payerName = "You" + } else { + self.payerName = selectedPayer?.firstName ?? "Unknown" + } + } + + 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/Expense/Detail Selection/ChooseGroupView.swift b/Splito/UI/Home/Expense/Detail Selection/ChooseGroupView.swift new file mode 100644 index 00000000..34b5d98c --- /dev/null +++ b/Splito/UI/Home/Expense/Detail Selection/ChooseGroupView.swift @@ -0,0 +1,108 @@ +// +// ChooseGroupView.swift +// Splito +// +// Created by Amisha Italiya on 27/03/24. +// + +import Data +import SwiftUI +import BaseStyle +import Kingfisher + +struct ChooseGroupView: View { + + @ObservedObject var viewModel: ChooseGroupViewModel + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(alignment: .center, spacing: 20) { + if case .loading = viewModel.currentViewState { + LoaderView(tintColor: primaryColor, scaleSize: 2) + } else if case .noGroups = viewModel.currentViewState { + NoGroupFoundView() + } else if case .hasGroups(let groups) = viewModel.currentViewState { + VSpacer(10) + + Text("Choose Group") + .font(.Header3()) + .foregroundColor(.primary) + + VSpacer(10) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 16) { + ForEach(groups) { group in + GroupCellView(group: group, isSelected: group.id == viewModel.selectedGroup?.id) + .onTapGesture { + viewModel.handleGroupSelection(group: group) + dismiss() + } + } + } + } + } + } + .padding(.horizontal, 30) + .background(backgroundColor) + .toastView(toast: $viewModel.toast) + .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + } +} + +private struct NoGroupFoundView: View { + + var body: some View { + VStack { + Text("You are not part of any group.") + .font(.subTitle1()) + .foregroundColor(primaryColor) + } + } +} + +private struct GroupCellView: View { + + var group: Groups + var isSelected: Bool + + var body: some View { + HStack(alignment: .center, spacing: 16) { + if let imageUrl = group.imageUrl, let url = URL(string: imageUrl) { + KFImage(url) + .placeholder({ _ in + ImageLoaderView() + }) + .setProcessor(ResizingImageProcessor(referenceSize: CGSize(width: (50 * UIScreen.main.scale), height: (50 * UIScreen.main.scale)), mode: .aspectFill)) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(.group) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + Text(group.name) + .font(.subTitle2()) + .foregroundColor(primaryText) + + Spacer() + + if isSelected { + Image(.checkMarkTick) + .resizable() + .frame(width: 24, height: 24) + } + } + .background(backgroundColor) + } +} + +#Preview { + ChooseGroupView(viewModel: ChooseGroupViewModel(selectedGroup: nil, onGroupSelection: { _ in })) +} diff --git a/Splito/UI/Home/Expense/Detail Selection/ChooseGroupViewModel.swift b/Splito/UI/Home/Expense/Detail Selection/ChooseGroupViewModel.swift new file mode 100644 index 00000000..61b5f744 --- /dev/null +++ b/Splito/UI/Home/Expense/Detail Selection/ChooseGroupViewModel.swift @@ -0,0 +1,58 @@ +// +// ChooseGroupViewModel.swift +// Splito +// +// Created by Amisha Italiya on 27/03/24. +// + +import Data +import Combine + +class ChooseGroupViewModel: BaseViewModel, ObservableObject { + + @Inject var preference: SplitoPreference + @Inject var groupRepository: GroupRepository + + @Published var selectedGroup: Groups? + @Published var currentViewState: ViewState = .initial + + var onGroupSelection: ((Groups) -> Void) + + init(selectedGroup: Groups?, onGroupSelection: @escaping ((Groups) -> Void)) { + self.selectedGroup = selectedGroup + self.onGroupSelection = onGroupSelection + super.init() + self.fetchGroups() + } + + func fetchGroups() { + currentViewState = .loading + groupRepository.fetchGroups(userId: preference.user?.id ?? "") + .sink { [weak self] completion in + switch completion { + case .finished: + return + case .failure(let error): + self?.currentViewState = .initial + self?.showToastFor(error) + } + } receiveValue: { [weak self] groups in + self?.currentViewState = groups.isEmpty ? .noGroups : .hasGroups(groups: groups) + } + .store(in: &cancelable) + } + + func handleGroupSelection(group: Groups) { + selectedGroup = group + onGroupSelection(group) + } +} + +extension ChooseGroupViewModel { + enum ViewState { + case initial + case loading + case hasGroups(groups: [Groups]) + case noGroups + } +} diff --git a/Splito/UI/Home/Expense/Detail Selection/ChoosePayerView.swift b/Splito/UI/Home/Expense/Detail Selection/ChoosePayerView.swift new file mode 100644 index 00000000..9b13c84f --- /dev/null +++ b/Splito/UI/Home/Expense/Detail Selection/ChoosePayerView.swift @@ -0,0 +1,130 @@ +// +// ChoosePayerView.swift +// Splito +// +// Created by Amisha Italiya on 27/03/24. +// + +import Data +import SwiftUI +import BaseStyle +import Kingfisher + +struct ChoosePayerView: View { + + @ObservedObject var viewModel: ChoosePayerViewModel + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(alignment: .center, spacing: 20) { + if case .loading = viewModel.currentViewState { + LoaderView(tintColor: primaryColor, scaleSize: 2) + } else if case .noUsers = viewModel.currentViewState { + NoMemberFoundView() + } else if case .hasUser(let users) = viewModel.currentViewState { + VSpacer(10) + + Text("Choose Payer") + .font(.Header3()) + .foregroundColor(.primary) + + VSpacer(10) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 16) { + ForEach(users) { user in + MemberCellView(member: user, isSelected: user.id == viewModel.selectedPayer?.id) + .onTapGesture { + viewModel.handlePayerSelection(user: user) + dismiss() + } + } + } + } + } + } + .padding(.horizontal, 30) + .background(backgroundColor) + .toastView(toast: $viewModel.toast) + .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + } +} + +private struct NoMemberFoundView: View { + + var body: some View { + VStack { + Text("No members in your selected group.") + .font(.subTitle1()) + .foregroundColor(primaryColor) + } + } +} + +private struct MemberCellView: View { + + @Inject var preference: SplitoPreference + + var member: AppUser + var isSelected: Bool + + var userName: String? + + init(member: AppUser, isSelected: Bool) { + self.member = member + self.isSelected = isSelected + if let user = preference.user, member.id == user.id { + self.userName = "You" + } else { + self.userName = (member.firstName ?? "") + " " + (member.lastName ?? "") + } + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + if let imageUrl = member.imageUrl, let url = URL(string: imageUrl) { + KFImage(url) + .placeholder({ _ in + ImageLoaderView() + }) + .setProcessor(ResizingImageProcessor(referenceSize: CGSize(width: (50 * UIScreen.main.scale), height: (50 * UIScreen.main.scale)), mode: .aspectFill)) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 25)) + .overlay( + Circle() + .strokeBorder(Color.gray, lineWidth: 1) + ) + } else { + Image(.user) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 25)) + .overlay( + Circle() + .strokeBorder(Color.gray, lineWidth: 1) + ) + } + + Text((userName ?? "").isEmpty ? "Unknown" : userName!) + .font(.subTitle2()) + .foregroundColor(primaryText) + + Spacer() + + if isSelected { + Image(.checkMarkTick) + .resizable() + .frame(width: 24, height: 24) + } + } + .background(backgroundColor) + } +} + +#Preview { + ChoosePayerView(viewModel: ChoosePayerViewModel(groupId: "", selectedPayer: nil, onPayerSelection: { _ in })) +} diff --git a/Splito/UI/Home/Expense/Detail Selection/ChoosePayerViewModel.swift b/Splito/UI/Home/Expense/Detail Selection/ChoosePayerViewModel.swift new file mode 100644 index 00000000..d2b06c7e --- /dev/null +++ b/Splito/UI/Home/Expense/Detail Selection/ChoosePayerViewModel.swift @@ -0,0 +1,59 @@ +// +// ChoosePayerViewModel.swift +// Splito +// +// Created by Amisha Italiya on 27/03/24. +// + +import Data +import Combine + +class ChoosePayerViewModel: BaseViewModel, ObservableObject { + + @Inject var groupRepository: GroupRepository + + @Published var groupId: String + @Published var selectedPayer: AppUser? + @Published var currentViewState: ViewState = .initial + + var onPayerSelection: ((AppUser) -> Void) + + init(groupId: String, selectedPayer: AppUser?, onPayerSelection: @escaping ((AppUser) -> Void)) { + self.groupId = groupId + self.selectedPayer = selectedPayer + self.onPayerSelection = onPayerSelection + super.init() + + self.fetchMembers() + } + + func fetchMembers() { + currentViewState = .loading + groupRepository.fetchMembersBy(groupId: groupId) + .sink { [weak self] completion in + switch completion { + case .finished: + return + case .failure(let error): + self?.currentViewState = .initial + self?.showToastFor(error) + } + } receiveValue: { users in + self.currentViewState = users.isEmpty ? .noUsers : .hasUser(users: users) + }.store(in: &cancelable) + } + + func handlePayerSelection(user: AppUser) { + selectedPayer = user + onPayerSelection(user) + } +} + +extension ChoosePayerViewModel { + enum ViewState { + case initial + case loading + case noUsers + case hasUser(users: [AppUser]) + } +} diff --git a/Splito/UI/Home/Expense/ExpenseRouteView.swift b/Splito/UI/Home/Expense/ExpenseRouteView.swift new file mode 100644 index 00000000..3f6bd9c7 --- /dev/null +++ b/Splito/UI/Home/Expense/ExpenseRouteView.swift @@ -0,0 +1,26 @@ +// +// ExpenseRouteView.swift +// Splito +// +// Created by Amisha Italiya on 26/03/24. +// + +import Data +import SwiftUI +import BaseStyle + +struct ExpenseRouteView: View { + + @StateObject var appRoute = Router(root: AppRoute.AddExpenseView) + + var body: some View { + RouterView(router: appRoute) { route in + switch route { + case .AddExpenseView: + AddExpenseView(viewModel: AddExpenseViewModel(router: appRoute)) + default: + EmptyRouteView(routeName: self) + } + } + } +} diff --git a/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift b/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift index 924b1966..8c9e2e55 100644 --- a/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift +++ b/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift @@ -52,7 +52,7 @@ class InviteMemberViewModel: BaseViewModel, ObservableObject { } receiveValue: { [weak self] group in guard let self, let group else { return } self.group = group - }.store(in: &cancelables) + }.store(in: &cancelable) } func storeSharedCode() { diff --git a/Splito/UI/Home/Groups/Add Member/JoinMemberViewModel.swift b/Splito/UI/Home/Groups/Add Member/JoinMemberViewModel.swift index b6522058..8167fe2d 100644 --- a/Splito/UI/Home/Groups/Add Member/JoinMemberViewModel.swift +++ b/Splito/UI/Home/Groups/Add Member/JoinMemberViewModel.swift @@ -12,7 +12,6 @@ class JoinMemberViewModel: BaseViewModel, ObservableObject { @Inject var preference: SplitoPreference @Inject var groupRepository: GroupRepository - @Inject var memberRepository: MemberRepository @Inject var codeRepository: ShareCodeRepository @Published var code = "" @@ -40,43 +39,45 @@ class JoinMemberViewModel: BaseViewModel, ObservableObject { guard let self else { return } guard let code else { - self.showToastFor(toast: ToastPrompt(type: .error, title: "Error", message: "Your entered code not exists.")) + self.showToastFor(toast: ToastPrompt(type: .error, title: "Error", message: "Entered code not exists.")) return } - self.addMember(groupId: code.groupId) { - _ = self.codeRepository.deleteSharedCode(documentId: code.id ?? "") - self.goToGroupHome() - } - }.store(in: &cancelables) + self.addMemberIfCodeExists(code: code) + }.store(in: &cancelable) + } + + func addMemberIfCodeExists(code: SharedCode) { + let expireDate = code.expireDate.dateValue() + let daysDifference = Calendar.current.dateComponents([.day], from: expireDate, to: Date()).day + + // Code will be valid until 2 days, so check for the day difference + guard let daysDifference, daysDifference <= codeRepository.CODE_EXPIRATION_LIMIT else { + showToastFor(toast: ToastPrompt(type: .error, title: "Error", message: "Entered code is expired.")) + return + } + + addMember(groupId: code.groupId) { + _ = self.codeRepository.deleteSharedCode(documentId: code.id ?? "") + self.goToGroupHome() + } } - // Add member to the collection func addMember(groupId: String, completion: @escaping () -> Void) { guard let userId = preference.user?.id else { return } currentState = .loading - let member = Member(userId: userId, groupId: groupId) - - memberRepository.addMemberToMembers(member: member) { [weak self] memberId in - guard let self, let memberId else { - self?.showAlertFor(message: "Something went wrong") - return - } - self.groupRepository.addMemberToGroup(groupId: groupId, memberId: memberId) - .sink { [weak self] result in - switch result { - case .failure(let error): - self?.currentState = .initial - self?.showToastFor(error) - completion() - case .finished: - self?.currentState = .initial - } - } receiveValue: { _ in + groupRepository.addMemberToGroup(memberId: userId, groupId: groupId) + .sink { [weak self] result in + if case .failure(let error) = result { + self?.currentState = .initial + self?.showToastFor(error) completion() - }.store(in: &self.cancelables) - } + } + } receiveValue: { _ in + self.currentState = .initial + completion() + }.store(in: &cancelable) } func goToGroupHome() { diff --git a/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift b/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift index 11c3234a..8c7d731e 100644 --- a/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift +++ b/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift @@ -54,14 +54,18 @@ struct CreateGroupView: View { sourceType: !viewModel.sourceTypeIsCamera ? .photoLibrary : .camera, image: $viewModel.profileImage, isPresented: $viewModel.showImagePicker) } - .navigationBarItems( - trailing: Button("Done") { - viewModel.handleDoneAction() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.handleDoneAction() + } label: { + Text("Done") + } + .font(.subTitle2()) + .tint(primaryColor) + .disabled(viewModel.groupName.count < 3 || viewModel.currentState == .loading) } - .font(.subTitle2()) - .tint(primaryColor) - .disabled(viewModel.groupName.count < 3 || viewModel.currentState == .loading) - ) + } } } diff --git a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift index 3da7814e..0de8eda9 100644 --- a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift +++ b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift @@ -24,7 +24,6 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { @Inject var preference: SplitoPreference @Inject var storageManager: StorageManager @Inject var groupRepository: GroupRepository - @Inject var memberRepository: MemberRepository @Published var groupName = "" @Published var sourceTypeIsCamera = false @@ -87,7 +86,7 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { func handleDoneAction() { currentState = .loading let userId = preference.user?.id ?? "" - let group = Groups(name: groupName, createdBy: userId, members: [], imageUrl: nil, createdAt: Timestamp()) + let group = Groups(name: groupName.capitalized, createdBy: userId, members: [userId], imageUrl: nil, createdAt: Timestamp()) let resizedImage = profileImage?.aspectFittedToHeight(200) let imageData = resizedImage?.jpegData(compressionQuality: 0.2) @@ -103,7 +102,7 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { } } receiveValue: { id in self.goToGroupHome(groupId: id) - }.store(in: &cancelables) + }.store(in: &cancelable) } func goToGroupHome(groupId: String) { diff --git a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift index b5e4758b..37533088 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift @@ -39,7 +39,7 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { guard let self, let group else { return } self.group = group self.groupState = group.members.count == 1 ? .noMember : .hasMembers - }.store(in: &cancelables) + }.store(in: &cancelable) } func handleCreateGroupClick() { diff --git a/Splito/UI/Home/Groups/GroupListViewModel.swift b/Splito/UI/Home/Groups/GroupListViewModel.swift index 12eef104..22a50360 100644 --- a/Splito/UI/Home/Groups/GroupListViewModel.swift +++ b/Splito/UI/Home/Groups/GroupListViewModel.swift @@ -42,11 +42,9 @@ class GroupListViewModel: BaseViewModel, ObservableObject { } } receiveValue: { [weak self] groups in guard let self else { return } - // Show only those groups in which the user is part of - let filteredGroups = groups.filter { $0.createdBy == userId || $0.members.contains(where: { $0.userId == userId }) } self.currentViewState = .initial - self.groupListState = filteredGroups.isEmpty ? .noGroup : .hasGroup(groups: filteredGroups) - }.store(in: &cancelables) + self.groupListState = groups.isEmpty ? .noGroup : .hasGroup(groups: groups) + }.store(in: &cancelable) } func handleCreateGroupBtnTap() { @@ -82,7 +80,7 @@ extension GroupListViewModel { switch self { case .noGroup: "noGroup" - case .hasGroup(let groups): + case .hasGroup: "hasGroup" } } diff --git a/Splito/UI/Home/Groups/GroupRouteView.swift b/Splito/UI/Home/Groups/GroupRouteView.swift index cd11bb7a..0a3cc9a9 100644 --- a/Splito/UI/Home/Groups/GroupRouteView.swift +++ b/Splito/UI/Home/Groups/GroupRouteView.swift @@ -14,22 +14,20 @@ struct GroupRouteView: View { @StateObject var appRoute = Router(root: AppRoute.GroupListView) var body: some View { - VStack(spacing: 0) { - RouterView(router: appRoute) { route in - switch route { - case .GroupListView: - GroupListView(viewModel: GroupListViewModel(router: appRoute)) - case .GroupHomeView(let id): - GroupHomeView(viewModel: GroupHomeViewModel(router: appRoute, groupId: id)) - case .CreateGroupView: - CreateGroupView(viewModel: CreateGroupViewModel(router: appRoute)) - case .InviteMemberView(let id): - InviteMemberView(viewModel: InviteMemberViewModel(router: appRoute, groupId: id)) - case .JoinMemberView: - JoinMemberView(viewModel: JoinMemberViewModel(router: appRoute)) - default: - EmptyRouteView(routeName: self) - } + RouterView(router: appRoute) { route in + switch route { + case .GroupListView: + GroupListView(viewModel: GroupListViewModel(router: appRoute)) + case .GroupHomeView(let id): + GroupHomeView(viewModel: GroupHomeViewModel(router: appRoute, groupId: id)) + case .CreateGroupView: + CreateGroupView(viewModel: CreateGroupViewModel(router: appRoute)) + case .InviteMemberView(let id): + InviteMemberView(viewModel: InviteMemberViewModel(router: appRoute, groupId: id)) + case .JoinMemberView: + JoinMemberView(viewModel: JoinMemberViewModel(router: appRoute)) + default: + EmptyRouteView(routeName: self) } } } diff --git a/Splito/UI/Home/HomeRouteView.swift b/Splito/UI/Home/HomeRouteView.swift index 6e1aaaa2..6566014c 100644 --- a/Splito/UI/Home/HomeRouteView.swift +++ b/Splito/UI/Home/HomeRouteView.swift @@ -11,48 +11,40 @@ import SwiftUI struct HomeRouteView: View { + @State private var openExpenseSheet = false + var body: some View { ZStack { TabView { - HomeView() - .tabItem { - Label("Friends", systemImage: "person") - } - .tag(0) - GroupRouteView() .tabItem { Label("Groups", systemImage: "person.2") } - .tag(1) - - HomeView() - .tabItem { -// Label("", systemImage: "plus.circle.fill") - } - .tag(1) - - 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) - .toolbarColorScheme(.light, for: .tabBar) - - CenterFabButton() + .overlay( + CenterFabButton { + openExpenseSheet = true + } + ) + .fullScreenCover(isPresented: $openExpenseSheet) { + ExpenseRouteView() + } } } } struct CenterFabButton: View { + + var onClick: () -> Void + var body: some View { VStack { Spacer() @@ -60,7 +52,7 @@ struct CenterFabButton: View { Spacer() Button { - // Open screen + onClick() } label: { Image(systemName: "plus.circle.fill") .resizable() diff --git a/Splito/UI/Login/LoginViewModel.swift b/Splito/UI/Login/LoginViewModel.swift index b6c9687a..3b6dd994 100644 --- a/Splito/UI/Login/LoginViewModel.swift +++ b/Splito/UI/Login/LoginViewModel.swift @@ -116,7 +116,7 @@ public class LoginViewModel: BaseViewModel, ObservableObject { } receiveValue: { [weak self] _ in guard let self else { return } self.onLoginSuccess() - }.store(in: &cancelables) + }.store(in: &cancelable) } func onLoginSuccess() { diff --git a/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpViewModel.swift b/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpViewModel.swift index 3aac146c..07389538 100644 --- a/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpViewModel.swift +++ b/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpViewModel.swift @@ -116,13 +116,13 @@ extension VerifyOtpViewModel { self.alert = .init(message: error.localizedDescription) self.showAlert = true case .finished: - self.preference.user = user self.preference.isVerifiedUser = true } - } receiveValue: { [weak self] _ in + } receiveValue: { [weak self] user in guard let self else { return } + self.preference.user = user self.onLoginSuccess() - }.store(in: &cancelables) + }.store(in: &cancelable) } func editButtonAction() { diff --git a/Splito/UI/Onboard/OnboardRouteView.swift b/Splito/UI/Onboard/OnboardRouteView.swift index 49d06878..a98a0816 100644 --- a/Splito/UI/Onboard/OnboardRouteView.swift +++ b/Splito/UI/Onboard/OnboardRouteView.swift @@ -37,12 +37,14 @@ struct OnboardRouteView: View { .onAppear { if preference.isOnboardShown { if preference.isVerifiedUser { - router.updateRoot(root: .ProfileView) + if let user = preference.user, let username = user.firstName, !username.isEmpty { + router.updateRoot(root: .HomeView) + } else { + router.updateRoot(root: .ProfileView) + } } else { router.updateRoot(root: .LoginView) } - } else { - router.updateRoot(root: .OnboardView) } } } diff --git a/Splito/UI/User Profile/UserProfileView.swift b/Splito/UI/User Profile/UserProfileView.swift index 6eb513a4..c8f32d4e 100644 --- a/Splito/UI/User Profile/UserProfileView.swift +++ b/Splito/UI/User Profile/UserProfileView.swift @@ -148,7 +148,7 @@ private struct UserDetailCell: View { VSpacer(5) - UserProfileDataEditableTextField(titletext: $titleText, isDisabled: isDisabled, placeholder: placeholder, fieldType: fieldType, keyboardType: keyboardType, focused: focused, autoCapitalizationType: autoCapitalizationType) + UserProfileDataEditableTextField(titleText: $titleText, isDisabled: isDisabled, placeholder: placeholder, fieldType: fieldType, keyboardType: keyboardType, focused: focused, autoCapitalizationType: autoCapitalizationType) VSpacer(8) @@ -189,7 +189,7 @@ private struct UserDetailCell: View { private struct UserProfileDataEditableTextField: View { - @Binding var titletext: String + @Binding var titleText: String let isDisabled: Bool let placeholder: String @@ -199,7 +199,7 @@ private struct UserProfileDataEditableTextField: View { var autoCapitalizationType: UITextAutocapitalizationType var body: some View { - TextField(placeholder, text: $titletext) + TextField(placeholder, text: $titleText) .font(.subTitle1()) .focused(focused, equals: fieldType) .foregroundColor(primaryText) diff --git a/Splito/UI/User Profile/UserProfileViewModel.swift b/Splito/UI/User Profile/UserProfileViewModel.swift index 14226a97..304e5d05 100644 --- a/Splito/UI/User Profile/UserProfileViewModel.swift +++ b/Splito/UI/User Profile/UserProfileViewModel.swift @@ -124,7 +124,7 @@ public class UserProfileViewModel: BaseViewModel, ObservableObject { if self.isOpenedFromOnboard { self.goToHome() } - }.store(in: &cancelables) + }.store(in: &cancelable) } } @@ -146,7 +146,7 @@ public class UserProfileViewModel: BaseViewModel, ObservableObject { } } receiveValue: { _ in print("UserProfileViewModel :: user deleted.") - }.store(in: &cancelables) + }.store(in: &cancelable) } else { print("UserProfileViewModel :: user not exists.") } diff --git a/install_dist_certs.sh b/install_dist_certs.sh index ac47afe3..365ec5b2 100644 --- a/install_dist_certs.sh +++ b/install_dist_certs.sh @@ -25,4 +25,4 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$BUILD_KE security lock-keychain "$BUILD_KEYCHAIN" # remove certs -rm -fr *.p12 +rm -fr -- *.p12 diff --git a/install_dist_profile.sh b/install_dist_profile.sh index f94b84ae..0a3117d0 100644 --- a/install_dist_profile.sh +++ b/install_dist_profile.sh @@ -9,10 +9,7 @@ echo "$BUILD_PROVISION_PROFILE" | base64 --decode > "$DIST_PROFILE_FILE" mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" # Copy the provisioning profile where Xcode can find it -cp ${DIST_PROFILE_FILE} "$HOME/Library/MobileDevice/Provisioning Profiles/${BUILD_PROVISION_UUID}.mobileprovision" - -# Lock the keychain -security lock-keychain "$BUILD_KEYCHAIN" +cp "${DIST_PROFILE_FILE}" "$HOME/Library/MobileDevice/Provisioning Profiles/${BUILD_PROVISION_UUID}.mobileprovision" # clean -rm -fr *.mobileprovision +rm -fr -- ./*.mobileprovision