diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..e5e4b538 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Thank you so much for your interest in contributing! All types of contributions are encouraged and valued. The Project Team looks forward to your contributions. + +## Filing Issues + +When in doubt, file an issue. We'd rather close a few duplicate issues than let a problem go unnoticed. Similarly, if you support a particular feature request, please let us know by commenting on the issue or [subscribing](https://help.github.com/articles/subscribing-to-conversations/) to the issue. + +If you are reporting a bug, please help speed up problem diagnosis by providing as much information as possible. Ideally, that would include a small sample project (or gist) that reproduces the problem. + +## Contributing Code + +We actively welcome your pull requests. You can find instructions on building the project in [README.md](https://github.com/canopas/splito). + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. Make sure your code lints. + +## Labels + +Labels on issues are managed by contributors; you don't have to worry about them. Here's a list of what they mean: + +- **bug**: Feature that should work, but doesn't. +- **enhancement**: Minor tweak/addition to existing behavior. +- **feature**: New behavior, bigger than enhancement. +- **question**: No need for any fix, usually a usage problem. +- **reproducible**: Has enough information to very easily reproduce, mostly in the form of a small project in a GitHub repo. +- **repro-needed**: We need some code to be able to reproduce and debug locally; otherwise, there's not much we can do. +- **duplicate**: There's another issue that already covers/tracks this. +- **wontfix**: Working as intended, or won't be fixed due to compatibility or other reasons. +- **invalid**: There isn't enough information to make a verdict, or unrelated. +- **non-library**: Issue is not in the core library code, but rather in documentation, samples, build process, or releases. + +## License + +By contributing to Splito, you agree that your contributions will be licensed under its Apache License, Version 2.0. See the LICENSE file for details. diff --git a/Data/Data/Extension/Double+Extension.swift b/Data/Data/Extension/Double+Extension.swift index 28842458..1d1ba6ca 100644 --- a/Data/Data/Extension/Double+Extension.swift +++ b/Data/Data/Extension/Double+Extension.swift @@ -31,4 +31,10 @@ public extension Double { return String(format: "%.2f", self.rounded()) // Fallback to a basic decimal format } } + + /// Rounds the `Double` value to the specified number of decimal places + func rounded(to decimals: Int) -> Double { + let multiplier = pow(10.0, Double(decimals)) + return (self * multiplier).rounded() / multiplier + } } diff --git a/Data/Data/Model/Expense.swift b/Data/Data/Model/Expense.swift index 82ecca45..9c1b61e3 100644 --- a/Data/Data/Model/Expense.swift +++ b/Data/Data/Model/Expense.swift @@ -73,7 +73,7 @@ extension Expense { switch self.splitType { case .equally: - return self.amount / Double(self.splitTo.count) + return calculateEqualSplitAmount(for: member) case .fixedAmount: return self.splitData?[member] ?? 0 case .percentage: @@ -85,6 +85,30 @@ extension Expense { } } + /// Returns the equal split amount for the member + private func calculateEqualSplitAmount(for member: String) -> Double { + let totalMembers = Double(self.splitTo.count) + let baseAmount = (self.amount / totalMembers).rounded(to: 2) // Base amount each member owes + let totalSplitAmount = baseAmount * totalMembers // The total split amount after rounding all members base amounts + let remainder = self.amount - totalSplitAmount // The leftover amount due to rounding + + // Sort members deterministically to ensure consistent assignment of the remainder. + let sortedMembers = self.splitTo.sorted() + + // Assign base amount to each member + var splitAmounts: [String: Double] = [:] + for splitMember in sortedMembers { + splitAmounts[splitMember] = baseAmount + } + + // Distribute remainder to the first member in the sorted list + if remainder > 0, let firstMember = sortedMembers.first { + splitAmounts[firstMember]! += remainder + } + + return splitAmounts[member] ?? 0 + } + /// It will return the owing amount to the member for that expense that he have to get or pay back public func getCalculatedSplitAmountOf(member: String) -> Double { let paidAmount = self.paidBy[member] ?? 0 diff --git a/Data/Data/Repository/GroupRepository.swift b/Data/Data/Repository/GroupRepository.swift index 55f17052..543af420 100644 --- a/Data/Data/Repository/GroupRepository.swift +++ b/Data/Data/Repository/GroupRepository.swift @@ -91,7 +91,7 @@ public class GroupRepository: ObservableObject { } public func removeMemberFrom(group: Groups, removedMember: AppUser) async throws { - guard let user = preference.user else { return } + guard let userId = preference.user?.id else { return } var group = group // make group inactive if there are no members @@ -107,7 +107,7 @@ public class GroupRepository: ObservableObject { } } - let activityType: ActivityType = user.id == removedMember.id ? .groupMemberLeft : .groupMemberRemoved + let activityType: ActivityType = userId == removedMember.id ? .groupMemberLeft : .groupMemberRemoved try await updateGroup(group: group, type: activityType, removedMember: removedMember) } diff --git a/README.md b/README.md index 5b83cc5d..d9b96f79 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Splito utilizes the latest iOS technologies and adheres to industry best practic - CocoaLumberjack for Logging ## Contribution 🤝 -Splito is an open-source project but currently, we are not accepting any contributions. +The Canopas team enthusiastically welcomes contributions and project participation! There are a bunch of things you can do if you want to contribute! The [Contributor Guide](CONTRIBUTING.md) has all the information you need for everything from reporting bugs to contributing entire new features. Please don't hesitate to jump in if you'd like to, or even ask us questions if something isn't clear. ## Credits Splito is owned and maintained by the [Canopas team](https://canopas.com/). You can follow them on X at [@canopas_eng](https://x.com/canopas_eng) for project updates and releases. If you are interested in building apps or designing products, please let us know. We'd love to hear from you! diff --git a/Splito/Localization/Localizable.xcstrings b/Splito/Localization/Localizable.xcstrings index 8c5d2981..c18aab92 100644 --- a/Splito/Localization/Localizable.xcstrings +++ b/Splito/Localization/Localizable.xcstrings @@ -202,6 +202,9 @@ "An email has been sent to %@ with instructions to reset your password." : { "extractionState" : "manual" }, + "An unexpected error occurred. Please try again later." : { + "extractionState" : "manual" + }, "Ana borrows $10 from Bob" : { }, @@ -445,6 +448,9 @@ "Incorrect email or password" : { "extractionState" : "manual" }, + "Invalid Credentials" : { + "extractionState" : "manual" + }, "Invalid Email" : { "extractionState" : "manual" }, @@ -607,9 +613,6 @@ "Please enter a cost for your expense first!" : { "extractionState" : "manual" }, - "Please enter a valid email" : { - "extractionState" : "manual" - }, "Please enter a valid email address." : { "extractionState" : "manual" }, @@ -796,6 +799,9 @@ "The code you've entered is not exists." : { "extractionState" : "manual" }, + "The credentials provided are invalid. Please try again." : { + "extractionState" : "manual" + }, "The email address is already associated with an existing account. Please use a different email or log in to your existing account." : { "extractionState" : "manual" }, @@ -910,6 +916,9 @@ "Use settle up to divide costs with your friends or colleagues. It's easy and ensures everyone pays or receives their fair share.\\n\\nLet's get started splitting bills easily with friends." : { "extractionState" : "manual" }, + "User account not found. Please verify your credentials." : { + "extractionState" : "manual" + }, "Warning" : { "extractionState" : "manual" }, @@ -1012,6 +1021,9 @@ "Your requested data not found." : { "extractionState" : "manual" }, + "Your session has expired. Please log in again." : { + "extractionState" : "manual" + }, "Your total share" : { "extractionState" : "manual" } diff --git a/Splito/UI/Home/Account/User Profile/UserProfileViewModel.swift b/Splito/UI/Home/Account/User Profile/UserProfileViewModel.swift index bda7b8b6..14f9e11e 100644 --- a/Splito/UI/Home/Account/User Profile/UserProfileViewModel.swift +++ b/Splito/UI/Home/Account/User Profile/UserProfileViewModel.swift @@ -241,8 +241,7 @@ extension UserProfileViewModel { user.reload { [weak self] error in if let error { self?.isDeleteInProgress = false - self?.showAlertFor(message: error.localizedDescription) - LogE("UserProfileViewModel: \(#function) Error reloading user: \(error).") + self?.handleFirebaseAuthErrors(error) } else { self?.promptForReAuthentication(user) } @@ -253,7 +252,7 @@ extension UserProfileViewModel { getAuthCredential(user) { [weak self] credential in guard let credential else { self?.isDeleteInProgress = false - LogE("UserProfileViewModel: \(#function) Credential are - \(String(describing: credential))") + LogE("UserProfileViewModel: \(#function) Failed to retrieve valid credential.") return } @@ -261,8 +260,7 @@ extension UserProfileViewModel { guard let self else { return } if let error { self.isDeleteInProgress = false - self.showAlertFor(message: error.localizedDescription) - LogE("UserProfileViewModel: \(#function) Error re-authenticating user: \(error).") + self.handleFirebaseAuthErrors(error) } else { Task { await self.deleteUser() @@ -355,7 +353,7 @@ extension UserProfileViewModel { guard let password = alert.textFields?.first?.text, let email = self?.preference.user?.emailId else { self?.isDeleteInProgress = false - LogE("UserProfileViewModel: \(#function) No email found for email login.") + LogE("UserProfileViewModel: \(#function) Missing email or password for email login.") return } let credential = EmailAuthProvider.credential(withEmail: email, password: password) @@ -364,4 +362,34 @@ extension UserProfileViewModel { TopViewController.shared.topViewController()?.present(alert, animated: true) } + + private func handleFirebaseAuthErrors(_ error: Error) { + guard let authErrorCode = FirebaseAuth.AuthErrorCode(rawValue: (error as NSError).code) else { + showAlertFor(title: "Error", message: "Something went wrong! Please try after some time.") + return + } + + switch authErrorCode { + case .webContextCancelled: + showAlertFor(title: "Error", message: "Something went wrong! Please try after some time.") + case .tooManyRequests: + showAlertFor(title: "Error", message: "Too many attempts, please try after some time.") + case .invalidEmail: + showAlertFor(title: "Invalid Email", message: "The email address is not valid. Please check and try again.") + case .userNotFound: + showAlertFor(title: "Error", message: "User account not found. Please verify your credentials.") + case .userDisabled: + showAlertFor(title: "Account Disabled", message: "This account has been disabled. Please contact support.") + case .invalidCredential: + showAlertFor(title: "Invalid Credentials", message: "The credentials provided are invalid. Please try again.") + case .networkError: + showAlertFor(title: "Error", message: "No internet connection!") + case .userTokenExpired: + showAlertFor(title: "Error", message: "Your session has expired. Please log in again.") + default: + showAlertFor(title: "Error", message: "An unexpected error occurred. Please try again later.") + } + + LogE("UserProfileViewModel: \(#function) Error re-authenticating user: \(error).") + } } diff --git a/Splito/UI/Home/ActivityLog/ActivityLogView.swift b/Splito/UI/Home/ActivityLog/ActivityLogView.swift index 90b7074e..650d8cce 100644 --- a/Splito/UI/Home/ActivityLog/ActivityLogView.swift +++ b/Splito/UI/Home/ActivityLog/ActivityLogView.swift @@ -16,7 +16,7 @@ struct ActivityLogView: View { var body: some View { VStack(alignment: .center, spacing: 0) { if .noInternet == viewModel.viewState || .somethingWentWrong == viewModel.viewState { - ErrorView(isForNoInternet: viewModel.viewState == .noInternet, onClick: viewModel.fetchActivityLogsInitialData) + ErrorView(isForNoInternet: viewModel.viewState == .noInternet, onClick: viewModel.fetchInitialActivityLogs) } else if case .loading = viewModel.viewState { LoaderView() Spacer(minLength: 60) @@ -76,7 +76,7 @@ private struct ActivityLogListView: View { .padding(.bottom, 62) } .refreshable { - viewModel.fetchActivityLogsInitialData() + viewModel.fetchInitialActivityLogs() } .onAppear { scrollToActivityLog(proxy) diff --git a/Splito/UI/Home/ActivityLog/ActivityLogViewModel.swift b/Splito/UI/Home/ActivityLog/ActivityLogViewModel.swift index 01db6d37..43c8b694 100644 --- a/Splito/UI/Home/ActivityLog/ActivityLogViewModel.swift +++ b/Splito/UI/Home/ActivityLog/ActivityLogViewModel.swift @@ -37,27 +37,48 @@ class ActivityLogViewModel: BaseViewModel, ObservableObject { self.router = router super.init() - self.fetchActivityLogsInitialData() + self.fetchInitialActivityLogs() self.fetchLatestActivityLogs() } - func fetchActivityLogsInitialData() { + // Listens for real-time updates and returns the latest activity logs for the current user + private func fetchLatestActivityLogs() { + guard let userId = preference.user?.id else { return } + + activityLogRepository.fetchLatestActivityLogs(userId: userId) { [weak self] activityLogs in + if let activityLogs { + for activityLog in activityLogs where !(self?.activityLogs.contains(where: { $0.id == activityLog.id }) ?? false) { + self?.activityLogs.append(activityLog) + } + self?.filterActivityLogs() + if self?.activityLogs.count == 1 { + self?.activityLogState = .hasActivity + } + } else { + self?.showToastForError() + } + } + } + + func fetchInitialActivityLogs() { Task { + lastDocument = nil await fetchActivityLogs() } } // MARK: - Data Loading private func fetchActivityLogs() async { - guard let userId = preference.user?.id else { + guard let userId = preference.user?.id, hasMoreLogs else { viewState = .initial return } do { - let result = try await activityLogRepository.fetchActivitiesBy(userId: userId, limit: ACTIVITY_LOG_LIMIT) - - activityLogs = result.data + let result = try await activityLogRepository.fetchActivitiesBy(userId: userId, + limit: ACTIVITY_LOG_LIMIT, + lastDocument: lastDocument) + activityLogs = lastDocument == nil ? result.data : (activityLogs + result.data) lastDocument = result.lastDocument hasMoreLogs = !(result.data.count < ACTIVITY_LOG_LIMIT) @@ -67,33 +88,20 @@ class ActivityLogViewModel: BaseViewModel, ObservableObject { LogD("ActivityLogViewModel: \(#function) Activity logs fetched successfully.") } catch { LogE("ActivityLogViewModel: \(#function) Failed to fetch activity logs: \(error).") - handleServiceError() + handleErrorState() } } func loadMoreActivityLogs() { Task { - await fetchMoreActivityLogs() + await fetchActivityLogs() } } - private func fetchMoreActivityLogs() async { - guard hasMoreLogs, let userId = preference.user?.id else { return } - - do { - let result = try await activityLogRepository.fetchActivitiesBy(userId: userId, limit: ACTIVITY_LOG_LIMIT, lastDocument: lastDocument) - - activityLogs.append(contentsOf: result.data) - lastDocument = result.lastDocument - hasMoreLogs = !(result.data.count < ACTIVITY_LOG_LIMIT) - - filterActivityLogs() - viewState = .initial - activityLogState = activityLogs.isEmpty ? .noActivity : .hasActivity - LogD("ActivityLogViewModel: \(#function) Activity logs fetched successfully.") - } catch { - viewState = .initial - LogE("ActivityLogViewModel: \(#function) Failed to fetch more activity logs: \(error).") + private func handleErrorState() { + if lastDocument == nil { + handleServiceError() + } else { showToastForError() } } @@ -109,25 +117,6 @@ class ActivityLogViewModel: BaseViewModel, ObservableObject { } } - // Listens for real-time updates and returns the latest activity logs for the current user - private func fetchLatestActivityLogs() { - guard let userId = preference.user?.id else { - viewState = .initial - return - } - - activityLogRepository.fetchLatestActivityLogs(userId: userId) { [weak self] activityLogs in - if let activityLogs { - for activityLog in activityLogs where !(self?.activityLogs.contains(where: { $0.id == activityLog.id }) ?? false) { - self?.activityLogs.append(activityLog) - } - self?.filterActivityLogs() - } else { - self?.showToastForError() - } - } - } - // MARK: - User Actions func handleActivityItemTap(_ activity: ActivityLog) { switch activity.type { 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 52e3aaa3..66f0ed23 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Transactions/GroupTransactionListView.swift @@ -189,7 +189,7 @@ private struct TransactionItemView: View { Text("\(transactionWithUser.transaction.amount.formattedCurrency)") .font(.subTitle2()) - .foregroundStyle(transactionWithUser.payer?.id == preference.user?.id ? errorColor : successColor) + .foregroundStyle(transactionWithUser.payer?.id == preference.user?.id ? successColor : transactionWithUser.receiver?.id == preference.user?.id ? errorColor : primaryText) } } .padding(.top, 20) diff --git a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift index 260fd62c..f4906302 100644 --- a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift @@ -201,8 +201,7 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { Task { do { - currentViewState = .loading - try await groupRepository.removeMemberFrom(group: group, removedMember: member) + try await groupRepository.removeMemberFrom(group: group, removedMember: member) if userId == member.id { NotificationCenter.default.post(name: .leaveGroup, object: group) @@ -214,9 +213,10 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { members.remove(at: index) } } + self.group?.members = members.map { $0.id } + NotificationCenter.default.post(name: .updateGroup, object: self.group) showToastFor(toast: ToastPrompt(type: .success, title: "Success", message: "Group member removed.")) } - currentViewState = .initial LogD("GroupSettingViewModel: \(#function) Member removed successfully.") } catch { currentViewState = .initial diff --git a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift index 4182a31e..17ced686 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift @@ -13,10 +13,12 @@ import FirebaseFirestore class GroupHomeViewModel: BaseViewModel, ObservableObject { private let EXPENSES_LIMIT = 10 + private let TRANSACTIONS_LIMIT = 5 @Inject var preference: SplitoPreference @Inject var groupRepository: GroupRepository @Inject var expenseRepository: ExpenseRepository + @Inject private var transactionRepository: TransactionRepository @Published private(set) var groupId: String @Published private(set) var overallOwingAmount: Double = 0.0 @@ -26,6 +28,7 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { @Published var groupState: GroupState = .loading @Published var expenses: [Expense] = [] + @Published var transactions: [Transactions] = [] @Published var expensesWithUser: [ExpenseWithUser] = [] @Published private(set) var memberOwingAmount: [String: Double] = [:] @Published private(set) var groupExpenses: [String: [ExpenseWithUser]] = [:] @@ -73,6 +76,7 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { func fetchGroupAndExpenses() { Task { await fetchGroup() + await fetchTransactions() await fetchExpenses() } } @@ -105,6 +109,22 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { } } + private func fetchTransactions() async { + if let state = validateGroupState() { + groupState = state + return + } + + do { + let result = try await transactionRepository.fetchTransactionsBy(groupId: groupId, limit: TRANSACTIONS_LIMIT) + transactions = result.transactions.uniqued() + LogD("GroupHomeViewModel: \(#function) Payments fetched successfully.") + } catch { + LogE("GroupHomeViewModel: \(#function) Failed to fetch payments: \(error).") + handleServiceError() + } + } + private func fetchExpenses() async { if let state = validateGroupState() { groupState = state @@ -242,7 +262,7 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { } groupState = group.members.count > 1 ? - ((expenses.isEmpty && group.balances.allSatisfy({ $0.balance == 0 })) ? .noExpense : .hasExpense) : (expenses.isEmpty ? .noMember : .hasExpense) + ((expenses.isEmpty && transactions.isEmpty) ? .noExpense : .hasExpense) : ((expenses.isEmpty && transactions.isEmpty) ? .noMember : .hasExpense) } // MARK: - Error Handling diff --git a/Splito/UI/Home/Groups/GroupListView.swift b/Splito/UI/Home/Groups/GroupListView.swift index 81d1282c..38b636be 100644 --- a/Splito/UI/Home/Groups/GroupListView.swift +++ b/Splito/UI/Home/Groups/GroupListView.swift @@ -31,34 +31,38 @@ struct GroupListView: View { } else if case .hasGroup = viewModel.groupListState { VSpacer(16) - VStack(spacing: 16) { - GroupListTabBarView(selectedTab: viewModel.selectedTab, - onSelect: viewModel.handleTabItemSelection(_:)) - - if viewModel.selectedTab == .all { - GroupListHeaderView(totalOweAmount: viewModel.totalOweAmount) - .padding(.bottom, viewModel.showSearchBar ? 0 : 2) + Group { + VStack(spacing: 16) { + GroupListTabBarView(selectedTab: viewModel.selectedTab, + onSelect: viewModel.handleTabItemSelection(_:)) + + if viewModel.selectedTab == .all { + GroupListHeaderView(totalOweAmount: viewModel.totalOweAmount) + .padding(.bottom, viewModel.showSearchBar ? 0 : 2) + } + } + .onTapGestureForced { + isFocused = false } - } - .onTapGestureForced { - UIApplication.shared.endEditing() - } - if viewModel.showSearchBar { - SearchBar(text: $viewModel.searchedGroup, isFocused: $isFocused, placeholder: "Search groups") - .padding(.vertical, -7) - .padding(.horizontal, 3) - .overlay(content: { - RoundedRectangle(cornerRadius: 12) - .stroke(outlineColor, lineWidth: 1) - }) - .focused($isFocused) - .onAppear { - isFocused = true - } - .padding([.horizontal, .top], 16) - .padding(.bottom, 8) + if viewModel.showSearchBar { + SearchBar(text: $viewModel.searchedGroup, isFocused: $isFocused, placeholder: "Search groups") + .padding(.vertical, -7) + .padding(.horizontal, 3) + .overlay(content: { + RoundedRectangle(cornerRadius: 12) + .stroke(outlineColor, lineWidth: 1) + }) + .focused($isFocused) + .onAppear { + isFocused = true + } + .padding([.horizontal, .top], 16) + .padding(.bottom, 8) + } } + .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) GroupListWithDetailView(isFocused: $isFocused, viewModel: viewModel) { isFocused = false @@ -67,8 +71,6 @@ struct GroupListView: View { } } } - .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) - .frame(maxWidth: .infinity, alignment: .center) .background(surfaceColor) .alertView.alert(isPresented: $viewModel.showAlert, alertStruct: viewModel.alert) .navigationTitle("") @@ -97,7 +99,7 @@ struct GroupListView: View { } } .overlay(alignment: .bottomTrailing) { - if !viewModel.showScrollToTopBtn { + if !viewModel.showScrollToTopBtn && viewModel.groupListState != .noGroup { VStack(spacing: 0) { Spacer() AddExpenseButtonView(onClick: viewModel.openAddExpenseSheet) @@ -217,6 +219,7 @@ private struct NoGroupsState: View { } .multilineTextAlignment(.center) .padding(.horizontal, 16) + .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(maxWidth: .infinity, alignment: .center) .frame(minHeight: geometry.size.height - 100, maxHeight: .infinity, alignment: .center) } diff --git a/Splito/UI/Home/Groups/GroupListWithDetailView.swift b/Splito/UI/Home/Groups/GroupListWithDetailView.swift index ee87d85b..f00b7266 100644 --- a/Splito/UI/Home/Groups/GroupListWithDetailView.swift +++ b/Splito/UI/Home/Groups/GroupListWithDetailView.swift @@ -153,6 +153,8 @@ private struct GroupListCellView: View { } } .padding(.vertical, 24) + .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) if !isLastGroup { Divider() @@ -237,7 +239,7 @@ private struct GroupNotFoundView: View { } .multilineTextAlignment(.center) .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: isIpad ? 600 : nil, alignment: .center) .frame(minHeight: viewModel.showSearchBar ? geometry.size.height - 20 : geometry.size.height - 70, maxHeight: .infinity, alignment: .center) } } diff --git a/Splito/UI/Login/EmailLogin/EmailLoginViewModel.swift b/Splito/UI/Login/EmailLogin/EmailLoginViewModel.swift index 113d4c85..97772f4b 100644 --- a/Splito/UI/Login/EmailLogin/EmailLoginViewModel.swift +++ b/Splito/UI/Login/EmailLogin/EmailLoginViewModel.swift @@ -147,9 +147,9 @@ public class EmailLoginViewModel: BaseViewModel, ObservableObject { switch authErrorCode { case .webContextCancelled: - showAlertFor(message: "Something went wrong! Please try after some time.") + showAlertFor(title: "Error", message: "Something went wrong! Please try after some time.") case .tooManyRequests: - showAlertFor(message: "Too many attempts, please try after some time.") + showAlertFor(title: "Error", message: "Too many attempts, please try after some time.") case .invalidEmail: showAlertFor(title: "Invalid Email", message: "The email address is not valid. Please check and try again.") case .emailAlreadyInUse: @@ -160,10 +160,13 @@ public class EmailLoginViewModel: BaseViewModel, ObservableObject { showAlertFor(title: "Account Disabled", message: "This account has been disabled. Please contact support.") case .invalidCredential: showAlertFor(title: "Incorrect email or password", message: "The email or password you entered is incorrect. Please try again.") + case .networkError: + showAlertFor(title: "Error", message: "No internet connection!") default: - LogE("EmailLoginViewModel: \(#function) \((isPasswordReset) ? "Password reset" : "Email login") fail with error: \(error).") isPasswordReset ? showAlertFor(title: "Error", message: "Unable to send a password reset email. Please try again later.") : showAlertFor(title: "Authentication failed", message: "Apologies, we were not able to complete the authentication process. Please try again later.") } + + LogE("EmailLoginViewModel: \(#function) \((isPasswordReset) ? "Password reset" : "Email login") fail with error: \(error).") } }