From fac97c3332300628c8f62e56ebacd44864e4e89f Mon Sep 17 00:00:00 2001 From: Nirali Canopas <105365731+cp-nirali-s@users.noreply.github.com> Date: Mon, 30 Dec 2024 15:27:20 +0530 Subject: [PATCH] Add reminder support Co-authored-by: Amisha --- Data/Data.xcodeproj/project.pbxproj | 12 +++ Data/Data/DI/AppAssembly.swift | 4 + Data/Data/Deeplink/DeepLinkManager.swift | 37 ++++++++ Data/Data/Utils/Constants.swift | 4 + Splito/Localization/Localizable.xcstrings | 11 ++- Splito/Plist/Info.plist | 29 ++++++ .../Balances/GroupBalancesView.swift | 93 +++++++++++++++---- .../UI/Home/Groups/GroupListViewModel.swift | 23 +++++ Splito/UI/Home/HomeRouteView.swift | 3 + Splito/UI/Home/HomeRouteViewModel.swift | 25 +++++ 10 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 Data/Data/Deeplink/DeepLinkManager.swift diff --git a/Data/Data.xcodeproj/project.pbxproj b/Data/Data.xcodeproj/project.pbxproj index ef67348f..4f47ca18 100644 --- a/Data/Data.xcodeproj/project.pbxproj +++ b/Data/Data.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 21559CA42CBD05570039F127 /* ActivityLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21559CA32CBD05570039F127 /* ActivityLog.swift */; }; 21559CAE2CBD2AED0039F127 /* ActivityLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21559CAD2CBD2AED0039F127 /* ActivityLogStore.swift */; }; 21559CB02CBD2B400039F127 /* ActivityLogRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21559CAF2CBD2B400039F127 /* ActivityLogRepository.swift */; }; + 21CF56512D1E804000B47A6D /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CF56502D1E804000B47A6D /* DeepLinkManager.swift */; }; 21D8D0832C0857F10061B365 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D8D0822C0857F10061B365 /* Constants.swift */; }; 64E499C5CFAEA368EC21313F /* Pods_DataTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1571EA0A08D442FEF7C09424 /* Pods_DataTests.framework */; }; 7EF3A291581F7EA20CB1042D /* Pods_Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E91B3E23688435064A60C0C4 /* Pods_Data.framework */; }; @@ -70,6 +71,7 @@ 21559CA32CBD05570039F127 /* ActivityLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLog.swift; sourceTree = ""; }; 21559CAD2CBD2AED0039F127 /* ActivityLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogStore.swift; sourceTree = ""; }; 21559CAF2CBD2B400039F127 /* ActivityLogRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogRepository.swift; sourceTree = ""; }; + 21CF56502D1E804000B47A6D /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; 21D8D0822C0857F10061B365 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 5B14CF1A2EEF27479BF50566 /* Pods-DataTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DataTests.debug.xcconfig"; path = "Target Support Files/Pods-DataTests/Pods-DataTests.debug.xcconfig"; sourceTree = ""; }; 803094012FE4C155F2A347B6 /* Pods-DataTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DataTests.release.xcconfig"; path = "Target Support Files/Pods-DataTests/Pods-DataTests.release.xcconfig"; sourceTree = ""; }; @@ -139,6 +141,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 21CF564F2D1E802E00B47A6D /* Deeplink */ = { + isa = PBXGroup; + children = ( + 21CF56502D1E804000B47A6D /* DeepLinkManager.swift */, + ); + path = Deeplink; + sourceTree = ""; + }; 9E4388A4C8057257EE82C256 /* Pods */ = { isa = PBXGroup; children = ( @@ -312,6 +322,7 @@ D83B15072B99976F004A5F4F /* Repository */, D8A7CA7E2BA867C80014EC67 /* Extension */, D89DBE4B2B8CBEB500E5F1BD /* Services */, + 21CF564F2D1E802E00B47A6D /* Deeplink */, D8AC25BC2B7F357A00CEAAD3 /* Helper */, ); path = Data; @@ -519,6 +530,7 @@ D8AC25C12B7F38D300CEAAD3 /* Injector.swift in Sources */, D8D14A542BA092F500F45FF2 /* ShareCodeRepository.swift in Sources */, D8A7CA7B2BA5B6AC0014EC67 /* ShareCodeStore.swift in Sources */, + 21CF56512D1E804000B47A6D /* DeepLinkManager.swift in Sources */, D865F8AE2BD7CB0B0084BD36 /* Array+Extension.swift in Sources */, 21559CB02CBD2B400039F127 /* ActivityLogRepository.swift in Sources */, D89DBE2B2B88817E00E5F1BD /* JSONUtils.swift in Sources */, diff --git a/Data/Data/DI/AppAssembly.swift b/Data/Data/DI/AppAssembly.swift index 5a0858c8..79fba529 100644 --- a/Data/Data/DI/AppAssembly.swift +++ b/Data/Data/DI/AppAssembly.swift @@ -86,5 +86,9 @@ public class AppAssembly: Assembly { container.register(TransactionRepository.self) { _ in TransactionRepository.init() }.inObjectScope(.container) + + container.register(DeepLinkManager.self) { _ in + DeepLinkManager() + }.inObjectScope(.container) } } diff --git a/Data/Data/Deeplink/DeepLinkManager.swift b/Data/Data/Deeplink/DeepLinkManager.swift new file mode 100644 index 00000000..76873a27 --- /dev/null +++ b/Data/Data/Deeplink/DeepLinkManager.swift @@ -0,0 +1,37 @@ +// +// DeepLinkManager.swift +// Data +// +// Created by Amisha Italiya on 27/12/24. +// + +import Foundation + +public enum DeepLinkType: Equatable { + case group(groupId: String) + + var key: String { + switch self { + case .group: + return "group" + } + } +} + +public class DeepLinkManager: ObservableObject { + + @Published public var type: DeepLinkType? + + public init() {} + + public func handleDeepLink(_ url: URL) { + let urlString = url.deletingLastPathComponent().absoluteString + + switch urlString { + case Constants.groupBaseUrl: + type = .group(groupId: url.lastPathComponent) + default: + type = nil + } + } +} diff --git a/Data/Data/Utils/Constants.swift b/Data/Data/Utils/Constants.swift index 85070d82..8b443f65 100644 --- a/Data/Data/Utils/Constants.swift +++ b/Data/Data/Utils/Constants.swift @@ -27,4 +27,8 @@ public struct Constants { public static var shareAppURL: String { return "https://apps.apple.com/in/app/splito/id6477442217" } + + public static var groupBaseUrl: String { + return "splito://groups/" + } } diff --git a/Splito/Localization/Localizable.xcstrings b/Splito/Localization/Localizable.xcstrings index c18aab92..366a9c69 100644 --- a/Splito/Localization/Localizable.xcstrings +++ b/Splito/Localization/Localizable.xcstrings @@ -432,6 +432,12 @@ }, "Groups help you stay organized by tracking and splitting expenses for various activities." : { + }, + "Hello %@, This is a reminder that I owe you %@ for expenses in the Splito group \\\"%@\\\". Please follow this link to review our activity: %@" : { + "extractionState" : "manual" + }, + "Hello %@, This is a reminder that you owe me %@ for expenses in the Splito group \\\"%@\\\". Please follow this link to review our activity and settle up: %@" : { + "extractionState" : "manual" }, "hour ago" : { "extractionState" : "manual" @@ -707,7 +713,7 @@ "extractionState" : "manual" }, "Settle up" : { - + "extractionState" : "manual" }, "Settle up bills together!" : { "extractionState" : "manual" @@ -850,6 +856,9 @@ "This group has been deleted." : { "extractionState" : "manual" }, + "This is a reminder that %@ owes %@ %@ for expenses in the Splito group \\\"%@\\\". Please follow this link to review your activity and settle up: %@" : { + "extractionState" : "manual" + }, "This month" : { "extractionState" : "manual" }, diff --git a/Splito/Plist/Info.plist b/Splito/Plist/Info.plist index 4bc91ca9..8fdc0afc 100644 --- a/Splito/Plist/Info.plist +++ b/Splito/Plist/Info.plist @@ -2,6 +2,25 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + CFBundleTypeName + CFBundleTypeRole + document + LSHandlerRank + Default + LSItemContentTypes + + LSItemContentTypes + + + + LSHandlerRank + Default + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,6 +41,16 @@ $(REVERSE_CLIENT_ID) + + CFBundleTypeRole + Editor + CFBundleURLName + SplitoApp + CFBundleURLSchemes + + splito + + CFBundleVersion $(app_version_code) 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 14755588..f1c2a8fc 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift @@ -121,11 +121,13 @@ private struct GroupBalanceItemView: View { .frame(maxWidth: .infinity, alignment: .leading) if memberBalance.totalOwedAmount != 0 { - ScrollToTopButton(icon: "chevron.down", iconColor: primaryText, - bgColor: container2Color, showWithAnimation: true, size: (10, 7), - isFirstGroupCell: memberBalance.isExpanded) { - toggleExpandBtn(memberBalance.id) - } + ScrollToTopButton( + icon: "chevron.down", iconColor: primaryText, bgColor: container2Color, + showWithAnimation: true, size: (10, 7), isFirstGroupCell: memberBalance.isExpanded, + onClick: { + toggleExpandBtn(memberBalance.id) + } + ) .onAppear { if memberBalance.isExpanded { toggleExpandBtn(memberBalance.id) @@ -145,12 +147,17 @@ private struct GroupBalanceItemView: View { private struct GroupBalanceItemMemberView: View { let SUB_IMAGE_HEIGHT: CGFloat = 24 + @Environment(\.dismiss) var dismiss + @Inject private var preference: SplitoPreference let id: String let balances: [String: Double] let viewModel: GroupBalancesViewModel + @State private var showShareReminderSheet = false + @State private var reminderText: String? + var body: some View { HStack(alignment: .top, spacing: 0) { HSpacer(32) @@ -179,24 +186,72 @@ private struct GroupBalanceItemMemberView: View { .foregroundStyle(disableText) } - HStack(alignment: .center, spacing: 16) { - HSpacer(SUB_IMAGE_HEIGHT) - - Button { - viewModel.handleSettleUpTap(payerId: hasDue ? id : memberId, receiverId: hasDue ? memberId : id, amount: amount) - } label: { - Text("Settle up") - .font(.caption1()) - .foregroundStyle(primaryText) - .padding(.vertical, 8) - .padding(.horizontal, 24) - .background(container2Color) - .cornerRadius(30) + RemindAndSettleBtnView( + handleRemindTap: { + let oweText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : + (memberId == preference.user?.id || id == preference.user?.id) ? "owes" : "" + reminderText = generateReminderText(owedMemberName: owedMemberName, owesText: oweText, + amount: amount, owesMemberName: owesMemberName) + showShareReminderSheet = true + }, handleSettleUpTap: { + viewModel.handleSettleUpTap(payerId: hasDue ? id : memberId, + receiverId: hasDue ? memberId : id, amount: amount) } - } + ) } } } } + .sheet(isPresented: $showShareReminderSheet) { + if let reminderText { + ShareSheetView(activityItems: [reminderText]) { isCompleted in + if isCompleted { + showShareReminderSheet = false + } + } + } + } + } + + private func generateReminderText(owedMemberName: String, owesText: String, amount: Double, owesMemberName: String) -> String { + let formattedAmount = amount.formattedCurrency + let groupName = viewModel.group?.name ?? "" + let deepLink = "\(Constants.groupBaseUrl)\(viewModel.groupId)" + + if owesText == "owe" { + return "Hello \(owesMemberName), This is a reminder that I owe you \(formattedAmount) for expenses in the Splito group \"\(groupName)\". Please follow this link to review our activity: \(deepLink)" + } else if owesText == "owes" { + return "Hello \(owedMemberName), This is a reminder that you owe me \(formattedAmount) for expenses in the Splito group \"\(groupName)\". Please follow this link to review our activity and settle up: \(deepLink)" + } else { + return "This is a reminder that \(owedMemberName) owes \(owesMemberName) \(formattedAmount) for expenses in the Splito group \"\(groupName)\". Please follow this link to review your activity and settle up: \(deepLink)" + } + } +} + +private struct RemindAndSettleBtnView: View { + let SUB_IMAGE_HEIGHT: CGFloat = 24 + + let handleRemindTap: () -> Void + let handleSettleUpTap: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 16) { + HSpacer(SUB_IMAGE_HEIGHT) + + balancesButton(title: "Remind", onClick: handleRemindTap) + balancesButton(title: "Settle up", onClick: handleSettleUpTap) + } + } + + func balancesButton(title: String, onClick: @escaping () -> Void) -> some View { + Button(action: onClick) { + Text(title.localized) + .font(.caption1()) + .foregroundStyle(primaryText) + .padding(.vertical, 8) + .padding(.horizontal, 24) + .background(container2Color) + .cornerRadius(30) + } } } diff --git a/Splito/UI/Home/Groups/GroupListViewModel.swift b/Splito/UI/Home/Groups/GroupListViewModel.swift index 2ebcbcc4..fad0cf63 100644 --- a/Splito/UI/Home/Groups/GroupListViewModel.swift +++ b/Splito/UI/Home/Groups/GroupListViewModel.swift @@ -16,6 +16,7 @@ class GroupListViewModel: BaseViewModel, ObservableObject { @Inject private var preference: SplitoPreference @Inject private var groupRepository: GroupRepository @Inject private var userRepository: UserRepository + @Inject private var deepLinkManager: DeepLinkManager @Published private(set) var currentViewState: ViewState = .loading @Published private(set) var groupListState: GroupListState = .noGroup @@ -67,6 +68,7 @@ class GroupListViewModel: BaseViewModel, ObservableObject { fetchGroupsInitialData() fetchLatestUser() + deepLinkObserver() } deinit { @@ -80,6 +82,27 @@ class GroupListViewModel: BaseViewModel, ObservableObject { } } + func deepLinkObserver() { + deepLinkManager.$type.sink { [weak self] type in + if case .group(let groupId) = type { + self?.removeExistingGroupPathIfNeeded(for: groupId) + self?.router.push(.GroupHomeView(groupId: groupId)) + } + } + .store(in: &cancelable) + } + + private func removeExistingGroupPathIfNeeded(for groupId: String) { + if let index = router.paths.firstIndex(where: { path in + if case .GroupHomeView(let id) = path, id == groupId { + return true + } + return false + }) { + router.paths.remove(at: index) + } + } + // MARK: - Data Loading private func fetchGroups(needToReload: Bool = false) async { guard let userId = preference.user?.id, hasMoreGroups || needToReload else { diff --git a/Splito/UI/Home/HomeRouteView.swift b/Splito/UI/Home/HomeRouteView.swift index e0d25422..d45bc7e1 100644 --- a/Splito/UI/Home/HomeRouteView.swift +++ b/Splito/UI/Home/HomeRouteView.swift @@ -62,5 +62,8 @@ struct HomeRouteView: View { viewModel.switchToActivityLog(activityId: activityId) } } + .onOpenURL { url in + viewModel.handleDeepLink(url: url) + } } } diff --git a/Splito/UI/Home/HomeRouteViewModel.swift b/Splito/UI/Home/HomeRouteViewModel.swift index 4a451042..10b1c82f 100644 --- a/Splito/UI/Home/HomeRouteViewModel.swift +++ b/Splito/UI/Home/HomeRouteViewModel.swift @@ -7,10 +7,12 @@ import SwiftUI import Data +import Combine class HomeRouteViewModel: ObservableObject { @Inject private var preference: SplitoPreference + @Inject private var deepLinkManager: DeepLinkManager @Published var isTabBarVisible: Bool = true @Published var openProfileView: Bool = false @@ -19,6 +21,25 @@ class HomeRouteViewModel: ObservableObject { @Published var selectedTab: Int = 0 @Published var activityLogId: String? + private var cancelable = Set() + + init() { + deepLinkObserver() + } + + func deepLinkObserver() { + deepLinkManager.$type.sink { [weak self] type in + guard let self else { return } + switch type { + case .group: + self.selectedTab = 0 + default: + break + } + } + .store(in: &cancelable) + } + func openProfileOrOnboardFlow() { if preference.user == nil { openOnboardFlow = true @@ -44,4 +65,8 @@ class HomeRouteViewModel: ObservableObject { activityLogId = activityId selectedTab = 1 } + + func handleDeepLink(url: URL) { + deepLinkManager.handleDeepLink(url) + } }