diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 5f8095a3..62ace6c2 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -58,6 +58,10 @@ 6D5E03CA2E2882290065AFBE /* Course.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03C92E2882290065AFBE /* Course.swift */; }; 6D5E03CD2E2882BB0065AFBE /* CourseSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03CC2E2882BB0065AFBE /* CourseSearchRequest.swift */; }; 6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03CF2E28853E0065AFBE /* CourseSearchResponse.swift */; }; + 6D61ABE32F57158000111C9B /* IntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE22F57158000111C9B /* IntroViewController.swift */; }; + 6D61ABE52F57158700111C9B /* IntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE42F57158700111C9B /* IntroViewModel.swift */; }; + 6D61ABE82F57174D00111C9B /* IntroDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE72F57174D00111C9B /* IntroDIContainer.swift */; }; + 6D61ABEB2F57179C00111C9B /* IntroCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABEA2F57179C00111C9B /* IntroCoordinator.swift */; }; 6D6879BC2E3E78E200E59C55 /* RouteLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6879BB2E3E78E200E59C55 /* RouteLineView.swift */; }; 6D6879BE2E4067B400E59C55 /* Refresh.json in Resources */ = {isa = PBXBuildFile; fileRef = 6D6879BD2E4067B400E59C55 /* Refresh.json */; }; 6D6879C02E40684C00E59C55 /* RefreshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6879BF2E40684C00E59C55 /* RefreshView.swift */; }; @@ -113,6 +117,8 @@ 6D91A90F2E38A1C60081BAFC /* BusDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91A90E2E38A1C60081BAFC /* BusDetailViewModel.swift */; }; 6D9283742E3AFF6A0090889B /* BusRouteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9283732E3AFF6A0090889B /* BusRouteCell.swift */; }; 6D9284422E3C6ADF0090889B /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9284412E3C6ADF0090889B /* PaddingLabel.swift */; }; + 6D9F79E92F57F24F008C1492 /* PushAlarmSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9F79E82F57F24F008C1492 /* PushAlarmSheetViewController.swift */; }; + 6D9F79EC2F57F266008C1492 /* PushAlarmSheetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9F79EB2F57F266008C1492 /* PushAlarmSheetViewModel.swift */; }; 6DADA5932EA09B6E00CA9BE2 /* AmplitudeSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 6DADA5922EA09B6E00CA9BE2 /* AmplitudeSwift */; }; 6DADA5972EA09BA400CA9BE2 /* AmplitudeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DADA5962EA09BA400CA9BE2 /* AmplitudeManager.swift */; }; 6DADA5992EA09BAC00CA9BE2 /* AmplitudeEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DADA5982EA09BAC00CA9BE2 /* AmplitudeEvent.swift */; }; @@ -142,7 +148,7 @@ 6DC3BEF32E04FE1D00831470 /* KeychainKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BEF22E04FE1D00831470 /* KeychainKey.swift */; }; 6DC3BF482E05C77D00831470 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF472E05C77D00831470 /* LoginViewModel.swift */; }; 6DC3BF4A2E05C79700831470 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF492E05C79700831470 /* LoginViewController.swift */; }; - 6DC3BF5E2E07123F00831470 /* LoginIntroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5D2E07123F00831470 /* LoginIntroCell.swift */; }; + 6DC3BF5E2E07123F00831470 /* IntroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5D2E07123F00831470 /* IntroCell.swift */; }; 6DC3BF602E071F0900831470 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */; }; 6DC3BF682E0721F300831470 /* LoginDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF672E0721F300831470 /* LoginDTO.swift */; }; 6DD632B12E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */; }; @@ -284,7 +290,7 @@ B6AC8BF82E2BDA2900410ECD /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AC8BF72E2BDA2900410ECD /* AppUpdateManager.swift */; }; B6AC8BFA2E2BE59700410ECD /* MyPageHomeRegisterRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AC8BF92E2BE59700410ECD /* MyPageHomeRegisterRouter.swift */; }; B6AC8BFC2E2BF27200410ECD /* UINavigationController+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AC8BFB2E2BF27200410ECD /* UINavigationController+Ext.swift */; }; - B6AC8BFE2E2D1F5400410ECD /* LoginItnro.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AC8BFD2E2D1F5400410ECD /* LoginItnro.swift */; }; + B6AC8BFE2E2D1F5400410ECD /* Intro.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AC8BFD2E2D1F5400410ECD /* Intro.swift */; }; B6ADE1942E0C0DE6008C4E23 /* LoginType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ADE1932E0C0DE6008C4E23 /* LoginType.swift */; }; B6B57EC22E1A89AB00B29EB1 /* LastTrainSearchBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B57EC12E1A89AB00B29EB1 /* LastTrainSearchBottomView.swift */; }; B6B57EC52E1AB0FE00B29EB1 /* MainRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B57EC42E1AB0FE00B29EB1 /* MainRoute.swift */; }; @@ -385,6 +391,10 @@ 6D5E03C92E2882290065AFBE /* Course.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Course.swift; sourceTree = ""; }; 6D5E03CC2E2882BB0065AFBE /* CourseSearchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseSearchRequest.swift; sourceTree = ""; }; 6D5E03CF2E28853E0065AFBE /* CourseSearchResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseSearchResponse.swift; sourceTree = ""; }; + 6D61ABE22F57158000111C9B /* IntroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroViewController.swift; sourceTree = ""; }; + 6D61ABE42F57158700111C9B /* IntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroViewModel.swift; sourceTree = ""; }; + 6D61ABE72F57174D00111C9B /* IntroDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroDIContainer.swift; sourceTree = ""; }; + 6D61ABEA2F57179C00111C9B /* IntroCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroCoordinator.swift; sourceTree = ""; }; 6D6879BB2E3E78E200E59C55 /* RouteLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLineView.swift; sourceTree = ""; }; 6D6879BD2E4067B400E59C55 /* Refresh.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Refresh.json; sourceTree = ""; }; 6D6879BF2E40684C00E59C55 /* RefreshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshView.swift; sourceTree = ""; }; @@ -438,6 +448,8 @@ 6D91A90E2E38A1C60081BAFC /* BusDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusDetailViewModel.swift; sourceTree = ""; }; 6D9283732E3AFF6A0090889B /* BusRouteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusRouteCell.swift; sourceTree = ""; }; 6D9284412E3C6ADF0090889B /* PaddingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingLabel.swift; sourceTree = ""; }; + 6D9F79E82F57F24F008C1492 /* PushAlarmSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushAlarmSheetViewController.swift; sourceTree = ""; }; + 6D9F79EB2F57F266008C1492 /* PushAlarmSheetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushAlarmSheetViewModel.swift; sourceTree = ""; }; 6DADA5962EA09BA400CA9BE2 /* AmplitudeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmplitudeManager.swift; sourceTree = ""; }; 6DADA5982EA09BAC00CA9BE2 /* AmplitudeEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmplitudeEvent.swift; sourceTree = ""; }; 6DB4DE8E2F0FE3B900F2DC4E /* RefreshObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshObserver.swift; sourceTree = ""; }; @@ -457,7 +469,7 @@ 6DC3BEF22E04FE1D00831470 /* KeychainKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainKey.swift; sourceTree = ""; }; 6DC3BF472E05C77D00831470 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; 6DC3BF492E05C79700831470 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; - 6DC3BF5D2E07123F00831470 /* LoginIntroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginIntroCell.swift; sourceTree = ""; }; + 6DC3BF5D2E07123F00831470 /* IntroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroCell.swift; sourceTree = ""; }; 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 6DC3BF672E0721F300831470 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = ""; }; 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckServiceRegionRequest.swift; sourceTree = ""; }; @@ -603,7 +615,7 @@ B6AC8BF72E2BDA2900410ECD /* AppUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateManager.swift; sourceTree = ""; }; B6AC8BF92E2BE59700410ECD /* MyPageHomeRegisterRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyPageHomeRegisterRouter.swift; sourceTree = ""; }; B6AC8BFB2E2BF27200410ECD /* UINavigationController+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Ext.swift"; sourceTree = ""; }; - B6AC8BFD2E2D1F5400410ECD /* LoginItnro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginItnro.swift; sourceTree = ""; }; + B6AC8BFD2E2D1F5400410ECD /* Intro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Intro.swift; sourceTree = ""; }; B6ADE1932E0C0DE6008C4E23 /* LoginType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginType.swift; sourceTree = ""; }; B6B57EC12E1A89AB00B29EB1 /* LastTrainSearchBottomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTrainSearchBottomView.swift; sourceTree = ""; }; B6B57EC42E1AB0FE00B29EB1 /* MainRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainRoute.swift; sourceTree = ""; }; @@ -850,6 +862,34 @@ path = CourseSearchDTO; sourceTree = ""; }; + 6D61ABE12F5714EF00111C9B /* Intro */ = { + isa = PBXGroup; + children = ( + 6D61ABE92F57178F00111C9B /* Coordinator */, + 6D61ABE22F57158000111C9B /* IntroViewController.swift */, + 6D61ABE42F57158700111C9B /* IntroViewModel.swift */, + 6DC3BF5D2E07123F00831470 /* IntroCell.swift */, + B6AC8BFD2E2D1F5400410ECD /* Intro.swift */, + ); + path = Intro; + sourceTree = ""; + }; + 6D61ABE62F57174500111C9B /* Intro */ = { + isa = PBXGroup; + children = ( + 6D61ABE72F57174D00111C9B /* IntroDIContainer.swift */, + ); + path = Intro; + sourceTree = ""; + }; + 6D61ABE92F57178F00111C9B /* Coordinator */ = { + isa = PBXGroup; + children = ( + 6D61ABEA2F57179C00111C9B /* IntroCoordinator.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; 6D6879C12E41A75B00E59C55 /* Withdraw */ = { isa = PBXGroup; children = ( @@ -1004,6 +1044,15 @@ path = BusDetail; sourceTree = ""; }; + 6D9F79EA2F57F253008C1492 /* PushAlarmSheet */ = { + isa = PBXGroup; + children = ( + 6D9F79E82F57F24F008C1492 /* PushAlarmSheetViewController.swift */, + 6D9F79EB2F57F266008C1492 /* PushAlarmSheetViewModel.swift */, + ); + path = PushAlarmSheet; + sourceTree = ""; + }; 6DADA5952EA09B9500CA9BE2 /* Amplitude */ = { isa = PBXGroup; children = ( @@ -1056,8 +1105,6 @@ B6CF46F32E0961F000304A85 /* Coordinator */, 6DC3BF472E05C77D00831470 /* LoginViewModel.swift */, 6DC3BF492E05C79700831470 /* LoginViewController.swift */, - 6DC3BF5D2E07123F00831470 /* LoginIntroCell.swift */, - B6AC8BFD2E2D1F5400410ECD /* LoginItnro.swift */, ); path = Login; sourceTree = ""; @@ -1258,6 +1305,7 @@ B65C12D82E042A320016D2F0 /* DIContainer */ = { isa = PBXGroup; children = ( + 6D61ABE62F57174500111C9B /* Intro */, B61C44962E40340C00285A4B /* LockScreen */, 6D2B8CB62E39C87F00608104 /* BusInfo */, B6793D572E38735A001BE9F5 /* Route */, @@ -1427,6 +1475,7 @@ B66401862E22761800A397AE /* Setting */ = { isa = PBXGroup; children = ( + 6D9F79EA2F57F253008C1492 /* PushAlarmSheet */, 6D1EE2D82E08E4BA00F7BBF1 /* PushAlarm */, ); path = Setting; @@ -1490,6 +1539,7 @@ B673C48C2E0424E200EE4AD0 /* Presentation */ = { isa = PBXGroup; children = ( + 6D61ABE12F5714EF00111C9B /* Intro */, 6D91A90B2E38A1AC0081BAFC /* BusDetail */, B637D6E52E30C6F600F73F14 /* Popup */, B6AC8BF22E2BD3F500410ECD /* WebView */, @@ -2085,7 +2135,7 @@ B65C13312E0658580016D2F0 /* MyPageViewModel.swift in Sources */, 6D9284422E3C6ADF0090889B /* PaddingLabel.swift in Sources */, B637D6EB2E30C75900F73F14 /* AtchaPopupInfo.swift in Sources */, - B6AC8BFE2E2D1F5400410ECD /* LoginItnro.swift in Sources */, + B6AC8BFE2E2D1F5400410ECD /* Intro.swift in Sources */, B65C133D2E06E00F0016D2F0 /* MyPageDIContainer.swift in Sources */, B65C134F2E07A6C70016D2F0 /* MainCoordinator.swift in Sources */, B66401892E22768000A397AE /* PushRegisterDIContainer.swift in Sources */, @@ -2123,7 +2173,7 @@ 6D73EB632E16121700F8DF8B /* CourseModifyViewModel.swift in Sources */, 6DC3BF4A2E05C79700831470 /* LoginViewController.swift in Sources */, B637D6E92E30C72700F73F14 /* AtchaPopupViewModel.swift in Sources */, - 6DC3BF5E2E07123F00831470 /* LoginIntroCell.swift in Sources */, + 6DC3BF5E2E07123F00831470 /* IntroCell.swift in Sources */, 6D91A8E82E29F68E0081BAFC /* OriginSettingBottomView.swift in Sources */, B66401802E2243D100A397AE /* SearchLocationRequest.swift in Sources */, 6D73EBD62E1748AD00F8DF8B /* CourseCell.swift in Sources */, @@ -2137,6 +2187,7 @@ 6D81574E2E13EC0F003688A6 /* Int+Ext.swift in Sources */, B6C507532E1296A8000AB39F /* MainViewController.swift in Sources */, B6AC8BFC2E2BF27200410ECD /* UINavigationController+Ext.swift in Sources */, + 6D61ABE82F57174D00111C9B /* IntroDIContainer.swift in Sources */, B65C13232E058C490016D2F0 /* AppVersionInfo.swift in Sources */, B69E31AE2E2683C9001040F4 /* PermissionViewModel.swift in Sources */, B65402CF2E767B6E00AB5862 /* DetailRouteSummaryCell.swift in Sources */, @@ -2162,6 +2213,7 @@ 6D5E03C82E2881830065AFBE /* FetchRecentSearchResponse.swift in Sources */, B6AC8BF62E2BD91000410ECD /* AppInfoProvider.swift in Sources */, 6DC3BEF12E04FE1600831470 /* KeychainWrapper.swift in Sources */, + 6D61ABEB2F57179C00111C9B /* IntroCoordinator.swift in Sources */, B6ADE1942E0C0DE6008C4E23 /* LoginType.swift in Sources */, B68309672E005B5300E2D029 /* UIView+Ext.swift in Sources */, 6D1EE2F72E0A933000F7BBF1 /* OnboardingCoordinator.swift in Sources */, @@ -2188,6 +2240,7 @@ B65C13042E057B8F0016D2F0 /* TokenStorage.swift in Sources */, B68309562E005A3600E2D029 /* AtchaFont.swift in Sources */, B65C12DA2E042A740016D2F0 /* AppDIContainer.swift in Sources */, + 6D61ABE52F57158700111C9B /* IntroViewModel.swift in Sources */, B6FB209B2E1D33550032751B /* HomeFindViewModel.swift in Sources */, 6DFF29B42E0CDA0E0039399F /* LocationService.swift in Sources */, 6D2B8CA42E39A74800608104 /* BusRealTimeInfo.swift in Sources */, @@ -2232,6 +2285,7 @@ 6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */, B673C4912E0424FD00EE4AD0 /* SplashViewModel.swift in Sources */, 6DB763692E45C53A00D06A49 /* AlarmRequest.swift in Sources */, + 6D61ABE32F57158000111C9B /* IntroViewController.swift in Sources */, B6C507502E129693000AB39F /* LocationStreamRepositoryImpl.swift in Sources */, B6AC8BD92E2A85D800410ECD /* SignOutUseCase.swift in Sources */, 6D26E0022F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift in Sources */, @@ -2239,9 +2293,11 @@ 6D1EE2E52E09A42F00F7BBF1 /* PushAlarmViewController.swift in Sources */, B6793D4F2E34972B001BE9F5 /* FetchTaxiFareRequest.swift in Sources */, 6D8157442E126C45003688A6 /* LockViewController.swift in Sources */, + 6D9F79E92F57F24F008C1492 /* PushAlarmSheetViewController.swift in Sources */, 6D6879DE2E43706800E59C55 /* PushAlarmPatchUseCase.swift in Sources */, B68309572E005A3600E2D029 /* ViewController.swift in Sources */, B6AC8BF82E2BDA2900410ECD /* AppUpdateManager.swift in Sources */, + 6D9F79EC2F57F266008C1492 /* PushAlarmSheetViewModel.swift in Sources */, B65402D32E76C84D00AB5862 /* DetailRouteBusLabel.swift in Sources */, 6DB7636B2E45C5EC00D06A49 /* AlarmUseCase.swift in Sources */, B6D083352E02D928003C28E1 /* AtchaListType.swift in Sources */, diff --git a/Atcha-iOS/App/AppFlowCoordinator.swift b/Atcha-iOS/App/AppFlowCoordinator.swift index 27451a16..dd2d147a 100644 --- a/Atcha-iOS/App/AppFlowCoordinator.swift +++ b/Atcha-iOS/App/AppFlowCoordinator.swift @@ -12,11 +12,13 @@ class AppFlowCoordinator { private let container: AppDIContainer private let window: UIWindow + // 메모리 유지를 위한 Coordinator 참조 private var splashCoordinator: SplashCoordinator? private var mainCoordinator: MainCoordinator? - private var loginCoordinator: LoginCoordinator? private var onboardingCoordinator: OnboardingCoordinator? private var lockScreenCoordinator: LockScreenCoordinator? + private var introCoordinator: IntroCoordinator? + init(window: UIWindow, container: AppDIContainer) { self.window = window @@ -28,28 +30,27 @@ class AppFlowCoordinator { window.rootViewController = navigationController window.makeKeyAndVisible() + // 세션 만료 시: 모든 뷰를 엎고 다시 앱 시작 SessionController.shared.routeToLogin = { [weak self] in - self?.showLoginFlow() + DispatchQueue.main.async { + AppDIContainer.shared.tokenStorage.clearAllTokens() + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) + self?.startApp() + } } let splashCoordinator = container.makeSplashCoordinator(navigationController: navigationController) splashCoordinator.routerHandler = { [weak self] router in - guard let self else { return } + guard let self = self else { return } switch router { - case .login: - showLoginFlow() + case .intro: + showIntroFlow() case .main: showMainFlow() - case .onboarding: - showOnboardingFlow() case .alarm(let info, let address): showMainFlow(info: info, address: address, bottomType: .departure) case .lockScreen(let info, let address): showLockScreenFlow(info: info, address: address) -// case .realTime(let info, let address): -// showMainFlow(info: info, address: address, bottomType: .realTime) -// case .finishTime(let info, let address): -// showMainFlow(info: info, address: address, bottomType: .finish) case .detailRoute(let lat, let lon, let address): print("lat : \(lat), lon : \(lon), address : \(address)") } @@ -61,26 +62,63 @@ class AppFlowCoordinator { private func showMainFlow(info: LegInfo? = nil, address: String? = nil, bottomType: MapBottomType = .search) { + let navigationController = UINavigationController() window.rootViewController = navigationController - mainCoordinator = container.makeMainCoordinator(navigationController: navigationController) - mainCoordinator?.signoutFinish = { [weak self] in + + let mainCoordinator = container.makeMainCoordinator(navigationController: navigationController) + self.mainCoordinator = mainCoordinator + + // [신규 유저 온보딩 흐름] + // Main 화면(지도)을 깔아둔 상태에서, 그 위(Navigation 스택)에 온보딩을 얹습니다. + mainCoordinator.routeToOnboarding = { [weak self] in + guard let self = self else { return } DispatchQueue.main.async { - self?.showLoginFlow() + let onboardingCoordinator = self.container.makeOnboardingCoordinator(navigationController: navigationController) + + // 온보딩(회원가입)이 성공적으로 끝났을 때 + onboardingCoordinator.onFinish = { [weak self] success in + DispatchQueue.main.async { + // 위에 쌓여있던 온보딩 화면들을 싹 치우고 밑에 깔려있던 Main(지도)으로 복귀! + navigationController.popToRootViewController(animated: true) + + // 집 주소가 등록되었으니, MainVC를 찔러서 현위치/마커를 새로고침하게 합니다. + if let mainVC = navigationController.viewControllers.first as? MainViewController { + mainVC.viewModel.setupLocation() + mainVC.shouldShowWelcomeToast = true + + mainVC.viewModel.isGuest = false + } + + self?.onboardingCoordinator = nil + } + } + + onboardingCoordinator.start() + self.onboardingCoordinator = onboardingCoordinator // 메모리 유지 } } - mainCoordinator?.lockScreenConfrim = { [weak self] info, address in + + // [락스크린 확인 흐름] + mainCoordinator.lockScreenConfrim = { [weak self] info, address in DispatchQueue.main.async { if let info, let address { -// self?.showMainFlow(info: info, address: address, bottomType: .realTime) self?.showMainFlow(info: info, address: address, bottomType: .detail) - // TODO: 상세화면 연동 로직 적용하기 } else { self?.showMainFlow() } } } - mainCoordinator?.start(info: info, address: address, bottomType: bottomType) + + // [회원 탈퇴 흐름] + mainCoordinator.withdrawFinish = { [weak self] in + DispatchQueue.main.async { + // 앱 데이터를 다 지웠으니, 스플래시부터 앱을 아예 새로 시작(리부팅)합니다! + self?.startApp() + } + } + + mainCoordinator.start(info: info, address: address, bottomType: bottomType) } private func showLockScreenFlow(info: LegInfo? = nil, @@ -94,7 +132,6 @@ class AppFlowCoordinator { switch router { case .lockScreen(let info, let address): if let info, let address { - // TODO: 상세화면 연동하기 로직 self?.showMainFlow(info: info, address: address, bottomType: .departure) } else { self?.showMainFlow() @@ -107,32 +144,21 @@ class AppFlowCoordinator { self.lockScreenCoordinator = lockScreenCoordinator } - private func showLoginFlow() { + private func showIntroFlow() { let navigationController = UINavigationController() window.rootViewController = navigationController - let loginCoordinator = container.makeLoginCoordinator(navigationController: navigationController) - loginCoordinator.onFinishWithExistUser = { [weak self] isExist in - DispatchQueue.main.async { - isExist ? self?.showMainFlow() : self?.showOnboardingFlow() - } - } - loginCoordinator.start() - self.loginCoordinator = loginCoordinator - } - - private func showOnboardingFlow() { - let navigationController = UINavigationController() - window.rootViewController = navigationController + let introCoordinator = container.makeIntroCoordinator(navigationController: navigationController) - let onboardingCoordinator = container.makeOnboardingCoordinator(navigationController: navigationController) - onboardingCoordinator.onFinish = { [weak self] success in + // 인트로에서 "게스트 모드로 시작하기" 등을 눌렀을 때 지도(Main)로 넘어갑니다. + introCoordinator.onFinishWithGuest = { [weak self] in DispatchQueue.main.async { - success ? self?.showMainFlow() : self?.showLoginFlow() + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) + self?.showMainFlow() } } - onboardingCoordinator.start() - self.onboardingCoordinator = onboardingCoordinator + introCoordinator.start() + self.introCoordinator = introCoordinator } } diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index 7dbaa426..0fc8fed5 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -25,6 +25,7 @@ final class AppCompositionRoot { let onboardingDIContainer: OnboardingDIContainer let mainDIContainer: MainDIContainer let lockScreenDIContainer: LockScreenDIContainer + let introDIContainer: IntroDIContainer // MARK: - Init init() { @@ -43,6 +44,7 @@ final class AppCompositionRoot { self.mainDIContainer = MainDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) self.lockScreenDIContainer = LockScreenDIContainer(apiService: apiService) + self.introDIContainer = IntroDIContainer() } } @@ -51,7 +53,8 @@ extension AppCompositionRoot: SplashCoordinatorFactory, LoginCoordinatorFactory, OnboardingCoordinatorFactory, MainCoordinatorFactory, - LockScreenCoordinatorFactory { + LockScreenCoordinatorFactory, + IntroCoordinatorFactory { func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { return splashDIContainer.makeSplashCoordinator(navigationController: navigationController) } @@ -71,4 +74,8 @@ extension AppCompositionRoot: SplashCoordinatorFactory, func makeLockScreenCoordinator(navigationController: UINavigationController) -> LockScreenCoordinator { return lockScreenDIContainer.makeLockScreenCoordinator(navigationController: navigationController) } + + func makeIntroCoordinator(navigationController: UINavigationController) -> IntroCoordinator { + return introDIContainer.makeIntroCoordinator(navigationController: navigationController) + } } diff --git a/Atcha-iOS/App/DIContainer/AppDIContainer.swift b/Atcha-iOS/App/DIContainer/AppDIContainer.swift index aceaf2d4..fbb8c25b 100644 --- a/Atcha-iOS/App/DIContainer/AppDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/AppDIContainer.swift @@ -21,6 +21,7 @@ final class AppDIContainer { let mainDIContainer: MainDIContainer let onboardingDIContainer: OnboardingDIContainer let lockScreenDIContainer: LockScreenDIContainer + let introDIContainer: IntroDIContainer let locationStateHolder: LocationStateHolder @@ -38,7 +39,8 @@ final class AppDIContainer { self.onboardingDIContainer = compositionRoot.onboardingDIContainer self.mainDIContainer = compositionRoot.mainDIContainer self.lockScreenDIContainer = compositionRoot.lockScreenDIContainer - + self.introDIContainer = compositionRoot.introDIContainer + // Shared state holders self.locationStateHolder = compositionRoot.locationStateHolder } diff --git a/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift b/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift index 5c5f7528..43ed90b9 100644 --- a/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift +++ b/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift @@ -32,14 +32,19 @@ protocol LockScreenCoordinatorFactory { func makeLockScreenCoordinator(navigationController: UINavigationController) -> LockScreenCoordinator } +protocol IntroCoordinatorFactory { + func makeIntroCoordinator(navigationController: UINavigationController) -> IntroCoordinator +} + /// A convenience alias that groups all coordinator factory protocols used to bootstrap flows. -typealias AppCoordinatorFactory = SplashCoordinatorFactory & LoginCoordinatorFactory & OnboardingCoordinatorFactory & MainCoordinatorFactory & LockScreenCoordinatorFactory +typealias AppCoordinatorFactory = SplashCoordinatorFactory & LoginCoordinatorFactory & OnboardingCoordinatorFactory & MainCoordinatorFactory & LockScreenCoordinatorFactory & IntroCoordinatorFactory extension AppDIContainer: SplashCoordinatorFactory, LoginCoordinatorFactory, OnboardingCoordinatorFactory, MainCoordinatorFactory, - LockScreenCoordinatorFactory { + LockScreenCoordinatorFactory, + IntroCoordinatorFactory { func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { return splashDIContainer.makeSplashCoordinator(navigationController: navigationController) } @@ -59,5 +64,9 @@ extension AppDIContainer: SplashCoordinatorFactory, func makeLockScreenCoordinator(navigationController: UINavigationController) -> LockScreenCoordinator { return lockScreenDIContainer.makeLockScreenCoordinator(navigationController: navigationController) } + + func makeIntroCoordinator(navigationController: UINavigationController) -> IntroCoordinator { + return introDIContainer.makeIntroCoordinator(navigationController: navigationController) + } } diff --git a/Atcha-iOS/App/DIContainer/Intro/IntroDIContainer.swift b/Atcha-iOS/App/DIContainer/Intro/IntroDIContainer.swift new file mode 100644 index 00000000..688a1817 --- /dev/null +++ b/Atcha-iOS/App/DIContainer/Intro/IntroDIContainer.swift @@ -0,0 +1,20 @@ +// +// LoginDIContainer.swift +// Atcha-iOS +// +// Created by geonhui Yu on 6/23/25. +// + +import UIKit +import Foundation + +final class IntroDIContainer { + func makeIntroViewModel() -> IntroViewModel { + IntroViewModel() + } + + func makeIntroCoordinator(navigationController: UINavigationController) -> IntroCoordinator { + IntroCoordinator(navigationController: navigationController, + diContainer: self) + } +} diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index 2dddd76e..866c9924 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -44,6 +44,10 @@ final class MainDIContainer { LockScreenDIContainer(apiService: apiService) }() + private lazy var loginDI: LoginDIContainer = { + LoginDIContainer(apiService: apiService) + }() + init(apiService: APIService, locationStateHolder: LocationStateHolder) { self.apiService = apiService self.locationStateHolder = locationStateHolder @@ -116,3 +120,11 @@ extension MainDIContainer{ return proximityDI } } + +// MARK: - Login +extension MainDIContainer{ + func makeLoginDIContainer() -> LoginDIContainer { + return loginDI + } +} + diff --git a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift index 0c48c08a..b9b5ab22 100644 --- a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift @@ -23,6 +23,7 @@ final class HomeRegisterDIContainer { private lazy var searchAddressUseCase: SearchAddressUseCase = SearchAddressUseCaseImpl(repository: addressRepository) private lazy var homePatchUseCase: HomePatchUseCase = HomePatchUseCaseImpl(repository: userRepository) private lazy var streamUseCase: ObserveLocationStreamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) + private lazy var signUpUseCase: SignUpUseCase = SignUpUseCaseImpl(repository: userRepository) func makeHomeRegisterViewModel(context: HomeRegisterContext) -> HomeRegisterViewModel { return HomeRegisterViewModel(context: context, @@ -40,7 +41,7 @@ final class HomeRegisterDIContainer { let viewModel = HomeFindViewModel(context: context, searchAddressUseCase: searchAddressUseCase, homePatchUseCase: homePatchUseCase, - locationStateHolder: locationStateHolder, streamUseCase: streamUseCase) + locationStateHolder: locationStateHolder, streamUseCase: streamUseCase, signUpUseCase: signUpUseCase) return viewModel } diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/AlarmManager.swift index 9196d8ae..7747603c 100644 --- a/Atcha-iOS/Core/Manager/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/AlarmManager.swift @@ -26,7 +26,7 @@ final class AlarmManager { // MARK: - State private var alarmVolume: Float = 1.0 private var currentSoundFile: String? - var selectedOption: PushAlarmOption = .onlySound + var selectedOption: PushAlarmOption = .onlyVibration private var interruptionObserver: NSObjectProtocol? private var silenceHintObserver: NSObjectProtocol? @@ -40,6 +40,7 @@ final class AlarmManager { loadStoredAlarmOption() setupAudioSession() startObservingAudioSession() + preloadPreviewSound() } // MARK: - Public: Volume / Option @@ -148,6 +149,16 @@ final class AlarmManager { } } } + + private func preloadPreviewSound() { + guard let url = Bundle.main.url(forResource: "siren", withExtension: "mp3") else { return } + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.prepareToPlay() + } catch { + print("알람음 프리로드 실패") + } + } } // MARK: - Private: Session / Storage @@ -166,8 +177,7 @@ extension AlarmManager { selectedOption = option print("알람 타입 불러오기: \(option)") } else { - selectedOption = .onlySound - print("알람 타입 기본값 사용: onlySound") + selectedOption = .onlyVibration } } @@ -532,6 +542,8 @@ extension AlarmManager { isPreviewing = true alarmVolume = volume + stopRepeatingVibration() + // 햅틱 엔진이 중단되어 있으면 재시작 if selectedOption == .onlyVibration || selectedOption == .both { restartHapticEngine() @@ -548,6 +560,9 @@ extension AlarmManager { } case .onlyVibration: + audioPlayer?.stop() + audioPlayer = nil + currentSoundFile = nil startRepeatingVibration() print("진동 미리듣기") diff --git a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index f7d3b4a0..5638b1b2 100644 --- a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift +++ b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift @@ -32,6 +32,11 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { // completion(.success(request)); return // } + if path.contains("/auth/check") || path.contains("/auth/login") { + completion(.success(request)) + return + } + if path.contains("/auth/logout") { if let refreshToken = tokenStorage.refreshToken { request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") diff --git a/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsKey.swift b/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsKey.swift index 9f364e56..a1a35ebd 100644 --- a/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsKey.swift +++ b/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsKey.swift @@ -12,6 +12,7 @@ public extension UserDefaultsWrapper.Key { static let providerToken: UserDefaultsWrapper.Key = "providerToken" static let userId: UserDefaultsWrapper.Key = "userId" static let reVisit: UserDefaultsWrapper.Key = "reVisit" + static let hasSeenIntro: UserDefaultsWrapper.Key = "hasSeenIntro" static let homeLat: UserDefaultsWrapper.Key = "homeLat" static let homeLon: UserDefaultsWrapper.Key = "homeLon" @@ -38,4 +39,12 @@ public extension UserDefaultsWrapper.Key { static let alarmRegister: UserDefaultsWrapper.Key = "alarmRegister" static let popRegister: UserDefaultsWrapper.Key = "popRegister" static let departureAlarmDidFire: UserDefaultsWrapper.Key = "departureAlarmDidFire" + + static let isGuest: UserDefaultsWrapper.Key = "isGuest" +} + +extension UserDefaults { + @objc dynamic var departureAlarmDidFire: Bool { + return bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) + } } diff --git a/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift b/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift index 4ee2d96b..8e8d7817 100644 --- a/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift +++ b/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift @@ -40,7 +40,7 @@ final class TMapWrapper: NSObject, MapRendering { mapView.locationDelgate = self } - func updateUserMarker(coordinate: CLLocationCoordinate2D) { + func updateUserMarker(coordinate: CLLocationCoordinate2D, isRegistered: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } if let marker = userMarker { @@ -48,7 +48,7 @@ final class TMapWrapper: NSObject, MapRendering { if marker.map == nil { marker.map = mapView } // ← 숨겨져 있던 마커 다시 보이게 } else { userMarker = TMapMarker(position: coordinate) - userMarker?.icon = UIImage.currentLocationMark + userMarker?.icon = isRegistered ? UIImage.currentLocationMark : UIImage.beforeCurrentLocation userMarker?.map = mapView } } @@ -159,7 +159,7 @@ final class TMapWrapper: NSObject, MapRendering { extension TMapWrapper: TMapViewDelegate, TmapViewLocationDelegate { func mapViewDidFinishLoadingMap() { mapView.setMapType(.Night) - mapView.setZoom(18) + mapView.setZoom(16) mapView.isShowCompass = false mapView.isRotationEnable = true delegate?.didFinishLoadingMap(self) diff --git a/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift b/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift index cca8d5d8..b5e31a3f 100644 --- a/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift +++ b/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift @@ -82,7 +82,7 @@ final class AtchaBallon: UIView { if showTopLine { // 알람 등록 전: 위 줄 보이게 (고정 문구) topLabel.isHidden = false - topLabel.attributedText = AtchaFont.B7_M_13("지도를 움직여 출발지를 설정해 봐요", color: .white) + topLabel.attributedText = AtchaFont.B7_M_13("지도를 움직여 출발지를 설정해요", color: .white) topLabel.alpha = 1 } else { // 알람 등록 후: 위 줄 숨김 diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1.png deleted file mode 100644 index 262a70b2..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@2x.png deleted file mode 100644 index ec215da5..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@3x.png deleted file mode 100644 index eaba6724..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Step1@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2.png deleted file mode 100644 index 87367bcf..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@2x.png deleted file mode 100644 index b21c352c..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@3x.png deleted file mode 100644 index 4865dfd7..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Step2@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3.png deleted file mode 100644 index 962a315e..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@2x.png deleted file mode 100644 index 027dec02..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@3x.png deleted file mode 100644 index decbdee0..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Step3@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4.png deleted file mode 100644 index dbf36410..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@2x.png deleted file mode 100644 index d07e3b61..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@3x.png deleted file mode 100644 index 6ecc9d67..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Step4@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Contents.json deleted file mode 100644 index 3979b7ee..00000000 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "Step5.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Step5@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Step5@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5.png deleted file mode 100644 index 994c222f..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@2x.png deleted file mode 100644 index 95949706..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@3x.png deleted file mode 100644 index 8fbb937f..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step5.imageset/Step5@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Contents.json deleted file mode 100644 index aa4d9639..00000000 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "Step6.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Step6@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Step6@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6.png deleted file mode 100644 index eaac6073..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@2x.png deleted file mode 100644 index 8419273a..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@2x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@3x.png deleted file mode 100644 index 0b75596d..00000000 Binary files a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step6.imageset/Step6@3x.png and /dev/null differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/Contents.json similarity index 72% rename from Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Contents.json rename to Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/Contents.json index a8a2fedb..cdadc626 100644 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step1.imageset/Contents.json +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Step1.png", + "filename" : "step1.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Step1@2x.png", + "filename" : "step1@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Step1@3x.png", + "filename" : "step1@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1.png new file mode 100644 index 00000000..128acd29 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@2x.png new file mode 100644 index 00000000..ec4db33d Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@3x.png new file mode 100644 index 00000000..738786a1 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step1.imageset/step1@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/Contents.json similarity index 72% rename from Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Contents.json rename to Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/Contents.json index 92cc0b32..f405eec8 100644 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step2.imageset/Contents.json +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Step2.png", + "filename" : "step2.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Step2@2x.png", + "filename" : "step2@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Step2@3x.png", + "filename" : "step2@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2.png new file mode 100644 index 00000000..11e7b267 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@2x.png new file mode 100644 index 00000000..ccd44a0c Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@3x.png new file mode 100644 index 00000000..06714fb4 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step2.imageset/step2@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/Contents.json similarity index 72% rename from Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Contents.json rename to Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/Contents.json index 4dff43af..a3e13161 100644 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step3.imageset/Contents.json +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Step3.png", + "filename" : "step3.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Step3@2x.png", + "filename" : "step3@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Step3@3x.png", + "filename" : "step3@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3.png new file mode 100644 index 00000000..284a6012 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@2x.png new file mode 100644 index 00000000..dbbc4e9b Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@3x.png new file mode 100644 index 00000000..078bf0f4 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step3.imageset/step3@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/Contents.json similarity index 72% rename from Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Contents.json rename to Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/Contents.json index 7bf67228..1fe64f94 100644 --- a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/Step4.imageset/Contents.json +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Step4.png", + "filename" : "step4.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Step4@2x.png", + "filename" : "step4@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Step4@3x.png", + "filename" : "step4@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4.png new file mode 100644 index 00000000..9c9ad0f2 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@2x.png new file mode 100644 index 00000000..22543ca5 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@3x.png new file mode 100644 index 00000000..60da199f Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Splash/step4.imageset/step4@3x.png differ diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index d4738e3d..ad6297bc 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -222,7 +222,7 @@ extension BaseViewController { activePermissionToast = nil return true - case .denied, .restricted, .notDetermined: + case .denied, .restricted: activePermissionToast?.hideImmediately() let toast = AtchaActionToast( @@ -235,39 +235,90 @@ extension BaseViewController { activePermissionToast = toast toast.show(in: view, duration: 2.0, topOffset: 10) + return false + + case .notDetermined: + // 💡 아직 권한을 묻기 전이거나 '한 번만 허용' 세션이 만료된 상태입니다. + // 이때는 토스트를 띄우지 않고 false만 반환하여 시스템 팝업이 뜰 기회를 줍니다. + return false - return true @unknown default: - return true + return false } } } + extension BaseViewController { - func ensureAlarmPermissionOrShowToast() -> Bool { + + /// 권한을 확인하고, 없으면 요청하거나(최초) 알럿/토스트를 띄웁니다. + /// - Parameter completion: 권한이 허용되었을 때 실행할 클로저 (등록 진행) + func ensureAlarmPermissionAndExecute(completion: @escaping () -> Void) { let center = UNUserNotificationCenter.current() - var isAuthorized = false - let semaphore = DispatchSemaphore(value: 0) - center.getNotificationSettings { settings in - switch settings.authorizationStatus { - case .authorized, .provisional: - isAuthorized = true - default: - isAuthorized = false + center.getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + guard let self = self else { return } + + switch settings.authorizationStatus { + case .authorized, .provisional: + // 1. 이미 허용되어 있음 -> 바로 콜백 실행 + self.activeAlarmPermissionToast?.hideImmediately() + self.activeAlarmPermissionToast = nil + completion() + + case .notDetermined: + // 2. 최초 요청 -> 권한 묻기 시스템 팝업 띄움 + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + DispatchQueue.main.async { + if granted { + // 사용자가 '허용'을 누름 -> 콜백 실행 + completion() + } else { + // 사용자가 '거절'을 누름 -> 최초 1회 Alert 띄우기 + self.handleAlarmPermissionDenied() + } + } + } + + case .denied, .ephemeral: + // 3. 이미 거절된 상태 -> 토스트 띄우기 + self.showAlarmPermissionToast() + + @unknown default: + break + } } - semaphore.signal() } + } + + // MARK: - 거절/토스트 처리 헬퍼 함수 + + private func handleAlarmPermissionDenied() { + let hasShownAlert = UserDefaults.standard.bool(forKey: "hasShownAlarmDeniedAlert") - semaphore.wait() - - if isAuthorized { - activeAlarmPermissionToast?.hideImmediately() - activeAlarmPermissionToast = nil - return true + if !hasShownAlert { + // 최초 거절 시 1회 Alert + UserDefaults.standard.set(true, forKey: "hasShownAlarmDeniedAlert") + + let alert = UIAlertController( + title: nil, + message: "알림을 허용하지 않으면\n막차 알람이 울리지 못해요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "닫기", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: "설정하기", style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + }) + present(alert, animated: true) + } else { + // 그 이후에는 토스트 + showAlarmPermissionToast() } - - // 권한 없으면: 토스트는 띄우되 진행은 막지 않음 + } + + private func showAlarmPermissionToast() { activeAlarmPermissionToast?.hideImmediately() let toast = AtchaActionToast( @@ -281,7 +332,5 @@ extension BaseViewController { activeAlarmPermissionToast = toast toast.show(in: view, duration: 5.0, topOffset: 10) - - return true } } diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift index 8b75af4d..5565128d 100644 --- a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift +++ b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift @@ -165,7 +165,7 @@ final class CourseSearchViewController: BaseViewController= 40 } + let hasConfigured = UserDefaults.standard.bool(forKey: "hasSeenAlarmSettingsSheet") - let isException = (busCount == 1) && (hasSubway == false) - - let shouldShowPopup = hasLongWaitBus && !isException - - let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - - if isAlarmRegistered { - if shouldShowPopup { - showCoursePopup(alarmRequest, alarmTapped) - } else { - showRe_RegisterPopup(alarmRequest, alarmTapped) + if !hasConfigured { + // 처음이라면 설정 시트를 먼저 띄움 + self.presentPushAlarmSheet { [weak self] in + UserDefaults.standard.set(true, forKey: "hasSeenAlarmSettingsSheet") + self?.handleAlarmPermissionAndRegistration(for: model) } } else { - if shouldShowPopup { - showCoursePopup(alarmRequest, alarmTapped) - } else { - viewModel.alarmRegister(alarmRequest) - viewModel.getAlarmTapped?(alarmTapped.0, alarmTapped.1) - - let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") - AmplitudeManager.shared.track( - .alarm_register, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) - ) - ) - navigationController?.popToRootViewController(animated: true) - } + // 이미 설정했다면 바로 권한 체크 및 등록 진행 + self.handleAlarmPermissionAndRegistration(for: model) } } @@ -448,3 +418,65 @@ extension CourseSearchViewController { present(popupVC, animated: false) } } + +extension CourseSearchViewController { + private func presentPushAlarmSheet(completion: @escaping () -> Void) { + let sheetVM = PushAlarmSheetViewModel() + let sheetVC = PushAlarmSheetViewController(viewModel: sheetVM) + + sheetVC.modalPresentationStyle = .overFullScreen + + sheetVC.onComplete = { + completion() + } + + sheetVC.onDismiss = { + } + + present(sheetVC, animated: false) + } + + /// 권한 확인 및 실제 서버 알람 등록 처리 + private func handleAlarmPermissionAndRegistration(for model: CourseUIModel) { + self.ensureAlarmPermissionAndExecute { [weak self] in + guard let self = self else { return } + + let pathInfo: [LegPathInfo] = model.course.toLegPathInfos() + let trafficInfo: [LegTrafficInfo] = model.course.toLegTrafficInfos() + let busInfo: [BusDetailInfo] = model.course.toBusInfos() + + let alarmRequest = AlarmRequest(lastRouteId: model.course.routeId) + let alarmData = (self.viewModel.startAddress, LegInfo(pathInfo: pathInfo, trafficInfo: trafficInfo, busInfo: busInfo)) + + // 팝업 노출 여부 결정 로직 (기존 로직 유지) + let busLegs = trafficInfo.filter { $0.mode == .bus } + let hasSubway = trafficInfo.contains { $0.mode == .subway } + let hasLongWaitBus = busLegs.contains { ($0.targetBusTerm ?? 0) >= 40 } + let isException = (busLegs.count == 1) && (hasSubway == false) + let shouldShowPopup = hasLongWaitBus && !isException + + let isAlreadyRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + + if isAlreadyRegistered { + if shouldShowPopup { + self.showCoursePopup(alarmRequest, alarmData) + } else { + self.showRe_RegisterPopup(alarmRequest, alarmData) + } + } else { + if shouldShowPopup { + self.showCoursePopup(alarmRequest, alarmData) + } else { + // 서버에 알람 등록 실행 + self.viewModel.alarmRegister(alarmRequest) + self.viewModel.getAlarmTapped?(alarmData.0, alarmData.1) + + // 앰플리튜드 트래킹 및 메인 이동 + let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") + AmplitudeManager.shared.track(.alarm_register, props(AmplitudeProperty.dwellTime(seconds: dwellSeconds))) + self.navigationController?.popToRootViewController(animated: true) + } + } + } + } +} diff --git a/Atcha-iOS/Presentation/Course/CourseSetting/CourseSettingViewController.swift b/Atcha-iOS/Presentation/Course/CourseSetting/CourseSettingViewController.swift index 62a1ba15..920bbb3b 100644 --- a/Atcha-iOS/Presentation/Course/CourseSetting/CourseSettingViewController.swift +++ b/Atcha-iOS/Presentation/Course/CourseSetting/CourseSettingViewController.swift @@ -201,7 +201,5 @@ extension CourseSettingViewController { func mapView(_ mapView: TMapWrapper, didSelectLocation coordinate: CLLocationCoordinate2D) { viewModel.currentLocation = coordinate } - func mapView(_ mapView: TMapWrapper, singleTapOnMap location: CLLocationCoordinate2D) { - - } + func mapViewDidStartScroll(_ mapView: TMapWrapper) {} } diff --git a/Atcha-iOS/Presentation/Intro/Coordinator/IntroCoordinator.swift b/Atcha-iOS/Presentation/Intro/Coordinator/IntroCoordinator.swift new file mode 100644 index 00000000..ff21e006 --- /dev/null +++ b/Atcha-iOS/Presentation/Intro/Coordinator/IntroCoordinator.swift @@ -0,0 +1,33 @@ +// +// LoginCoordinator.swift +// Atcha-iOS +// +// Created by geonhui Yu on 6/23/25. +// + +import UIKit +import Foundation + +final class IntroCoordinator { + private let navigationController: UINavigationController + private let diContainer: IntroDIContainer + + var onFinishWithGuest: (() -> Void)? + + init(navigationController: UINavigationController, + diContainer: IntroDIContainer) { + self.navigationController = navigationController + self.diContainer = diContainer + } + + func start() { + let viewModel = diContainer.makeIntroViewModel() + + viewModel.onFinishWithGuest = { [weak self] in + self?.onFinishWithGuest?() + } + + let viewController = IntroViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } +} diff --git a/Atcha-iOS/Presentation/Intro/Intro.swift b/Atcha-iOS/Presentation/Intro/Intro.swift new file mode 100644 index 00000000..6c87e933 --- /dev/null +++ b/Atcha-iOS/Presentation/Intro/Intro.swift @@ -0,0 +1,37 @@ +// +// LoginItnro.swift +// Atcha-iOS +// +// Created by geonhui Yu on 7/20/25. +// + +import UIKit + +enum Intro: CaseIterable { + case step1 + case step2 + case step3 + case step4 + + var title: String { + switch self { + case .step1: + return "번거롭던 막차 찾기,\n이제 클릭 한 번이면 돼요" + case .step2: + return "늦은 출발순으로\n다양한 막차 경로 확인해요" + case .step3: + return "원하는 경로 선택하고\n출발 시간에 알람 받아요" + case .step4: + return "경로 보고\n안전하게 귀가해요" + } + } + + var image: UIImage { + switch self { + case .step1: return UIImage.step1 + case .step2: return UIImage.step2 + case .step3: return UIImage.step3 + case .step4: return UIImage.step4 + } + } +} diff --git a/Atcha-iOS/Presentation/Login/LoginIntroCell.swift b/Atcha-iOS/Presentation/Intro/IntroCell.swift similarity index 70% rename from Atcha-iOS/Presentation/Login/LoginIntroCell.swift rename to Atcha-iOS/Presentation/Intro/IntroCell.swift index 697ed181..180926a3 100644 --- a/Atcha-iOS/Presentation/Login/LoginIntroCell.swift +++ b/Atcha-iOS/Presentation/Intro/IntroCell.swift @@ -8,8 +8,8 @@ import UIKit import SnapKit -final class LoginIntroCell: UICollectionViewCell { - static let id = "LoginIntroCell" +final class IntroCell: UICollectionViewCell { + static let id = "IntroCell" private let titleLabel = UILabel() private let imageView = UIImageView() @@ -26,8 +26,8 @@ final class LoginIntroCell: UICollectionViewCell { setupAutoLayout() } - func configure(info: LoginIntro) { - titleLabel.attributedText = AtchaFont.H1_B_26(info.title, color: AtchaColor.white, alignment: .center) + func configure(info: Intro) { + titleLabel.attributedText = AtchaFont.H2_B_22(info.title, color: AtchaColor.white, alignment: .center) imageView.image = info.image } @@ -38,17 +38,16 @@ final class LoginIntroCell: UICollectionViewCell { } private func setupAutoLayout() { - titleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(30) + imageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(125.34) make.centerX.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.height.equalTo(imageView.snp.width).multipliedBy(415.32 / 392.0) } - imageView.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(60) + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(40) make.centerX.equalToSuperview() - make.width.equalToSuperview().multipliedBy(0.8) - make.height.equalTo(imageView.snp.width).multipliedBy(320.0 / 350.0) -// make.bottom.lessThanOrEqualToSuperview().inset(40) } } } diff --git a/Atcha-iOS/Presentation/Intro/IntroViewController.swift b/Atcha-iOS/Presentation/Intro/IntroViewController.swift new file mode 100644 index 00000000..dbf1dd20 --- /dev/null +++ b/Atcha-iOS/Presentation/Intro/IntroViewController.swift @@ -0,0 +1,258 @@ +// +// IntroViewController.swift +// Atcha-iOS +// +// Created by wodnd on 3/3/26. +// + +import UIKit +import SnapKit +import AuthenticationServices +import QuartzCore + +final class IntroViewController: BaseViewController { + private var appleLoginDelegateWrapper: AppleLoginDelegateWrapper? + private let backgroundImageView: UIImageView = UIImageView() + private let guestLoginButton: UIButton = UIButton(type: .custom) + + private let pageControl = UIPageControl() + private var autoScrollTimer: Timer? + private let multiplier = 3 + private var isInitialSetup = true + + private let gradientLayer = CAGradientLayer() + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + + let collectionView = UICollectionView(frame: .zero, + collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.isPagingEnabled = true + collectionView.showsHorizontalScrollIndicator = false + collectionView.register(IntroCell.self, + forCellWithReuseIdentifier: IntroCell.id) + collectionView.delegate = self + collectionView.dataSource = self + return collectionView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupButtons() + setupAutoLayout() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + gradientLayer.frame = backgroundImageView.bounds + + // 컬렉션뷰 레이아웃이 완료된 후 중간 위치로 초기화 + if isInitialSetup { + let itemCount = Intro.allCases.count + let middleIndex = itemCount * (multiplier / 2) + let indexPath = IndexPath(item: middleIndex, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + isInitialSetup = false + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + autoScrollTimer = Timer.scheduledTimer(timeInterval: 4.0, + target: self, + selector: #selector(goToNextPage), + userInfo: nil, + repeats: true) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + autoScrollTimer?.invalidate() + autoScrollTimer = nil + } + + private func setupUI() { + view.addSubViews(backgroundImageView, pageControl, collectionView, guestLoginButton) + + let topColor = UIColor(red: 0x0A/255.0, green: 0x0A/255.0, blue: 0x0A/255.0, alpha: 1.0) + let bottomColor = UIColor(red: 0x18/255.0, green: 0x18/255.0, blue: 0x1A/255.0, alpha: 1.0) + + gradientLayer.colors = [topColor.cgColor, bottomColor.cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) + backgroundImageView.layer.insertSublayer(gradientLayer, at: 0) + + pageControl.numberOfPages = Intro.allCases.count + pageControl.currentPage = 0 + pageControl.currentPageIndicatorTintColor = AtchaColor.white + pageControl.pageIndicatorTintColor = AtchaColor.gray300 + pageControl.isUserInteractionEnabled = false + } + + private func setupAutoLayout() { + backgroundImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + collectionView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.bottom.equalTo(pageControl.snp.top).offset(-32) // 페이지 컨트롤과의 간격 + } + + pageControl.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(guestLoginButton.snp.top).offset(-81.34) + make.height.equalTo(6) + } + + guestLoginButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(40) + make.height.equalTo(56) + } + } + + @objc private func goToNextPage() { + let itemsPerPage = Intro.allCases.count + let currentOffset = collectionView.contentOffset.x + let pageWidth = collectionView.bounds.width + let currentPage = Int(currentOffset / pageWidth) + let nextPage = currentPage + 1 + + let indexPath = IndexPath(item: nextPage, section: 0) + collectionView.scrollToItem(at: indexPath, + at: .centeredHorizontally, + animated: true) + + // 실제 페이지 번호 업데이트 (0-5 범위 내에서) + let actualPage = nextPage % itemsPerPage + pageControl.currentPage = actualPage + } + + private func setupButtons() { + configureLoginButton( + button: guestLoginButton, + labelText: "앗차 시작하기", + textColor: AtchaColor.white, + bgColor: AtchaColor.gray910 + ) + + guestLoginButton.addTarget(self, action: #selector(didTapGuestLogin), for: .touchUpInside) + } + + private func configureLoginButton(button: UIButton, + labelText: String, + textColor: UIColor, + bgColor: UIColor) { + + + let label = UILabel() + label.attributedText = AtchaFont.B1_R_17(lineHeight: 0, labelText, color: textColor, alignment: .center) + label.textAlignment = .center + + button.layer.cornerRadius = 8 + button.layer.backgroundColor = bgColor.cgColor + + label.isUserInteractionEnabled = false + + // 스택 뷰 없이 버튼에 직접 추가 + button.addSubViews(label) + + + label.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + button.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(15) + make.height.equalTo(56) + } + } +} + +extension IntroViewController { + // MARK: - Actions + @objc private func didTapGuestLogin() { + viewModel.guestLoginTapped() + } +} + +extension IntroViewController: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return self.view.window! + } +} + +extension IntroViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + // 무한 스크롤을 위해 실제 아이템 수의 배수만큼 생성 + return Intro.allCases.count * multiplier + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: IntroCell.id, for: indexPath) as? IntroCell else { + + return UICollectionViewCell() + } + + // 실제 인덱스로 변환 (0-5 범위로 순환) + let actualIndex = indexPath.item % Intro.allCases.count + cell.configure(info: Intro.allCases[actualIndex]) + return cell + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: collectionView.bounds.width, + height: collectionView.bounds.height) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let itemsPerPage = Intro.allCases.count + let pageWidth = scrollView.frame.width + let currentPage = Int(scrollView.contentOffset.x / pageWidth + 0.5) + + // 실제 페이지 번호 업데이트 (0-5 범위 내에서) + let actualPage = currentPage % itemsPerPage + pageControl.currentPage = actualPage + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + resetScrollPositionIfNeeded() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + resetScrollPositionIfNeeded() + } + + // 스크롤 위치가 끝에 가까워지면 중간으로 재배치 + private func resetScrollPositionIfNeeded() { + let itemsPerPage = Intro.allCases.count + let pageWidth = collectionView.bounds.width + let currentPage = Int(collectionView.contentOffset.x / pageWidth + 0.5) + let totalPages = itemsPerPage * multiplier + + // 끝 부분에 가까워지면 중간으로 이동 + if currentPage <= itemsPerPage / 2 { + let newPage = currentPage + itemsPerPage + let newOffset = CGPoint(x: CGFloat(newPage) * pageWidth, y: 0) + collectionView.setContentOffset(newOffset, animated: false) + } else if currentPage >= totalPages - itemsPerPage / 2 { + let newPage = currentPage - itemsPerPage + let newOffset = CGPoint(x: CGFloat(newPage) * pageWidth, y: 0) + collectionView.setContentOffset(newOffset, animated: false) + } + } +} + diff --git a/Atcha-iOS/Presentation/Intro/IntroViewModel.swift b/Atcha-iOS/Presentation/Intro/IntroViewModel.swift new file mode 100644 index 00000000..80b69401 --- /dev/null +++ b/Atcha-iOS/Presentation/Intro/IntroViewModel.swift @@ -0,0 +1,23 @@ +// +// IntroViewModel.swift +// Atcha-iOS +// +// Created by wodnd on 3/3/26. +// + +import Foundation +import AuthenticationServices + +final class IntroViewModel: BaseViewModel { + + var onFinishWithGuest: (() -> Void)? + +} + +// MARK: - Intro +extension IntroViewModel { + func guestLoginTapped() { + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + onFinishWithGuest?() + } +} diff --git a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift index 441d7d56..d8619350 100644 --- a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift +++ b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift @@ -15,4 +15,5 @@ enum MainRoute { case lockScreen(info: LegInfo?, address: String?) // 잠금화면 case proximity // 가까운 거리 알림 모달 case dismissLockScreen + case loginSheet } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift index f6c48a17..82bd67d1 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift @@ -81,6 +81,7 @@ final class DetailRouteBusCell: UICollectionViewCell { var currentBusInfo: [RealTimeBusArrival] = [] private var isArrivedEffectOn = false + private var isAlarmFired: Bool = false override init(frame: CGRect) { super.init(frame: frame) @@ -110,6 +111,9 @@ final class DetailRouteBusCell: UICollectionViewCell { stationListStackViewTopConstraint?.isActive = false stationListStackViewBottomConstraint?.isActive = false endLabelTopConstraintWithoutStack?.isActive = true + + isAlarmFired = false + busTimerStackView.isHidden = true } override func preferredLayoutAttributesFitting( @@ -266,7 +270,8 @@ final class DetailRouteBusCell: UICollectionViewCell { setupArrivalConstraints() } - func configure(info: LegTrafficInfo?) { + func configure(info: LegTrafficInfo?, isAlarmFired: Bool) { + self.isAlarmFired = isAlarmFired currentLegTrafficInfo = info stationInfos = [] @@ -303,6 +308,8 @@ final class DetailRouteBusCell: UICollectionViewCell { color: .gray100)) endCombinedLabel.append(AtchaFont.B3_M_15(" 하차", color: .gray500)) endLabel.attributedText = endCombinedLabel + + self.busTimerStackView.isHidden = !isAlarmFired } @@ -494,6 +501,13 @@ extension DetailRouteBusCell { private func updateBusTimerLabels() { + guard isAlarmFired else { + busTimerStackView.isHidden = true + return + } + + busTimerStackView.isHidden = false + func labelText(for info: RealTimeBusArrival) -> NSAttributedString { if info.busStatus == .end { return AtchaFont.B6_R_14("운행 종료", color: .gray) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index d6a438bd..58b410f7 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -72,6 +72,7 @@ final class DetailRouteSubwayCell: UICollectionViewCell { var currentLegTrafficInfo: LegTrafficInfo? = nil private var isArrivedEffectOn = false + private var isAlarmFired: Bool = false override init(frame: CGRect) { super.init(frame: frame) @@ -105,6 +106,8 @@ final class DetailRouteSubwayCell: UICollectionViewCell { subwayCountdownTimer?.invalidate() subwayCountdownTimer = nil currentRemainingSec = nil + isAlarmFired = false + subwayTimerLabel.isHidden = true } override func preferredLayoutAttributesFitting( @@ -270,7 +273,8 @@ final class DetailRouteSubwayCell: UICollectionViewCell { setupArrivalConstraints() } - func configure(info: LegTrafficInfo?) { + func configure(info: LegTrafficInfo?, isAlarmFired: Bool) { + self.isAlarmFired = isAlarmFired currentLegTrafficInfo = info stationInfos = [] @@ -304,9 +308,11 @@ final class DetailRouteSubwayCell: UICollectionViewCell { endCombinedLabel.append(AtchaFont.B3_M_15(" 하차", color: .gray500)) endLabel.attributedText = endCombinedLabel -// if isCurrentTimeBetween(startTime: info.startTime, endTime: info.endTime) { -// isNowUserLocationArrived() -// } + // if isCurrentTimeBetween(startTime: info.startTime, endTime: info.endTime) { + // isNowUserLocationArrived()updateSubwayTimerLabel + // } + + self.subwayTimerLabel.isHidden = !isAlarmFired } private func addStationNameLabel(info: [PassStopList]) { @@ -326,7 +332,7 @@ final class DetailRouteSubwayCell: UICollectionViewCell { animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 } - + func stopArrivedEffectIfNeeded() { guard isArrivedEffectOn else { return } isArrivedEffectOn = false @@ -406,111 +412,119 @@ extension DetailRouteSubwayCell { } extension DetailRouteSubwayCell { - + func setupSubwayRealTime(routeName: String?, infos: [SubwayRealTimeInfo]) { stopSubwayCountdownTimer() currentRemainingSec = nil - + subwayTimerLabel.isHidden = false subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("", color: .white) - + guard let routeName, !routeName.isEmpty else { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) return } - + let key = routeName.components(separatedBy: ":").last ?? routeName - + let matched = infos.first { info in let apiRaw = info.routeName ?? "" let apiKey = apiRaw.components(separatedBy: ":").last ?? apiRaw return apiKey == key } - + guard let matched else { subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) return } - + if let destination = matched.destination { subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("\(destination)행", color: .white) } else { subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("", color: .white) } - + guard let sec = matched.remainingTime, sec >= 0 else { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) return } - + currentRemainingSec = sec updateSubwayTimerLabel() startSubwayCountdownTimerIfNeeded() } - + private func startSubwayCountdownTimerIfNeeded() { if subwayCountdownTimer != nil { return } - + subwayCountdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.decrementSubwayRemainingTime() } - + if let timer = subwayCountdownTimer { RunLoop.main.add(timer, forMode: .common) } } - + private func stopSubwayCountdownTimer() { subwayCountdownTimer?.invalidate() subwayCountdownTimer = nil } - + private func decrementSubwayRemainingTime() { guard let sec = currentRemainingSec else { stopSubwayCountdownTimer() return } - + let next = sec - 1 currentRemainingSec = next - + if next <= 0 { currentRemainingSec = 0 updateSubwayTimerLabel() stopSubwayCountdownTimer() return } - + updateSubwayTimerLabel() } - + private func updateSubwayTimerLabel() { + + guard isAlarmFired else { + subwayTimerLabel.isHidden = true + return + } + + subwayTimerLabel.isHidden = false + guard let sec = currentRemainingSec else { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) return } - + if sec == 0 { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) return } - + if sec <= 120 { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("곧 도착", color: .widearea) return } - + subwayTimerLabel.attributedText = AtchaFont.B6_R_14(formatSecondsToHMS(sec), color: .widearea) } - + private func formatSecondsToHMS(_ seconds: Int?) -> String { guard let seconds, seconds >= 0 else { return "" } - + let h = seconds / 3600 let m = (seconds % 3600) / 60 let s = seconds % 60 - + if h > 0 { // 시가 있으면 시/분/초 // (원하면 "1시간 0분 5초"처럼 0분도 보여줄지 결정 가능) @@ -520,12 +534,12 @@ extension DetailRouteSubwayCell { return "\(h)시간 \(s)초" } } - + if m > 0 { // 시가 없으면 분/초 return "\(m)분 \(s)초" } - + // 분도 없으면 초만 return "\(s)초" } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift index 29e2f693..05f586e7 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class DetailRouteInfoBottomView: UIView { enum SheetState { @@ -45,6 +46,12 @@ final class DetailRouteInfoBottomView: UIView { var getNewBusRealTime: (() -> Void)? private var currentNearLegIDs: Set = [] + private var cancellables = Set() + + private var isAlarmFired: Bool { + return UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + } + override init(frame: CGRect) { super.init(frame: frame) setupView() @@ -52,6 +59,7 @@ final class DetailRouteInfoBottomView: UIView { setupAutoLayout() setupCollectionView() setupDataSource() + bindAlarmStatus() } required init?(coder: NSCoder) { @@ -61,6 +69,17 @@ final class DetailRouteInfoBottomView: UIView { setupAutoLayout() setupCollectionView() setupDataSource() + bindAlarmStatus() + } + + private func bindAlarmStatus() { + UserDefaults.standard.publisher(for: \.departureAlarmDidFire) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + // 이제 self.collectionView에 직접 접근이 가능하므로 에러가 해결됩니다! + self?.collectionView.reloadData() + } + .store(in: &cancellables) } private func setupView() { @@ -313,7 +332,7 @@ extension DetailRouteInfoBottomView { ) self?.onBusDetail?(info) } - cell.configure(info: item.info) + cell.configure(info: item.info, isAlarmFired: self.isAlarmFired) if let route = item.info?.route, !route.isEmpty { let cellKey = route.components(separatedBy: ":").last ?? route @@ -338,7 +357,7 @@ extension DetailRouteInfoBottomView { // self?.applySnapshot() self?.collectionView.collectionViewLayout.invalidateLayout() } - cell.configure(info: item.info) + cell.configure(info: item.info, isAlarmFired: self.isAlarmFired) if let route = item.info?.route, !route.isEmpty { let key = route.components(separatedBy: ":").last ?? route @@ -359,11 +378,13 @@ extension DetailRouteInfoBottomView { } func updateProximityHighlight(nearLegIDs: Set) { - currentNearLegIDs = nearLegIDs + let actualNearIDs = isAlarmFired ? nearLegIDs : [] + currentNearLegIDs = actualNearIDs + for cell in collectionView.visibleCells { if let busCell = cell as? DetailRouteBusCell, let leg = busCell.currentLegTrafficInfo { - if nearLegIDs.contains(leg.id) { + if actualNearIDs.contains(leg.id) { busCell.isNowUserLocationArrived() } else { busCell.stopArrivedEffectIfNeeded() @@ -372,7 +393,7 @@ extension DetailRouteInfoBottomView { if let subwayCell = cell as? DetailRouteSubwayCell, let leg = subwayCell.currentLegTrafficInfo { - if nearLegIDs.contains(leg.id) { + if actualNearIDs.contains(leg.id) { subwayCell.isNowUserLocationArrived() } else { subwayCell.stopArrivedEffectIfNeeded() @@ -381,7 +402,7 @@ extension DetailRouteInfoBottomView { if let walkCell = cell as? DetailRouteWalkCell, let leg = walkCell.currentLegTrafficInfo { - if nearLegIDs.contains(leg.id) { + if actualNearIDs.contains(leg.id) { walkCell.isNowUserLocationArrived() } else { walkCell.stopArrivedEffectIfNeeded() diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 2cede176..7b3861d5 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -43,32 +43,36 @@ final class DetailRouteViewController: BaseViewController, bindView() bindFollowLogic() installMapUserGestureDetector() -//#if DEBUG -//// ✅ 서울아산병원(대략) -//viewModel.mockLocation = CLLocationCoordinate2D(latitude:37.566956, longitude: 126.979406) -//viewModel.currentLocation = viewModel.mockLocation -//#endif + bindAlarmFireStatus() + //#if DEBUG + //// ✅ 서울아산병원(대략) + //viewModel.mockLocation = CLLocationCoordinate2D(latitude:37.566956, longitude: 126.979406) + //viewModel.currentLocation = viewModel.mockLocation + //#endif } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - - if !isAlarmFired && !allCoordinates.isEmpty { + + if !isAlarmFired && !allCoordinates.isEmpty && !lastRouteFitApplied { mapContainerView.adjustMapToFit(coordinates: allCoordinates) + lastRouteFitApplied = true // 플래그를 세워 중복 호출 방지 - mapContainerView.snp.remakeConstraints { make in - make.horizontalEdges.equalToSuperview() - make.top.equalToSuperview() - make.height.equalToSuperview().multipliedBy(0.65) - } - } else if isAlarmFired { - mapContainerView.snp.remakeConstraints { make in - make.horizontalEdges.equalToSuperview() - make.top.equalToSuperview() - make.bottom.equalToSuperview().inset(200) - } + // mapContainerView.snp.remakeConstraints { make in + // make.horizontalEdges.equalToSuperview() + // make.top.equalToSuperview() + // make.height.equalToSuperview().multipliedBy(0.65) + // } } + // 알람이 울린 상태라면 Fit 로직은 아예 건너뛰고 제약조건만 업데이트 + // else if isAlarmFired { + // mapContainerView.snp.remakeConstraints { make in + // make.horizontalEdges.equalToSuperview() + // make.top.equalToSuperview() + // make.bottom.equalToSuperview().inset(200) + // } + // } registerGradient.frame = registerContainer.bounds } @@ -227,7 +231,9 @@ final class DetailRouteViewController: BaseViewController, viewModel.$nearLegIDs .receive(on: RunLoop.main) .sink { [weak self] near in - self?.bottomSheet.updateProximityHighlight(nearLegIDs: near) + guard let self = self else { return } + let idsToHighlight = self.isAlarmFired ? near : [] + self.bottomSheet.updateProximityHighlight(nearLegIDs: idsToHighlight) } .store(in: &cancellables) @@ -248,38 +254,38 @@ final class DetailRouteViewController: BaseViewController, .store(in: &cancellables) Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) - .receive(on: DispatchQueue.global(qos: .userInitiated)) - .sink { [weak self] trafficInfos, pathInfos in - guard let self else { return } - guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } - - var dict: [UUID: [CLLocationCoordinate2D]] = [:] - - for (traffic, path) in zip(trafficInfos, pathInfos) { - guard traffic.mode == path.mode else { continue } - - if let shape = path.passShape, !shape.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(shape) - continue - } - - if let steps = path.step, !steps.isEmpty { - let merged = steps - .compactMap { $0.linestring } - .filter { !$0.isEmpty } - .joined(separator: " ") - - if !merged.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(merged) - } - } + .receive(on: DispatchQueue.global(qos: .userInitiated)) + .sink { [weak self] trafficInfos, pathInfos in + guard let self else { return } + guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } + + var dict: [UUID: [CLLocationCoordinate2D]] = [:] + + for (traffic, path) in zip(trafficInfos, pathInfos) { + guard traffic.mode == path.mode else { continue } + + if let shape = path.passShape, !shape.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(shape) + continue } - - DispatchQueue.main.async { [weak self] in - self?.legPolylineById = dict + + if let steps = path.step, !steps.isEmpty { + let merged = steps + .compactMap { $0.linestring } + .filter { !$0.isEmpty } + .joined(separator: " ") + + if !merged.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(merged) + } } } - .store(in: &cancellables) + + DispatchQueue.main.async { [weak self] in + self?.legPolylineById = dict + } + } + .store(in: &cancellables) } private func bindFollowLogic() { @@ -289,10 +295,11 @@ final class DetailRouteViewController: BaseViewController, .receive(on: RunLoop.main) .sink { [weak self] coord in guard let self else { return } - + // 1) 유저 마커 업데이트 (메인) - self.mapContainerView.updateUserMarker(location: coord) - + let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + // 2) 지도 follow 로직 (메인) if self.isAlarmFired { // 알람 울린 후엔 계속 따라감 @@ -302,39 +309,39 @@ final class DetailRouteViewController: BaseViewController, self.mapContainerView.setupZoomCenter(location: coord) } // else: fit 유지 (건드리지 않음) - + // 3) 근처(150m) 지나가면 반짝임 계산 (백그라운드) let threshold: CLLocationDistance = 150 - + let polylines = self.legPolylineById - let orderedLegs = self.viewModel.legTrafficInfo // ✅ 화면 표시 순서(위→아래) - + let orderedLegs = self.viewModel.legTrafficInfo // 화면 표시 순서(위→아래) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } - + // 1) near 후보들을 Set으로 수집 var nearCandidates = Set() - + for (id, polyline) in polylines { let d = self.distanceToPolylineMeters(point: coord, polyline: polyline) if d <= threshold { nearCandidates.insert(id) } } - - // 2) ✅ "위에 있는 셀 우선" = orderedLegs 순서로 첫 매칭 1개만 남김 + + // 2) "위에 있는 셀 우선" = orderedLegs 순서로 첫 매칭 1개만 남김 var picked: Set = [] if let first = orderedLegs.first(where: { nearCandidates.contains($0.id) })?.id { picked = [first] } - + DispatchQueue.main.async { [weak self] in self?.viewModel.nearLegIDs = picked } } } .store(in: &cancellables) - + // 헤딩 viewModel.$deviceHeading .compactMap { $0 } @@ -348,6 +355,36 @@ final class DetailRouteViewController: BaseViewController, .store(in: &cancellables) } + private func bindAlarmFireStatus() { + // UserDefaults의 변화를 실시간으로 구독합니다. + UserDefaults.standard.publisher(for: \.departureAlarmDidFire) + .removeDuplicates() // 같은 값이 연속으로 들어오는 것 방지 + .receive(on: RunLoop.main) + .sink { [weak self] isFired in + guard let self = self else { return } + + if isFired { + // 1. 추적 플래그 ON + self.isFollowingUser = true + + // 2. 헤딩(회전) 시작 + self.viewModel.startHeading() + + // 3. 유저 마커 스타일 변경 (알람 후 전용 마커가 있다면) + self.mapContainerView.afterUserMarker() + + // 4. 즉시 현재 위치로 지도 중심 이동 + if let currentCoord = self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: currentCoord) + } + } else { + self.isFollowingUser = false + self.viewModel.stopHeading() + } + } + .store(in: &cancellables) + } + private func addRouteLine(infos: [LegPathInfo]) { var shapeStrings: [String] = [] var colors: [UIColor] = [] @@ -392,7 +429,15 @@ final class DetailRouteViewController: BaseViewController, mapContainerView.addTrafficLine(passShape: shape, color: color, markerImage: image, isFirst: isFirst, isLast: isLast) } - mapContainerView.adjustMapToFit(coordinates: allCoordinates) + if isAlarmFired { + lastRouteFitApplied = true + return + } + + if !lastRouteFitApplied && !allCoordinates.isEmpty { + mapContainerView.adjustMapToFit(coordinates: allCoordinates) + lastRouteFitApplied = true + } } private func convertShapeToCoords(_ shape: String) -> [CLLocationCoordinate2D] { @@ -411,59 +456,118 @@ final class DetailRouteViewController: BaseViewController, } @objc private func didTapAlarmRegister() { - let busLegs = viewModel.legTrafficInfo.filter { $0.mode == .bus } - let busCount = busLegs.count - let hasSubway = viewModel.legTrafficInfo.contains { $0.mode == .subway } - let hasLongWaitBus = busLegs.contains { ($0.targetBusTerm ?? 0) >= 40 } - - let isException = (busCount == 1) && (hasSubway == false) + let hasConfigured = UserDefaults.standard.bool(forKey: "hasSeenAlarmSettingsSheet") + if !hasConfigured { + // 처음이라면? 설정 시트를 먼저 띄웁니다. + self.presentPushAlarmSheet { [weak self] in + // 시트 완료 시 플래그 저장 후 권한/등록 단계로 진행 + UserDefaults.standard.set(true, forKey: "hasSeenAlarmSettingsSheet") + self?.handleAlarmPermissionAndRegistration() + } + } else { + // 이미 설정해본 적이 있다면? 바로 권한 체크 및 등록 진행 + self.handleAlarmPermissionAndRegistration() + } + } + + private func presentPushAlarmSheet(completion: @escaping () -> Void) { + let sheetVM = PushAlarmSheetViewModel() + let sheetVC = PushAlarmSheetViewController(viewModel: sheetVM) - let shouldShowPopup = hasLongWaitBus && !isException + // 뒷배경이 보이도록 설정 + sheetVC.modalPresentationStyle = .overFullScreen - let routeId = viewModel.infos.pathInfo.first?.routeId + sheetVC.onComplete = { + completion() + } - let alarmRequest = AlarmRequest(lastRouteId: routeId) - let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + sheetVC.onDismiss = { + } - if isAlarmRegistered { - if shouldShowPopup { - showCoursePopup(alarmRequest) - } else { - showRe_RegisterPopup(alarmRequest) - } - } else { - if shouldShowPopup { - showCoursePopup(alarmRequest) + present(sheetVC, animated: false) + } + + /// 2~3단계: 권한 확인 및 실제 서버 알람 등록 처리 + private func handleAlarmPermissionAndRegistration() { + self.ensureAlarmPermissionAndExecute { [weak self] in + guard let self = self else { return } + + // 여기서부터는 권한이 허용된 상태에서만 실행되는 기존 비즈니스 로직입니다. + let busLegs = self.viewModel.legTrafficInfo.filter { $0.mode == .bus } + let busCount = busLegs.count + let hasSubway = self.viewModel.legTrafficInfo.contains { $0.mode == .subway } + let hasLongWaitBus = busLegs.contains { ($0.targetBusTerm ?? 0) >= 40 } + + let isException = (busCount == 1) && (hasSubway == false) + let shouldShowPopup = hasLongWaitBus && !isException + + let routeId = self.viewModel.infos.pathInfo.first?.routeId + let alarmRequest = AlarmRequest(lastRouteId: routeId) + let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + + if isAlarmRegistered { + // 이미 등록된 알람이 있을 때 (재등록/중복 팝업) + if shouldShowPopup { + self.showCoursePopup(alarmRequest) + } else { + self.showRe_RegisterPopup(alarmRequest) + } } else { - viewModel.alarmRegister(alarmRequest) - viewModel.getAlarmTapped?(viewModel.address, viewModel.infos) - let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") - AmplitudeManager.shared.track( - .another_alarm_register, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) + // 신규 알람 등록 + if shouldShowPopup { + self.showCoursePopup(alarmRequest) + } else { + self.viewModel.alarmRegister(alarmRequest) + self.viewModel.getAlarmTapped?(self.viewModel.address, self.viewModel.infos) + + let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") + AmplitudeManager.shared.track( + .another_alarm_register, + props(AmplitudeProperty.dwellTime(seconds: dwellSeconds)) ) - ) + + // 등록 완료 후 메인 지도로 이동 (필요시 호출) + self.navigationController?.popToMainViewControllerNoAnimation() + } } } } private func applyMapModeOnAppearOrAlarmChange() { if isAlarmFired { - // (2) 알람 울린 후: 무조건 따라가기 ON isFollowingUser = true shouldCenterToCurrentLocationOnce = true viewModel.startHeading() - lastRouteFitApplied = false + lastRouteFitApplied = true // 알람 시에는 Fit 방지 + + // ✅ 1. 지도의 크기(제약 조건)를 여기서 먼저 결정 + mapContainerView.snp.remakeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.top.equalToSuperview() + make.bottom.equalToSuperview().inset(200) + } + + // ✅ 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 + if let currentCoord = viewModel.currentLocation { + mapContainerView.setupCenter(location: currentCoord) // setupZoomCenter 대신 setupCenter(이동만) + mapContainerView.setupZoomCenter(location: currentCoord) // 필요 시 줌까지 + } } else { - // (1) 알람 울리기 전: 경로 전체 보이기 고정 isFollowingUser = false shouldCenterToCurrentLocationOnce = false viewModel.stopHeading() - - // 화면 재진입 때마다 fit으로 "다시" 고정하려면 매번 호출 - mapContainerView.adjustMapToFit(coordinates: allCoordinates) - lastRouteFitApplied = true + + // 알람 전 맵 크기 설정 + mapContainerView.snp.remakeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.top.equalToSuperview() + make.height.equalToSuperview().multipliedBy(0.65) + } + + if !allCoordinates.isEmpty { + mapContainerView.adjustMapToFit(coordinates: allCoordinates) + lastRouteFitApplied = true + } } } @@ -481,11 +585,11 @@ extension DetailRouteViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() - + isFollowingUser = true shouldCenterToCurrentLocationOnce = true viewModel.startHeading() - + viewModel.setupLocation() mapContainerView.snp.remakeConstraints { make in @@ -497,15 +601,15 @@ extension DetailRouteViewController { @objc private func didTapReload() { refreshButton.start() - + if !isAlarmFired { isFollowingUser = false shouldCenterToCurrentLocationOnce = false viewModel.stopHeading() - + mapContainerView.adjustMapToFit(coordinates: allCoordinates) } - + viewModel.fetchInfo() AmplitudeManager.shared.track(.course_refresh_click) } @@ -518,6 +622,15 @@ extension DetailRouteViewController { func mapView(_ mapView: TMapWrapper, didSelectLocation coordinate: CLLocationCoordinate2D) {} func didFinishLoadingMap(_ mapView: TMapWrapper) { + if isAlarmFired { + if let currentCoord = viewModel.currentLocation { + // 애니메이션 없이 즉시 이동하여 '깜빡임' 방지 + mapContainerView.setupCenter(location: currentCoord) + mapContainerView.setupZoomCenter(location: currentCoord) + } + } + + // 2. 그 다음 경로선 그리기 시작 viewModel.$legtPathInfo .filter { !$0.isEmpty } .receive(on: DispatchQueue.main) @@ -596,33 +709,33 @@ extension DetailRouteViewController { polyline: [CLLocationCoordinate2D] ) -> CLLocationDistance { guard polyline.count >= 2 else { return .greatestFiniteMagnitude } - + let p = MKMapPoint(point) var best = CLLocationDistance.greatestFiniteMagnitude - + for i in 0..<(polyline.count - 1) { let a = MKMapPoint(polyline[i]) let b = MKMapPoint(polyline[i + 1]) - + let abx = b.x - a.x let aby = b.y - a.y let apx = p.x - a.x let apy = p.y - a.y - + let ab2 = abx*abx + aby*aby if ab2 == 0 { // 같은 점이면 점-점 거리 best = min(best, p.distance(to: a)) continue } - + // 투영 비율 t를 0~1로 clamp var t = (apx*abx + apy*aby) / ab2 t = max(0, min(1, t)) - + let closest = MKMapPoint(x: a.x + t*abx, y: a.y + t*aby) best = min(best, p.distance(to: closest)) } - + return best } } @@ -630,28 +743,28 @@ extension DetailRouteViewController { extension DetailRouteViewController: UIGestureRecognizerDelegate { private func installMapUserGestureDetector() { let targetView = mapContainerView.gestureTargetView - + let pan = UIPanGestureRecognizer(target: self, action: #selector(userDidManipulateMap)) pan.cancelsTouchesInView = false pan.delegate = self targetView.addGestureRecognizer(pan) - + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(userDidManipulateMap)) pinch.cancelsTouchesInView = false pinch.delegate = self targetView.addGestureRecognizer(pinch) - + let rotate = UIRotationGestureRecognizer(target: self, action: #selector(userDidManipulateMap)) rotate.cancelsTouchesInView = false rotate.delegate = self targetView.addGestureRecognizer(rotate) } - + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { true } - + @objc private func userDidManipulateMap(_ g: UIGestureRecognizer) { if g.state == .began { isFollowingUser = false diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 6daebe15..3f8a08bb 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -11,6 +11,7 @@ import CoreLocation import TMapSDK import VSMSDK import SnapKit +import Combine final class MainViewController: BaseViewController, TMapWrapperDelegate{ @@ -18,14 +19,11 @@ final class MainViewController: BaseViewController, private let mapContainerView: TMapContainerView = TMapContainerView() private let lastTrainSearchView: LastTrainSearchBottomView = LastTrainSearchBottomView() // 알람 등록 전 private let lastTrainDepartView: LastTrainDepartBottomView = LastTrainDepartBottomView() // 알람 등록 이후 - // private let lastTrainRealTimeView: LastTrainRealTimeBottomView = LastTrainRealTimeBottomView() // 알람 등록 이후, 시간 지남 - // private let lastTrainArrivalView: LastTrainArrivalBottomView = LastTrainArrivalBottomView() // 알람 등록 이후, 시간 지남 private let flagImageView: UIImageView = UIImageView() private let alarmTimeoutView: UIView = UIView() private let myPageButton: UIButton = UIButton() private let loactionButton: UIButton = UIButton() - // private let atchaImageView: UIImageView = UIImageView() private let atchaImageView: CharacterJumpView = CharacterJumpView() private let ballonView: AtchaBallon = AtchaBallon() private let decimalFormatter: NumberFormatter = { @@ -37,7 +35,6 @@ final class MainViewController: BaseViewController, return f }() - private var firstAddress: String? // MARK: - 말풍선 기본 설정 @@ -96,36 +93,23 @@ final class MainViewController: BaseViewController, private var lastJumpTime: CFTimeInterval = 0 private let minJumpInterval: CFTimeInterval = 1.0 + // MARK: - 상태 제어 변수 (추적/회전 관련) private var routeStartCoordinate: CLLocationCoordinate2D? private var shouldCenterToCurrentLocationOnce = false private var isFollowingUser = false private var lastCourseUpdateAt: CFTimeInterval = 0 private let courseValidWindow: CFTimeInterval = 1.2 - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - let isAlarmRegistered = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue - ) ?? false - - let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - - if !isAlarmRegistered { - isFollowingUser = false - ensureLocationPermissionOrShowToast() - } - AmplitudeManager.shared.trackScreen(.main) - - if isAlarmRegistered && !isAlarmFired, - let startCoord = routeStartCoordinate { - isFollowingUser = false - mapContainerView.setupZoomCenter(location: startCoord) - } - - if isAlarmRegistered && isAlarmFired { - isFollowingUser = true - } - } + +// private var isGuest: Bool { +// return UserDefaultsWrapper.shared.bool( +// forKey: UserDefaultsWrapper.Key.isGuest.rawValue +// ) ?? false +// } + + var shouldShowWelcomeToast: Bool = false + + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() @@ -142,7 +126,10 @@ final class MainViewController: BaseViewController, setupUI() setupAutoLayout() - installMapUserGestureDetector() + // installMapUserGestureDetector() + mapContainerView.onUserInteraction = { [weak self] in + self?.stopFollowingOnUserInteraction() + } bindView() } @@ -152,25 +139,59 @@ final class MainViewController: BaseViewController, let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - DispatchQueue.main.asyncAfter(deadline: .now()) { [weak self] in - self?.viewModel.setupLocation() + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } if !isAlarmRegistered { - self?.mapContainerView.beforeUserMarker() - } else { - self?.mapContainerView.afterUserMarker() + // 1. 앱 진입 시 현위치 1번 찍기 (초기화) + self.mapContainerView.beforeUserMarker() + self.isFollowingUser = false + self.viewModel.stopHeading() + + if let currentCoord = self.viewModel.selectedLocation ?? self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: currentCoord) + self.shouldCenterToCurrentLocationOnce = false // 이미 이동했으니 대기 안 함 + } else { + self.shouldCenterToCurrentLocationOnce = true // 값이 없다면 위치를 찾을 때까지 대기 + } + + } else if isAlarmRegistered && !isAlarmFired { + // 2. 알람 등록 후 (다른 화면 갔다가 돌아왔을 때 출발지 기준으로 보여줌) + self.mapContainerView.afterUserMarker() + self.isFollowingUser = false + self.viewModel.stopHeading() + if let startCoord = self.routeStartCoordinate { + self.mapContainerView.setupZoomCenter(location: startCoord) + } + + } else if isAlarmRegistered && isAlarmFired { + // 3. 알람 울리고 나서는 계속 따라가고 회전 + self.mapContainerView.afterUserMarker() + self.isFollowingUser = true + self.viewModel.startHeading() } } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - if isAlarmRegistered && !isAlarmFired, - let startCoord = routeStartCoordinate { - isFollowingUser = false - mapContainerView.setupZoomCenter(location: startCoord) + if shouldShowWelcomeToast { + shouldShowWelcomeToast = false // 한 번 띄우고 바로 꺼줌 + + // 첫 번째 토스트: 집 주소 등록 완료 + AtchaToast(message: "집 주소가 등록되었어요").show(in: self.view) + + // 두 번째 토스트: 위치 권한 체크 + let status = CLLocationManager.authorizationStatus() + if status != .authorizedAlways && status != .authorizedWhenInUse { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.ensureLocationPermissionOrShowToast() + } + } } - if isAlarmRegistered && isAlarmFired { - isFollowingUser = true - } + AmplitudeManager.shared.trackScreen(.main) } override func viewWillDisappear(_ animated: Bool) { @@ -183,18 +204,18 @@ final class MainViewController: BaseViewController, .forEach { $0.hideImmediately() } } + // MARK: - Setup UI + private func setupUI() { view.addSubViews( mapContainerView, flagImageView, atchaImageView, lastTrainSearchView, - myPageButton, loactionButton, lastTrainDepartView, - // lastTrainRealTimeView, - // lastTrainArrivalView, - ballonView + ballonView, + myPageButton ) mapContainerView.delegate = self @@ -241,19 +262,6 @@ extension MainViewController { make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() } - // lastTrainRealTimeView.snp.makeConstraints { make in - // make.horizontalEdges.equalToSuperview() - // make.bottom.equalToSuperview() - // } - // lastTrainArrivalView.snp.makeConstraints { make in - // make.horizontalEdges.equalToSuperview() - // make.bottom.equalToSuperview() - // } - myPageButton.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide.snp.top) - make.trailing.equalToSuperview().inset(16) - make.width.height.equalTo(40) - } loactionButton.snp.makeConstraints { make in make.bottom.equalTo(lastTrainSearchView.snp.top).inset(-16) make.trailing.equalToSuperview().inset(16) @@ -273,6 +281,12 @@ extension MainViewController { make.top.equalToSuperview() make.bottom.equalTo(lastTrainSearchView.snp.top).inset(30) } + + myPageButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide.snp.top) + make.trailing.equalToSuperview().inset(16) + make.width.height.equalTo(40) + } } } @@ -290,6 +304,19 @@ extension MainViewController { bindLockView() bindAlarmTimeoutView() bindDeviceHeadingUpdates() + bindPermissionAlert() + bindAlarmFireStatus() + } + + private func bindPermissionAlert() { + viewModel.$showLocationDeniedAlert + .filter { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.presentLocationDeniedAlert() + self?.viewModel.showLocationDeniedAlert = false // 띄운 뒤 신호 초기화 + } + .store(in: &cancellables) } // MARK: - bind Lock View @@ -310,73 +337,86 @@ extension MainViewController { .sink { [weak self] in self?.handleSearchViewAction($0) } .store(in: &cancellables) - // lastTrainRealTimeView.actionPublisher - // .sink { [weak self] in self?.handleRealTimeViewAction($0) } - // .store(in: &cancellables) - lastTrainDepartView.actionPublisher .receive(on: RunLoop.main) .sink { [weak self] in self?.handleTrainDepartAction($0) } .store(in: &cancellables) - - // lastTrainArrivalView.actionPublisher - // .receive(on: RunLoop.main) - // .sink { [weak self] in self?.handleArrivalViewAction($0) } - // .store(in: &cancellables) + } + + private func bindAlarmFireStatus() { + // UserDefaults의 변화를 실시간으로 구독합니다. + UserDefaults.standard.publisher(for: \.departureAlarmDidFire) + .removeDuplicates() // 같은 값이 연속으로 들어오는 것 방지 + .receive(on: RunLoop.main) + .sink { [weak self] isFired in + guard let self = self else { return } + + if isFired { + // 1. 추적 플래그 ON + self.isFollowingUser = true + + // 2. 헤딩(회전) 시작 + self.viewModel.startHeading() + + // 3. 유저 마커 스타일 변경 (알람 후 전용 마커가 있다면) + self.mapContainerView.afterUserMarker() + + // 4. 즉시 현재 위치로 지도 중심 이동 + if let currentCoord = self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: currentCoord) + } + } else { + self.isFollowingUser = false + self.viewModel.stopHeading() + } + } + .store(in: &cancellables) } private func handleSearchViewAction(_ action: LastTrainSearchBottomView.Action) { switch action { case .currentTapped: - AmplitudeManager.shared.track(.origin_search_click) - - viewModel.handleRoute(route: .changeCourse( - location: Location(name: "", lat: 0.0, lon: 0.0, businessCategory: "", address: "", radius: ""))) - case .searchTapped: - AmplitudeManager.shared.track(.course_search_click) - - guard let startCoord = viewModel.currentLocation else { - view.showToast(message: "현재 위치를 확인 중이에요. 잠시 후 다시 시도해 주세요.") - return - } - - let wrapper = UserDefaultsWrapper.shared - let endLatStr = wrapper.string(forKey: UserDefaultsWrapper.Key.homeLat.rawValue) ?? "37.554722" - let endLonStr = wrapper.string(forKey: UserDefaultsWrapper.Key.homeLon.rawValue) ?? "126.970833" - - guard let endLat = Double(endLatStr), let endLon = Double(endLonStr) else { - view.showToast(message: "저장된 목적지 좌표가 잘못되었어요.") - return + if viewModel.isGuest { + presentLoginAlert() + } else { + AmplitudeManager.shared.track(.origin_search_click) + + viewModel.handleRoute(route: .changeCourse( + location: Location(name: "", lat: 0.0, lon: 0.0, businessCategory: "", address: "", radius: ""))) } - let endCoord = CLLocationCoordinate2D(latitude: endLat, longitude: endLon) - - if ProximityManager.shared.isWithinThreshold(from: startCoord, to: endCoord) { - viewModel.handleRoute(route: .proximity) - return + case .searchTapped: + if viewModel.isGuest { + presentLoginAlert() + } else { + AmplitudeManager.shared.track(.course_search_click) + + guard let startCoord = viewModel.currentLocation else { + view.showToast(message: "현재 위치를 확인 중이에요. 잠시 후 다시 시도해 주세요.") + return + } + + let wrapper = UserDefaultsWrapper.shared + let endLatStr = wrapper.string(forKey: UserDefaultsWrapper.Key.homeLat.rawValue) ?? "37.554722" + let endLonStr = wrapper.string(forKey: UserDefaultsWrapper.Key.homeLon.rawValue) ?? "126.970833" + + guard let endLat = Double(endLatStr), let endLon = Double(endLonStr) else { + view.showToast(message: "저장된 목적지 좌표가 잘못되었어요.") + return + } + let endCoord = CLLocationCoordinate2D(latitude: endLat, longitude: endLon) + + if ProximityManager.shared.isWithinThreshold(from: startCoord, to: endCoord) { + viewModel.handleRoute(route: .proximity) + return + } + + viewModel.handleRoute(route: .courseSearch( + startLat: "", startLon: "", startAddress: "" + )) } - - viewModel.handleRoute(route: .courseSearch( - startLat: "", startLon: "", startAddress: "" - )) } } - // private func handleRealTimeViewAction(_ action: LastTrainRealTimeBottomView.Action) { - // switch action { - // case .refreshBusTime, .reloadTapped: - // break - // // TODO: 새로운 통신으로 변경하기 - // // viewModel.getBusRealTime() - // case .exitTapped: - // showAlarmExitPopup() - // case .detailRoadMapTapped: viewModel.handleRoute(route: .detailRoute(address: "", - // infos: LegInfo(pathInfo: [], trafficInfo: [], busInfo: []), - // context: .afterReigster) - // ) - // case .finishAlarm: viewModel.bottomType = .finish - // } - // } - private func handleTrainDepartAction(_ action: LastTrainDepartBottomView.Action) { switch action { case .exitTapped: @@ -403,15 +443,6 @@ extension MainViewController { } } - // private func handleArrivalViewAction(_ action: LastTrainArrivalBottomView.Action) { - // switch action { - // case .exitTapped: - // showAlarmExitPopup() - // case .detailRoadMapTapped: viewModel.handleRoute(route: .detailRoute(address: "", - // infos: LegInfo(pathInfo: [], trafficInfo: [], busInfo: []), - // context: .afterReigster)) - // } - // } private func showAlarmTimeoutPopup() { let popupVM = AtchaPopupViewModel(info: .alarmTimeout) let popupVC = AtchaPopupViewController(viewModel: popupVM) @@ -452,7 +483,10 @@ extension MainViewController { viewModel.requestPermissionAndStartTracking() viewModel.removeLegInfoAndAddress() viewModel.stopHeading() + + // 4. 알람 해제 시 1번(초기 상태)으로 돌아감 isFollowingUser = false + shouldCenterToCurrentLocationOnce = true // 이번 한 번은 프리 말풍선 자동 표시를 건너뛰도록 플래그 세팅 deferPreBalloonOnce = true @@ -463,17 +497,16 @@ extension MainViewController { mapContainerView.clearMapView() mapContainerView.beforeUserMarker() - if let coord = viewModel.currentLocation { + if let coord = viewModel.selectedLocation ?? viewModel.currentLocation { mapContainerView.setupCenter(location: coord) } else { - // 위치 아직 없으면 한 번은 센터 이동 허용 + 위치 요청 viewModel.setupLocation() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self else { return } - view.showToast(message: "알람이 종료되었어요") + self.view.showToast(message: "알람이 종료되었어요") // 2초 뒤 수동으로 말풍선 표시 (이때 플래그 해제) DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { @@ -482,59 +515,62 @@ extension MainViewController { } UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) - } } // MARK: - ViewModel Bindings + private func bindCurrentLocationUpdates() { - viewModel.$currentLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [weak self] coord in - guard let self = self else { return } - - // UserDefaults 기준으로 실제 알람 등록 여부 - let isAlarmRegistered = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue - ) ?? false - - let isAlarmFired = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue - ) ?? false - - if !isAlarmRegistered && !isAlarmFired { - self.mapContainerView.setupCenter(location: coord) - } - - if isAlarmRegistered && !isAlarmFired && shouldCenterToCurrentLocationOnce { - self.mapContainerView.setupCenter(location: coord) - shouldCenterToCurrentLocationOnce = false + viewModel.$currentLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] coord in + guard let self = self else { return } + + // 1. 파란색 내 위치 마커는 무조건 실시간 업데이트 + + let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + // 2. 알람이 울린 상태면 무조건 강제로 센터 유지 + if isAlarmRegistered && isAlarmFired { + self.isFollowingUser = true + self.viewModel.startHeading() + self.mapContainerView.setupCenter(location: coord) + return + } + + // 3. 앱 최초 진입이거나, 내가 현위치 버튼을 눌러서 '추적 모드'일 때만 카메라 중심 이동 + if self.shouldCenterToCurrentLocationOnce || self.isFollowingUser { + self.mapContainerView.setupCenter(location: coord) + self.shouldCenterToCurrentLocationOnce = false + } } - - if isAlarmRegistered && isAlarmFired { - self.mapContainerView.setupCenter(location: coord) + .store(in: &cancellables) + } + + private func bindSelectedLocationUpdates() { + viewModel.$selectedLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { _ in + // 👉 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! } + .store(in: &cancellables) + } + + private func bindDeviceHeadingUpdates() { + viewModel.$deviceHeading + .compactMap { $0 } + .removeDuplicates(by: { abs($0 - $1) < 2 }) + .receive(on: RunLoop.main) + .sink { [weak self] heading in + guard let self else { return } + guard self.isFollowingUser else { return } - // 알람 등록 + 출발 전 + departure 화면에서는 - // 자동으로는 절대 현위치 안 따라감 -// if isAlarmRegistered { -// if isAlarmFired { -// viewModel.startHeading() -// self.mapContainerView.setupCenter(location: coord) -// return -// } -// -// if self.shouldCenterToCurrentLocationOnce { -// self.mapContainerView.setupCenter(location: coord) -// self.shouldCenterToCurrentLocationOnce = false -// } else { -// return -// } -// } else { -// self.mapContainerView.setupCenter(location: coord) -// } + self.mapContainerView.setHeading(heading) } .store(in: &cancellables) } @@ -552,7 +588,7 @@ extension MainViewController { if self.latestIsServiceRegion == false { self.showOrUpdatePreBalloon( .text( - top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해 봐요" : nil, + top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요" ) ) @@ -564,38 +600,6 @@ extension MainViewController { .store(in: &cancellables) } -// private func bindCourseUpdates() { -// viewModel.$currentCourse -// .compactMap { $0 } -// .removeDuplicates(by: { abs($0 - $1) < 3 }) -// .throttle(for: .milliseconds(250), scheduler: RunLoop.main, latest: true) -// .sink { [weak self] course in -// guard let self else { return } -// guard self.isFollowingUser else { return } -// self.lastCourseUpdateAt = CACurrentMediaTime() -// self.mapContainerView.setHeading(course) -// } -// .store(in: &cancellables) -// } - - private func bindDeviceHeadingUpdates() { - viewModel.$deviceHeading - .compactMap { $0 } - .removeDuplicates(by: { abs($0 - $1) < 2 }) - .receive(on: RunLoop.main) - .sink { [weak self] heading in - guard let self else { return } - guard self.isFollowingUser else { return } - -// let now = CACurrentMediaTime() -// let hasRecentCourse = (now - self.lastCourseUpdateAt) < self.courseValidWindow -// guard !hasRecentCourse else { return } - - self.mapContainerView.setHeading(heading) - } - .store(in: &cancellables) - } - private func updateAddress(_ address: String) { if firstAddress == nil { firstAddress = address @@ -605,82 +609,11 @@ extension MainViewController { lastTrainSearchView.setupCurrentLocationTitle(title) } - private func bindSelectedLocationUpdates() { - viewModel.$selectedLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { [weak self] coord in - guard let self else { return } - - // 마커는 실시간으로 계속 업데이트 - self.mapContainerView.updateUserMarker(location: coord) - - // ====== center 이동 정책(기존 currentLocation 로직 이관) ====== - - let isAlarmRegistered = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue - ) ?? false - - let isAlarmFired = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue - ) ?? false - - // 유저가 "내 위치 따라가기" 모드면 무조건 센터 이동 - if self.isFollowingUser { - self.mapContainerView.setupCenter(location: coord) - return - } - - // 알람 등록 상태에서는 자동 추적을 기본적으로 막는 기존 정책 유지 - if isAlarmRegistered { - if isAlarmFired { - // 알람 울린 이후에는 따라가도 됨(기존 로직 유지) - self.viewModel.startHeading() - self.mapContainerView.setupCenter(location: coord) - return - } - } else { - // 알람 미등록: 기본은 현재 위치로 센터 이동 - self.mapContainerView.setupCenter(location: coord) - } - } - .store(in: &cancellables) - } - -// private func bindSelectedLocationUpdates() { -// viewModel.$selectedLocation -// .removeDuplicates() -// .compactMap { $0 } -// .receive(on: RunLoop.main) -// .sink { [weak self] in -// guard let self = self else { return } -// self.mapContainerView.updateUserMarker(location: $0) -// -// if self.isFollowingUser { -// viewModel.startHeading() -// self.mapContainerView.setupCenter(location: $0) -// return -// } -// -// let isAlarmFired = UserDefaultsWrapper.shared.bool( -// forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue -// ) ?? false -// -// if isAlarmFired { -// self.mapContainerView.setupCenter(location: $0) -// } -// } -// .store(in: &cancellables) -// } - private func bindAddressDescriptionUpdates() { viewModel.$addressDesc .receive(on: RunLoop.main) .sink { [weak self] desc in self?.lastTrainDepartView.setupLoaction(location: desc) - // self?.lastTrainArrivalView.setupLoaction(location: desc) - // self?.lastTrainRealTimeView.setupLoaction(location: desc) } .store(in: &cancellables) } @@ -697,15 +630,6 @@ extension MainViewController { case .departure: self?.shouldCenterToCurrentLocationOnce = false self?.lastTrainDepartView.setupLegInfo(info: info) - // case .detail: - // self?.viewModel.handleRoute(route: .detailRoute(address: "", - // infos: LegInfo(pathInfo: [], trafficInfo: [], busInfo: []), - // context: .afterReigster)) - // case .realTime: do {} - // self?.lastTrainRealTimeView.setupLegInfo(info: info) - // case .finish: - // break - // self?.lastTrainArrivalView.setupLegInfo(info: info) default: do {} } @@ -721,14 +645,6 @@ extension MainViewController { } .store(in: &cancellables) - // viewModel.$busRealTimeInfo - // .compactMap { $0 } - // .receive(on: RunLoop.main) - // .sink { [weak self] info in - // self?.lastTrainRealTimeView.setupBusRealTime(realTime: info) - // } - // .store(in: &cancellables) - viewModel.$departureTime .compactMap { $0 } .receive(on: RunLoop.main) @@ -790,7 +706,6 @@ extension MainViewController { let popBallonDelay: TimeInterval = popRegister ? 2.7 : 2.4 // 앱을 켤 때부터 알람이 이미 등록되어 있었다면, post-delay(기존 2.0초)를 0으로 - let postRevealDelay: TimeInterval = wasAlarmRegisteredOnLaunch ? 0.0 : popBallonDelay self.scheduleFirstBalloon(gen: gen, @@ -802,7 +717,6 @@ extension MainViewController { case .search: if !isSame { cancelBalloonQueueAndHide() } - // viewModel.stopAlarmTimer() viewModel.stopFinishAlarmTimer() lastTrainSearchView.isHidden = false flagImageView.isHidden = false @@ -857,88 +771,100 @@ extension MainViewController { } private func bindTaxiFareUpdates() { - viewModel.$taxiFare - .compactMap { $0 } - .map { Int($0) } - .removeDuplicates() + // taxiFare와 isGuest 중 하나라도 바뀌면 이 블록이 실행됩니다. + Publishers.CombineLatest(viewModel.$taxiFare, viewModel.$isGuest) .receive(on: RunLoop.main) - .sink { [weak self] fareInt in - guard let self else { return } + .sink { [weak self] fare, isGuest in + guard let self = self, let fare = fare else { return } + + let fareInt = Int(fare) let fareStr = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" self.latestFareString = fareStr if self.isPreAlarmBalloonActive(), self.latestIsServiceRegion == true { + // 이제 파라미터로 들어오는 최신 isGuest 상태에 따라 ??? 혹은 금액이 결정됩니다. + let displayFare = isGuest ? "???원" : "\(fareStr)원" let content: BalloonContent = .separation( - gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fareStr)원" + gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)" ) + if self.ballonView.isHidden { - // 아직 안 떠 있으면 처음처럼 보여 주기 self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) } else { - // 이미 떠 있으면 내용만 교체 self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) } } + // 알람 등록 후 말풍선 큐 갱신 if !self.postAlarmMessages.isEmpty { + let displayFare = isGuest ? "???원" : "\(fareStr)원" self.postAlarmMessages[self.postAlarmMessages.count - 1] = - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fareStr)원") + .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)") } } .store(in: &cancellables) } private func bindServiceRegionUpdates() { - viewModel.$isServiceRegion - .removeDuplicates() - .receive(on: RunLoop.main) - .sink { [weak self] ok in - guard let self else { return } - let previous = self.latestIsServiceRegion - self.latestIsServiceRegion = ok - - switch ok { - case .some(true): - self.lastTrainSearchView.updateSearchEnabled(true) + viewModel.$isServiceRegion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] ok in + guard let self = self else { return } + let previous = self.latestIsServiceRegion + self.latestIsServiceRegion = ok - if previous == nil { - // 초기 표시 로직은 함수 쪽에서 요금 없으면 no-op - self.showInitialPreAlarmBalloons(force: true) - } else if self.isPreAlarmBalloonActive() { - if let fare = self.latestFareString { - // 요금 있으면 택시비만 표시/업데이트 + switch ok { + case .some(true): + // 서비스 지역으로 들어옴! + self.lastTrainSearchView.updateSearchEnabled(true) + + if previous == nil { + self.showInitialPreAlarmBalloons(force: true) + } else if self.isPreAlarmBalloonActive() { + + // 수정: 게스트 모드면 요금(fare)이 없어도 바로 ???로 띄워줘야 함! + if viewModel.isGuest { + let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원") + if self.ballonView.isHidden { + self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) + } else { + self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) + } + + } else if let fare = self.latestFareString { + // 일반 회원이고 요금이 있을 때 + let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fare)원") + if self.ballonView.isHidden { + self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) + } else { + self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) + } + } + } + + case .some(false): + // 서비스 지역을 벗어남 (울산 등) + self.lastTrainSearchView.updateSearchEnabled(false) + if previous == nil { + self.showInitialPreAlarmBalloons(force: true) + } else if self.isPreAlarmBalloonActive() { let content: BalloonContent = - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fare)원") + .text(top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, + bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요") if self.ballonView.isHidden { self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) } else { self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) } - } else { - } - } - - case .some(false): - self.lastTrainSearchView.updateSearchEnabled(false) - if previous == nil { - self.showInitialPreAlarmBalloons(force: true) - } else if self.isPreAlarmBalloonActive() { - let content: BalloonContent = - .text(top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해 봐요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요") - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) } + + case .none: + self.lastTrainSearchView.updateSearchEnabled(false) } - - case .none: - self.lastTrainSearchView.updateSearchEnabled(false) } - } - .store(in: &cancellables) - } + .store(in: &cancellables) + } // MARK: - Constraint Helper private func updateAtchaImageConstraint(relativeTo view: UIView) { @@ -1040,9 +966,11 @@ extension MainViewController { extension MainViewController { func didFinishLoadingMap(_ mapView: TMapWrapper) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - self.viewModel.setupLocation() self.hideLoading() + // 지도가 완전히 로드된 이 시점에 setupLocation()을 호출해야 합니다! + self.viewModel.setupLocation() + let wrapper = UserDefaultsWrapper.shared if let legInfo: LegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self), let address: String = wrapper.string(forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) { @@ -1060,18 +988,28 @@ extension MainViewController { } @objc private func didTapMyPageButton() { - viewModel.handleRoute(route: .myPage) + if viewModel.isGuest { + presentLoginAlert() + } else { + viewModel.handleRoute(route: .myPage) + } } @objc private func didTapLocationButton() { - ensureLocationPermissionOrShowToast() - - isFollowingUser = true - shouldCenterToCurrentLocationOnce = true - viewModel.startHeading() - viewModel.currentLocation = nil - viewModel.setupLocation() - } + guard ensureLocationPermissionOrShowToast() else { return } + + isFollowingUser = true + viewModel.startHeading() + + // 🚨 수정: 무조건 내 "진짜 위치(currentLocation)"로 지도를 이동시킴 + if let coord = viewModel.currentLocation { + mapContainerView.setupCenter(location: coord) + viewModel.selectedLocation = coord // 주소도 현위치로 다시 검색하게 덮어씀 + } else { + shouldCenterToCurrentLocationOnce = true + viewModel.setupLocation() + } + } private func safeStartJump() { let now = CACurrentMediaTime() @@ -1262,51 +1200,62 @@ extension MainViewController { // 초기 프리 말풍선 private func showInitialPreAlarmBalloons(force: Bool = false) { - guard let isService = latestIsServiceRegion else { return } - guard isPreAlarmBalloonActive() else { return } - - if preSessionShowTopLine == nil { - preSessionShowTopLine = !isRevisit - } - let showTopLine = preSessionShowTopLine ?? true - let d1 = balloonInitialDelayFirst - - if isService { - // 서비스 지역인데 아직 요금이 없으면 말풍선은 띄우지 않지만, - // 재방문 처리(상단 라인 억제용)는 반드시 해두고 return - guard let fare = latestFareString else { - if !isRevisit { - UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + guard let isService = latestIsServiceRegion else { return } + guard isPreAlarmBalloonActive() else { return } + + if preSessionShowTopLine == nil { + preSessionShowTopLine = !isRevisit + } + let showTopLine = preSessionShowTopLine ?? true + let d1 = balloonInitialDelayFirst + + if isService { + // 서비스 지역인데 아직 요금이 없으면 말풍선은 띄우지 않지만, + // 재방문 처리(상단 라인 억제용)는 반드시 해두고 return + guard let fare = latestFareString else { + if !isRevisit { + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + } + + // [추가 로직] 게스트일 경우 서버에서 요금을 안 주거나 늦게 줄 수 있으므로 + // 요금(fare)이 없어도 바로 ???로 띄워줍니다! + if viewModel.isGuest { + showOrUpdatePreBalloon( + .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원"), + delay: d1, animated: true, showTopLine: showTopLine + ) + hasShownInitialBalloon = true + } + return } - hasShownInitialBalloon = true - return + + // 요금이 있고 서비스 지역일 때 + let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" + showOrUpdatePreBalloon( + .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), + delay: d1, animated: true, showTopLine: showTopLine + ) + + } else { + // 비서비스 지역은 기존 안내 문구 유지 + showOrUpdatePreBalloon( + .text(top: showTopLine ? "지도를 움직여 출발지를 설정해요" : nil, + bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), + delay: d1 + ) } - // 요금 있으면 택시비 말풍선만 페이드인 - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fare)원"), - delay: d1, animated: true, showTopLine: showTopLine - ) - } else { - // 비서비스 지역은 기존 안내 문구 - showOrUpdatePreBalloon( - .text(top: showTopLine ? "지도를 움직여 출발지를 설정해 봐요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), - delay: d1 - ) - } - - // 여기까지 도달했을 때도 초기 방문이면 reVisit 저장 - if !isRevisit { - UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - self?.atchaImageView.stop() - self?.atchaImageView.start() + // 여기까지 도달했을 때도 초기 방문이면 reVisit 저장 + if !isRevisit { + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + self?.atchaImageView.stop() + self?.atchaImageView.start() + } + hasShownInitialBalloon = true } - hasShownInitialBalloon = true - } // 즉시 표시(터치 등): 3초 뒤 오토숨김 private func showOrUpdateImmediateBalloon(_ content: BalloonContent) { @@ -1368,14 +1317,17 @@ extension MainViewController { } } -// MARK: - Delegate +// MARK: - Map Delegate & Gesture extension MainViewController { func mapView(_ mapView: TMapWrapper, didUpdateLocation coordinate: CLLocationCoordinate2D) { - viewModel.currentLocation = coordinate + // 위치가 업데이트 될 때마다 호출됨 (조작 방해를 막기 위해 비워둠) + viewModel.selectedLocation = coordinate } func mapView(_ mapView: TMapWrapper, didSelectLocation coordinate: CLLocationCoordinate2D) { - viewModel.currentLocation = coordinate + // 지도 단순 터치(탭) 시 추적 해제 + stopFollowingOnUserInteraction() + viewModel.selectedLocation = coordinate } } @@ -1405,8 +1357,52 @@ extension MainViewController: UIGestureRecognizerDelegate { } @objc private func userDidManipulateMap(_ g: UIGestureRecognizer) { - if g.state == .began { + // 드래그, 줌 등의 제스처 발생 시 추적 해제 + if g.state == .began || g.state == .changed { + stopFollowingOnUserInteraction() + } + } + + // 조작 감지 시 공통 처리 로직 (경우의 수 1,2,3 반영) + private func stopFollowingOnUserInteraction() { + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + + // 3. 알람이 울린 후라면 지도를 터치해도 계속 따라가도록 무시 + if isAlarmFired { + return + } + + // 1, 2. 평상시엔 지도를 조작하면 추적과 회전을 중지 + if isFollowingUser { isFollowingUser = false + shouldCenterToCurrentLocationOnce = false + viewModel.stopHeading() } } } + +extension MainViewController { + private func presentLoginAlert() { + self.viewModel.handleRoute(route: .loginSheet) + } +} + +extension MainViewController { + private func presentLocationDeniedAlert() { + let alert = UIAlertController( + title: nil, + message: "위치 권한을 허용하지 않으면\n현위치의 막차를 확인할 수 없어요.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "닫기", style: .cancel, handler: nil)) + + alert.addAction(UIAlertAction(title: "설정하기", style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + }) + + present(alert, animated: true) + } +} + diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index e8e81a75..1ccf84df 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -35,7 +35,7 @@ final class MainViewModel: BaseViewModel{ @Published var showAlarmStopPopUpView: Bool = false @Published var departureStr: String? -// @Published var currentCourse: CLLocationDirection? + // @Published var currentCourse: CLLocationDirection? @Published var deviceHeading: CLLocationDirection? private let headingManager = HeadingManager() @@ -54,6 +54,9 @@ final class MainViewModel: BaseViewModel{ var courseSearchResultHandler: ((String, LegInfo) -> Void)? @Published private(set) var lastReverseGeocode: Location? + @Published var showLocationDeniedAlert: Bool = false + @Published var isGuest: Bool = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false + init(authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, fetchTaxiFareUseCase: FetchTaxiFareUseCase, @@ -77,15 +80,30 @@ final class MainViewModel: BaseViewModel{ } func bind() { + // 1. currentLocation은 주소 검색을 하지 않고 마커 이동 용도로만 둡니다. $currentLocation + .compactMap { $0 } + .removeDuplicates() + .sink { _ in } + .store(in: &cancellables) + + // 2. selectedLocation(지도의 중심)이 바뀔 때만 주소를 검색합니다! + $selectedLocation .compactMap { $0 } .removeDuplicates() .debounce(for: .seconds(0.3), scheduler: RunLoop.main) - .sink { [weak self] _ in - guard let self, let loc = self.currentLocation else { return } + .sink { [weak self] loc in + guard let self = self else { return } Task { await self.updateAddressOnly(for: loc) } } .store(in: &cancellables) + $isGuest + .removeDuplicates() + .sink { [weak self] guest in + guard let self = self, !guest else { return } // 게스트에서 회원으로 바뀐 경우만 + Task { await self.refreshRegionAndFareForCurrentAddress() } + } + .store(in: &cancellables) $address .compactMap { $0 } @@ -108,15 +126,15 @@ final class MainViewModel: BaseViewModel{ guard let lat = lastReverseGeocode?.lat, let lon = lastReverseGeocode?.lon else { return } - // 서비스지역 먼저 + // 서비스지역 먼저 (비회원도 이건 알아야 하므로 유지) do { let okReq = CheckServiceRegionRequest(lat: lat, lon: lon) let ok = try await searchAddressUseCase.checkServiceRegion(okReq) await MainActor.run { self.isServiceRegion = ok } } catch { print("서비스 지역 확인 실패: \(error)") } - // 택시비는 서비스지역 O일 때만 - guard self.isServiceRegion == true else { return } + + guard self.isServiceRegion == true, !isGuest else { return } let req = FetchTaxiFareRequest( originLat: lastReverseGeocode?.lat, originLon: lastReverseGeocode?.lon, @@ -195,27 +213,30 @@ final class MainViewModel: BaseViewModel{ func requestPermissionAndStartTracking() { Task { let status = await authorizationUseCase.askLocationPermission() - let _ = await authorizationUseCase.askPushPermission() - guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } + guard status == .authorizedAlways || status == .authorizedWhenInUse else { + let hasShown = UserDefaults.standard.bool(forKey: "hasShownMainLocationAlert") + if (status == .denied || status == .restricted) && !hasShown { + UserDefaults.standard.set(true, forKey: "hasShownMainLocationAlert") + await MainActor.run { self.showLocationDeniedAlert = true } + } + return + } self.startHeading() streamTask = Task { var didSendInitialLocation = false for await location in streamUseCase.startUpdate() { - let currentLocation = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) + let newLocation = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) + // 내 진짜 GPS 위치는 계속 업데이트 + self.currentLocation = newLocation + + // [수정]: 지도의 중심(selectedLocation)은 "앱 최초 진입 시" 딱 1번만 GPS 위치로 맞춰줍니다. if !didSendInitialLocation { - self.currentLocation = currentLocation + self.selectedLocation = newLocation didSendInitialLocation = true } - -// let course = location.course -// if course >= 0 { -// self.currentCourse = course -// } - - selectedLocation = currentLocation } } } @@ -474,6 +495,8 @@ extension MainViewModel { routeHandler?(.proximity) case .dismissLockScreen: routeHandler?(.dismissLockScreen) + case .loginSheet: + routeHandler?(.loginSheet) } } } @@ -540,6 +563,12 @@ extension MainViewModel { print("서비스 지역 확인 실패:", error) } } + + func refreshCurrentMapCenterData() { + Task { + await self.refreshRegionAndFareForCurrentAddress() + } + } } // MARK: - Network diff --git a/Atcha-iOS/Presentation/Location/View/LastTrainSearchBottomView.swift b/Atcha-iOS/Presentation/Location/View/LastTrainSearchBottomView.swift index 9c9c576d..bfd726a5 100644 --- a/Atcha-iOS/Presentation/Location/View/LastTrainSearchBottomView.swift +++ b/Atcha-iOS/Presentation/Location/View/LastTrainSearchBottomView.swift @@ -49,6 +49,10 @@ final class LastTrainSearchBottomView: UIView { style: .filled(.disabled), image: .imgSearch16Px) {} + private let isGuest = UserDefaultsWrapper.shared.bool( + forKey: UserDefaultsWrapper.Key.isGuest.rawValue + ) ?? false + override init(frame: CGRect) { super.init(frame: frame) setupView() diff --git a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift index d0a095e3..2cb92c39 100644 --- a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift +++ b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift @@ -14,6 +14,8 @@ final class TMapContainerView: UIView { private var tMapWrapper: TMapWrapper! var gestureTargetView: UIView { tMapWrapper.mapView } + var onUserInteraction: (() -> Void)? + weak var delegate: TMapWrapperDelegate? { didSet { tMapWrapper?.delegate = delegate @@ -46,6 +48,18 @@ final class TMapContainerView: UIView { } } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + + // 지도가 터치되었다면 애니메이션에 씹히기 전에 즉시 멈춤 신호를 보냅니다. + if hitView != nil { + DispatchQueue.main.async { + self.onUserInteraction?() + } + } + return hitView + } + func setupCenter(location: CLLocationCoordinate2D) { tMapWrapper.mapView.setCenter(location) } @@ -55,8 +69,8 @@ final class TMapContainerView: UIView { tMapWrapper.mapView.setZoom(16) } - func updateUserMarker(location: CLLocationCoordinate2D) { - tMapWrapper.updateUserMarker(coordinate: location) + func updateUserMarker(location: CLLocationCoordinate2D, isRegistered: Bool) { + tMapWrapper.updateUserMarker(coordinate: location, isRegistered: isRegistered) } func afterUserMarker() { tMapWrapper.afterUserMarker() } diff --git a/Atcha-iOS/Presentation/Login/Coordinator/LoginCoordinator.swift b/Atcha-iOS/Presentation/Login/Coordinator/LoginCoordinator.swift index 233f59e7..35330cd9 100644 --- a/Atcha-iOS/Presentation/Login/Coordinator/LoginCoordinator.swift +++ b/Atcha-iOS/Presentation/Login/Coordinator/LoginCoordinator.swift @@ -13,6 +13,7 @@ final class LoginCoordinator { private let diContainer: LoginDIContainer var onFinishWithExistUser: ((Bool) -> Void)? + var onCancel: (() -> Void)? init(navigationController: UINavigationController, diContainer: LoginDIContainer) { @@ -25,7 +26,13 @@ final class LoginCoordinator { viewModel.isExistUser = { [weak self] isExist in self?.onFinishWithExistUser?(isExist) } + + viewModel.loginCancelled = { [weak self] in + self?.onCancel?() + } + let viewController = LoginViewController(viewModel: viewModel) - navigationController.pushViewController(viewController, animated: true) + viewController.modalPresentationStyle = .overFullScreen + navigationController.present(viewController, animated: false) } } diff --git a/Atcha-iOS/Presentation/Login/LoginItnro.swift b/Atcha-iOS/Presentation/Login/LoginItnro.swift deleted file mode 100644 index 589b209d..00000000 --- a/Atcha-iOS/Presentation/Login/LoginItnro.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// LoginItnro.swift -// Atcha-iOS -// -// Created by geonhui Yu on 7/20/25. -// - -import UIKit - -enum LoginIntro: CaseIterable { - case step1 - case step2 - case step3 - case step4 - case step5 - case step6 - - var title: String { - switch self { - case .step1: - return "번거롭던 막차 찾기\n이제 두 단계면 충분해요" - case .step2: - return "우리집 미리 등록 해두고\n출발지만 선택해요" - case .step3: - return "알람 등록하면\n출발 시간에 맞춰 울려요" - case .step4: - return "푸시 알림으로\n남은 시간 알려드릴게요" - case .step5: - return "지금 몇시지? 하지 마세요\n출발 알림 받고 막차 타러 출발!" - case .step6: - return "이제 경로만 따라가면 돼요\n안전하게 귀가해요" - } - } - - var image: UIImage { - switch self { - case .step1: return UIImage.step1 - case .step2: return UIImage.step2 - case .step3: return UIImage.step3 - case .step4: return UIImage.step4 - case .step5: return UIImage.step5 - case .step6: return UIImage.step6 - } - } -} diff --git a/Atcha-iOS/Presentation/Login/LoginViewController.swift b/Atcha-iOS/Presentation/Login/LoginViewController.swift index 968d7529..4987e3cf 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewController.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewController.swift @@ -12,33 +12,10 @@ import QuartzCore final class LoginViewController: BaseViewController { private var appleLoginDelegateWrapper: AppleLoginDelegateWrapper? - private let backgroundImageView: UIImageView = UIImageView() private let kakaoLoginButton: UIButton = UIButton(type: .custom) private let appleLoginButton: UIButton = UIButton(type: .custom) - private let pageControl = UIPageControl() - private var autoScrollTimer: Timer? - private let multiplier = 3 // 실제 아이템 수 * multiplier 만큼 셀 생성 - private var isInitialSetup = true - - private let gradientLayer = CAGradientLayer() - - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.minimumLineSpacing = 0 - - let collectionView = UICollectionView(frame: .zero, - collectionViewLayout: layout) - collectionView.backgroundColor = .clear - collectionView.isPagingEnabled = true - collectionView.showsHorizontalScrollIndicator = false - collectionView.register(LoginIntroCell.self, - forCellWithReuseIdentifier: LoginIntroCell.id) - collectionView.delegate = self - collectionView.dataSource = self - return collectionView - }() + private let sheetHandler: UIView = UIView() private lazy var loginButtonStackView: UIStackView = { let stack = UIStackView(arrangedSubviews: [kakaoLoginButton, @@ -50,80 +27,70 @@ final class LoginViewController: BaseViewController { return stack }() + private let dimView = UIView() + private let containerView = UIView() + private let sheetHeight: CGFloat = 198 + override func viewDidLoad() { super.viewDidLoad() + setupDim() setupUI() setupLoginButtons() setupAutoLayout() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - gradientLayer.frame = backgroundImageView.bounds - // 컬렉션뷰 레이아웃이 완료된 후 중간 위치로 초기화 - if isInitialSetup { - let itemCount = LoginIntro.allCases.count - let middleIndex = itemCount * (multiplier / 2) - let indexPath = IndexPath(item: middleIndex, section: 0) - collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) - isInitialSetup = false - } + containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) + setupGestures() } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - autoScrollTimer = Timer.scheduledTimer(timeInterval: 4.0, - target: self, - selector: #selector(goToNextPage), - userInfo: nil, - repeats: true) + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + self.dimView.alpha = 1 + self.containerView.transform = .identity // 원래 위치로 복귀 + }) } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + private func setupDim() { + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve - autoScrollTimer?.invalidate() - autoScrollTimer = nil + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) + dimView.alpha = 0 + view.addSubview(dimView) } private func setupUI() { - view.addSubViews(backgroundImageView, pageControl, collectionView, loginButtonStackView) + containerView.backgroundColor = .gray940 + containerView.layer.cornerRadius = 20 + containerView.clipsToBounds = true + view.addSubview(containerView) + + view.backgroundColor = .clear - // 수직 그라데이션 배경 적용 (top: #121212, bottom: #1E1E1E) - let topColor = UIColor(red: 0x12/255.0, green: 0x12/255.0, blue: 0x12/255.0, alpha: 1.0) - let bottomColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1.0) + sheetHandler.backgroundColor = AtchaColor.gray700 + sheetHandler.layer.cornerRadius = 2 + sheetHandler.clipsToBounds = true - gradientLayer.colors = [topColor.cgColor, bottomColor.cgColor] - gradientLayer.locations = [0.0, 1.0] - gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) - gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) - backgroundImageView.layer.insertSublayer(gradientLayer, at: 0) + containerView.addSubViews(sheetHandler, loginButtonStackView) - pageControl.numberOfPages = LoginIntro.allCases.count - pageControl.currentPage = 0 - pageControl.currentPageIndicatorTintColor = AtchaColor.main - pageControl.pageIndicatorTintColor = AtchaColor.gray300 - pageControl.isUserInteractionEnabled = false } private func setupAutoLayout() { - backgroundImageView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } + dimView.snp.makeConstraints { $0.edges.equalToSuperview() } - pageControl.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(56) + containerView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() + make.height.equalTo(sheetHeight) } - collectionView.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview() - make.top.equalTo(pageControl.snp.bottom) - make.bottom.equalTo(loginButtonStackView.snp.top) + sheetHandler.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.centerX.equalToSuperview() + make.height.equalTo(4) + make.width.equalTo(40) } loginButtonStackView.snp.makeConstraints { make in @@ -132,28 +99,13 @@ final class LoginViewController: BaseViewController { } } - @objc private func goToNextPage() { - let itemsPerPage = LoginIntro.allCases.count - let currentOffset = collectionView.contentOffset.x - let pageWidth = collectionView.bounds.width - let currentPage = Int(currentOffset / pageWidth) - let nextPage = currentPage + 1 - - let indexPath = IndexPath(item: nextPage, section: 0) - collectionView.scrollToItem(at: indexPath, - at: .centeredHorizontally, - animated: true) - - // 실제 페이지 번호 업데이트 (0-5 범위 내에서) - let actualPage = nextPage % itemsPerPage - pageControl.currentPage = actualPage - } private func setupLoginButtons() { configureLoginButton( button: kakaoLoginButton, icon: UIImage.kakao, - labelText: "카카오로 계속하기", + labelText: "카카오톡으로 3초만에 시작", + fontClosure: { AtchaFont.B2_SB_15($0, color: $1, alignment: $2) }, textColor: AtchaColor.black, bgColor: AtchaColor.Etc.kakao, iconTint: AtchaColor.Etc.kakaoLogo @@ -162,7 +114,8 @@ final class LoginViewController: BaseViewController { configureLoginButton( button: appleLoginButton, icon: UIImage.apple, - labelText: "Apple로 계속하기", + labelText: "Apple로 시작", + fontClosure: { AtchaFont.B3_M_15($0, color: $1, alignment: $2) }, textColor: AtchaColor.white, bgColor: AtchaColor.black, iconTint: AtchaColor.white @@ -175,6 +128,7 @@ final class LoginViewController: BaseViewController { private func configureLoginButton(button: UIButton, icon: UIImage, labelText: String, + fontClosure: (String, UIColor, NSTextAlignment) -> NSAttributedString, textColor: UIColor, bgColor: UIColor, iconTint: UIColor) { @@ -182,39 +136,34 @@ final class LoginViewController: BaseViewController { let iconView = UIImageView(image: icon) iconView.contentMode = .scaleAspectFit iconView.tintColor = iconTint - + let label = UILabel() - label.attributedText = AtchaFont.B_15(labelText, color: textColor) + label.attributedText = fontClosure(labelText, textColor, .center) label.textAlignment = .center - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 10 - - stackView.addArrangedSubview(iconView) - stackView.addArrangedSubview(label) - button.layer.cornerRadius = 8 button.layer.backgroundColor = bgColor.cgColor - - stackView.isUserInteractionEnabled = false + // 터치 이벤트를 버튼이 받도록 iconView.isUserInteractionEnabled = false label.isUserInteractionEnabled = false - button.addSubview(stackView) + // 스택 뷰 없이 버튼에 직접 추가 + button.addSubViews(iconView, label) + // 1. 아이콘: 왼쪽에서 일정 간격 띄워서 수직 중앙 정렬 iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) // 피그마 수치에 맞게 조정 (16~24 권장) + make.centerY.equalToSuperview() make.width.height.equalTo(24) } - stackView.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.centerY.equalToSuperview() + label.snp.makeConstraints { make in + make.center.equalToSuperview() } button.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview().inset(20) + make.horizontalEdges.equalToSuperview().inset(24) make.height.equalTo(52) } } @@ -243,66 +192,57 @@ extension LoginViewController: ASAuthorizationControllerPresentationContextProvi } } -extension LoginViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - // 무한 스크롤을 위해 실제 아이템 수의 배수만큼 생성 - return LoginIntro.allCases.count * multiplier - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LoginIntroCell.id, for: indexPath) as? LoginIntroCell else { - - return UICollectionViewCell() +// MARK: - Gestures & Animations +extension LoginViewController { + private func setupGestures() { + // 1. 빈 배경(Dim) 터치 시 닫기 + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapDimView)) + dimView.addGestureRecognizer(tapGesture) + dimView.isUserInteractionEnabled = true + + // 2. 시트를 아래로 스와이프해서 닫기 + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + containerView.addGestureRecognizer(panGesture) + } + + @objc private func didTapDimView() { + dismissSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + switch gesture.state { + case .changed: + // 아래로 내릴 때만 움직이게 (위로는 안 올라가게 막음) + if translation.y > 0 { + containerView.transform = CGAffineTransform(translationX: 0, y: translation.y) + } + case .ended, .cancelled: + // 충분히 빨리 내렸거나 절반 이상 내렸으면 닫기 + if velocity.y > 1000 || translation.y > (sheetHeight / 2) { + dismissSheet() + } else { + // 아니면 다시 원래 자리로 복귀 (튕겨 올라옴) + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + self.containerView.transform = .identity + }) + } + default: + break } - - // 실제 인덱스로 변환 (0-5 범위로 순환) - let actualIndex = indexPath.item % LoginIntro.allCases.count - cell.configure(info: LoginIntro.allCases[actualIndex]) - return cell - } - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize { - return CGSize(width: collectionView.bounds.width, - height: collectionView.bounds.height) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let itemsPerPage = LoginIntro.allCases.count - let pageWidth = scrollView.frame.width - let currentPage = Int(scrollView.contentOffset.x / pageWidth + 0.5) - - // 실제 페이지 번호 업데이트 (0-5 범위 내에서) - let actualPage = currentPage % itemsPerPage - pageControl.currentPage = actualPage - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - resetScrollPositionIfNeeded() - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - resetScrollPositionIfNeeded() } - // 스크롤 위치가 끝에 가까워지면 중간으로 재배치 - private func resetScrollPositionIfNeeded() { - let itemsPerPage = LoginIntro.allCases.count - let pageWidth = collectionView.bounds.width - let currentPage = Int(collectionView.contentOffset.x / pageWidth + 0.5) - let totalPages = itemsPerPage * multiplier - - // 끝 부분에 가까워지면 중간으로 이동 - if currentPage <= itemsPerPage / 2 { - let newPage = currentPage + itemsPerPage - let newOffset = CGPoint(x: CGFloat(newPage) * pageWidth, y: 0) - collectionView.setContentOffset(newOffset, animated: false) - } else if currentPage >= totalPages - itemsPerPage / 2 { - let newPage = currentPage - itemsPerPage - let newOffset = CGPoint(x: CGFloat(newPage) * pageWidth, y: 0) - collectionView.setContentOffset(newOffset, animated: false) + // 자연스럽게 시트가 내려가고 딤이 옅어지며 닫히는 애니메이션 + private func dismissSheet() { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn, animations: { + self.dimView.alpha = 0 + self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) + }) { _ in + self.dismiss(animated: false) { + self.viewModel.loginCancelled?() + } } } } - diff --git a/Atcha-iOS/Presentation/Login/LoginViewModel.swift b/Atcha-iOS/Presentation/Login/LoginViewModel.swift index ac09f3e3..cccca474 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewModel.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewModel.swift @@ -16,6 +16,7 @@ final class LoginViewModel: BaseViewModel { } var isExistUser: ((Bool) -> Void)? + var loginCancelled: (() -> Void)? func kakaoLoginTapped() { Task { @@ -69,13 +70,12 @@ extension LoginViewModel { .rawValue) UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit .rawValue) - + AmplitudeManager.shared.bindUser(id: String(id)) AmplitudeManager.shared.flush() } - - + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) print("로그인 완료") } catch { print("로그인 실패: \(error.localizedDescription)") @@ -86,6 +86,8 @@ extension LoginViewModel { func checkRegistration(provider: LoginType, token: String) { Task { let request = AuthCheckRequest(provider: provider.rawValue, accessToken: token) + + print("토큰값 확인: \(request)") let result = try await loginUseCase.checkRegistration(request) UserDefaultsWrapper.shared.set(token, forKey: UserDefaultsWrapper.Key.providerToken.rawValue) diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 66a7eacb..970f95ba 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -14,12 +14,14 @@ final class MainCoordinator { private let diContainer: MainDIContainer private var myPageCoordinator: MyPageCoordinator? private var busDetailCoordinator: BusDetailCoordinator? + private var loginCoordinator: LoginCoordinator? private var mainViewModel: MainViewModel? - var signoutFinish: (() -> Void)? + var routeToOnboarding: (() -> Void)? var lockScreenConfrim: ((LegInfo?, String?) -> Void)? var routeHandler: ((MainRoute) -> Void)? + var withdrawFinish: (() -> Void)? init(navigationController: UINavigationController, diContainer: MainDIContainer) { @@ -58,7 +60,23 @@ final class MainCoordinator { diContainer: myPageDI ) self.myPageCoordinator = myPageCoordinator - myPageCoordinator.signoutFinish = self.signoutFinish + myPageCoordinator.signoutFinish = { [weak self] in + DispatchQueue.main.async { + guard let self = self else { return } + + self.mainViewModel?.isGuest = true + self.mainViewModel?.bottomType = .search + self.navigationController.popToRootViewController(animated: true) + + self.myPageCoordinator = nil + } + } + myPageCoordinator.withdrawFinish = { [weak self] in + DispatchQueue.main.async { + self?.withdrawFinish?() + } + } + myPageCoordinator.start() case let .courseSearch(startLat, startLon, startAddress): let courseDI = diContainer.makeCourseDIContainer() @@ -283,17 +301,53 @@ final class MainCoordinator { case .dismissLockScreen: dismissPresentedIfNeeded { - DispatchQueue.global(qos: .utility).async { - self.mainViewModel?.showLockView = false - self.mainViewModel?.showAlarmStopPopUpView = true + DispatchQueue.global(qos: .utility).async { + self.mainViewModel?.showLockView = false + self.mainViewModel?.showAlarmStopPopUpView = true + + AlarmManager.shared.sendBackgroundPush( + title: "출발 알람이 자동 종료되었어요", + body: "클릭해서 경로 재탐색하기" + ) + + } + } + case .loginSheet: + let loginDI = diContainer.makeLoginDIContainer() + let loginCoordinator = LoginCoordinator( + navigationController: self.navigationController, + diContainer: loginDI + ) + self.loginCoordinator = loginCoordinator + + loginCoordinator.onFinishWithExistUser = { [weak self] isExist in + DispatchQueue.main.async { + self?.navigationController.dismiss(animated: true) { + guard let self = self else { return } - AlarmManager.shared.sendBackgroundPush( - title: "출발 알람이 자동 종료되었어요", - body: "클릭해서 경로 재탐색하기" - ) + let newGuestStatus = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false + self.mainViewModel?.isGuest = newGuestStatus + if isExist { +// self.mainViewModel?.setupLocation() + self.mainViewModel?.refreshCurrentMapCenterData() + } else { + self.routeToOnboarding?() + } + + // 로그인 코디네이터 메모리 해제 + self.loginCoordinator = nil } } + } + + loginCoordinator.onCancel = { [weak self] in + DispatchQueue.main.async { + self?.loginCoordinator = nil + } + } + + loginCoordinator.start() } routeHandler?(route) @@ -304,7 +358,7 @@ final class MainCoordinator { while let presented = top?.presentedViewController { top = presented } - + if top !== navigationController.topViewController { top?.dismiss(animated: false, completion: completion) } else { diff --git a/Atcha-iOS/Presentation/Onboarding/Coordinator/OnboardingCoordinator.swift b/Atcha-iOS/Presentation/Onboarding/Coordinator/OnboardingCoordinator.swift index 27da9101..193ef366 100644 --- a/Atcha-iOS/Presentation/Onboarding/Coordinator/OnboardingCoordinator.swift +++ b/Atcha-iOS/Presentation/Onboarding/Coordinator/OnboardingCoordinator.swift @@ -42,6 +42,12 @@ final class OnboardingCoordinator { private func showHomeFind() { let vm = diContainer.makeHomeFindViewModel() vm.routeHandler = { [weak self] route in self?.handle(route: route) } + vm.onFinish = { [weak self] isSuccess in + if isSuccess { + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + } + self?.onFinish?(isSuccess) + } let vc = diContainer.makeHomeFindViewController(viewModel: vm) navigationController.pushViewController(vc, animated: true) } @@ -53,31 +59,27 @@ final class OnboardingCoordinator { navigationController.pushViewController(vc, animated: true) } - private func showPushRegister() { - let vm = diContainer.makePushAlarmViewModel(context: .onboarding) - vm.routeHandler = { [weak self] route in self?.handle(route: route) } - vm.onFinish = { [weak self] isSuccess in self?.onFinish?(isSuccess) } - let vc = diContainer.makePushAlarmViewController(viewModel: vm) - navigationController.pushViewController(vc, animated: true) - } - - private func showPermission() { - let vm = diContainer.makePermissionViewModel() - let vc = diContainer.makePermissionViewController(viewModel: vm) - vc.modalPresentationStyle = .overFullScreen - navigationController.present(vc, animated: false) - } + // private func showPushRegister() { + // let vm = diContainer.makePushAlarmViewModel(context: .onboarding) + // vm.routeHandler = { [weak self] route in self?.handle(route: route) } + // vm.onFinish = { [weak self] isSuccess in self?.onFinish?(isSuccess) } + // let vc = diContainer.makePushAlarmViewController(viewModel: vm) + // navigationController.pushViewController(vc, animated: true) + // } + // + // private func showPermission() { + // let vm = diContainer.makePermissionViewModel() + // let vc = diContainer.makePermissionViewController(viewModel: vm) + // vc.modalPresentationStyle = .overFullScreen + // navigationController.present(vc, animated: false) + // } private func handle(route: HomeRouter) { switch route { case .homeRegister: showHomeFind() - case .permission: - showPermission() case .searchAdress: showSearchAddress() - case .pushRegister: - showPushRegister() } routeHandler?(route) diff --git a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift index bdfd1a5b..22114e34 100644 --- a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift +++ b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift @@ -23,11 +23,7 @@ final class HomeRegisterViewController: BaseViewController CGRect { + var customBounds = super.trackRect(forBounds: bounds) + customBounds.size.height = trackHeight + return customBounds + } +} diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift index f4fdba59..b54d4190 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift @@ -46,8 +46,6 @@ final class PushAlarmViewController: BaseViewController { AlarmManager.shared.stopPreview() switch self.viewModel.context { - case .onboarding: - self.viewModel.signUp() case .myPage: self.onSettingComplete?(true) self.navigationController?.popViewController(animated: true) @@ -59,7 +57,6 @@ final class PushAlarmViewController: BaseViewController { AmplitudeManager.shared.trackScreen(.alarm_setting) - ensureAlarmPermissionOrShowToast() } override func viewDidLoad() { @@ -89,8 +86,6 @@ final class PushAlarmViewController: BaseViewController { private func applyContext(_ context: PushAlarmContext, animated: Bool) { let updates = { switch context { - case .onboarding: - self.setupOnbaordingUI() case .myPage: self.setupMyPageUI() } diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift index b8d8bbe5..80915394 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift @@ -9,7 +9,6 @@ import Foundation import Combine enum PushAlarmContext { - case onboarding case myPage } diff --git a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift new file mode 100644 index 00000000..6edad519 --- /dev/null +++ b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift @@ -0,0 +1,365 @@ +// +// PushAlarmSheetViewController.swift +// Atcha-iOS +// +// Created by wodnd on 3/4/26. +// + +import UIKit +import SnapKit +import QuartzCore +import AVFAudio +import MediaPlayer + +final class PushAlarmSheetViewController: BaseViewController { + + // MARK: - UI Components + private let dimView = UIView() + private let containerView = UIView() + private let sheetHeight: CGFloat = 430 + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = AtchaFont.H2_B_22(lineHeight: 0, "알람 받을 방법을\n설정해 주세요") + label.textColor = AtchaColor.white + label.numberOfLines = 2 + return label + }() + + private let closeImageView: UIImageView = { + let image = UIImageView() + image.image = .xGray + image.tintColor = AtchaColor.gray100 + return image + }() + + private let alarmListStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.alignment = .fill + stack.distribution = .fill + return stack + }() + + private lazy var completeButton: AtchaButton = AtchaButton( + text: "완료", + size: .h52, + style: .filled(.primary) + ) + + // MARK: - Properties + private var alarmCheckmarkLists: [AtchaList] = [] + private var selectedOption: PushAlarmOption? + + // 화면이 닫혔을 때 코디네이터나 부모에게 알리기 위한 콜백 + var onDismiss: (() -> Void)? + var onComplete: (() -> Void)? // 완료 버튼 눌렀을 때만 호출 + private var isConfirmed: Bool = false + + private let volumeSlider: AtchaSlider = AtchaSlider() + private var volumeObservation: NSKeyValueObservation? + + // MARK: - View Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + + setupDim() + setupUI() + setupAutoLayout() + setupAlarmLists() + setupGestures() + observeVolumeChanges() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + self.dimView.alpha = 1 + self.containerView.transform = .identity + }) { _ in + AlarmManager.shared.previewAlarmVolume(0.3) + } + volumeSlider.isHidden = true + } + + deinit { + AlarmManager.shared.stopPreview() + volumeObservation?.invalidate() + } + + // MARK: - Setup Methods + private func setupDim() { + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) + dimView.alpha = 0 + view.addSubview(dimView) + } + + private func setupUI() { + containerView.backgroundColor = .gray950 + containerView.layer.cornerRadius = 24 + containerView.clipsToBounds = true + view.addSubview(containerView) + + view.backgroundColor = .clear + containerView.addSubViews(titleLabel, closeImageView, alarmListStackView, completeButton, volumeSlider) + + // 버튼 타겟 설정 + closeImageView.isUserInteractionEnabled = true + + let closeTap = UITapGestureRecognizer(target: self, action: #selector(didTapCloseButton)) + closeImageView.addGestureRecognizer(closeTap) + completeButton.addTarget(self, action: #selector(didTapCompleteButton), for: .touchUpInside) + + // 초기 상태: 화면 아래에 숨김 + containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) + + volumeSlider.minimumValue = 1 / 16 + volumeSlider.maximumValue = 1.0 + volumeSlider.minimumTrackTintColor = AtchaColor.white + volumeSlider.maximumTrackTintColor = AtchaColor.gray910 + volumeSlider.backgroundColor = .clear + volumeSlider.isUserInteractionEnabled = true + volumeSlider.isContinuous = false + volumeSlider.setThumbImage(UIImage.volumeThumb, for: .normal) + volumeSlider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(sliderTapped(_:))) + volumeSlider.addGestureRecognizer(tapGesture) + volumeSlider.setValue(0.3, animated: true) + } + + private func setupAutoLayout() { + dimView.snp.makeConstraints { $0.edges.equalToSuperview() } + + containerView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() + make.height.equalTo(sheetHeight) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.leading.equalToSuperview().inset(24) + } + + closeImageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.trailing.equalToSuperview().inset(24) + make.width.height.equalTo(24) + } + + alarmListStackView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.horizontalEdges.equalToSuperview() + } + + + volumeSlider.snp.makeConstraints { make in + make.top.equalTo(alarmListStackView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(20) + } + + completeButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(24) + make.bottom.equalToSuperview().inset(40) + } + } + + private func setupAlarmLists() { + // 이미지 순서: 소리 및 진동, 소리, 진동 + let options: [PushAlarmOption] = [.both, .onlySound, .onlyVibration] + + // 기본 선택값을 .both로 강제 설정 + let currentOption = PushAlarmOption.onlyVibration + self.selectedOption = currentOption + AlarmManager.shared.setAlarmOption(currentOption) + + options.forEach { option in + let isSelected = (option == currentOption) + let listView = AtchaList( + title: option.rawValue, + listType: .radioButton(isOn: isSelected) + ) + + listView.backgroundColor = isSelected ? AtchaColor.opacity100 : .clear + + listView.onSelect = { [weak self] selected in + guard let self = self else { return } + + self.alarmCheckmarkLists.forEach { + $0.setRadio(false) + $0.backgroundColor = .clear + } + selected.setRadio(true) + selected.backgroundColor = AtchaColor.opacity100 + self.selectedOption = option + + self.updateVolumeSliderVisibility(for: option) + // 매니저의 옵션을 먼저 변경한 뒤 미리보기 호출 + AlarmManager.shared.setAlarmOption(option) + if option != .onlyVibration { + AlarmManager.shared.previewAlarmVolume(self.volumeSlider.value) + } else { + AlarmManager.shared.stopPreview() + } + } + + listView.snp.makeConstraints { $0.height.equalTo(52) } + alarmListStackView.addArrangedSubview(listView) + alarmCheckmarkLists.append(listView) + } + } +} + +// MARK: - Actions +extension PushAlarmSheetViewController { + + // X 버튼 클릭 시 실행 + @objc private func didTapCloseButton() { + AlarmManager.shared.stopPreview() + isConfirmed = false + dismissSheet() + } + + // 완료 버튼 클릭 시 실행 + @objc private func didTapCompleteButton() { + if let option = selectedOption { + AlarmManager.shared.stopPreview() + AlarmManager.shared.setAlarmOption(option) + AlarmManager.shared.setAlarmArmed(true) + isConfirmed = true + } + dismissSheet() + } + + @objc private func sliderChanged(_ sender: UISlider) { + let clampedValue = max(sender.value, 0.1) + sender.setValue(clampedValue, animated: false) + + AlarmManager.shared.previewAlarmVolume(clampedValue) + AlarmManager.shared.setAlarmVolume(clampedValue) + setVolume(clampedValue) + } + + @objc func sliderTapped(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: volumeSlider) + let percentage = point.x / volumeSlider.bounds.width + let delta = Float(percentage) * (volumeSlider.maximumValue - volumeSlider.minimumValue) + var newValue = volumeSlider.minimumValue + delta + + newValue = max(newValue, 0.1) + volumeSlider.setValue(newValue, animated: true) + AlarmManager.shared.setAlarmVolume(newValue) + setVolume(newValue) + } + + private func setVolume(_ volume: Float) { + let clampedVolume = max(volume, 0.1) // 최소 볼륨 제한 + + DispatchQueue.main.async { + let volumeView = MPVolumeView() + + guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { + print("UISlider를 찾을 수 없습니다.") + return + } + + let currentVolume = slider.value + print("현재 시스템 볼륨: \(currentVolume)") + + if currentVolume <= 0.1 || currentVolume < clampedVolume { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + slider.value = clampedVolume + print("볼륨이 \(clampedVolume)으로 설정되었습니다.") + } + } else { + print("현재 볼륨이 설정하려는 값보다 높아 변경하지 않습니다.") + } + } + } + + private func observeVolumeChanges() { + volumeObservation = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] (session, change) in + guard let self = self, let newVolume = change.newValue else { return } + DispatchQueue.main.async { [weak self] in + let clamped = max(newVolume, 0.1) + self?.volumeSlider.value = clamped + self?.setVolume(clamped) + } + } + } + + private func updateVolumeSliderVisibility(for option: PushAlarmOption) { + // 소리가 포함된 옵션(.both, .onlySound)일 때만 true + let isSoundEnabled = (option == .both || option == .onlySound) + + UIView.animate(withDuration: 0.2) { + self.volumeSlider.isHidden = !isSoundEnabled + self.volumeSlider.alpha = isSoundEnabled ? 1 : 0 + } + } +} + +// MARK: - Gestures & Animations +extension PushAlarmSheetViewController { + private func setupGestures() { + // 배경 터치 시 닫기 + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapDimView)) + dimView.addGestureRecognizer(tapGesture) + dimView.isUserInteractionEnabled = true + + // 스와이프해서 닫기 + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + containerView.addGestureRecognizer(panGesture) + } + + @objc private func didTapDimView() { + AlarmManager.shared.stopPreview() + dismissSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + switch gesture.state { + case .changed: + if translation.y > 0 { + containerView.transform = CGAffineTransform(translationX: 0, y: translation.y) + } + case .ended, .cancelled: + if velocity.y > 1000 || translation.y > (sheetHeight / 2) { + AlarmManager.shared.stopPreview() + dismissSheet() + } else { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + self.containerView.transform = .identity + }) + } + default: break + } + } + + // 닫기 애니메이션 공통 로직 + private func dismissSheet() { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn, animations: { + self.dimView.alpha = 0 + self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) + }) { _ in + self.dismiss(animated: false) { + if self.isConfirmed { + self.onComplete?() + } + self.onDismiss?() + } + } + } +} diff --git a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewModel.swift b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewModel.swift new file mode 100644 index 00000000..7dc157bd --- /dev/null +++ b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewModel.swift @@ -0,0 +1,14 @@ +// +// PushAlarmSheetViewModel.swift +// Atcha-iOS +// +// Created by wodnd on 3/4/26. +// + +import Foundation +import Combine + +final class PushAlarmSheetViewModel: BaseViewModel { + + +} diff --git a/Atcha-iOS/Presentation/Splash/Coordinator/SplashRouter.swift b/Atcha-iOS/Presentation/Splash/Coordinator/SplashRouter.swift index 6f8bd0c1..7cf326f0 100644 --- a/Atcha-iOS/Presentation/Splash/Coordinator/SplashRouter.swift +++ b/Atcha-iOS/Presentation/Splash/Coordinator/SplashRouter.swift @@ -8,8 +8,7 @@ import Foundation enum SplashRouter { - case login // 로그인 - case onboarding // 온보딩 + case intro // 로그인 case main // 메인 case lockScreen(info: LegInfo?, address: String?) // 잠금화면 case alarm(info: LegInfo?, address: String?) // 알람 등록 완료 된경우 diff --git a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift index 85078dfb..75b7b124 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift @@ -86,15 +86,21 @@ final class SplashViewModel: BaseViewModel { return } - if let _ = wrapper.string(forKey: UserDefaultsWrapper.Key.providerToken.rawValue) { // 로그인만 진행한 경우 - if let _ = AppDIContainer.shared.tokenStorage.accessToken { // 토큰도 정상적으로 존재하는 경우 - fetchUserInfo() + if AppDIContainer.shared.tokenStorage.accessToken != nil { + // 1. 토큰이 있는 경우 (로그인 유저) -> 유저정보 받고 메인으로! + fetchUserInfo() + routerHandler?(.main) + } else { + // 2. 토큰이 없는 경우 (신규 유저 or 로그아웃/탈퇴 유저) + let hasSeenIntro = wrapper.bool(forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) ?? false + + if hasSeenIntro { + // 이미 인트로를 보고 넘긴 적이 있다면 (그냥 게스트 유저) -> 바로 메인으로! routerHandler?(.main) } else { - routerHandler?(.onboarding) + // 설치 후 처음 켰거나, 탈퇴(초기화) 후 처음 킨 경우 -> 인트로 화면으로! + routerHandler?(.intro) } - } else { - routerHandler?(.login) } } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift index 5d8de373..a7b84ca0 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift @@ -80,7 +80,9 @@ final class HomeFindViewController: BaseViewController, ) if ok { self.viewModel.handleRegister() - self.navigationController?.popToViewController(ofType: HomeRegisterViewController.self) + if viewModel.context == .myPage { + self.navigationController?.popToViewController(ofType: HomeRegisterViewController.self) + } } else { AtchaToast(message: "앗차는 현재 서울, 경기, 인천에서만 이용 가능해요") .show(in: self.view) @@ -232,5 +234,7 @@ extension HomeFindViewController { func mapView(_ mapView: TMapWrapper, didSelectLocation coordinate: CLLocationCoordinate2D) { viewModel.currentLocation = coordinate } + + func mapViewDidStartScroll(_ mapView: TMapWrapper) {} } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index cae206cf..065ed7fc 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -22,19 +22,25 @@ final class HomeFindViewModel: BaseViewModel { locationStateHolder.currentLocation != nil } + var onFinish: ((Bool) -> Void)? + private let searchAddressUseCase: SearchAddressUseCase private let homePatchUseCase: HomePatchUseCase private let locationStateHolder: LocationStateHolder private let streamUseCase: ObserveLocationStreamUseCase + private let signUpUseCase: SignUpUseCase init(context: HomeRegisterContext, searchAddressUseCase: SearchAddressUseCase, homePatchUseCase: HomePatchUseCase, locationStateHolder: LocationStateHolder, - streamUseCase: ObserveLocationStreamUseCase) { + streamUseCase: ObserveLocationStreamUseCase, + signUpUseCase: SignUpUseCase) { + self.context = context self.searchAddressUseCase = searchAddressUseCase self.homePatchUseCase = homePatchUseCase + self.signUpUseCase = signUpUseCase self.locationStateHolder = locationStateHolder self.streamUseCase = streamUseCase self.buildingName = locationStateHolder.buildingName @@ -72,7 +78,7 @@ final class HomeFindViewModel: BaseViewModel { switch context { case .onboarding: saveCurrentLoaction() - + signUp() case .myPage: guard let currentLocation, let address else { @@ -220,3 +226,67 @@ extension HomeFindViewModel { return try await searchAddressUseCase.searchLocation(request) } } + +// MARK: - SignUP +extension HomeFindViewModel { + func signUp() { + guard let provider = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.provider.rawValue) else { + print("플랫폼 정보 없음") + return + } + + guard let fcmToken = AppDIContainer.shared.tokenStorage.fcmToken else { + print("FCM 토큰이 없습니다.") + return + } + + let request = SignUpRequest( + provider: provider, + userName: "", + address: locationStateHolder.address ?? "", + lat: locationStateHolder.currentLocation?.latitude ?? 0.0, + lon: locationStateHolder.currentLocation?.longitude ?? 0.0, + alertFrequencies: [1, 10], + fcmToken: fcmToken + ) + + // TODO: 위치 변경해야할 듯 + UserDefaultsWrapper.shared.set(locationStateHolder.currentLocation?.latitude ?? 0.0, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) + UserDefaultsWrapper.shared.set(locationStateHolder.currentLocation?.longitude ?? 0.0, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) + + Task { + do { + let response = try await signUpUseCase.excute(request) + + AppDIContainer.shared.tokenStorage.accessToken = response.accessToken + AppDIContainer.shared.tokenStorage.refreshToken = response.refreshToken + + UserDefaultsWrapper.shared.set(response.id, forKey: UserDefaultsWrapper.Key.userId.rawValue) + if let lat = response.lat, let lon = response.lon, let id = response.id { + UserDefaultsWrapper.shared.set(lat, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) + UserDefaultsWrapper.shared.set(lon, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) + UserDefaultsWrapper.shared.set(id, forKey: UserDefaultsWrapper.Key.userId + .rawValue) + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit + .rawValue) + + let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("signup_dwell") + AmplitudeManager.shared.track( + .signup, + props( + AmplitudeProperty.dwellTime(seconds: dwellSeconds) + ) + ) + print("회원가입 lat/lon 저장 완료: \(lat), \(lon)") + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + } else { + print("회원가입 응답에 lat/lon 없음") + } + + onFinish?(true) + } catch { + onFinish?(false) + } + } + } +} diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index d8c1ad03..669e8daf 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift @@ -28,6 +28,8 @@ final class MyAccountViewModel: BaseViewModel { AppDIContainer.shared.tokenStorage.clearRefreshToken() UserDefaultsWrapper.shared.removeAll() AppDIContainer.shared.locationStateHolder.clear() + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) await MainActor.run { logout?() } diff --git a/Atcha-iOS/Presentation/User/MyPage/MyPageCoordinator.swift b/Atcha-iOS/Presentation/User/MyPage/MyPageCoordinator.swift index 9b9ebfab..609a9cb6 100644 --- a/Atcha-iOS/Presentation/User/MyPage/MyPageCoordinator.swift +++ b/Atcha-iOS/Presentation/User/MyPage/MyPageCoordinator.swift @@ -17,6 +17,7 @@ final class MyPageCoordinator { private var myPageViewModelRef: MyPageViewModel? var signoutFinish: (() -> Void)? + var withdrawFinish: (() -> Void)? init(navigationController: UINavigationController, diContainer: MyPageDIContainer) { @@ -131,7 +132,7 @@ final class MyPageCoordinator { private func showWithdraw() { let vm = diContainer.makeWithdrawViewModel() vm.signOutFinish = { [weak self] in - self?.signoutFinish?() + self?.withdrawFinish?() } let vc = diContainer.makeWithdrawViewController(viewModel: vm) navigationController.pushViewController(vc, animated: true) diff --git a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift index ff4e45da..23e4d074 100644 --- a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift +++ b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift @@ -31,6 +31,7 @@ final class WithdrawViewModel: BaseViewModel { AppDIContainer.shared.tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() AppDIContainer.shared.locationStateHolder.clear() + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) signOutFinish?() } catch { print("error 발생")