diff --git a/BaseStyle/BaseStyle/Views/AlertPrompt.swift b/BaseStyle/BaseStyle/Views/AlertPrompt.swift index 649914663..448b31888 100644 --- a/BaseStyle/BaseStyle/Views/AlertPrompt.swift +++ b/BaseStyle/BaseStyle/Views/AlertPrompt.swift @@ -5,7 +5,6 @@ // Created by Amisha Italiya on 22/02/24. // -import Foundation import SwiftUI public struct AlertPrompt { @@ -28,7 +27,7 @@ public struct AlertPrompt { } } -public struct Backport { +public struct AlertView { public let content: Content public init(content: Content) { @@ -37,10 +36,10 @@ public struct Backport { } public extension View { - var backport: Backport { Backport(content: self) } + var alertView: AlertView { AlertView(content: self) } } -public extension Backport where Content: View { +public extension AlertView where Content: View { @ViewBuilder func alert(isPresented: Binding, alertStruct: AlertPrompt) -> some View { content .alert(alertStruct.title.localized, isPresented: isPresented) { @@ -55,7 +54,7 @@ public extension Backport where Content: View { }) } if alertStruct.positiveBtnTitle == nil && alertStruct.negativeBtnTitle == nil { - Button("Ok".localized, role: .cancel, action: { + Button("Ok", role: .cancel, action: { isPresented.wrappedValue = false }) } diff --git a/Data/Data/DI/Injector.swift b/Data/Data/DI/Injector.swift index c0d482240..c8d5429a0 100644 --- a/Data/Data/DI/Injector.swift +++ b/Data/Data/DI/Injector.swift @@ -19,7 +19,7 @@ public class Injector { appAssembler = Assembler([AppAssembly()]) } - public func setTestAassembler(assemblies: [Assembly]) { + public func setTestAssembler(assemblies: [Assembly]) { appAssembler = Assembler(assemblies) } } diff --git a/Data/Data/Model/ActivityLog.swift b/Data/Data/Model/ActivityLog.swift index 30f39cacd..a1d3a32d7 100644 --- a/Data/Data/Model/ActivityLog.swift +++ b/Data/Data/Model/ActivityLog.swift @@ -27,11 +27,13 @@ public struct ActivityLog: Codable, Identifiable, Hashable { public let expenseName: String? public let payerName: String? public let receiverName: String? + public let paymentReason: String? public let amount: Double? - public init(type: ActivityType, groupId: String, activityId: String, groupName: String, actionUserName: String, - recordedOn: Timestamp, previousGroupName: String? = nil, removedMemberName: String? = nil, - expenseName: String? = nil, payerName: String? = nil, receiverName: String? = nil, amount: Double? = nil) { + public init(type: ActivityType, groupId: String, activityId: String, groupName: String, + actionUserName: String, recordedOn: Timestamp, previousGroupName: String? = nil, + removedMemberName: String? = nil, expenseName: String? = nil, payerName: String? = nil, + receiverName: String? = nil, paymentReason: String? = nil, amount: Double? = nil) { self.type = type self.groupId = groupId self.activityId = activityId @@ -43,6 +45,7 @@ public struct ActivityLog: Codable, Identifiable, Hashable { self.expenseName = expenseName self.payerName = payerName self.receiverName = receiverName + self.paymentReason = paymentReason self.amount = amount } @@ -59,6 +62,7 @@ public struct ActivityLog: Codable, Identifiable, Hashable { case expenseName = "expense_name" case payerName = "payer_name" case receiverName = "receiver_name" + case paymentReason = "payment_reason" case amount } } diff --git a/Data/Data/Model/Groups.swift b/Data/Data/Model/Groups.swift index 273cb9654..97e6ada87 100644 --- a/Data/Data/Model/Groups.swift +++ b/Data/Data/Model/Groups.swift @@ -22,8 +22,9 @@ public struct Groups: Codable, Identifiable { public var hasExpenses: Bool public var isActive: Bool - public init(name: String, createdBy: String, updatedBy: String, imageUrl: String? = nil, members: [String], balances: [GroupMemberBalance], - createdAt: Timestamp, updatedAt: Timestamp, hasExpenses: Bool = false, isActive: Bool = true) { + public init(name: String, createdBy: String, updatedBy: String, imageUrl: String? = nil, + members: [String], balances: [GroupMemberBalance], createdAt: Timestamp = Timestamp(), + updatedAt: Timestamp = Timestamp(), hasExpenses: Bool = false, isActive: Bool = true) { self.name = name self.createdBy = createdBy self.updatedBy = updatedBy diff --git a/Data/Data/Model/Transaction.swift b/Data/Data/Model/Transaction.swift index 53690f9f0..4274d4864 100644 --- a/Data/Data/Model/Transaction.swift +++ b/Data/Data/Model/Transaction.swift @@ -17,19 +17,22 @@ public struct Transactions: Codable, Hashable, Identifiable { public var updatedBy: String public var note: String? public var imageUrl: String? + public var reason: String? public var amount: Double public var date: Timestamp public var updatedAt: Timestamp public var isActive: Bool - public init(payerId: String, receiverId: String, addedBy: String, updatedBy: String, note: String? = nil, - imageUrl: String? = nil, amount: Double, date: Timestamp, updatedAt: Timestamp = Timestamp(), isActive: Bool = true) { + public init(payerId: String, receiverId: String, addedBy: String, updatedBy: String, + note: String? = nil, imageUrl: String? = nil, reason: String? = nil, amount: Double, + date: Timestamp, updatedAt: Timestamp = Timestamp(), isActive: Bool = true) { self.payerId = payerId self.receiverId = receiverId self.addedBy = addedBy self.updatedBy = updatedBy self.note = note self.imageUrl = imageUrl + self.reason = reason self.amount = amount self.date = date self.updatedAt = updatedAt @@ -42,8 +45,9 @@ public struct Transactions: Codable, Hashable, Identifiable { case receiverId = "receiver_id" case addedBy = "added_by" case updatedBy = "updated_by" - case note = "note" + case note case imageUrl = "image_url" + case reason case amount case date case updatedAt = "updated_at" diff --git a/Data/Data/Repository/ActivityLogRepository.swift b/Data/Data/Repository/ActivityLogRepository.swift index 2c9d5d82f..6f80357ec 100644 --- a/Data/Data/Repository/ActivityLogRepository.swift +++ b/Data/Data/Repository/ActivityLogRepository.swift @@ -35,6 +35,7 @@ struct ActivityLogContext { var currentUser: AppUser? var payerName: String? var receiverName: String? + var paymentReason: String? var previousGroupName: String? var removedMemberName: String? } diff --git a/Data/Data/Repository/GroupRepository.swift b/Data/Data/Repository/GroupRepository.swift index 42bce377e..4702bd011 100644 --- a/Data/Data/Repository/GroupRepository.swift +++ b/Data/Data/Repository/GroupRepository.swift @@ -114,10 +114,12 @@ public class GroupRepository: ObservableObject { } public func deleteGroup(group: Groups) async throws { - var group = group + guard let userId = preference.user?.id else { return } - // Make group inactive - group.isActive = false + var group = group + group.isActive = false // Make group inactive + group.updatedBy = userId + group.updatedAt = Timestamp() try await updateGroup(group: group, type: .groupDeleted) } @@ -193,17 +195,19 @@ public class GroupRepository: ObservableObject { try await store.fetchGroupsBy(userId: userId, limit: limit, lastDocument: lastDocument) } - public func fetchMemberBy(userId: String) async throws -> AppUser? { + public func fetchMemberBy(memberId: String) async throws -> AppUser? { // Use a synchronous read to check if the member already exists in groupMembers - let existingMember = groupMembersQueue.sync { groupMembers.first(where: { $0.id == userId }) } - - if let existingMember { - return existingMember // Return the available member from groupMembers + if let existingMember = groupMembersQueue.sync(execute: { + groupMembers.first(where: { $0.id == memberId }) + }) { + await updateCurrentUserImageUrl(for: [memberId]) + return existingMember // Return the cached member } - let member = try await userRepository.fetchUserBy(userID: userId) + // Fetch the member from the repository if not found locally + let member = try await userRepository.fetchUserBy(userID: memberId) - // Append to groupMembers safely with a barrier, ensuring thread safety + // Append the newly fetched member to groupMembers in a thread-safe manner if let member { try await withCheckedThrowingContinuation { continuation in groupMembersQueue.async(flags: .barrier) { @@ -218,23 +222,26 @@ public class GroupRepository: ObservableObject { public func fetchMembersBy(memberIds: [String]) async throws -> [AppUser] { var members: [AppUser] = [] - // Filter out memberIds that already exist in groupMembers to minimise API calls + // Filter out memberIds that already exist in groupMembers to minimize API calls let missingMemberIds = memberIds.filter { memberId in let cachedMember = self.groupMembersQueue.sync { self.groupMembers.first { $0.id == memberId } } return cachedMember == nil } if missingMemberIds.isEmpty { + await updateCurrentUserImageUrl(for: memberIds) return groupMembersQueue.sync { self.groupMembers.filter { memberIds.contains($0.id) } } } + // Fetch missing members concurrently using a TaskGroup try await withThrowingTaskGroup(of: AppUser?.self) { groupTask in for memberId in missingMemberIds { groupTask.addTask { - try await self.fetchMemberBy(userId: memberId) + try await self.fetchMemberBy(memberId: memberId) } } + // Collect results from the task group & add to the groupMembers array for try await member in groupTask { if let member { members.append(member) @@ -249,4 +256,16 @@ public class GroupRepository: ObservableObject { return members } + + // Updates the current user's image url in groupMembers if applicable + private func updateCurrentUserImageUrl(for memberIds: [String]) async { + guard let currentUser = preference.user, memberIds.contains(currentUser.id) else { return } + + groupMembersQueue.async(flags: .barrier) { + if let index = self.groupMembers.firstIndex(where: { $0.id == currentUser.id }), + self.groupMembers[index].imageUrl != currentUser.imageUrl { + self.groupMembers[index].imageUrl = currentUser.imageUrl + } + } + } } diff --git a/Data/Data/Repository/TransactionRepository.swift b/Data/Data/Repository/TransactionRepository.swift index 76567520e..82b09a5fc 100644 --- a/Data/Data/Repository/TransactionRepository.swift +++ b/Data/Data/Repository/TransactionRepository.swift @@ -77,8 +77,8 @@ public class TransactionRepository: ObservableObject { private func hasTransactionChanged(_ transaction: Transactions, oldTransaction: Transactions) -> Bool { return oldTransaction.payerId != transaction.payerId || oldTransaction.receiverId != transaction.receiverId || oldTransaction.updatedBy != transaction.updatedBy || oldTransaction.note != transaction.note || - oldTransaction.imageUrl != transaction.imageUrl || oldTransaction.amount != transaction.amount || - oldTransaction.date.dateValue() != transaction.date.dateValue() || + oldTransaction.imageUrl != transaction.imageUrl || oldTransaction.reason != transaction.reason || + oldTransaction.amount != transaction.amount || oldTransaction.date.dateValue() != transaction.date.dateValue() || oldTransaction.updatedAt.dateValue() != transaction.updatedAt.dateValue() || oldTransaction.isActive != transaction.isActive } @@ -103,8 +103,9 @@ public class TransactionRepository: ObservableObject { guard let self else { return nil } let payerName = (user.id == transaction.payerId && memberId == transaction.payerId) ? (user.id == transaction.addedBy ? "You" : "you") : (memberId == transaction.payerId) ? "you" : members.payer.nameWithLastInitial let receiverName = (memberId == transaction.receiverId) ? "you" : (memberId == transaction.receiverId) ? "you" : members.receiver.nameWithLastInitial - let context = ActivityLogContext(group: group, transaction: transaction, type: type, memberId: memberId, - currentUser: user, payerName: payerName, receiverName: receiverName) + let context = ActivityLogContext(group: group, transaction: transaction, type: type, + memberId: memberId, currentUser: user, payerName: payerName, + receiverName: receiverName, paymentReason: transaction.reason) return await self.addActivityLog(context: context) } @@ -131,10 +132,9 @@ public class TransactionRepository: ObservableObject { let actionUserName = (context.memberId == currentUser.id) ? "You" : currentUser.nameWithLastInitial let amount: Double = (context.memberId == transaction.payerId) ? transaction.amount : (context.memberId == transaction.receiverId) ? -transaction.amount : 0 - return ActivityLog(type: context.type, groupId: groupId, activityId: transactionId, - groupName: context.group?.name ?? "", actionUserName: actionUserName, - recordedOn: Timestamp(date: Date()), payerName: context.payerName, - receiverName: context.receiverName, amount: amount) + return ActivityLog(type: context.type, groupId: groupId, activityId: transactionId, groupName: context.group?.name ?? "", + actionUserName: actionUserName, recordedOn: Timestamp(date: Date()), payerName: context.payerName, + receiverName: context.receiverName, paymentReason: context.paymentReason, amount: amount) } private func addActivityLog(context: ActivityLogContext) async -> Error? { diff --git a/Data/Data/Store/ExpenseStore.swift b/Data/Data/Store/ExpenseStore.swift index 236879ce5..1e2d4c2e0 100644 --- a/Data/Data/Store/ExpenseStore.swift +++ b/Data/Data/Store/ExpenseStore.swift @@ -31,7 +31,7 @@ public class ExpenseStore: ObservableObject { func updateExpense(groupId: String, expense: Expense) async throws { if let expenseId = expense.id { - try expenseReference(groupId: groupId).document(expenseId).setData(from: expense, merge: true) + try expenseReference(groupId: groupId).document(expenseId).setData(from: expense, merge: false) } else { LogE("ExpenseStore: \(#function) Expense not found.") throw ServiceError.dataNotFound diff --git a/Data/Data/Store/GroupStore.swift b/Data/Data/Store/GroupStore.swift index df418e92c..899d6755f 100644 --- a/Data/Data/Store/GroupStore.swift +++ b/Data/Data/Store/GroupStore.swift @@ -35,7 +35,7 @@ class GroupStore: ObservableObject { func updateGroup(group: Groups) async throws { if let groupId = group.id { - try groupReference.document(groupId).setData(from: group, merge: true) + try groupReference.document(groupId).setData(from: group, merge: false) } else { LogE("GroupStore: \(#function) Group not found.") throw ServiceError.dataNotFound diff --git a/Data/Data/Store/TransactionStore.swift b/Data/Data/Store/TransactionStore.swift index ece45943f..2999743ef 100644 --- a/Data/Data/Store/TransactionStore.swift +++ b/Data/Data/Store/TransactionStore.swift @@ -31,7 +31,7 @@ public class TransactionStore: ObservableObject { func updateTransaction(groupId: String, transaction: Transactions) async throws { if let transactionId = transaction.id { - try transactionReference(groupId: groupId).document(transactionId).setData(from: transaction, merge: true) + try transactionReference(groupId: groupId).document(transactionId).setData(from: transaction, merge: false) } else { LogE("TransactionStore: \(#function) Payment not found.") throw ServiceError.dataNotFound diff --git a/Data/Data/Store/UserStore.swift b/Data/Data/Store/UserStore.swift index aa4a358c2..1102ec85b 100644 --- a/Data/Data/Store/UserStore.swift +++ b/Data/Data/Store/UserStore.swift @@ -28,7 +28,7 @@ class UserStore: ObservableObject { } func updateUser(user: AppUser) async throws -> AppUser? { - try usersCollection.document(user.id).setData(from: user, merge: true) + try usersCollection.document(user.id).setData(from: user, merge: false) return user } diff --git a/Splito/Localization/Localizable.xcstrings b/Splito/Localization/Localizable.xcstrings index fb749de72..948ac5d07 100644 --- a/Splito/Localization/Localizable.xcstrings +++ b/Splito/Localization/Localizable.xcstrings @@ -45,6 +45,16 @@ }, "%@" : { + }, + "%@ %@ " : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@ " + } + } + } }, "%@ %@ %@" : { "localizations" : { @@ -61,9 +71,6 @@ }, "%@ and %@" : { "extractionState" : "manual" - }, - "%@ owes " : { - }, "%@ owes you " : { @@ -88,6 +95,16 @@ } } }, + "%@ paid %@ for '%@'" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ paid %2$@ for '%3$@'" + } + } + } + }, "%@ people" : { "extractionState" : "manual" }, @@ -311,6 +328,9 @@ "Enter a description" : { "extractionState" : "manual" }, + "Enter a reason for this payment" : { + "extractionState" : "manual" + }, "Enter a valid phone number." : { "extractionState" : "manual" }, @@ -336,7 +356,7 @@ "extractionState" : "manual" }, "Enter your note here..." : { - + "extractionState" : "manual" }, "Enter your phone number" : { "extractionState" : "manual" diff --git a/Splito/UI/Home/Account/AccountHomeView.swift b/Splito/UI/Home/Account/AccountHomeView.swift index c02fdd29b..fbd7e157f 100644 --- a/Splito/UI/Home/Account/AccountHomeView.swift +++ b/Splito/UI/Home/Account/AccountHomeView.swift @@ -56,7 +56,7 @@ struct AccountHomeView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .sheet(isPresented: $viewModel.showShareSheet) { MailComposeView(logFilePath: viewModel.logFilePath, showToast: viewModel.showMailSendToast) } diff --git a/Splito/UI/Home/Account/User Profile/UserProfileView.swift b/Splito/UI/Home/Account/User Profile/UserProfileView.swift index 0b3f866e0..3083d212f 100644 --- a/Splito/UI/Home/Account/User Profile/UserProfileView.swift +++ b/Splito/UI/Home/Account/User Profile/UserProfileView.swift @@ -52,7 +52,7 @@ struct UserProfileView: View { .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toastView(toast: $viewModel.toast) .toolbarRole(.editor) .toolbar { diff --git a/Splito/UI/Home/ActivityLog/ActivityLogView.swift b/Splito/UI/Home/ActivityLog/ActivityLogView.swift index d0caba00e..90b7074e5 100644 --- a/Splito/UI/Home/ActivityLog/ActivityLogView.swift +++ b/Splito/UI/Home/ActivityLog/ActivityLogView.swift @@ -32,7 +32,7 @@ struct ActivityLogView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toolbar { ToolbarItem(placement: .topBarLeading) { Text("Activity") @@ -192,6 +192,7 @@ private struct ActivityLogDescriptionView: View { let type: ActivityType let payerName: String var receiverName: String + var paymentReason: String? let groupName: String var oldGroupName: String let actionUserName: String @@ -202,6 +203,7 @@ private struct ActivityLogDescriptionView: View { self.type = activityLog.type self.payerName = activityLog.payerName ?? "Someone" self.receiverName = activityLog.receiverName ?? "Someone" + self.paymentReason = activityLog.paymentReason self.groupName = activityLog.groupName self.oldGroupName = activityLog.previousGroupName ?? "" self.actionUserName = activityLog.actionUserName @@ -276,18 +278,35 @@ private struct ActivityLogDescriptionView: View { if actionUserName != payerName && actionUserName != receiverName { transactionDescription() } else if actionUserName != payerName { - highlightedText(actionUserName) + disabledText(" recorded a payment from ") + - highlightedText(payerName) + disabledText(" in") + highlightedText(" \"\(groupName)\".") + if let paymentReason, !paymentReason.isEmpty { + highlightedText(actionUserName) + disabledText(" recorded a payment from ") + + highlightedText(payerName) + disabledText(" for") + highlightedText(" \"\(paymentReason)\"") + + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } else { + highlightedText(actionUserName) + disabledText(" recorded a payment from ") + + highlightedText(payerName) + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } } else { - highlightedText(payerName) + disabledText(" paid ") + highlightedText(receiverName) + - disabledText(" in") + highlightedText(" \"\(groupName)\".") + if let paymentReason, !paymentReason.isEmpty { + highlightedText(payerName) + disabledText(" paid ") + highlightedText(receiverName) + disabledText(" for") + + highlightedText(" \"\(paymentReason)\"") + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } else { + highlightedText(payerName) + disabledText(" paid ") + highlightedText(receiverName) + + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } } } @ViewBuilder private func transactionDescription(action: String = "added") -> some View { - highlightedText(actionUserName) + disabledText(" \(action) a payment from ") + highlightedText(payerName) + - disabledText(" to ") + highlightedText(receiverName) + disabledText(" in") + highlightedText(" \"\(groupName)\".") + if let paymentReason, !paymentReason.isEmpty { + highlightedText(actionUserName) + disabledText(" \(action) a payment from ") + highlightedText(payerName) + + disabledText(" to ") + highlightedText(receiverName) + disabledText(" for") + + highlightedText(" \"\(paymentReason)\"") + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } else { + highlightedText(actionUserName) + disabledText(" \(action) a payment from ") + highlightedText(payerName) + + disabledText(" to ") + highlightedText(receiverName) + disabledText(" in") + highlightedText(" \"\(groupName)\".") + } } private func highlightedText(_ text: String) -> Text { diff --git a/Splito/UI/Home/Expense/AddExpenseView.swift b/Splito/UI/Home/Expense/AddExpenseView.swift index 43b335303..d28fb7efc 100644 --- a/Splito/UI/Home/Expense/AddExpenseView.swift +++ b/Splito/UI/Home/Expense/AddExpenseView.swift @@ -46,7 +46,7 @@ struct AddExpenseView: View { .navigationTitle(viewModel.expenseId == nil ? "Add expense" : "Edit expense") .navigationBarTitleDisplayMode(.inline) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .sheet(isPresented: $viewModel.showGroupSelection) { NavigationStack { SelectGroupView(viewModel: SelectGroupViewModel(selectedGroup: viewModel.selectedGroup, @@ -78,8 +78,12 @@ struct AddExpenseView: View { } .sheet(isPresented: $viewModel.showAddNoteEditor) { NavigationStack { - AddNoteView(viewModel: AddNoteViewModel(group: viewModel.selectedGroup, expense: viewModel.expense, note: viewModel.expenseNote, - handleSaveNoteTap: viewModel.handleNoteSaveBtnTap(note:))) + AddNoteView(viewModel: AddNoteViewModel( + group: viewModel.selectedGroup, expense: viewModel.expense, note: viewModel.expenseNote, + handleSaveNoteTap: { note, _ in + viewModel.handleNoteSaveBtnTap(note: note) + } + )) } } .sheet(isPresented: $viewModel.showImagePicker) { diff --git a/Splito/UI/Home/Expense/AddExpenseViewModel.swift b/Splito/UI/Home/Expense/AddExpenseViewModel.swift index 09e2cd02c..2986402ed 100644 --- a/Splito/UI/Home/Expense/AddExpenseViewModel.swift +++ b/Splito/UI/Home/Expense/AddExpenseViewModel.swift @@ -163,7 +163,7 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { private func fetchUserData(for userId: String) async -> AppUser? { do { - let user = try await groupRepository.fetchMemberBy(userId: userId) + let user = try await groupRepository.fetchMemberBy(memberId: userId) LogD("AddExpenseViewModel: \(#function) Member fetched successfully.") return user } catch { @@ -389,7 +389,7 @@ extension AddExpenseViewModel { if !group.hasExpenses { selectedGroup?.hasExpenses = true } - await updateGroupMemberBalance(expense: expense, updateType: .Add) + await updateGroupMemberBalance(expense: newExpense, updateType: .Add) showLoader = false LogD("AddExpenseViewModel: \(#function) Expense added successfully.") @@ -458,7 +458,8 @@ extension AddExpenseViewModel { private func hasExpenseChanged(_ expense: Expense, oldExpense: Expense) -> Bool { return oldExpense.amount != expense.amount || oldExpense.paidBy != expense.paidBy || oldExpense.splitTo != expense.splitTo || oldExpense.splitType != expense.splitType || - oldExpense.splitData != expense.splitData || oldExpense.isActive != expense.isActive + oldExpense.splitData != expense.splitData || oldExpense.isActive != expense.isActive || + oldExpense.date != expense.date } private func updateGroupMemberBalance(expense: Expense, updateType: ExpenseUpdateType) async { diff --git a/Splito/UI/Home/Expense/Detail Selection/Payer/ChooseMultiplePayerView.swift b/Splito/UI/Home/Expense/Detail Selection/Payer/ChooseMultiplePayerView.swift index c3dcfc668..a918cf3f2 100644 --- a/Splito/UI/Home/Expense/Detail Selection/Payer/ChooseMultiplePayerView.swift +++ b/Splito/UI/Home/Expense/Detail Selection/Payer/ChooseMultiplePayerView.swift @@ -45,7 +45,7 @@ struct ChooseMultiplePayerView: View { .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) - BottomInfoCardView(title: "₹ \(String(format: "%.2f", viewModel.totalAmount)) of \(viewModel.expenseAmount.formattedCurrency)", + BottomInfoCardView(title: "\(viewModel.totalAmount.formattedCurrency) of \(viewModel.expenseAmount.formattedCurrency)", value: "\((viewModel.expenseAmount - viewModel.totalAmount).formattedCurrencyWithSign) left") } } @@ -53,7 +53,7 @@ struct ChooseMultiplePayerView: View { .interactiveDismissDisabled() .toolbar(.hidden, for: .navigationBar) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onTapGesture { UIApplication.shared.endEditing() } diff --git a/Splito/UI/Home/Expense/Detail Selection/Payer/ChoosePayerView.swift b/Splito/UI/Home/Expense/Detail Selection/Payer/ChoosePayerView.swift index b8034902b..56c9bb83a 100644 --- a/Splito/UI/Home/Expense/Detail Selection/Payer/ChoosePayerView.swift +++ b/Splito/UI/Home/Expense/Detail Selection/Payer/ChoosePayerView.swift @@ -71,7 +71,7 @@ struct ChoosePayerView: View { .interactiveDismissDisabled() .toolbar(.hidden, for: .navigationBar) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) } } diff --git a/Splito/UI/Home/Expense/Detail Selection/SelectGroupView.swift b/Splito/UI/Home/Expense/Detail Selection/SelectGroupView.swift index c5e9d57cb..bf394027f 100644 --- a/Splito/UI/Home/Expense/Detail Selection/SelectGroupView.swift +++ b/Splito/UI/Home/Expense/Detail Selection/SelectGroupView.swift @@ -67,7 +67,7 @@ struct SelectGroupView: View { .interactiveDismissDisabled() .toolbar(.hidden, for: .navigationBar) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) } } diff --git a/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift b/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift index 85209c9b2..4c789ecd7 100644 --- a/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift +++ b/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift @@ -28,7 +28,7 @@ struct ExpenseDetailsView: View { ExpenseInfoView(viewModel: viewModel) - if let imageUrl = viewModel.expense?.imageUrl { + if let imageUrl = viewModel.expense?.imageUrl, !imageUrl.isEmpty { VStack(spacing: 8) { Text("Attachment:") .font(.subTitle3()) @@ -60,7 +60,7 @@ struct ExpenseDetailsView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .fullScreenCover(isPresented: $viewModel.showEditExpenseSheet) { NavigationStack { AddExpenseView(viewModel: AddExpenseViewModel(router: viewModel.router, groupId: viewModel.groupId, expenseId: viewModel.expenseId)) diff --git a/Splito/UI/Home/Expense/Expense Split Option/ExpenseSplitOptionsView.swift b/Splito/UI/Home/Expense/Expense Split Option/ExpenseSplitOptionsView.swift index 3b5630e40..5e9b91916 100644 --- a/Splito/UI/Home/Expense/Expense Split Option/ExpenseSplitOptionsView.swift +++ b/Splito/UI/Home/Expense/Expense Split Option/ExpenseSplitOptionsView.swift @@ -56,7 +56,7 @@ struct ExpenseSplitOptionsView: View { .interactiveDismissDisabled() .toolbar(.hidden, for: .navigationBar) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onTapGesture { UIApplication.shared.endEditing() } @@ -75,7 +75,7 @@ private struct SplitOptionsBottomView: View { memberCount: viewModel.selectedMembers.count, isAllSelected: viewModel.isAllSelected, isForEqualSplit: true, onAllBtnTap: viewModel.handleAllBtnAction) case .fixedAmount: - BottomInfoCardView(title: "₹ \(String(format: "%.2f", viewModel.totalFixedAmount)) of \(viewModel.expenseAmount.formattedCurrency)", + BottomInfoCardView(title: "\(viewModel.totalFixedAmount.formattedCurrency) of \(viewModel.expenseAmount.formattedCurrency)", value: "\((viewModel.expenseAmount - viewModel.totalFixedAmount).formattedCurrencyWithSign) left") case .percentage: BottomInfoCardView(title: "\(String(format: "%.0f", viewModel.totalPercentage))% of 100%", diff --git a/Splito/UI/Home/Expense/Note/AddNoteView.swift b/Splito/UI/Home/Expense/Note/AddNoteView.swift index c0e4a1447..91cc5330c 100644 --- a/Splito/UI/Home/Expense/Note/AddNoteView.swift +++ b/Splito/UI/Home/Expense/Note/AddNoteView.swift @@ -13,23 +13,30 @@ struct AddNoteView: View { @StateObject var viewModel: AddNoteViewModel - @State private var tempNote: String = "" - @FocusState private var isFocused: Bool + @State private var tempPaymentReason: String = "" + + @FocusState private var focusedField: AddNoteViewModel.AddNoteField? var body: some View { - VStack(alignment: .leading, spacing: 8) { - TextField("Enter your note here...", text: $tempNote, axis: .vertical) - .font(.subTitle2()) - .foregroundStyle(primaryText) - .focused($isFocused) - .tint(primaryColor) - .autocorrectionDisabled() - .padding(.horizontal, 16) - .padding(.vertical, 12) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(outlineColor, lineWidth: 1) - } + VStack(alignment: .leading, spacing: 16) { + if let paymentReason = viewModel.paymentReason { + NoteInputFieldView( + text: $tempPaymentReason, focusedField: $focusedField, + title: "Reason", placeholder: "Enter a reason for this payment", + axis: .horizontal, submitLabel: .next, field: .reason, + onSubmit: { + focusedField = .note + }, + onAppear: { + tempPaymentReason = paymentReason.isEmpty ? "Payment" : paymentReason + } + ) + } + + NoteInputFieldView( + text: $viewModel.note, focusedField: $focusedField, title: "Note", + placeholder: "Enter your note here...", field: .note, + onAppear: { }) Spacer() } @@ -40,10 +47,9 @@ struct AddNoteView: View { .navigationTitle("Add note") .navigationBarTitleDisplayMode(.inline) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onAppear { - tempNote = viewModel.note - isFocused = true + focusedField = viewModel.paymentReason != nil ? .reason : .note } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -51,9 +57,8 @@ struct AddNoteView: View { } ToolbarItem(placement: .topBarTrailing) { CheckmarkButton(showLoader: viewModel.showLoader) { - viewModel.note = tempNote.trimming(spaces: .leadingAndTrailing) Task { - let isActionSucceed = await viewModel.handleSaveNoteAction() + let isActionSucceed = await viewModel.handleSaveNoteAction(tempPaymentReason: tempPaymentReason) if isActionSucceed { dismiss() } else { @@ -66,6 +71,46 @@ struct AddNoteView: View { } } +private struct NoteInputFieldView: View { + + @Binding var text: String + var focusedField: FocusState.Binding + + let title: String + let placeholder: String + var axis: Axis = .vertical + var submitLabel: SubmitLabel = .return + var field: AddNoteViewModel.AddNoteField + + var onSubmit: (() -> Void)? + var onAppear: (() -> Void) + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.body3()) + .foregroundStyle(disableText) + + TextField(placeholder.localized, text: $text, axis: axis) + .font(.subTitle2()) + .foregroundStyle(primaryText) + .tint(primaryColor) + .autocorrectionDisabled() + .padding(16) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(outlineColor, lineWidth: 1) + } + .focused(focusedField, equals: field) + .submitLabel(submitLabel) + .onSubmit { + onSubmit?() + } + } + .onAppear(perform: onAppear) + } +} + struct CancelButton: View { @Environment(\.dismiss) var dismiss diff --git a/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift b/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift index d23223665..9f77e7b4e 100644 --- a/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift +++ b/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift @@ -16,19 +16,21 @@ class AddNoteViewModel: BaseViewModel, ObservableObject { @Inject private var transactionRepository: TransactionRepository @Published var note: String + @Published var paymentReason: String? @Published private(set) var showLoader: Bool = false private let group: Groups? private let expense: Expense? private let payment: Transactions? - private let handleSaveNoteTap: ((String) -> Void)? + private let handleSaveNoteTap: ((_ note: String, _ reason: String?) -> Void)? - init(group: Groups?, expense: Expense? = nil, payment: Transactions? = nil, - note: String, handleSaveNoteTap: ((String) -> Void)? = nil) { + init(group: Groups?, expense: Expense? = nil, payment: Transactions? = nil, note: String, + paymentReason: String? = nil, handleSaveNoteTap: ((_ note: String, _ reason: String?) -> Void)? = nil) { self.group = group self.expense = expense self.payment = payment self.note = note + self.paymentReason = paymentReason self.handleSaveNoteTap = handleSaveNoteTap super.init() } @@ -38,15 +40,18 @@ class AddNoteViewModel: BaseViewModel, ObservableObject { self.showToastFor(toast: ToastPrompt(type: .error, title: "Oops", message: "Failed to save note.")) } - func handleSaveNoteAction() async -> Bool { + func handleSaveNoteAction(tempPaymentReason: String) async -> Bool { + note = note.trimming(spaces: .leadingAndTrailing) + paymentReason = (tempPaymentReason == "Payment") ? nil : tempPaymentReason.trimming(spaces: .leadingAndTrailing) + if let handleSaveNoteTap { - handleSaveNoteTap(note) + handleSaveNoteTap(note, paymentReason) return true } if let expense, expense.note != note { return await updateExpenseNote() - } else if let payment, payment.note != note { + } else if let payment, payment.note != note || (payment.reason ?? "") != (paymentReason ?? "") { return await updatePaymentNote() } @@ -88,6 +93,7 @@ class AddNoteViewModel: BaseViewModel, ObservableObject { var updatedPayment = payment updatedPayment.note = note + updatedPayment.reason = paymentReason updatedPayment = try await transactionRepository.updateTransaction(group: group, transaction: updatedPayment, oldTransaction: payment, members: members, type: .transactionUpdated) @@ -114,3 +120,10 @@ class AddNoteViewModel: BaseViewModel, ObservableObject { return nil } } + +extension AddNoteViewModel { + enum AddNoteField { + case note + case reason + } +} diff --git a/Splito/UI/Home/Groups/Add Member/InviteMemberView.swift b/Splito/UI/Home/Groups/Add Member/InviteMemberView.swift index 8c5408b8a..582341172 100644 --- a/Splito/UI/Home/Groups/Add Member/InviteMemberView.swift +++ b/Splito/UI/Home/Groups/Add Member/InviteMemberView.swift @@ -67,7 +67,7 @@ struct InviteMemberView: View { .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .sheet(isPresented: $viewModel.showShareSheet) { ShareSheetView(activityItems: ["Let's split the expense! Use invite code \(viewModel.inviteCode) to join the \(viewModel.group?.name ?? "") group, if you don't have an app then please download it."]) { isCompleted in if isCompleted { diff --git a/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift b/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift index 83b0a0444..74ba8e38c 100644 --- a/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift +++ b/Splito/UI/Home/Groups/Add Member/InviteMemberViewModel.swift @@ -41,8 +41,7 @@ class InviteMemberViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading private func fetchGroup() async { do { - let group = try await groupRepository.fetchGroupBy(id: groupId) - self.group = group + self.group = try await groupRepository.fetchGroupBy(id: groupId) viewState = .initial LogD("InviteMemberViewModel: \(#function) Group fetched successfully.") } catch { diff --git a/Splito/UI/Home/Groups/Add Member/JoinMemberView.swift b/Splito/UI/Home/Groups/Add Member/JoinMemberView.swift index d9a3c146b..1818afb23 100644 --- a/Splito/UI/Home/Groups/Add Member/JoinMemberView.swift +++ b/Splito/UI/Home/Groups/Add Member/JoinMemberView.swift @@ -60,7 +60,7 @@ struct JoinMemberView: View { .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onTapGesture { isFocused = false } diff --git a/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift b/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift index 02b4dd61c..e095500ef 100644 --- a/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift +++ b/Splito/UI/Home/Groups/Create Group/CreateGroupView.swift @@ -55,7 +55,7 @@ struct CreateGroupView: View { isFocused = true } .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) diff --git a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift index 3baa59742..a822abade 100644 --- a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift +++ b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift @@ -38,6 +38,7 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { super.init() } +// MARK: - User Actions private func checkCameraPermission(authorized: @escaping (() -> Void)) { switch AVCaptureDevice.authorizationStatus(for: .video) { case .notDetermined: @@ -100,8 +101,8 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { guard let userId = preference.user?.id else { return false } let memberBalance = GroupMemberBalance(id: userId, balance: 0, totalSummary: []) - let group = Groups(name: groupName.trimming(spaces: .leadingAndTrailing), createdBy: userId, updatedBy: userId, imageUrl: nil, - members: [userId], balances: [memberBalance], createdAt: Timestamp(), updatedAt: Timestamp()) + let group = Groups(name: groupName.trimming(spaces: .leadingAndTrailing), createdBy: userId, + updatedBy: userId, members: [userId], balances: [memberBalance]) do { showLoader = true diff --git a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift index e3fdb1dd6..7b83f73ed 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift @@ -7,6 +7,7 @@ import SwiftUI import BaseStyle +import Data struct GroupBalancesView: View { @@ -46,7 +47,7 @@ struct GroupBalancesView: View { .background(surfaceColor) .interactiveDismissDisabled() .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .fullScreenCover(isPresented: $viewModel.showSettleUpSheet) { NavigationStack { GroupPaymentView( @@ -71,6 +72,8 @@ struct GroupBalancesView: View { @MainActor private struct GroupBalanceItemView: View { + @Inject private var preference: SplitoPreference + let memberBalance: MembersCombinedBalance let viewModel: GroupBalancesViewModel @@ -88,7 +91,7 @@ private struct GroupBalanceItemView: View { let hasDue = memberBalance.totalOwedAmount < 0 let name = viewModel.getMemberName(id: memberBalance.id, needFullName: true) - let owesOrGetsBack = hasDue ? "owes" : "gets back" + let owesOrGetsBack = hasDue ? (memberBalance.id == preference.user?.id ? "owe" : "owes") : (memberBalance.id == preference.user?.id ? "get back" : "gets back") if memberBalance.totalOwedAmount == 0 { Group { @@ -142,6 +145,8 @@ private struct GroupBalanceItemView: View { private struct GroupBalanceItemMemberView: View { let SUB_IMAGE_HEIGHT: CGFloat = 24 + @Inject private var preference: SplitoPreference + let id: String let balances: [String: Double] let viewModel: GroupBalancesViewModel @@ -156,13 +161,14 @@ private struct GroupBalanceItemMemberView: View { let imageUrl = viewModel.getMemberImage(id: memberId) let owesMemberName = viewModel.getMemberName(id: hasDue ? memberId : id) let owedMemberName = viewModel.getMemberName(id: hasDue ? id : memberId) + let owesText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : "owes" VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 16) { MemberProfileImageView(imageUrl: imageUrl, height: SUB_IMAGE_HEIGHT, scaleEffect: 0.6) Group { - Text("\(owedMemberName) owes ") + Text("\(owedMemberName.capitalized) \(owesText.localized) ") + Text(amount.formattedCurrency) .foregroundColor(hasDue ? errorColor : successColor) diff --git a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift index 08c352afd..ec9875462 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift @@ -130,8 +130,8 @@ class GroupBalancesViewModel: BaseViewModel, ObservableObject { } func getMemberName(id: String, needFullName: Bool = false) -> String { - guard let member = getMemberDataBy(id: id) else { return "" } - return needFullName ? member.fullName : member.nameWithLastInitial + guard let userId = preference.user?.id, let member = getMemberDataBy(id: id) else { return "" } + return needFullName ? (id == userId ? "You" : member.fullName) : (id == userId ? "you" : member.nameWithLastInitial) } // MARK: - User Actions diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift index 1da174500..b17ffe3e9 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift @@ -38,7 +38,7 @@ struct GroupSettleUpView: View { .background(surfaceColor) .interactiveDismissDisabled() .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toolbarRole(.editor) .toolbar { ToolbarItem(placement: .topBarLeading) { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift index a268b074f..2d7ac721b 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift @@ -39,13 +39,8 @@ class GroupSettleUpViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading func fetchGroupDetails() async { do { - let group = try await groupRepository.fetchGroupBy(id: groupId) - guard let group else { - viewState = .initial - return - } - self.group = group - calculateMemberPayableAmount(group: group) + self.group = try await groupRepository.fetchGroupBy(id: groupId) + calculateMemberPayableAmount() viewState = .initial LogD("GroupSettleUpViewModel: \(#function) Group fetched successfully.") } catch { @@ -54,8 +49,8 @@ class GroupSettleUpViewModel: BaseViewModel, ObservableObject { } } - func calculateMemberPayableAmount(group: Groups) { - guard let userId = preference.user?.id else { + func calculateMemberPayableAmount() { + guard let group, let userId = preference.user?.id else { viewState = .initial return } diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift index d9e431fef..abb68251e 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift @@ -68,9 +68,12 @@ struct GroupPaymentView: View { .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) - AddNoteImageFooterView(date: $viewModel.paymentDate, showImagePickerOptions: $viewModel.showImagePickerOptions, + AddNoteImageFooterView(date: $viewModel.paymentDate, + showImagePickerOptions: $viewModel.showImagePickerOptions, image: viewModel.paymentImage, imageUrl: viewModel.paymentImageUrl, - isNoteEmpty: viewModel.paymentNote.isEmpty, handleNoteBtnTap: viewModel.handleNoteBtnTap, + isNoteEmpty: (viewModel.paymentNote.isEmpty && + viewModel.paymentReason.isEmpty), + handleNoteBtnTap: viewModel.handleNoteBtnTap, handleImageTap: viewModel.handlePaymentImageTap, handleActionSelection: viewModel.handleActionSelection(_:)) } @@ -80,7 +83,7 @@ struct GroupPaymentView: View { .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onTapGesture { UIApplication.shared.endEditing() } @@ -108,8 +111,10 @@ struct GroupPaymentView: View { } .sheet(isPresented: $viewModel.showAddNoteEditor) { NavigationStack { - AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, note: viewModel.paymentNote, - handleSaveNoteTap: viewModel.handleNoteSaveBtnTap(note:))) + AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, + note: viewModel.paymentNote, + paymentReason: viewModel.paymentReason, + handleSaveNoteTap: viewModel.handleNoteSaveBtnTap(note:reason:))) } } } @@ -205,7 +210,9 @@ struct DatePickerView: View { .sheet(isPresented: $showDatePicker) { VStack(spacing: 0) { NavigationBarTopView(title: "Choose date", leadingButton: EmptyView(), - trailingButton: DismissButton(padding: (16, 0), foregroundColor: primaryText, onDismissAction: { + trailingButton: DismissButton(padding: (16, 0), + foregroundColor: primaryText, + onDismissAction: { showDatePicker = false }) .fontWeight(.regular) diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift index 7ff3b36e7..6f9d473ac 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift @@ -24,6 +24,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { @Published var paymentImage: UIImage? @Published var paymentNote: String = "" + @Published var paymentReason: String = "" @Published private(set) var paymentImageUrl: String? @Published var showImagePicker = false @@ -110,6 +111,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { paymentDate = transaction?.date.dateValue() ?? Date.now paymentNote = transaction?.note ?? "" paymentImageUrl = transaction?.imageUrl + paymentReason = transaction?.reason ?? "" viewState = .initial LogD("GroupPaymentViewModel: \(#function) Payment fetched successfully.") @@ -122,8 +124,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { private func getPayerUserDetail() async { do { viewState = .loading - let user = try await userRepository.fetchUserBy(userID: payerId) - if let user { payer = user } + payer = try await userRepository.fetchUserBy(userID: payerId) viewState = .initial LogD("GroupPaymentViewModel: \(#function) Payer fetched successfully.") } catch { @@ -135,8 +136,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { private func getPayableUserDetail() async { do { viewState = .loading - let user = try await userRepository.fetchUserBy(userID: receiverId) - if let user { receiver = user } + receiver = try await userRepository.fetchUserBy(userID: receiverId) viewState = .initial LogD("GroupPaymentViewModel: \(#function) Payable fetched successfully.") } catch { @@ -150,9 +150,10 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { showAddNoteEditor = true } - func handleNoteSaveBtnTap(note: String) { + func handleNoteSaveBtnTap(note: String, reason: String?) { showAddNoteEditor = false self.paymentNote = note + self.paymentReason = reason ?? "" } func handlePaymentImageTap() { @@ -239,12 +240,13 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { newTransaction.updatedAt = Timestamp() newTransaction.updatedBy = userId newTransaction.note = paymentNote + newTransaction.reason = paymentReason return await updateTransaction(transaction: newTransaction, oldTransaction: transaction) } else { let transaction = Transactions(payerId: payerId, receiverId: receiverId, addedBy: userId, - updatedBy: userId, note: paymentNote, amount: amount, - date: .init(date: paymentDate)) + updatedBy: userId, note: paymentNote, reason: paymentReason, + amount: amount, date: .init(date: paymentDate)) return await addTransaction(transaction: transaction) } } @@ -290,6 +292,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { NotificationCenter.default.post(name: .updateTransaction, object: self.transaction) } + guard let transaction = self.transaction else { return false } guard hasTransactionChanged(transaction, oldTransaction: oldTransaction) else { return true } await updateGroupMemberBalance(updateType: .Update(oldTransaction: oldTransaction)) @@ -312,7 +315,8 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { private func hasTransactionChanged(_ transaction: Transactions, oldTransaction: Transactions) -> Bool { return oldTransaction.payerId != transaction.payerId || oldTransaction.receiverId != transaction.receiverId || - oldTransaction.amount != transaction.amount || oldTransaction.isActive != transaction.isActive + oldTransaction.amount != transaction.amount || oldTransaction.isActive != transaction.isActive || + oldTransaction.date != transaction.date } private func updateGroupMemberBalance(updateType: TransactionUpdateType) async { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Getting Paid/GroupWhoGettingPaidView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Getting Paid/GroupWhoGettingPaidView.swift index 706df949c..d9fdcf137 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Getting Paid/GroupWhoGettingPaidView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Getting Paid/GroupWhoGettingPaidView.swift @@ -40,7 +40,7 @@ struct GroupWhoGettingPaidView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onAppear(perform: viewModel.fetchInitialViewData) .toolbarRole(.editor) .toolbar { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Is paying/GroupWhoIsPayingView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Is paying/GroupWhoIsPayingView.swift index 10daa2cf8..86fd45939 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Is paying/GroupWhoIsPayingView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Who Is paying/GroupWhoIsPayingView.swift @@ -40,7 +40,7 @@ struct GroupWhoIsPayingView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onAppear(perform: viewModel.fetchInitialViewData) .toolbarRole(.editor) .toolbar { @@ -53,6 +53,8 @@ struct GroupWhoIsPayingView: View { struct GroupPayingMemberView: View { + @Inject private var preference: SplitoPreference + let member: AppUser let isSelected: Bool @@ -61,6 +63,14 @@ struct GroupPayingMemberView: View { let onMemberTap: (String) -> Void + private var memberName: String { + if let user = preference.user, user.id == member.id { + return "You" + } else { + return member.fullName + } + } + init(member: AppUser, isSelected: Bool = false, isLastMember: Bool, disableMemberTap: Bool = false, onMemberTap: @escaping (String) -> Void) { self.member = member self.isSelected = isSelected @@ -73,7 +83,7 @@ struct GroupPayingMemberView: View { HStack(alignment: .center, spacing: 16) { MemberProfileImageView(imageUrl: member.imageUrl) - Text(member.fullName.localized) + Text(memberName.localized) .font(.subTitle2()) .foregroundStyle(primaryText) .lineLimit(1) diff --git a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsView.swift b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsView.swift index 30a961d41..0e9a238d8 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsView.swift @@ -37,7 +37,7 @@ struct GroupTotalsView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toolbarRole(.editor) .toolbar { ToolbarItem(placement: .topBarLeading) { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift index 5b7f1a5c5..23376fb6d 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift @@ -35,8 +35,7 @@ class GroupTotalsViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading private func fetchGroup() async { do { - let latestGroup = try await groupRepository.fetchGroupBy(id: groupId) - group = latestGroup + group = try await groupRepository.fetchGroupBy(id: groupId) filterDataForSelectedTab() viewState = .initial LogD("GroupTotalsViewModel: \(#function) Group fetched successfully.") diff --git a/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift b/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift index cf77c4b43..52e3aaa3f 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift @@ -34,7 +34,7 @@ struct GroupTransactionListView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toolbarRole(.editor) .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -57,14 +57,12 @@ private struct TransactionListWithDetailView: View { EmptyTransactionView(geometry: geometry) } else { let firstMonth = viewModel.filteredTransactions.keys.sorted(by: sortMonthYearStrings).first - let lastMonth = viewModel.filteredTransactions.keys.sorted(by: sortMonthYearStrings).last ForEach(viewModel.filteredTransactions.keys.sorted(by: sortMonthYearStrings), id: \.self) { month in Section(header: sectionHeader(month: month)) { ForEach(viewModel.filteredTransactions[month] ?? [], id: \.transaction.id) { transaction in TransactionItemView(transactionWithUser: transaction, isLastCell: transaction.transaction.id == (viewModel.filteredTransactions[month] ?? []).last?.transaction.id) - .padding(.bottom, (month == lastMonth && viewModel.filteredTransactions[month]?.last?.transaction.id == transaction.transaction.id) ? 50 : 0) .onTouchGesture { viewModel.handleTransactionItemTap(transaction.transaction.id) } @@ -129,7 +127,7 @@ private struct TransactionListWithDetailView: View { return Text(month) .font(.Header4()) .foregroundStyle(primaryText) - .padding(.vertical, 8) + .padding(.vertical, 5) .padding(.horizontal, 16) } } @@ -154,7 +152,7 @@ private struct TransactionItemView: View { } var body: some View { - VStack(spacing: 20) { + VStack(spacing: 0) { HStack(alignment: .center, spacing: 0) { let dateComponents = transactionWithUser.transaction.date.dateValue().dayAndMonthText VStack(spacing: 0) { @@ -177,9 +175,15 @@ private struct TransactionItemView: View { .padding(.trailing, 16) HStack(spacing: 0) { - Text("\(payerName.localized) paid \(receiverName.localized)") - .font(.subTitle2()) - .foregroundStyle(primaryText) + if let reason = transactionWithUser.transaction.reason, !reason.isEmpty { + Text("\(payerName.localized) paid \(receiverName.localized) for '\(reason.localized)'") + .font(.subTitle2()) + .foregroundStyle(primaryText) + } else { + Text("\(payerName.localized) paid \(receiverName.localized)") + .font(.subTitle2()) + .foregroundStyle(primaryText) + } Spacer() @@ -189,6 +193,7 @@ private struct TransactionItemView: View { } } .padding(.top, 20) + .padding(.bottom, isLastCell ? 13 : 20) .padding(.horizontal, 16) if !isLastCell { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListViewModel.swift index 458071012..742994115 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListViewModel.swift @@ -130,7 +130,7 @@ class GroupTransactionListViewModel: BaseViewModel, ObservableObject { return existingUser // Return the available user from groupMembers } else { do { - let user = try await groupRepository.fetchMemberBy(userId: userId) + let user = try await groupRepository.fetchMemberBy(memberId: userId) if let user { groupMembers.append(user) } diff --git a/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailView.swift b/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailView.swift index 2b20b7456..c8d7ba3e8 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailView.swift @@ -68,7 +68,7 @@ struct GroupTransactionDetailView: View { } .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .fullScreenCover(isPresented: $viewModel.showEditTransactionSheet) { NavigationStack { GroupPaymentView( @@ -103,7 +103,8 @@ struct GroupTransactionDetailView: View { } .fullScreenCover(isPresented: $viewModel.showAddNoteEditor) { NavigationStack { - AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, note: viewModel.paymentNote)) + AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, + note: viewModel.paymentNote, paymentReason: viewModel.paymentReason)) } } .navigationDestination(isPresented: $showImageDisplayView) { @@ -164,7 +165,9 @@ private struct TransactionInfoView: View { .background(container2Color) .cornerRadius(16) - TransactionSummaryView(date: viewModel.transaction?.date.dateValue(), amount: viewModel.transaction?.amount, payerName: payerName, receiverName: receiverName, addedUserName: addedUserName) + TransactionSummaryView(date: viewModel.transaction?.date.dateValue(), amount: viewModel.transaction?.amount, + reason: viewModel.paymentReason, payerName: payerName, + receiverName: receiverName, addedUserName: addedUserName) } .multilineTextAlignment(.center) } @@ -197,17 +200,26 @@ private struct TransactionSummaryView: View { let date: Date? let amount: Double? + let reason: String? let payerName: String let receiverName: String let addedUserName: String var body: some View { VStack(spacing: 0) { - Text("\(payerName.localized) paid \(receiverName.localized)") - .font(.subTitle2()) - .foregroundStyle(primaryText) - .lineSpacing(2) - .padding(.bottom, 8) + if let reason, !reason.isEmpty { + Text("\(payerName.localized) paid \(receiverName.localized) for '\(reason.localized)'") + .font(.subTitle2()) + .foregroundStyle(primaryText) + .lineSpacing(2) + .padding(.bottom, 8) + } else { + Text("\(payerName.localized) paid \(receiverName.localized)") + .font(.subTitle2()) + .foregroundStyle(primaryText) + .lineSpacing(2) + .padding(.bottom, 8) + } Text(amount?.formattedCurrency ?? "₹ 0") .font(.Header2()) diff --git a/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailViewModel.swift index 757b56458..2b921b061 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Transactions/Transaction Detail/GroupTransactionDetailViewModel.swift @@ -16,6 +16,8 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { @Inject private var transactionRepository: TransactionRepository @Published var paymentNote: String = "" + @Published var paymentReason: String? + @Published private(set) var transaction: Transactions? @Published private(set) var transactionUsersData: [AppUser] = [] @@ -50,8 +52,7 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading private func fetchGroup() async { do { - let group = try await groupRepository.fetchGroupBy(id: groupId) - self.group = group + self.group = try await groupRepository.fetchGroupBy(id: groupId) viewState = .initial LogD("GroupTransactionDetailViewModel: \(#function) Group fetched successfully.") } catch { @@ -63,8 +64,7 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { func fetchTransaction() async { do { viewState = .loading - let transaction = try await transactionRepository.fetchTransactionBy(groupId: groupId, transactionId: transactionId) - self.transaction = transaction + self.transaction = try await transactionRepository.fetchTransactionBy(groupId: groupId, transactionId: transactionId) await setTransactionUsersData() self.viewState = .initial LogD("GroupTransactionDetailViewModel: \(#function) Payment fetched successfully.") @@ -75,7 +75,10 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { } private func setTransactionUsersData() async { - guard let transaction else { return } + guard let transaction else { + viewState = .initial + return + } var userData: [AppUser] = [] var members: [String] = [] @@ -92,6 +95,7 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { self.transactionUsersData = userData self.paymentNote = transaction.note ?? "" + self.paymentReason = transaction.reason ?? "" } private func fetchUserData(for userId: String) async -> AppUser? { @@ -199,7 +203,6 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { payer: payer, receiver: receiver) NotificationCenter.default.post(name: .deleteTransaction, object: self.transaction) await updateGroupMemberBalance(updateType: .Delete) - self.showToastFor(toast: .init(type: .success, title: "Success", message: "Payment deleted successfully.")) viewState = .initial LogD("GroupTransactionDetailViewModel: \(#function) Payment deleted successfully.") @@ -261,6 +264,7 @@ class GroupTransactionDetailViewModel: BaseViewModel, ObservableObject { guard let updatedTransaction = notification.object as? Transactions else { return } transaction = updatedTransaction paymentNote = updatedTransaction.note ?? "" + paymentReason = updatedTransaction.reason ?? "" } // MARK: - Error Handling diff --git a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift index 53176e983..72f98bc3c 100644 --- a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift +++ b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift @@ -44,7 +44,7 @@ struct GroupSettingView: View { .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) .toastView(toast: $viewModel.toast) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .confirmationDialog("", isPresented: $viewModel.showLeaveGroupDialog, titleVisibility: .hidden) { Button("Leave Group", action: viewModel.onRemoveAndLeaveFromGroupTap) } diff --git a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift index dcf8eaf08..260fd62c9 100644 --- a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift @@ -45,8 +45,7 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading private func fetchGroupDetails() async { do { - let group = try await groupRepository.fetchGroupBy(id: groupId) - self.group = group + self.group = try await groupRepository.fetchGroupBy(id: groupId) self.checkForGroupAdmin() await fetchGroupMembers() currentViewState = .initial diff --git a/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift b/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift index 611cc6e67..99067f5a3 100644 --- a/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift +++ b/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift @@ -53,14 +53,12 @@ struct GroupExpenseListView: View { onClick: viewModel.openAddExpenseSheet) } else if !viewModel.groupExpenses.isEmpty { let firstMonth = viewModel.groupExpenses.keys.sorted(by: sortMonthYearStrings).first - let lastMonth = viewModel.groupExpenses.keys.sorted(by: sortMonthYearStrings).last ForEach(viewModel.groupExpenses.keys.sorted(by: sortMonthYearStrings), id: \.self) { month in Section(header: sectionHeader(month: month)) { ForEach(viewModel.groupExpenses[month] ?? [], id: \.expense.id) { expense in GroupExpenseItemView(expenseWithUser: expense, isLastItem: expense.expense == (viewModel.groupExpenses[month] ?? []).last?.expense) - .padding(.bottom, (month == lastMonth && viewModel.groupExpenses[month]?.last?.expense.id == expense.expense.id) ? 50 : 0) .onTouchGesture { viewModel.handleExpenseItemTap(expenseId: expense.expense.id ?? "") } @@ -127,7 +125,7 @@ struct GroupExpenseListView: View { .font(.Header4()) .foregroundStyle(primaryText) .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.vertical, 5) Spacer() } .onTapGestureForced { @@ -175,7 +173,7 @@ private struct GroupExpenseItemView: View { } var body: some View { - VStack(spacing: 20) { + VStack(spacing: 0) { HStack(alignment: .center, spacing: 0) { let dateComponents = expense.date.dateValue().dayAndMonthText VStack(spacing: 0) { @@ -243,6 +241,7 @@ private struct GroupExpenseItemView: View { } .padding(.horizontal, 16) .padding(.top, 20) + .padding(.bottom, isLastItem ? 13 : 20) if !isLastItem { Divider() diff --git a/Splito/UI/Home/Groups/Group/GroupHomeView.swift b/Splito/UI/Home/Groups/Group/GroupHomeView.swift index 16d7b08f8..f6050be4c 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeView.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeView.swift @@ -49,7 +49,7 @@ struct GroupHomeView: View { .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .onDisappear { if viewModel.showSearchBar { isFocused = false diff --git a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift index 1052e25ab..df7246892 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift @@ -194,7 +194,7 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { func fetchMemberData(for memberId: String) async -> AppUser? { do { - let member = try await groupRepository.fetchMemberBy(userId: memberId) + let member = try await groupRepository.fetchMemberBy(memberId: memberId) if let member { addMemberIfNotExist(member) } diff --git a/Splito/UI/Home/Groups/Group/GroupHomeViewModelExtension.swift b/Splito/UI/Home/Groups/Group/GroupHomeViewModelExtension.swift index bbb80435d..8cca02216 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeViewModelExtension.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeViewModelExtension.swift @@ -137,7 +137,7 @@ extension GroupHomeViewModel { await updateGroupMemberBalance(expense: deletedExpense, updateType: .Delete) LogD("GroupHomeViewModel: \(#function) Expense deleted successfully.") } catch { - LogE("GroupHomeViewModel: \(#function) Failed to delete expense \(expenseId): \(error)") + LogE("GroupHomeViewModel: \(#function) Failed to delete expense \(expenseId): \(error).") showToastForError() } } diff --git a/Splito/UI/Home/Groups/GroupListView.swift b/Splito/UI/Home/Groups/GroupListView.swift index 9c26afc9a..9898de6c2 100644 --- a/Splito/UI/Home/Groups/GroupListView.swift +++ b/Splito/UI/Home/Groups/GroupListView.swift @@ -70,7 +70,7 @@ struct GroupListView: View { .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -127,6 +127,7 @@ struct GroupListView: View { JoinMemberView(viewModel: JoinMemberViewModel(router: viewModel.router)) } } + .onAppear(perform: viewModel.fetchCurrentUser) } } diff --git a/Splito/UI/Home/Groups/GroupListViewModel.swift b/Splito/UI/Home/Groups/GroupListViewModel.swift index b47cf18df..9d7f53b68 100644 --- a/Splito/UI/Home/Groups/GroupListViewModel.swift +++ b/Splito/UI/Home/Groups/GroupListViewModel.swift @@ -165,7 +165,7 @@ class GroupListViewModel: BaseViewModel, ObservableObject { if let user { self?.totalOweAmount = user.totalOweAmount } else { - self?.handleServiceError() + self?.showToastForError() } } } @@ -181,6 +181,22 @@ class GroupListViewModel: BaseViewModel, ObservableObject { return nil } } + + func fetchCurrentUser() { + guard let userId = preference.user?.id else { return } + + Task.detached { [weak self] in + do { + guard let self else { return } + let user = try await self.userRepository.fetchUserBy(userID: userId) + await MainActor.run { + self.preference.user = user + } + } catch { + LogE("GroupListViewModel: \(#function) Failed to fetch current user: \(error).") + } + } + } } // MARK: - User Actions diff --git a/Splito/UI/Login/LoginView.swift b/Splito/UI/Login/LoginView.swift index 8c3519125..480f73aea 100644 --- a/Splito/UI/Login/LoginView.swift +++ b/Splito/UI/Login/LoginView.swift @@ -46,7 +46,7 @@ struct LoginView: View { } } .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .ignoresSafeArea(edges: .top) .toolbar(.hidden, for: .navigationBar) } @@ -63,9 +63,14 @@ private struct LoginOptionsView: View { var body: some View { VStack(spacing: 8) { - LoginOptionsButtonView(image: .googleIcon, buttonName: "Sign in with Google", showLoader: showGoogleLoading, onClick: onGoogleLoginClick) - LoginOptionsButtonView(systemImage: ("apple.logo", primaryText, (14, 16)), buttonName: "Sign in with Apple", showLoader: showAppleLoading, onClick: onAppleLoginClick) - LoginOptionsButtonView(systemImage: ("phone.fill", primaryLightText, (12, 12)), buttonName: "Sign in with Phone Number", bgColor: primaryColor, buttonTextColor: primaryLightText, showLoader: false, onClick: onPhoneLoginClick) + LoginOptionsButtonView(image: .googleIcon, buttonName: "Sign in with Google", + showLoader: showGoogleLoading, onClick: onGoogleLoginClick) + LoginOptionsButtonView(systemImage: ("apple.logo", primaryText, (14, 16)), + buttonName: "Sign in with Apple", showLoader: showAppleLoading, + onClick: onAppleLoginClick) + LoginOptionsButtonView(systemImage: ("phone.fill", primaryLightText, (12, 12)), + buttonName: "Sign in with Phone Number", bgColor: primaryColor, + buttonTextColor: primaryLightText, showLoader: false, onClick: onPhoneLoginClick) } .padding(.horizontal, 16) .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) @@ -83,7 +88,10 @@ private struct LoginOptionsButtonView: View { let showLoader: Bool let onClick: () -> Void - init(image: ImageResource? = nil, systemImage: (name: String, color: Color, size: (width: CGFloat, height: CGFloat))? = nil, buttonName: String, bgColor: Color = container2Color, buttonTextColor: Color = primaryDarkColor, showLoader: Bool, onClick: @escaping () -> Void) { + init(image: ImageResource? = nil, + systemImage: (name: String, color: Color, size: (width: CGFloat, height: CGFloat))? = nil, + buttonName: String, bgColor: Color = container2Color, buttonTextColor: Color = primaryDarkColor, + showLoader: Bool, onClick: @escaping () -> Void) { self.image = image self.systemImage = systemImage self.buttonName = buttonName diff --git a/Splito/UI/Login/LoginViewModel.swift b/Splito/UI/Login/LoginViewModel.swift index d3724b9c4..780529b8c 100644 --- a/Splito/UI/Login/LoginViewModel.swift +++ b/Splito/UI/Login/LoginViewModel.swift @@ -67,7 +67,7 @@ public class LoginViewModel: BaseViewModel, ObservableObject { request.nonce = NonceGenerator.sha256(currentNonce) appleSignInDelegates = SignInWithAppleDelegates { (token, fName, lName, email) in - let credential = OAuthProvider.credential(providerID: AuthProviderID(rawValue: "apple.com")!, + let credential = OAuthProvider.credential(providerID: AuthProviderID.apple, idToken: token, rawNonce: self.currentNonce) self.showAppleLoading = true self.performFirebaseLogin(showAppleLoading: self.showAppleLoading, credential: credential, diff --git a/Splito/UI/Login/PhoneLogin/PhoneLoginView.swift b/Splito/UI/Login/PhoneLogin/PhoneLoginView.swift index 8528de43f..0ace1840d 100644 --- a/Splito/UI/Login/PhoneLogin/PhoneLoginView.swift +++ b/Splito/UI/Login/PhoneLogin/PhoneLoginView.swift @@ -48,7 +48,7 @@ public struct PhoneLoginView: View { } } .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .ignoresSafeArea(edges: .top) .toolbar(.hidden, for: .navigationBar) .overlay(alignment: .topLeading) { diff --git a/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpView.swift b/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpView.swift index e07d0d3ff..43d32d00c 100644 --- a/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpView.swift +++ b/Splito/UI/Login/PhoneLogin/VerifyOtp/VerifyOtpView.swift @@ -58,7 +58,7 @@ public struct VerifyOtpView: View { } } .background(surfaceColor) - .backport.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) + .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .toastView(toast: $viewModel.toast) .onTapGesture { UIApplication.shared.endEditing() diff --git a/functions/firestore-debug.log b/functions/firestore-debug.log new file mode 100644 index 000000000..6f2f15d94 --- /dev/null +++ b/functions/firestore-debug.log @@ -0,0 +1,16 @@ +Dec 02, 2024 6:45:43 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start +INFO: Started WebSocket server on ws://127.0.0.1:9150 +API endpoint: http://127.0.0.1:8080 +If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run: + + export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 + +If you are running a Firestore in Datastore Mode project, run: + + export DATASTORE_EMULATOR_HOST=127.0.0.1:8080 + +Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues. +Dev App Server is now running. + +*** shutting down gRPC server since JVM is shutting down +*** server shut down diff --git a/functions/lib/locales/en.json b/functions/lib/locales/en.json index 87da7eac7..f8e175194 100644 --- a/functions/lib/locales/en.json +++ b/functions/lib/locales/en.json @@ -12,9 +12,13 @@ "expense_deleted": "Expense deleted: {expenseName} \n{amountMessage}", "expense_restored": "Expense restored: {expenseName} \n{amountMessage}", "transaction_added": "{payerName} paid {receiverName} {amountMessage}", + "transaction_added_with_reason": "{payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_updated": "Payment updated: {payerName} paid {receiverName} {amountMessage}", + "transaction_updated_with_reason": "Payment updated: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_deleted": "Payment deleted: {payerName} paid {receiverName} {amountMessage}", + "transaction_deleted_with_reason": "Payment deleted: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_restored": "Payment restored: {payerName} paid {receiverName} {amountMessage}", + "transaction_restored_with_reason": "Payment restored: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "new_activity": "New activity detected", "owe": "You owe {amount}", "getBack": "You get back {amount}", diff --git a/functions/package.json b/functions/package.json index c72e8aac9..d7c7a0d1b 100644 --- a/functions/package.json +++ b/functions/package.json @@ -3,6 +3,7 @@ "scripts": { "lint": "eslint --ext .js,.ts .", "build": "tsc", + "clean": "rm -rf lib && rm -rf node_modules", "build:watch": "tsc --watch", "serve": "npm run build && firebase emulators:start --only functions", "shell": "npm run build && firebase functions:shell", @@ -16,7 +17,8 @@ "main": "lib/index.js", "dependencies": { "firebase-admin": "^12.6.0", - "firebase-functions": "^6.0.1" + "firebase-functions": "^6.1.1", + "lodash": "^4.17.21" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.12.0", diff --git a/functions/src/index.ts b/functions/src/index.ts index 711104a81..3d0ec333c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,7 +16,7 @@ if (admin.apps.length === 0) { } } else { logger.debug('Firebase app already initialized'); -} +} exports.onGroupWrite = onGroupWrite; -exports.onActivityCreate = onActivityCreate; \ No newline at end of file +exports.onActivityCreate = onActivityCreate; diff --git a/functions/src/locales/en.json b/functions/src/locales/en.json index 0892cd513..a382cd53a 100644 --- a/functions/src/locales/en.json +++ b/functions/src/locales/en.json @@ -12,9 +12,13 @@ "expense_deleted": "Expense deleted: {expenseName} \n{amountMessage}", "expense_restored": "Expense restored: {expenseName} \n{amountMessage}", "transaction_added": "{payerName} paid {receiverName} {amountMessage}", + "transaction_added_with_reason": "{payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_updated": "Payment updated: {payerName} paid {receiverName} {amountMessage}", + "transaction_updated_with_reason": "Payment updated: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_deleted": "Payment deleted: {payerName} paid {receiverName} {amountMessage}", + "transaction_deleted_with_reason": "Payment deleted: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "transaction_restored": "Payment restored: {payerName} paid {receiverName} {amountMessage}", + "transaction_restored_with_reason": "Payment restored: {payerName} paid {receiverName} {amountMessage} for \"{paymentReason}\"", "new_activity": "New activity detected", "owe": "You owe {amount}", "getBack": "You get back {amount}", diff --git a/functions/src/notifications_service/notifications_service.ts b/functions/src/notifications_service/notifications_service.ts index b51df42de..f4bd73e3d 100644 --- a/functions/src/notifications_service/notifications_service.ts +++ b/functions/src/notifications_service/notifications_service.ts @@ -8,7 +8,13 @@ import messages from '../locales/en.json'; // Initialize Firebase app if not already initialized if (admin.apps.length === 0) { - admin.initializeApp(); + try { + admin.initializeApp(); + logger.info('Firebase app initialized in notifications_service'); + } catch (error) { + logger.error('Failed to initialize Firebase app in notifications_service:', error); + throw error; + } } const db: Firestore = getFirestore(); @@ -39,6 +45,7 @@ interface ActivityData { expense_name?: string; payer_name?: string; receiver_name?: string + payment_reason?: string amount?: number; } @@ -79,8 +86,9 @@ function generateNotificationMessage(activityData: ActivityData) { const amountMessage = generateAmountMessage(amount); const expenseName = activityData.expense_name ?? messages.unknown; const actionUserName = activityData.action_user_name; - const payerName = activityData.payer_name ?? messages.someone; + const payerName = capitalizeFirstLetter(activityData.payer_name ?? messages.someone); const receiverName = activityData.receiver_name ?? messages.someone; + const paymentReason = activityData.payment_reason; const groupName = activityData.group_name; const previousGroupName = activityData.previous_group_name ?? messages.unknown; @@ -122,16 +130,16 @@ function generateNotificationMessage(activityData: ActivityData) { return messages.expense_restored.replace("{expenseName}", expenseName).replace("{amountMessage}", amountMessage); case 'transaction_added': - return messages.transaction_added.replace("{payerName}", payerName).replace("{receiverName}", receiverName).replace("{amountMessage}", formatCurrency(Math.abs(amount))); + return getTransactionMessage("transaction_added_with_reason", "transaction_added", payerName, receiverName, amount, paymentReason); case 'transaction_updated': - return messages.transaction_updated.replace("{payerName}", payerName).replace("{receiverName}", receiverName).replace("{amountMessage}", formatCurrency(Math.abs(amount))); + return getTransactionMessage("transaction_updated_with_reason", "transaction_updated", payerName, receiverName, amount, paymentReason); case 'transaction_deleted': - return messages.transaction_deleted.replace("{payerName}", payerName).replace("{receiverName}", receiverName).replace("{amountMessage}", formatCurrency(Math.abs(amount))); + return getTransactionMessage("transaction_deleted_with_reason", "transaction_deleted", payerName, receiverName, amount, paymentReason); case 'transaction_restored': - return messages.transaction_restored.replace("{payerName}", payerName).replace("{receiverName}", receiverName).replace("{amountMessage}", formatCurrency(Math.abs(amount))); + return getTransactionMessage("transaction_restored_with_reason", "transaction_restored", payerName, receiverName, amount, paymentReason); default: return messages.new_activity; @@ -149,6 +157,30 @@ function generateAmountMessage(owedAmount: number): string { } } +// Generates a transaction message with dynamic values based on the payment reason +type MessageKeys = keyof typeof messages; +function getTransactionMessage( + messageKeyWithReason: MessageKeys, + messageKeyWithoutReason: MessageKeys, + payerName: string, + receiverName: string, + amount: number, + paymentReason?: string, +): string { + const messageKey = paymentReason?.trim() ? messageKeyWithReason : messageKeyWithoutReason; + return messages[messageKey] + .replace("{payerName}", payerName) + .replace("{receiverName}", receiverName) + .replace("{amountMessage}", formatCurrency(Math.abs(amount))) + .replace("{paymentReason}", paymentReason || ""); +}; + +// Capitalizes the first letter and lowers the rest for consistent formatting +function capitalizeFirstLetter(name: string): string { + if (!name) return ''; + return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); +} + // Function to send notification using FCM with retry mechanism async function sendNotification(userId: string, title: string, body: string, activityId: string, maxRetries = 5) { const baseDelay = 1000; // Initial delay in milliseconds diff --git a/functions/src/users_service/users_service.ts b/functions/src/users_service/users_service.ts index 66b806cf0..587d23e34 100644 --- a/functions/src/users_service/users_service.ts +++ b/functions/src/users_service/users_service.ts @@ -7,7 +7,13 @@ import * as _ from 'lodash'; // Initialize Firebase app if not already initialized if (admin.apps.length === 0) { - admin.initializeApp(); + try { + admin.initializeApp(); + logger.info('Firebase app initialized in users_service'); + } catch (error) { + logger.error('Failed to initialize Firebase app in users_service:', error); + throw error; // Prevent further function execution + } } const db = admin.firestore(); diff --git a/lint b/lint index 126c90061..b9c2ae4dc 100644 --- a/lint +++ b/lint @@ -6,13 +6,20 @@ Running command: npm --prefix "$RESOURCE_DIR" run build > tsc ✔ functions: Finished running predeploy script. -Running command: npm --prefix "$RESOURCE_DIR" run lint +i functions: preparing codebase default for deployment +i functions: ensuring required API cloudfunctions.googleapis.com is enabled... +i functions: ensuring required API cloudbuild.googleapis.com is enabled... +i artifactregistry: ensuring required API artifactregistry.googleapis.com is enabled... +✔ functions: required API cloudbuild.googleapis.com is enabled +✔ functions: required API cloudfunctions.googleapis.com is enabled +✔ artifactregistry: required API artifactregistry.googleapis.com is enabled +⚠ functions: package.json indicates an outdated version of firebase-functions. Please upgrade using npm install --save firebase-functions@latest in your functions directory. +i functions: Loading and analyzing source code for codebase default to determine what to deploy +Serving at port 8638 -> lint -> eslint --ext .js,.ts . - -Running command: npm --prefix "$RESOURCE_DIR" run build - -> build -> tsc +{"severity":"DEBUG","message":"Firebase app already initialized"} +i extensions: ensuring required API firebaseextensions.googleapis.com is enabled... +✔ extensions: required API firebaseextensions.googleapis.com is enabled +i functions: preparing functions directory for uploading... +i functions: packaged /Users/NiraliSonani/Desktop/Splito/functions (97.65 KB) for uploading diff --git a/package.json b/package.json index 82f26eebb..535428047 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,7 @@ { "dependencies": { - "firebase-admin": "^12.0.0" + "firebase-admin": "^13.0.1", + "firebase-functions": "^6.1.1", + "lodash": "^4.17.21" } }