diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index f0c1a489..6baace4b 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -20,6 +20,12 @@ 6D1EE2F72E0A933000F7BBF1 /* OnboardingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1EE2F62E0A933000F7BBF1 /* OnboardingCoordinator.swift */; }; 6D26DFA82F289E64005097A4 /* HeadingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26DFA72F289E64005097A4 /* HeadingManager.swift */; }; 6D26DFAA2F2D3B27005097A4 /* CloseOnlyNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26DFA92F2D3B27005097A4 /* CloseOnlyNavigationBar.swift */; }; + 6D26DFFC2F3C16FD005097A4 /* SubwayInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26DFFB2F3C16FD005097A4 /* SubwayInfoUseCase.swift */; }; + 6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26DFFF2F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift */; }; + 6D26E0022F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26E0012F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift */; }; + 6D26E0052F3C18B0005097A4 /* SubwayRealTimeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26E0042F3C18B0005097A4 /* SubwayRealTimeInfo.swift */; }; + 6D26E0072F3C197F005097A4 /* SubwayInfoRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26E0062F3C197F005097A4 /* SubwayInfoRepository.swift */; }; + 6D26E0092F3C1A0C005097A4 /* SubwayInfoRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D26E0082F3C1A0C005097A4 /* SubwayInfoRepositoryImpl.swift */; }; 6D2B8C942E38A97500608104 /* BusDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2B8C932E38A97500608104 /* BusDetailHeaderView.swift */; }; 6D2B8C972E38ABF600608104 /* BusInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2B8C962E38ABF600608104 /* BusInfoViewController.swift */; }; 6D2B8C992E38ABFD00608104 /* BusInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2B8C982E38ABFD00608104 /* BusInfoViewModel.swift */; }; @@ -336,6 +342,12 @@ 6D1EE2F62E0A933000F7BBF1 /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = ""; }; 6D26DFA72F289E64005097A4 /* HeadingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingManager.swift; sourceTree = ""; }; 6D26DFA92F2D3B27005097A4 /* CloseOnlyNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseOnlyNavigationBar.swift; sourceTree = ""; }; + 6D26DFFB2F3C16FD005097A4 /* SubwayInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayInfoUseCase.swift; sourceTree = ""; }; + 6D26DFFF2F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayRealTimeInfoRequest.swift; sourceTree = ""; }; + 6D26E0012F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayRealTimeInfoResponse.swift; sourceTree = ""; }; + 6D26E0042F3C18B0005097A4 /* SubwayRealTimeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayRealTimeInfo.swift; sourceTree = ""; }; + 6D26E0062F3C197F005097A4 /* SubwayInfoRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayInfoRepository.swift; sourceTree = ""; }; + 6D26E0082F3C1A0C005097A4 /* SubwayInfoRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubwayInfoRepositoryImpl.swift; sourceTree = ""; }; 6D2B8C932E38A97500608104 /* BusDetailHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusDetailHeaderView.swift; sourceTree = ""; }; 6D2B8C962E38ABF600608104 /* BusInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusInfoViewController.swift; sourceTree = ""; }; 6D2B8C982E38ABFD00608104 /* BusInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusInfoViewModel.swift; sourceTree = ""; }; @@ -703,6 +715,31 @@ path = HeadingManager; sourceTree = ""; }; + 6D26DFFD2F3C179D005097A4 /* SubwayInfoDTO */ = { + isa = PBXGroup; + children = ( + 6D26DFFE2F3C17B3005097A4 /* SubwayRealTimeInfo */, + ); + path = SubwayInfoDTO; + sourceTree = ""; + }; + 6D26DFFE2F3C17B3005097A4 /* SubwayRealTimeInfo */ = { + isa = PBXGroup; + children = ( + 6D26DFFF2F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift */, + 6D26E0012F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift */, + ); + path = SubwayRealTimeInfo; + sourceTree = ""; + }; + 6D26E0032F3C189D005097A4 /* SubwayInfo */ = { + isa = PBXGroup; + children = ( + 6D26E0042F3C18B0005097A4 /* SubwayRealTimeInfo.swift */, + ); + path = SubwayInfo; + sourceTree = ""; + }; 6D2B8C952E38ABED00608104 /* BusInfo */ = { isa = PBXGroup; children = ( @@ -1128,6 +1165,7 @@ B65C12CF2E04292F0016D2F0 /* Entity */ = { isa = PBXGroup; children = ( + 6D26E0032F3C189D005097A4 /* SubwayInfo */, 6D2B8CA02E39A73300608104 /* BusInfo */, B65C12DD2E042CF00016D2F0 /* UserInfo.swift */, B66401842E2244CF00A397AE /* Location.swift */, @@ -1147,6 +1185,7 @@ 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */, 6D1EE2ED2E0A7D7300F7BBF1 /* OnboardingUseCase.swift */, 6D73EBE52E1B51E000F8DF8B /* CourseUseCase.swift */, + 6D26DFFB2F3C16FD005097A4 /* SubwayInfoUseCase.swift */, 6D2B8CB92E39C8E100608104 /* BusInfoUseCase.swift */, 6DB7636A2E45C5EC00D06A49 /* AlarmUseCase.swift */, ); @@ -1162,6 +1201,7 @@ 6D1EE2EF2E0A7F0000F7BBF1 /* OnboardingRepository.swift */, 6D73EBE72E1B54A500F8DF8B /* CourseRepository.swift */, 6D2B8CBD2E39C9CB00608104 /* BusInfoRepository.swift */, + 6D26E0062F3C197F005097A4 /* SubwayInfoRepository.swift */, 6DB7636C2E45C69400D06A49 /* AlarmRepository.swift */, ); path = Repository; @@ -1181,6 +1221,7 @@ children = ( B6C5074B2E129656000AB39F /* Location */, 6D2B8CBF2E39CC8800608104 /* BusInfoRepositoryImpl.swift */, + 6D26E0082F3C1A0C005097A4 /* SubwayInfoRepositoryImpl.swift */, B65C12E32E042D6B0016D2F0 /* UserRepositoryImpl.swift */, B65C131F2E058C360016D2F0 /* AppVersionRepositoryImpl.swift */, 6D73EBE92E1B551600F8DF8B /* CourseRepositoryImpl.swift */, @@ -1265,6 +1306,7 @@ 6DB763672E45C52600D06A49 /* AlarmDTO */, 6D6879CC2E42119500E59C55 /* UserInfoPatchDTO */, 6D2B8C9F2E39A70500608104 /* BusInfoDTO */, + 6D26DFFD2F3C179D005097A4 /* SubwayInfoDTO */, B6AC8BDC2E2BAD6100410ECD /* UserDTO */, 6D5E03BA2E24F3D50065AFBE /* SearchHistoryDTO */, B664017E2E2243C800A397AE /* LocationDTO */, @@ -1988,6 +2030,7 @@ 6D91A90F2E38A1C60081BAFC /* BusDetailViewModel.swift in Sources */, B6CF46F52E09620100304A85 /* LoginCoordinator.swift in Sources */, B6FB208A2E1BE5200032751B /* FetchTaxiFareRepositoryImpl.swift in Sources */, + 6D26DFFC2F3C16FD005097A4 /* SubwayInfoUseCase.swift in Sources */, 6DB4DE932F114D6A00F2DC4E /* AtchaActionToast.swift in Sources */, B68309532E005A3600E2D029 /* AppDelegate.swift in Sources */, B65402D52E76CACC00AB5862 /* DetailRouteSummaryView.swift in Sources */, @@ -2042,6 +2085,7 @@ B6793D542E386DFE001BE9F5 /* DetailRouteViewController.swift in Sources */, B65C12FE2E057AF90016D2F0 /* NetworkConstant.swift in Sources */, B6AC8BE72E2BC4E300410ECD /* AlarmSettingDIContainer.swift in Sources */, + 6D26E0092F3C1A0C005097A4 /* SubwayInfoRepositoryImpl.swift in Sources */, 6D8D8B132E01B338003DABB2 /* AtchaButton.swift in Sources */, B66401832E22449000A397AE /* SearchLocationResponse.swift in Sources */, 6D368BA42E02D5E200144EE7 /* IconTitleNavigationBar.swift in Sources */, @@ -2084,6 +2128,7 @@ 6D26DFA82F289E64005097A4 /* HeadingManager.swift in Sources */, 6D91A9012E33E3620081BAFC /* LoadingView.swift in Sources */, B664018B2E2277A900A397AE /* PushAlarmOption.swift in Sources */, + 6D26E0072F3C197F005097A4 /* SubwayInfoRepository.swift in Sources */, B65C13082E057C590016D2F0 /* APIService.swift in Sources */, 6D6879D02E4211B800E59C55 /* HomePatchRequest.swift in Sources */, 6D9283742E3AFF6A0090889B /* BusRouteCell.swift in Sources */, @@ -2165,10 +2210,12 @@ 6DD632C22E544DB500C6A66E /* PushAlarmBottomView.swift in Sources */, 6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */, 6D91A8E62E29F5BC0081BAFC /* CourseSettingViewModel.swift in Sources */, + 6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */, B673C4912E0424FD00EE4AD0 /* SplashViewModel.swift in Sources */, 6DB763692E45C53A00D06A49 /* AlarmRequest.swift in Sources */, B6C507502E129693000AB39F /* LocationStreamRepositoryImpl.swift in Sources */, B6AC8BD92E2A85D800410ECD /* SignOutUseCase.swift in Sources */, + 6D26E0022F3C17DE005097A4 /* SubwayRealTimeInfoResponse.swift in Sources */, 6D1EE2F02E0A7F0000F7BBF1 /* OnboardingRepository.swift in Sources */, 6D1EE2E52E09A42F00F7BBF1 /* PushAlarmViewController.swift in Sources */, B6793D4F2E34972B001BE9F5 /* FetchTaxiFareRequest.swift in Sources */, @@ -2223,6 +2270,7 @@ B69E31AC2E2683B8001040F4 /* PermissionViewController.swift in Sources */, 6D1EE2EE2E0A7D7300F7BBF1 /* OnboardingUseCase.swift in Sources */, B6AC8BDB2E2B3AA200410ECD /* LogoutuseCase.swift in Sources */, + 6D26E0052F3C18B0005097A4 /* SubwayRealTimeInfo.swift in Sources */, 6DFF29912E0CBE820039399F /* RegisterLocationViewController.swift in Sources */, B6793D692E3B3FB8001BE9F5 /* DetailRouteSubwayCell.swift in Sources */, B6793D602E3A5E23001BE9F5 /* DetailRouteProgressView.swift in Sources */, @@ -2301,7 +2349,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.5; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2349,7 +2397,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.5; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Atcha-iOS/App/DIContainer/Route/RouteDIContainer.swift b/Atcha-iOS/App/DIContainer/Route/RouteDIContainer.swift index 952a286c..e37da4fa 100644 --- a/Atcha-iOS/App/DIContainer/Route/RouteDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Route/RouteDIContainer.swift @@ -13,6 +13,7 @@ final class RouteDIContainer { private lazy var requestUseCase = RequestLocationAuthorizationUseCaseImpl(repository: PermissionRepositoryImpl()) private lazy var streamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) private lazy var busInfoUseCase = BusInfoUseCaseImpl(repository: BusInfoRepositoryImpl(apiService: apiService)) + private lazy var subwayInfoUseCase = SubwayInfoUseCaseImpl(repository: SubwayInfoRepositoryImpl(apiService: apiService)) private lazy var alarmUseCase = AlarmUseCaseImpl(repository: AlarmRepositoryImpl(apiService: apiService)) init(apiService: APIService) { @@ -24,6 +25,7 @@ final class RouteDIContainer { infos: infos, context: context, busInfoUseCase: busInfoUseCase, + subwayInfoUseCase: subwayInfoUseCase, authorizationUseCase: requestUseCase, streamUseCase: streamUseCase, alarmUseCase: alarmUseCase) diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/AlarmManager.swift index 832ef469..9196d8ae 100644 --- a/Atcha-iOS/Core/Manager/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/AlarmManager.swift @@ -415,7 +415,7 @@ extension AlarmManager { // 1) UserDefaults에서 값 다시 읽기 let savedVolume = UserDefaultsWrapper.shared.float( forKey: UserDefaultsWrapper.Key.alarmVolume.rawValue - ) ?? 0.7 + ) ?? 0.3 // 2) AlarmManager 상태 업데이트 alarmVolume = savedVolume diff --git a/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift b/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift index 02e6ba49..8747e2ad 100644 --- a/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift +++ b/Atcha-iOS/Core/ViewWrapper/TMapWrapper.swift @@ -29,7 +29,7 @@ final class TMapWrapper: NSObject, MapRendering { private var trafficMarkers: [TMapMarker] = [] public init(frame: CGRect) { - self.mapView = TMapView(frame: UIScreen.main.bounds) + self.mapView = TMapView(frame: frame) super.init() configureDefaultSettings() } @@ -153,6 +153,7 @@ final class TMapWrapper: NSObject, MapRendering { trafficMarkers.forEach { $0.map = nil } trafficMarkers.removeAll() } + } extension TMapWrapper: TMapViewDelegate, TmapViewLocationDelegate { @@ -180,3 +181,21 @@ extension TMapWrapper: TMapViewDelegate, TmapViewLocationDelegate { delegate?.mapView(self, didUpdateLocation: newPosition) } } + +extension TMapWrapper { + /// 현위치를 "routeInset 기준"으로 센터링하되, 화면에서 위로 50pt 올라가 보이게 + func centerUserWithRouteInset(_ location: CLLocationCoordinate2D, yOffsetUp points: CGFloat = 50) { + let dLat = 0.00015 + let dLon = 0.00015 + + let box = [ + CLLocationCoordinate2D(latitude: location.latitude - dLat, longitude: location.longitude - dLon), + CLLocationCoordinate2D(latitude: location.latitude + dLat, longitude: location.longitude + dLon) + ] + + // 기존 route inset 그대로 + bottom만 50 증가 = 화면에서 더 위로 보임 + let inset = UIEdgeInsets(top: 110, left: 30, bottom: 160 + points, right: 30) + + mapView.fitMapBoundsWithPolygons([TMapPolygon(coordinates: box)], inset: inset) + } +} diff --git a/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoRequest.swift b/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoRequest.swift new file mode 100644 index 00000000..502842e3 --- /dev/null +++ b/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoRequest.swift @@ -0,0 +1,12 @@ +// +// BusRealTimeInfoRequest.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation + +struct SubwayRealTimeInfoRequest: Codable { + let routeName: String +} diff --git a/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoResponse.swift b/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoResponse.swift new file mode 100644 index 00000000..b360f14b --- /dev/null +++ b/Atcha-iOS/Data/Model/SubwayInfoDTO/SubwayRealTimeInfo/SubwayRealTimeInfoResponse.swift @@ -0,0 +1,45 @@ +// +// BusRealTimeInfoResponse.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation + +struct SubwayRealTimeInfoResponse: Codable { + let routeName: String? + let subwayArrivalStatus: String? + let remainingTime: Int? + let isLast: Bool? + let expectedArrivalTime: String? + let trainNo: String? + let destination: String? + let direction: String? +} + +extension SubwayRealTimeInfoResponse { + func toEntity() -> SubwayRealTimeInfo? { + guard + let routeName, + let subwayArrivalStatus, + let remainingTime, + let isLast, + let expectedArrivalTime, + let trainNo, + let destination, + let direction + else { return nil } + + return SubwayRealTimeInfo( + routeName: routeName, + subwayArrivalStatus: subwayArrivalStatus, + remainingTime: remainingTime, + isLast: isLast, + expectedArrivalTime: expectedArrivalTime, + trainNo: trainNo, + destination: destination, + direction: direction + ) + } +} diff --git a/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift new file mode 100644 index 00000000..0204fe0a --- /dev/null +++ b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift @@ -0,0 +1,31 @@ +// +// SubwayInfoRepositoryImpl.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation +import Alamofire + +final class SubwayInfoRepositoryImpl: SubwayInfoRepository { + + private let apiService: APIService + + init(apiService: APIService) { + self.apiService = apiService + } + + // 실시간 지하철 정보 조회 + func subwayRealTimeInfo(_ request: SubwayRealTimeInfoRequest) async throws -> [SubwayRealTimeInfoResponse] { + let endpoint = Endpoint( + path: "/routes/user-routes/subway-arrival", + method: .get, + parameters: ["routeName": request.routeName], + encoding: URLEncoding.queryString + ) + + let result: [SubwayRealTimeInfoResponse] = try await apiService.request(endpoint) + return result + } +} diff --git a/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift index 273276ed..c81ac046 100644 --- a/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift +++ b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift @@ -44,6 +44,7 @@ enum AtchaColor{ static let town = UIColor(named: "town")! static let mainline = UIColor(named: "mainline")! static let widearea = UIColor(named: "widearea")! + static let general = UIColor(named: "general")! } // MARK: - Transportation/Subway diff --git a/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Bus/general.colorset/Contents.json b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Bus/general.colorset/Contents.json new file mode 100644 index 00000000..c95002ff --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Bus/general.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA9", + "green" : "0x9B", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/Contents.json new file mode 100644 index 00000000..f00c72ef --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-getOff.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-getOff@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-getOff@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff.png new file mode 100644 index 00000000..e6d49587 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@2x.png new file mode 100644 index 00000000..cd3515f1 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@3x.png new file mode 100644 index 00000000..377685c7 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general-getOff.imageset/general-getOff@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/Contents.json new file mode 100644 index 00000000..c21578f8 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general.png new file mode 100644 index 00000000..1e84a4ef Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@2x.png new file mode 100644 index 00000000..73bf197a Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@3x.png new file mode 100644 index 00000000..a9e14448 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/General/general.imageset/general@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/Contents.json new file mode 100644 index 00000000..6bcc0b8b --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "general-border.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/general-border.svg b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/general-border.svg new file mode 100644 index 00000000..6fd63d5f --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Color/border/general-border.imageset/general-border.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/Contents.json new file mode 100644 index 00000000..bdc69de7 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bus-general.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bus-general@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bus-general@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general.png new file mode 100644 index 00000000..4ecb6ceb Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@2x.png new file mode 100644 index 00000000..74f3a832 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@3x.png new file mode 100644 index 00000000..c0d16724 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus/bus-general.imageset/bus-general@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/Contents.json new file mode 100644 index 00000000..3e0c4340 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bus-general-20px.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bus-general-20px@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bus-general-20px@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px.png new file mode 100644 index 00000000..19359be4 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@2x.png new file mode 100644 index 00000000..7ae79fa8 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@3x.png new file mode 100644 index 00000000..5b686732 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/Bus20/General/bus-general-20px.imageset/bus-general-20px@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/Contents.json new file mode 100644 index 00000000..517cff12 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-long-opacity.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-long-opacity@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-long-opacity@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity.png new file mode 100644 index 00000000..4c16d83f Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@2x.png new file mode 100644 index 00000000..3de65914 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@3x.png new file mode 100644 index 00000000..0cfb0653 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-opacity.imageset/general-long-opacity@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/Contents.json new file mode 100644 index 00000000..d598cb6e --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-long-turn.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-long-turn@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-long-turn@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn.png new file mode 100644 index 00000000..75db153c Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@2x.png new file mode 100644 index 00000000..447ea872 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@3x.png new file mode 100644 index 00000000..8a8d4a6d Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long-turn.imageset/general-long-turn@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/Contents.json new file mode 100644 index 00000000..7e763c74 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-long.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-long@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-long@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long.png new file mode 100644 index 00000000..1530c078 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@2x.png new file mode 100644 index 00000000..d775bb74 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@3x.png new file mode 100644 index 00000000..d1e4a937 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-long.imageset/general-long@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/Contents.json new file mode 100644 index 00000000..654b1db6 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-short-opacity.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-short-opacity@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-short-opacity@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity.png new file mode 100644 index 00000000..f91735ff Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@2x.png new file mode 100644 index 00000000..c58ebf68 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@3x.png new file mode 100644 index 00000000..0a527fb1 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-opacity.imageset/general-short-opacity@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/Contents.json new file mode 100644 index 00000000..a46f21cd --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-short-turn.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-short-turn@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-short-turn@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn.png new file mode 100644 index 00000000..397f86ad Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@2x.png new file mode 100644 index 00000000..ef73978e Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@3x.png new file mode 100644 index 00000000..c2c3e6f6 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short-turn.imageset/general-short-turn@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/Contents.json new file mode 100644 index 00000000..624651c8 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "general-short.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "general-short@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "general-short@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short.png new file mode 100644 index 00000000..4aca74cd Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@2x.png new file mode 100644 index 00000000..af05ef8b Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@3x.png new file mode 100644 index 00000000..94d163e2 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Course/BusRoute/General/general-short.imageset/general-short@3x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/TransportationIconView.swift b/Atcha-iOS/DesignSource/AtchaImage/TransportationIconView.swift index 6efa35ae..535133cc 100644 --- a/Atcha-iOS/DesignSource/AtchaImage/TransportationIconView.swift +++ b/Atcha-iOS/DesignSource/AtchaImage/TransportationIconView.swift @@ -58,7 +58,7 @@ let subwayIcon: [String: String] = [ ] let busIcon: [String: String] = [ - "1": "regular", + "1": "general", "10": "regular", "12": "regular", @@ -131,7 +131,7 @@ let subwayGetOffIcon: [String: String] = [ ] let busGetOffIcon: [String: String] = [ - "1": "regular-getOff", + "1": "general-getOff", "10": "regular-getOff", "12": "regular-getOff", @@ -231,7 +231,7 @@ extension TransportMode { ] static let busIcon: [String: UIImage] = [ - "1": .regular, "10": .regular, "12": .regular, + "1": .general, "10": .regular, "12": .regular, "3": .town, "13": .town, "21": .town, "2": .mainline, "11": .mainline, "4": .widearea, "6": .widearea, "14": .widearea, @@ -240,7 +240,7 @@ extension TransportMode { ] static let busBorderIcon: [String: UIImage] = [ - "1": .regularBorder, "10": .regularBorder, "12": .regularBorder, + "1": .generalBorder, "10": .regularBorder, "12": .regularBorder, "3": .townBorder, "13": .townBorder, "21": .townBorder, "2": .mainlineBorder, "11": .mainlineBorder, "4": .wideareaBorder, "6": .wideareaBorder, "14": .wideareaBorder, @@ -249,7 +249,7 @@ extension TransportMode { ] static let busGetOffIcon: [String: String] = [ - "1": "regular-getOff", "10": "regular-getOff", "12": "regular-getOff", + "1": "general-getOff", "10": "regular-getOff", "12": "regular-getOff", "3": "town-getOff", "13": "town-getOff", "21": "town-getOff", "2": "mainline-getOff", "11": "mainline-getOff", "4": "widearea-getOff", "6": "widearea-getOff", "14": "widearea-getOff", @@ -259,7 +259,7 @@ extension TransportMode { ] static let busColor: [String: UIColor] = [ - "1": .regular, "10": .regular, "12": .regular, + "1": .general, "10": .regular, "12": .regular, "3": .town, "13": .town, "21": .town, "2": .mainline, "11": .mainline, "4": .widearea, "6": .widearea, "14": .widearea, @@ -303,7 +303,7 @@ enum BusType: String { var icon: UIImage { switch self { - case .일반, .외곽, .지선: + case .외곽, .지선: return UIImage(named: "bus-regular") ?? UIImage(named: "bus-default")! case .좌석, .간선: return UIImage(named: "bus-mainline") ?? UIImage(named: "bus-default")! @@ -313,6 +313,8 @@ enum BusType: String { return UIImage(named: "bus-widearea") ?? UIImage(named: "bus-default")! case .공항, .리무진: return UIImage(named: "bus-airport") ?? UIImage(named: "bus-default")! + case .일반: + return UIImage(named: "bus-general") ?? UIImage(named: "bus-default")! case .unknown: return UIImage(named: "bus-default")! } diff --git a/Atcha-iOS/Domain/Entity/SubwayInfo/SubwayRealTimeInfo.swift b/Atcha-iOS/Domain/Entity/SubwayInfo/SubwayRealTimeInfo.swift new file mode 100644 index 00000000..03799060 --- /dev/null +++ b/Atcha-iOS/Domain/Entity/SubwayInfo/SubwayRealTimeInfo.swift @@ -0,0 +1,33 @@ +// +// SubwayRealTimeInfo.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation + +enum SubwayStatus: String, Decodable { + case approaching = "APPROACHING" + case arriving = "ARRIVING" + case departed = "DEPARTED" + case operating = "OPERATING" + case waiting = "WAITING" + case unknown + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = SubwayStatus(rawValue: raw) ?? .unknown + } +} + +struct SubwayRealTimeInfo: Codable { + let routeName: String? + let subwayArrivalStatus: String? + let remainingTime: Int? + let isLast: Bool? + let expectedArrivalTime: String? + let trainNo: String? + let destination: String? + let direction: String? +} diff --git a/Atcha-iOS/Domain/Repository/SubwayInfoRepository.swift b/Atcha-iOS/Domain/Repository/SubwayInfoRepository.swift new file mode 100644 index 00000000..b407c3ce --- /dev/null +++ b/Atcha-iOS/Domain/Repository/SubwayInfoRepository.swift @@ -0,0 +1,12 @@ +// +// SubwayInfoRepository.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation +protocol SubwayInfoRepository { + // 실시간 지하철 정보 조회 + func subwayRealTimeInfo(_ request: SubwayRealTimeInfoRequest) async throws -> [SubwayRealTimeInfoResponse] +} diff --git a/Atcha-iOS/Domain/UseCase/SubwayInfoUseCase.swift b/Atcha-iOS/Domain/UseCase/SubwayInfoUseCase.swift new file mode 100644 index 00000000..2b2a0841 --- /dev/null +++ b/Atcha-iOS/Domain/UseCase/SubwayInfoUseCase.swift @@ -0,0 +1,27 @@ +// +// SubwayInfoUseCase.swift +// Atcha-iOS +// +// Created by wodnd on 2/11/26. +// + +import Foundation + +protocol SubwayInfoUseCase { + // 실시간 지하철 정보 조회 + func subwayRealTimeInfo(_ request: SubwayRealTimeInfoRequest) async throws -> [SubwayRealTimeInfo] +} + +final class SubwayInfoUseCaseImpl: SubwayInfoUseCase { + private let repository: SubwayInfoRepository + + init(repository: SubwayInfoRepository) { + self.repository = repository + } + + // 실시간 버스 정보 조회 + func subwayRealTimeInfo(_ request: SubwayRealTimeInfoRequest) async throws -> [SubwayRealTimeInfo] { + let dtos = try await repository.subwayRealTimeInfo(request) + return dtos.compactMap { $0.toEntity() } + } +} diff --git a/Atcha-iOS/Presentation/BusDetail/BusRouteCell.swift b/Atcha-iOS/Presentation/BusDetail/BusRouteCell.swift index 5da3fe46..bce811fb 100644 --- a/Atcha-iOS/Presentation/BusDetail/BusRouteCell.swift +++ b/Atcha-iOS/Presentation/BusDetail/BusRouteCell.swift @@ -222,7 +222,7 @@ class BusRouteCell: UICollectionViewCell { "\(updated.toHourMinuteSecondString) (\(remainText))", color: AtchaColor.Etc.remainTime ) - } else if updated > 0 { // 3분 이하 → "곧 도착" + } else if updated > 0 { // 2분 이하 → "곧 도착" let congestionText = congestion?.displayText let remainText: String if let congestionText, !congestionText.isEmpty { @@ -262,7 +262,17 @@ class BusRouteCell: UICollectionViewCell { isLastStation: isLastStation, color: .mainline ) - case .일반, .외곽, .지선: // regular + case .일반: // general + realTimeBusImageView.image = UIImage.busGeneral20Px + configureRouteLine( + isCurrentStation: isCurrentStation, + isTurnPoint: isTurnPoint, + isAfterTurnPoint: isAfterTurnPoint, + isFirstStation: isFirstStation, + isLastStation: isLastStation, + color: .general + ) + case .외곽, .지선: // regular realTimeBusImageView.image = UIImage.busRegular20Px configureRouteLine( isCurrentStation: isCurrentStation, diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseStepItemView.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseStepItemView.swift index 17facd48..92ca9422 100644 --- a/Atcha-iOS/Presentation/Course/CourseSearch/CourseStepItemView.swift +++ b/Atcha-iOS/Presentation/Course/CourseSearch/CourseStepItemView.swift @@ -72,8 +72,6 @@ final class CourseStepItemView: UIView { isHidden = false - stationLabel.attributedText = AtchaFont.R_12(leg.start?.name ?? "", color: AtchaColor.white) - busNumberLabel.isHidden = true endStationLabel.isHidden = true endPointView.isHidden = true @@ -85,6 +83,8 @@ final class CourseStepItemView: UIView { switch leg.mode { case .bus: + stationLabel.attributedText = AtchaFont.R_12(leg.start?.name ?? "", color: AtchaColor.white) + let key = leg.type ?? "0" let imageName = busIcon[key] ?? busDefaultIcon trafficImageView.image = UIImage(named: imageName) @@ -138,13 +138,18 @@ final class CourseStepItemView: UIView { } } case .subway: + let startText = stationText(leg.start?.name) + stationLabel.attributedText = AtchaFont.R_12(startText, color: AtchaColor.white) + let key = leg.type ?? "0" let imageName = subwayIcon[key] ?? subwayDefaultIcon trafficImageView.image = UIImage(named: imageName) if isLast { endStationLabel.isHidden = false - endStationLabel.attributedText = AtchaFont.R_12(leg.end?.name ?? "", color: AtchaColor.white) + + let endText = stationText(leg.end?.name) + endStationLabel.attributedText = AtchaFont.R_12(endText, color: AtchaColor.white) endStationLabel.snp.remakeConstraints { make in make.leading.equalTo(stationLabel.snp.leading) @@ -179,6 +184,7 @@ final class CourseStepItemView: UIView { } default: trafficImageView.image = nil + stationLabel.attributedText = AtchaFont.R_12(leg.start?.name ?? "", color: AtchaColor.white) stationLabel.snp.remakeConstraints { make in make.leading.equalTo(trafficImageView.snp.trailing).offset(8) make.top.equalTo(trafficImageView.snp.top) @@ -187,4 +193,10 @@ final class CourseStepItemView: UIView { } } } + + private func stationText(_ name: String?) -> String { + let n = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !n.isEmpty else { return "" } + return n.hasSuffix("역") ? n : "\(n)역" + } } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift index cc5aed7c..f6c48a17 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift @@ -49,9 +49,9 @@ final class DetailRouteBusCell: UICollectionViewCell { private let stationListStackView = UIStackView() private let busTimerFirstLabel: UILabel = UILabel() - private let busTimerSecondLabel: UILabel = UILabel() +// private let busTimerSecondLabel: UILabel = UILabel() private lazy var busTimerStackView: UIStackView = { - let stack = UIStackView(arrangedSubviews: [busTimerFirstLabel, busTimerSecondLabel]) + let stack = UIStackView(arrangedSubviews: [busTimerFirstLabel]) stack.axis = .vertical stack.alignment = .leading stack.spacing = 4 @@ -80,6 +80,8 @@ final class DetailRouteBusCell: UICollectionViewCell { var currentLegTrafficInfo: LegTrafficInfo? = nil var currentBusInfo: [RealTimeBusArrival] = [] + private var isArrivedEffectOn = false + override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -93,13 +95,41 @@ final class DetailRouteBusCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + // MARK: - 수정 1: prepareForReuse에서 constraint 상태 리셋 추가 override func prepareForReuse() { super.prepareForReuse() animationView.isHidden = true animationView.stopAnimation() backgroundColor = .clear - busTimerStackView.isHidden = true + + isExpanded = false + stationListStackView.isHidden = true + stationListStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + stationListStackViewTopConstraint?.isActive = false + stationListStackViewBottomConstraint?.isActive = false + endLabelTopConstraintWithoutStack?.isActive = true + } + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + setNeedsLayout() + layoutIfNeeded() + + let targetSize = CGSize( + width: layoutAttributes.frame.width, + height: UIView.layoutFittingCompressedSize.height + ) + let size = contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + let newAttributes = layoutAttributes + newAttributes.frame.size.height = ceil(size.height) + return newAttributes } private func setupUI() { @@ -134,7 +164,7 @@ final class DetailRouteBusCell: UICollectionViewCell { private func setupInitialConstraintState() { stationListStackViewTopConstraint?.isActive = false stationListStackViewBottomConstraint?.isActive = false - endLabelTopConstraintWithoutStack?.isActive = true // ✅ isExpanded = false 상태 + endLabelTopConstraintWithoutStack?.isActive = true } private func setupLineImageView() { @@ -177,6 +207,8 @@ final class DetailRouteBusCell: UICollectionViewCell { } } + // DetailRouteBusCell.swift + private func setupInfoConstrains() { busBadgeView.snp.makeConstraints { make in make.leading.equalTo(startLabel.snp.leading) @@ -195,8 +227,15 @@ final class DetailRouteBusCell: UICollectionViewCell { stationListStackView.snp.makeConstraints { $0.leading.equalTo(startLabel) - stationListStackViewTopConstraint = $0.top.equalTo(summaryView.snp.bottom).offset(16).constraint - stationListStackViewBottomConstraint = $0.bottom.equalTo(endLabel.snp.top).offset(-28).constraint + stationListStackViewTopConstraint = $0.top + .equalTo(summaryView.snp.bottom) + .offset(16) + .constraint + stationListStackViewBottomConstraint = $0.bottom + .equalTo(endStackView.snp.top) // ← endLabel 대신 endStackView 기준으로 변경 + .offset(-28) + .priority(.high) + .constraint } } @@ -208,9 +247,13 @@ final class DetailRouteBusCell: UICollectionViewCell { make.edges.equalToSuperview().inset(10) } endStackView.snp.makeConstraints { make in - endLabelTopConstraintWithoutStack = make.top.equalTo(summaryView.snp.bottom).offset(36).constraint + endLabelTopConstraintWithoutStack = make.top + .equalTo(summaryView.snp.bottom) + .offset(36) + .priority(.high) + .constraint make.horizontalEdges.equalToSuperview().offset(16) - make.bottom.equalToSuperview() + make.bottom.equalToSuperview().priority(.high) make.height.equalTo(36) } } @@ -262,96 +305,6 @@ final class DetailRouteBusCell: UICollectionViewCell { endLabel.attributedText = endCombinedLabel } - func setupBusRealTimeInfo(info: LegTrafficInfo?, busInfo: [RealTimeBusArrival]) { - if !isCurrentTimeBetween(startTime: info?.startTime, - endTime: info?.endTime) { - return - } - - busTimerStackView.isHidden = false - - countdownTimer?.invalidate() - reloadTimer?.invalidate() - - currentBusInfo = busInfo - - guard !busInfo.isEmpty else { - busTimerStackView.isHidden = false - busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("정보 없음", color: .gray300) - busTimerSecondLabel.text = "" - return - } - - currentBusInfo = currentBusInfo.filter { $0.remainingTime ?? 0 > 0 } - if currentBusInfo.isEmpty { - busTimerStackView.isHidden = false - busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) - return - } - - busTimerStackView.isHidden = false - updateBusTimerLabels() - - // 1초마다 시간 감소 - countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.decrementRemainingTime() - } - - // 30초마다 재요청 - reloadTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in - self?.getNewBusRealTime?() - } - } - - private func decrementRemainingTime() { - for i in 0.. 0 else { continue } - currentBusInfo[i].remainingTime = time - 1 - } - - currentBusInfo = currentBusInfo.filter { - if let time = $0.remainingTime { - return time > 0 - } - return false - } - - if currentBusInfo.isEmpty { - busTimerStackView.isHidden = true - countdownTimer?.invalidate() - } else { - updateBusTimerLabels() - } - } - - private func updateBusTimerLabels() { - func labelText(for info: RealTimeBusArrival) -> NSAttributedString { - if info.busStatus == .end { - return AtchaFont.B6_R_14("운행 종료", color: .gray) - } - - guard let remaining = info.remainingTime else { - return AtchaFont.B6_R_14("정보 없음", color: .gray) - } - - if remaining <= 90 { - return AtchaFont.B6_R_14("곧 도착", color: .widearea) - } else { - return AtchaFont.B6_R_14(formatSecondsToMinutesAndSeconds(remaining), color: .widearea) - } - } - - switch currentBusInfo.count { - case 2: - busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) - busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) - case 1: - busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) - busTimerSecondLabel.text = "" - default: - busTimerStackView.isHidden = true - } - } private func isCurrentTimeBetween(startTime: String?, endTime: String?) -> Bool { guard let startTime, let endTime else { return false } @@ -392,19 +345,19 @@ final class DetailRouteBusCell: UICollectionViewCell { } func isNowUserLocationArrived() { - // 해당시간에 들어와야 애니메이션 실행 합니다. + if isArrivedEffectOn { return } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 } - - private func formatSecondsToMinutesAndSeconds(_ seconds: Int?) -> String { - guard let seconds = seconds else { return "시간 없음" } - - let minutes = seconds / 60 - let remainingSeconds = seconds % 60 - - return "\(minutes)분 \(remainingSeconds)초" + + func stopArrivedEffectIfNeeded() { + guard isArrivedEffectOn else { return } + isArrivedEffectOn = false + animationView.stopAnimation() + animationView.isHidden = true + backgroundColor = .clear } } @@ -420,19 +373,25 @@ extension DetailRouteBusCell { @objc private func handleSummaryButton() { isExpanded.toggle() - stationListStackView.isHidden = !isExpanded + if isExpanded { + endLabelTopConstraintWithoutStack?.isActive = false + stationListStackViewTopConstraint?.isActive = true + stationListStackViewBottomConstraint?.isActive = true + } else { + stationListStackViewTopConstraint?.isActive = false + stationListStackViewBottomConstraint?.isActive = false + endLabelTopConstraintWithoutStack?.isActive = true + } + + stationListStackView.isHidden = !isExpanded stationListStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } addStationNameLabel(info: stationInfos) - UIView.animate(withDuration: 0.3) { [weak self] in - guard let self else { return } - stationListStackViewTopConstraint?.isActive = isExpanded - stationListStackViewBottomConstraint?.isActive = isExpanded - endLabelTopConstraintWithoutStack?.isActive = !isExpanded - self.layoutIfNeeded() - } - didTapSummary?() + self.contentView.setNeedsLayout() + self.contentView.layoutIfNeeded() + + didTapSummary?() // ← 이 안에서 applySnapshot() 호출됨 } @objc private func handleBusBackTapped() { @@ -462,3 +421,132 @@ extension DetailRouteBusCell { } } } + +// MARK: - Bus Realtime + +extension DetailRouteBusCell { + func setupBusRealTimeInfo(info: LegTrafficInfo?, busInfo: [RealTimeBusArrival]) { + busTimerStackView.isHidden = false + + let filtered = busInfo + .filter { ($0.remainingTime ?? -1) > 0 } + + currentBusInfo = filtered + + guard !busInfo.isEmpty else { + stopCountdownTimer() + busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) + return + } + + guard !currentBusInfo.isEmpty else { + stopCountdownTimer() + busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) + return + } + + updateBusTimerLabels() + startCountdownTimerIfNeeded() + } + + private func startCountdownTimerIfNeeded() { + // 이미 돌고 있으면 유지 + if countdownTimer != nil { return } + + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.decrementRemainingTime() + } + + // 스크롤 중에도 잘 돌게 common mode 추천 + if let timer = countdownTimer { + RunLoop.main.add(timer, forMode: .common) + } + } + + private func stopCountdownTimer() { + countdownTimer?.invalidate() + countdownTimer = nil + } + + // 1초마다 감소 + private func decrementRemainingTime() { + guard !currentBusInfo.isEmpty else { + stopCountdownTimer() + return + } + + for i in 0.. 0 } + + if currentBusInfo.isEmpty { + stopCountdownTimer() + busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) +// busTimerSecondLabel.text = "" + return + } + + updateBusTimerLabels() + } + + private func updateBusTimerLabels() { + + func labelText(for info: RealTimeBusArrival) -> NSAttributedString { + if info.busStatus == .end { + return AtchaFont.B6_R_14("운행 종료", color: .gray) + } + + guard let remaining = info.remainingTime else { + return AtchaFont.B6_R_14("", color: .gray300) + } + + if remaining <= 120 { + return AtchaFont.B6_R_14("곧 도착", color: .widearea) + } else { + return AtchaFont.B6_R_14(formatSecondsToHMS(remaining), color: .widearea) + } + } + + switch currentBusInfo.count { + case 2: + busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) +// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + case 1: + busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) +// busTimerSecondLabel.text = "" + default: + // 3개 이상이면 우선 2개만 보여주거나, 숨기지 말고 2개만 보여주자 + busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) +// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + } + } + + 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분도 보여줄지 결정 가능) + if m > 0 { + return "\(h)시간 \(m)분 \(s)초" + } else { + return "\(h)시간 \(s)초" + } + } + + if m > 0 { + // 시가 없으면 분/초 + return "\(m)분 \(s)초" + } + + // 분도 없으면 초만 + return "\(s)초" + } +} diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index 3fa68b6f..d6a438bd 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -57,6 +57,22 @@ final class DetailRouteSubwayCell: UICollectionViewCell { private var stationListStackViewBottomConstraint: Constraint? private var endLabelTopConstraintWithoutStack: Constraint? + private let subwayDirectionLabel = UILabel() + private let subwayTimerLabel = UILabel() + private lazy var subwayRealtimeStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [subwayDirectionLabel, subwayTimerLabel]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 8 + return stack + }() + + private var subwayCountdownTimer: Timer? + private var currentRemainingSec: Int? + + var currentLegTrafficInfo: LegTrafficInfo? = nil + private var isArrivedEffectOn = false + override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -75,6 +91,40 @@ final class DetailRouteSubwayCell: UICollectionViewCell { animationView.isHidden = true animationView.stopAnimation() backgroundColor = .clear + + isExpanded = false + stationListStackView.isHidden = true + stationListStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + stationListStackViewTopConstraint?.isActive = false + stationListStackViewBottomConstraint?.isActive = false + endLabelTopConstraintWithoutStack?.isActive = true + + subwayDirectionLabel.text = nil + subwayTimerLabel.text = nil + subwayCountdownTimer?.invalidate() + subwayCountdownTimer = nil + currentRemainingSec = nil + } + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + setNeedsLayout() + layoutIfNeeded() + + let targetSize = CGSize( + width: layoutAttributes.frame.width, + height: UIView.layoutFittingCompressedSize.height + ) + let size = contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + let newAttributes = layoutAttributes + newAttributes.frame.size.height = ceil(size.height) + return newAttributes } private func setupUI() { @@ -89,7 +139,7 @@ final class DetailRouteSubwayCell: UICollectionViewCell { stickContainerView, startStackView, endStackView, - subwayBadgeLabel, + subwayRealtimeStackView, summaryView, stationListStackView) @@ -106,6 +156,10 @@ final class DetailRouteSubwayCell: UICollectionViewCell { stationListStackView.axis = .vertical stationListStackView.spacing = 10 stationListStackView.isHidden = true + + subwayDirectionLabel.numberOfLines = 1 + subwayTimerLabel.numberOfLines = 1 + } private func setupInitialConstraintState() { @@ -157,20 +211,33 @@ final class DetailRouteSubwayCell: UICollectionViewCell { } private func setupInfoConstrains() { - subwayBadgeLabel.snp.makeConstraints { make in + // subwayBadgeLabel.snp.makeConstraints { make in + // make.leading.equalTo(startLabel.snp.leading) + // make.top.equalTo(startStackView.snp.bottom).offset(8) + // } + + subwayRealtimeStackView.snp.makeConstraints { make in make.leading.equalTo(startLabel.snp.leading) make.top.equalTo(startStackView.snp.bottom).offset(8) } summaryView.snp.makeConstraints { make in make.leading.equalTo(startLabel.snp.leading) - make.top.equalTo(subwayBadgeLabel.snp.bottom).offset(16) + make.top.equalTo(subwayRealtimeStackView.snp.bottom).offset(16) } stationListStackView.snp.makeConstraints { $0.leading.equalTo(startLabel) - stationListStackViewTopConstraint = $0.top.equalTo(summaryView.snp.bottom).offset(16).constraint - stationListStackViewBottomConstraint = $0.bottom.equalTo(endLabel.snp.top).offset(-28).constraint + stationListStackViewTopConstraint = $0.top + .equalTo(summaryView.snp.bottom) + .offset(16) + .constraint + + stationListStackViewBottomConstraint = $0.bottom + .equalTo(endStackView.snp.top) + .offset(-28) + .priority(.high) + .constraint } } @@ -182,11 +249,17 @@ final class DetailRouteSubwayCell: UICollectionViewCell { make.edges.equalToSuperview().inset(10) } endStackView.snp.makeConstraints { make in - endLabelTopConstraintWithoutStack = make.top.equalTo(summaryView.snp.bottom).offset(36).constraint + endLabelTopConstraintWithoutStack = make.top + .equalTo(summaryView.snp.bottom) + .offset(36) + .priority(.high) // ✅ high 우선순위 + .constraint make.horizontalEdges.equalToSuperview().offset(16) - make.bottom.equalToSuperview() + make.bottom.equalToSuperview().priority(.high) // ✅ high 우선순위 make.height.equalTo(36) } + + endLabelTopConstraintWithoutStack?.isActive = false } private func setupConstraints() { @@ -198,6 +271,8 @@ final class DetailRouteSubwayCell: UICollectionViewCell { } func configure(info: LegTrafficInfo?) { + currentLegTrafficInfo = info + stationInfos = [] guard let info = info, let passStopList = info.passStopList, @@ -229,9 +304,9 @@ 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() +// } } private func addStationNameLabel(info: [PassStopList]) { @@ -245,11 +320,20 @@ final class DetailRouteSubwayCell: UICollectionViewCell { } func isNowUserLocationArrived() { - // 해당시간에 들어와야 애니메이션 실행 합니다. + if isArrivedEffectOn { return } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 } + + func stopArrivedEffectIfNeeded() { + guard isArrivedEffectOn else { return } + isArrivedEffectOn = false + animationView.stopAnimation() + animationView.isHidden = true + backgroundColor = .clear + } private func isCurrentTimeBetween(startTime: String?, endTime: String?) -> Bool { guard let startTime, let endTime else { return false } @@ -299,16 +383,150 @@ extension DetailRouteSubwayCell { @objc private func handleSummaryButton() { isExpanded.toggle() - stationListStackView.isHidden = !isExpanded + if isExpanded { + endLabelTopConstraintWithoutStack?.isActive = false + stationListStackViewTopConstraint?.isActive = true + stationListStackViewBottomConstraint?.isActive = true + } else { + stationListStackViewTopConstraint?.isActive = false + stationListStackViewBottomConstraint?.isActive = false + endLabelTopConstraintWithoutStack?.isActive = true + } + + stationListStackView.isHidden = !isExpanded stationListStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } addStationNameLabel(info: stationInfos) - stationListStackViewTopConstraint?.isActive = isExpanded - stationListStackViewBottomConstraint?.isActive = isExpanded - endLabelTopConstraintWithoutStack?.isActive = !isExpanded + contentView.setNeedsLayout() + contentView.layoutIfNeeded() - UIView.animate(withDuration: 0.3) { self.layoutIfNeeded() } didTapSummary?() } } + +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 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분도 보여줄지 결정 가능) + if m > 0 { + return "\(h)시간 \(m)분 \(s)초" + } else { + return "\(h)시간 \(s)초" + } + } + + if m > 0 { + // 시가 없으면 분/초 + return "\(m)분 \(s)초" + } + + // 분도 없으면 초만 + return "\(s)초" + } +} diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteWalkCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteWalkCell.swift index 886942bc..a23fd5e3 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteWalkCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteWalkCell.swift @@ -18,6 +18,8 @@ final class DetailRouteWalkCell: UICollectionViewCell { private let timeLabel: UILabel = UILabel() private let distanceLabel: UILabel = UILabel() private let summaryLabel: UILabel = UILabel() + private var isArrivedEffectOn = false + var currentLegTrafficInfo: LegTrafficInfo? = nil override init(frame: CGRect) { super.init(frame: frame) @@ -32,10 +34,12 @@ final class DetailRouteWalkCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() - animationView.isHidden = true - animationIconImageView.isHidden = true - animationView.stopAnimation() - backgroundColor = .clear + currentLegTrafficInfo = nil + stopArrivedEffectIfNeeded() +// animationView.isHidden = true +// animationIconImageView.isHidden = true +// animationView.stopAnimation() +// backgroundColor = .clear } private func setupUI() { @@ -74,6 +78,8 @@ final class DetailRouteWalkCell: UICollectionViewCell { } func configure(info: LegTrafficInfo?) { + currentLegTrafficInfo = info + guard let sectionTime = info?.sectionTime else { return } let timeText = AtchaFont.B6_R_14("\(sectionTime) 걷기", color: .gray200) let distanceText = AtchaFont.B6_R_14(" \(info?.distance ?? 0)m", color: .gray500) @@ -82,11 +88,34 @@ final class DetailRouteWalkCell: UICollectionViewCell { combined.append(distanceText) summaryLabel.attributedText = combined - if isCurrentTimeBetween(startTime: info?.startTime, endTime: info?.endTime) { - isNowUserLocationArrived() - } } + func isNowUserLocationArrived() { + if isArrivedEffectOn { return } + isArrivedEffectOn = true + + animationIconImageView.isHidden = false + animationView.isHidden = false + animationView.startAnimationIfNeeded(forceRestart: true) + backgroundColor = UIColor.opacity100 + } + + func stopArrivedEffectIfNeeded() { + guard isArrivedEffectOn else { + animationView.stopAnimation() + animationView.isHidden = true + animationIconImageView.isHidden = true + backgroundColor = .clear + return + } + + isArrivedEffectOn = false + animationView.stopAnimation() + animationView.isHidden = true + animationIconImageView.isHidden = true + backgroundColor = .clear + } + private func isCurrentTimeBetween(startTime: String?, endTime: String?) -> Bool { guard let startTime, let endTime else { return false } @@ -124,11 +153,4 @@ final class DetailRouteWalkCell: UICollectionViewCell { return todayNow >= todayStart && todayNow < todayEnd } } - - func isNowUserLocationArrived() { - animationIconImageView.isHidden = false - animationView.isHidden = false - animationView.startAnimationIfNeeded(forceRestart: true) - backgroundColor = UIColor.opacity100 - } } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift index 53ebcd73..29e2f693 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift @@ -40,8 +40,10 @@ final class DetailRouteInfoBottomView: UIView { private let handleView: UIView = UIView() private var startAddress: String = "" private var busRealTimeInfo: [[RealTimeBusArrival]] = [] + private var subwayRealTimeInfo: [SubwayRealTimeInfo] = [] var onBusDetail: ((BusDetailInfo) -> Void)? var getNewBusRealTime: (() -> Void)? + private var currentNearLegIDs: Set = [] override init(frame: CGRect) { super.init(frame: frame) @@ -133,7 +135,36 @@ final class DetailRouteInfoBottomView: UIView { // v2 func setupBusTimerLabel(_ time: [[RealTimeBusArrival]]) { busRealTimeInfo = time - collectionView.reloadData() + + for cell in collectionView.visibleCells { + guard let busCell = cell as? DetailRouteBusCell else { continue } + + // 현재 셀이 어떤 route였는지 알아야 다시 매칭 가능 + guard let route = busCell.currentLegTrafficInfo?.route else { continue } + let cellKey = route.components(separatedBy: ":").last ?? route + + let matchedInfo = busRealTimeInfo.first(where: { list in + guard let apiRaw = list.first?.routeName else { return false } + let apiKey = apiRaw.components(separatedBy: ":").last ?? apiRaw + return apiKey == cellKey + }) ?? [] + + + guard !matchedInfo.isEmpty else { continue } + busCell.setupBusRealTimeInfo(info: busCell.currentLegTrafficInfo, busInfo: matchedInfo) + } + } + + func setupSubwayTimerLabel(_ infos: [SubwayRealTimeInfo]) { + subwayRealTimeInfo = infos + + for cell in collectionView.visibleCells { + guard let subwayCell = cell as? DetailRouteSubwayCell else { continue } + guard let route = subwayCell.currentLegTrafficInfo?.route, !route.isEmpty else { continue } + + let matched = subwayRealTimeInfo.filter { $0.routeName == route } + subwayCell.setupSubwayRealTime(routeName: route, infos: matched) + } } func setupStartAddress(_ address: String) { @@ -141,9 +172,13 @@ final class DetailRouteInfoBottomView: UIView { collectionView.reloadData() } - private func applySnapshot(animatingDifferences: Bool = true) { + private func applySnapshot(animatingDifferences: Bool = false) { guard collectionView.dataSource != nil else { return } - dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in + guard let self else { return } + self.updateProximityHighlight(nearLegIDs: self.currentNearLegIDs) + } } } @@ -202,8 +237,8 @@ extension DetailRouteInfoBottomView { case .transport(let transportMode): switch transportMode { case .walk: height = 74 - case .bus: height = 175 - case .subway: height = 154 + case .bus: height = 200 + case .subway: height = 200 case .unknown: height = 38 } case .end: height = 58 @@ -250,7 +285,8 @@ extension DetailRouteInfoBottomView { for: indexPath ) as! DetailRouteBusCell cell.didTapSummary = { [weak self] in - self?.applySnapshot() + // self?.applySnapshot() + self?.collectionView.collectionViewLayout.invalidateLayout() } cell.getNewBusRealTime = { [weak self] in self?.getNewBusRealTime?() @@ -279,11 +315,15 @@ extension DetailRouteInfoBottomView { } cell.configure(info: item.info) - // 이 부분에서 나눠서 값을 넣어줘야 해 - - // cell.setupBusRealTimeInfo(busInfo: self.busRealTimeInfo) - if let routeName = item.info?.route { - let matchedInfo = self.busRealTimeInfo.first(where: { $0.first?.routeName == routeName }) ?? [] + if let route = item.info?.route, !route.isEmpty { + let cellKey = route.components(separatedBy: ":").last ?? route + + let matchedInfo = self.busRealTimeInfo.first(where: { list in + guard let apiRaw = list.first?.routeName else { return false } + let apiKey = apiRaw.components(separatedBy: ":").last ?? apiRaw + return apiKey == cellKey + }) ?? [] + cell.setupBusRealTimeInfo(info: item.info, busInfo: matchedInfo) } @@ -295,9 +335,20 @@ extension DetailRouteInfoBottomView { for: indexPath ) as! DetailRouteSubwayCell cell.didTapSummary = { [weak self] in - self?.applySnapshot() + // self?.applySnapshot() + self?.collectionView.collectionViewLayout.invalidateLayout() } cell.configure(info: item.info) + + if let route = item.info?.route, !route.isEmpty { + let key = route.components(separatedBy: ":").last ?? route + let matched = self.subwayRealTimeInfo.filter { info in + let apiRaw = info.routeName ?? "" + let apiKey = apiRaw.components(separatedBy: ":").last ?? apiRaw + return apiKey == key + } + cell.setupSubwayRealTime(routeName: route, infos: matched) + } return cell default: @@ -306,6 +357,38 @@ extension DetailRouteInfoBottomView { } } } + + func updateProximityHighlight(nearLegIDs: Set) { + currentNearLegIDs = nearLegIDs + for cell in collectionView.visibleCells { + if let busCell = cell as? DetailRouteBusCell, + let leg = busCell.currentLegTrafficInfo { + if nearLegIDs.contains(leg.id) { + busCell.isNowUserLocationArrived() + } else { + busCell.stopArrivedEffectIfNeeded() + } + } + + if let subwayCell = cell as? DetailRouteSubwayCell, + let leg = subwayCell.currentLegTrafficInfo { + if nearLegIDs.contains(leg.id) { + subwayCell.isNowUserLocationArrived() + } else { + subwayCell.stopArrivedEffectIfNeeded() + } + } + + if let walkCell = cell as? DetailRouteWalkCell, + let leg = walkCell.currentLegTrafficInfo { + if nearLegIDs.contains(leg.id) { + walkCell.isNowUserLocationArrived() + } else { + walkCell.stopArrivedEffectIfNeeded() + } + } + } + } } // MARK: - Gesture diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteSummaryView.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteSummaryView.swift index 019114d9..c6ba43ef 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteSummaryView.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteSummaryView.swift @@ -42,14 +42,15 @@ final class DetailRouteSummaryView: UIView { } arrowImageView.snp.makeConstraints { make in - make.centerY.equalTo(summaryLabel) make.leading.equalTo(summaryLabel.snp.trailing).offset(4) make.trailing.equalToSuperview() - make.width.height.equalTo(10) + make.top.bottom.equalToSuperview() + make.width.equalTo(10) } } func configure(duration: String, stops: Int) { summaryLabel.attributedText = AtchaFont.B7_M_13("\(duration), \(stops - 1)개 정류장 이동", color: .white) + arrowImageView.isHidden = false } } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 66732926..2cede176 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -9,6 +9,8 @@ import UIKit import CoreLocation import TMapSDK import VSMSDK +import MapKit +import Combine final class DetailRouteViewController: BaseViewController, TMapWrapperDelegate { @@ -24,6 +26,14 @@ final class DetailRouteViewController: BaseViewController, private var registerGradient = CAGradientLayer() private let alarmRegisterButton: AtchaButton = AtchaButton(text: "막차 알람 받기", size: .h52, style: .filled(.primary), image: .bellOutlined) + private var isFollowingUser = false + private var shouldCenterToCurrentLocationOnce = false + private var lastRouteFitApplied = false + private var isAlarmFired: Bool { + UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + } + private var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] + override func viewDidLoad() { super.viewDidLoad() @@ -31,12 +41,35 @@ final class DetailRouteViewController: BaseViewController, setupUI() setupAutoLayout() bindView() + bindFollowLogic() + installMapUserGestureDetector() +//#if DEBUG +//// ✅ 서울아산병원(대략) +//viewModel.mockLocation = CLLocationCoordinate2D(latitude:37.566956, longitude: 126.979406) +//viewModel.currentLocation = viewModel.mockLocation +//#endif + } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - // 레이아웃이 확정된 뒤에 호출해야 현재 프레임(절반 화면) 기준으로 정확히 맞춰짐 - mapContainerView.adjustMapToFit(coordinates: allCoordinates) + + if !isAlarmFired && !allCoordinates.isEmpty { + mapContainerView.adjustMapToFit(coordinates: allCoordinates) + + 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) + } + } + registerGradient.frame = registerContainer.bounds } @@ -44,6 +77,11 @@ final class DetailRouteViewController: BaseViewController, AmplitudeManager.shared.trackScreen(.course_detail) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyMapModeOnAppearOrAlarmChange() + } + private func setupUI(context: DetailRouteContext) { switch context { case .beforeRegister: @@ -110,13 +148,6 @@ final class DetailRouteViewController: BaseViewController, backButton.setCornerRadius(18) backButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside) - // relaodButton.setImage(UIImage.refreshOutlined, for: .normal) - // relaodButton.tintColor = .white - // relaodButton.backgroundColor = .gray600 - // relaodButton.clipsToBounds = true - // relaodButton.setCornerRadius(24) - // relaodButton.addTarget(self, action: #selector(didTapReload), for: .touchUpInside) - refreshButton.isUserInteractionEnabled = true let tap = UITapGestureRecognizer(target: self, action: #selector(didTapReload)) refreshButton.addGestureRecognizer(tap) @@ -134,11 +165,6 @@ final class DetailRouteViewController: BaseViewController, } private func setupAutoLayout() { - mapContainerView.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview() - make.top.equalToSuperview() - make.height.equalToSuperview().multipliedBy(0.65) - } backButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(12) @@ -158,13 +184,23 @@ final class DetailRouteViewController: BaseViewController, make.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.snp.bottom).inset(40) } + + mapContainerView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.top.equalToSuperview() + make.height.equalToSuperview().multipliedBy(0.65) + } } private func bindView() { viewModel.$legTrafficInfo .receive(on: RunLoop.main) .compactMap { $0 } - .sink { [weak self] infos in self?.bottomSheet.setupRouteInfo(infos) } + .sink { [weak self] infos in + guard let self else { return } + self.bottomSheet.setupRouteInfo(infos) + self.bottomSheet.updateProximityHighlight(nearLegIDs: self.viewModel.nearLegIDs) + } .store(in: &cancellables) viewModel.$busRealTimeInfos @@ -175,25 +211,23 @@ final class DetailRouteViewController: BaseViewController, } .store(in: &cancellables) + viewModel.$subwayRealTimeInfos + .receive(on: RunLoop.main) + .sink { [weak self] infos in + self?.bottomSheet.setupSubwayTimerLabel(infos) + } + .store(in: &cancellables) + viewModel.$address .receive(on: RunLoop.main) .compactMap { $0 } .sink { [weak self] address in self?.bottomSheet.setupStartAddress(address) } .store(in: &cancellables) - viewModel.$currentLocation - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [weak self] location in - guard let self else { return } - mapContainerView.updateUserMarker(location: location) - let offsetLatitude = location.latitude - 0.0003 - let offsetLocation = CLLocationCoordinate2D( - latitude: offsetLatitude, - longitude: location.longitude - ) - - mapContainerView.setupZoomCenter(location: offsetLocation) + viewModel.$nearLegIDs + .receive(on: RunLoop.main) + .sink { [weak self] near in + self?.bottomSheet.updateProximityHighlight(nearLegIDs: near) } .store(in: &cancellables) @@ -212,6 +246,106 @@ final class DetailRouteViewController: BaseViewController, self?.setupUI(context: ctx) } .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) + } + } + } + + DispatchQueue.main.async { [weak self] in + self?.legPolylineById = dict + } + } + .store(in: &cancellables) + } + + private func bindFollowLogic() { + // 위치 + viewModel.$currentLocation + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] coord in + guard let self else { return } + + // 1) 유저 마커 업데이트 (메인) + self.mapContainerView.updateUserMarker(location: coord) + + // 2) 지도 follow 로직 (메인) + if self.isAlarmFired { + // 알람 울린 후엔 계속 따라감 + self.mapContainerView.setupZoomCenter(location: coord) + } else if self.isFollowingUser { + // 알람 전: following 켰을 때만 따라감 + self.mapContainerView.setupZoomCenter(location: coord) + } + // else: fit 유지 (건드리지 않음) + + // 3) 근처(150m) 지나가면 반짝임 계산 (백그라운드) + let threshold: CLLocationDistance = 150 + + let polylines = self.legPolylineById + 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개만 남김 + 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 } + .removeDuplicates(by: { abs($0 - $1) < 2 }) + .receive(on: RunLoop.main) + .sink { [weak self] heading in + guard let self else { return } + guard isFollowingUser || isAlarmFired else { return } + mapContainerView.setHeading(heading) + } + .store(in: &cancellables) } private func addRouteLine(infos: [LegPathInfo]) { @@ -314,6 +448,25 @@ final class DetailRouteViewController: BaseViewController, } } + private func applyMapModeOnAppearOrAlarmChange() { + if isAlarmFired { + // (2) 알람 울린 후: 무조건 따라가기 ON + isFollowingUser = true + shouldCenterToCurrentLocationOnce = true + viewModel.startHeading() + lastRouteFitApplied = false + } else { + // (1) 알람 울리기 전: 경로 전체 보이기 고정 + isFollowingUser = false + shouldCenterToCurrentLocationOnce = false + viewModel.stopHeading() + + // 화면 재진입 때마다 fit으로 "다시" 고정하려면 매번 호출 + mapContainerView.adjustMapToFit(coordinates: allCoordinates) + lastRouteFitApplied = true + } + } + deinit { activePermissionToast?.hideImmediately() mapContainerView.deinitMapView() @@ -328,11 +481,31 @@ extension DetailRouteViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() + + isFollowingUser = true + shouldCenterToCurrentLocationOnce = true + viewModel.startHeading() + viewModel.setupLocation() + + mapContainerView.snp.remakeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.top.equalToSuperview() + make.bottom.equalToSuperview().inset(200) + } } @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) } @@ -416,3 +589,74 @@ extension DetailRouteViewController { present(popupVC, animated: false) } } + +extension DetailRouteViewController { + private func distanceToPolylineMeters( + point: CLLocationCoordinate2D, + 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 + } +} + +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 + shouldCenterToCurrentLocationOnce = false + viewModel.stopHeading() + } + } +} diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index d196d35e..d043f990 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -16,6 +16,7 @@ enum DetailRouteContext { final class DetailRouteViewModel: BaseViewModel { private let busInfoUseCase: BusInfoUseCase + private let subwayInfoUseCase: SubwayInfoUseCase private let authorizationUseCase: RequestLocationAuthorizationUseCase private let streamUseCase: ObserveLocationStreamUseCase private let alarmUseCase: AlarmUseCase @@ -33,13 +34,30 @@ final class DetailRouteViewModel: BaseViewModel { // @Published var busRealTimeInfo: [RealTimeBusArrival] = [] @Published var busRealTimeInfos: [[RealTimeBusArrival]] = [] + private var busRoutes: [String] = [] + private var busRealTimeMap: [String: [RealTimeBusArrival]] = [:] + private var busPollingTask: Task? + + @Published var subwayRealTimeInfos: [SubwayRealTimeInfo] = [] + private var subwayRoutes: [String] = [] + private var subwayPollingTask: Task? @Published private(set) var context: DetailRouteContext + @Published var nearLegIDs: Set = [] + + @Published var deviceHeading: CLLocationDirection? + private let headingManager = HeadingManager() + +//#if DEBUG +//@Published var mockLocation: CLLocationCoordinate2D? = nil +//#endif + init(address: String, infos: LegInfo, context: DetailRouteContext, busInfoUseCase: BusInfoUseCase, + subwayInfoUseCase: SubwayInfoUseCase, authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, alarmUseCase: AlarmUseCase) { @@ -47,6 +65,7 @@ final class DetailRouteViewModel: BaseViewModel { self.address = address self.context = context self.busInfoUseCase = busInfoUseCase + self.subwayInfoUseCase = subwayInfoUseCase self.authorizationUseCase = authorizationUseCase self.streamUseCase = streamUseCase self.alarmUseCase = alarmUseCase @@ -57,18 +76,44 @@ final class DetailRouteViewModel: BaseViewModel { } func fetchInfo() { - self.legtPathInfo = infos.pathInfo - self.legTrafficInfo = infos.trafficInfo - let busDetailInfo = infos.busInfo.filter { $0.routeName?.isEmpty == false } + legtPathInfo = infos.pathInfo + legTrafficInfo = infos.trafficInfo + print("위치: \(legtPathInfo)") + + guard context == .afterReigster else { return } + // 버스 + let routes = infos.busInfo + .compactMap { $0.routeName } + .filter { !$0.isEmpty && $0.contains(":") } + busRoutes = Array(Set(routes)) // 중복 제거 + busRealTimeMap.removeAll() busRealTimeInfos = [] - busDetailInfo.forEach { info in - if let routeName = info.routeName, routeName.contains(":") { - Task { - await getBusRealTimeInfo(request: routeName) - } - } + + // 최초 1회 로드 + Task { [weak self] in + await self?.refreshAllBusRealTime() + } + + // 15초 폴링 시작 + startBusPolling() + + let subwayRoutes = Array(Set( + infos.trafficInfo + .filter { $0.mode == .subway } + .compactMap { $0.route } + .filter { !$0.isEmpty } + )) + + self.subwayRoutes = subwayRoutes + // 여기서 removeAll 하면 첫 표시가 비었다가 생길 수 있음. + // "최초 진입 때만 비우고", 폴링에서는 기존 유지가 더 안정적. + self.subwayRealTimeInfos = [] + + Task { [weak self] in + await self?.refreshAllSubwayRealTime() } + startSubwayPolling() } @MainActor @@ -78,29 +123,92 @@ final class DetailRouteViewModel: BaseViewModel { let response = try await busInfoUseCase.getBusRealTimeInfo(request) busRealTimeInfos.append(response) // busRealTimeInfo = response - print("실시간 버스 조회 성공요! : \(busRealTimeInfos)") } catch { print("실시간 버스 조회 실패요!") } } } + @MainActor + func getSubwayRealTimeInfo(routeName: String) async { + do { + let infos = try await subwayInfoUseCase.subwayRealTimeInfo(.init(routeName: routeName)) + subwayRealTimeInfos.removeAll { $0.routeName == routeName } // 기존 제거 + subwayRealTimeInfos.append(contentsOf: infos) + } catch { + print("실시간 지하철 조회 실패: \(error)") + } + } + func requestPermissionAndStartTracking() { - Task { - let status = await authorizationUseCase.askLocationPermission() - guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } - - streamTask = Task { - for await location in streamUseCase.startUpdate() { - let currentLocation = CLLocationCoordinate2D(latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude) - - self.currentLocation = currentLocation - break + Task { + let status = await authorizationUseCase.askLocationPermission() + guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } + + streamTask?.cancel() + streamTask = Task { + for await location in streamUseCase.startUpdate() { + let coord = CLLocationCoordinate2D( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + await MainActor.run { self.currentLocation = coord } + } } } } - } + + func startHeading() { + headingManager.onHeading = { [weak self] h in + DispatchQueue.main.async { self?.deviceHeading = h } + } + headingManager.start() + } + + func stopHeading() { + headingManager.stop() + } + + func stopTracking() { + streamTask?.cancel() + streamUseCase.stopUpdate() + headingManager.stop() + } + + deinit { + stopTracking() + stopBusPolling() + stopSubwayPolling() + } + +// func requestPermissionAndStartTracking() { +// Task { +// let status = await authorizationUseCase.askLocationPermission() +// guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } +// +// streamTask = Task { [weak self] in +// guard let self else { return } +// +// for await location in streamUseCase.startUpdate() { +// let real = CLLocationCoordinate2D( +// latitude: location.coordinate.latitude, +// longitude: location.coordinate.longitude +// ) +// +// #if DEBUG +// // ✅ 디버그에선 mock 있으면 그걸로 덮어씀 +// if let mock = self.mockLocation { +// self.currentLocation = mock +// } else { +// self.currentLocation = real +// } +// #else +// self.currentLocation = real +// #endif +// } +// } +// } +// } func setupLocation() { requestPermissionAndStartTracking() @@ -122,4 +230,79 @@ final class DetailRouteViewModel: BaseViewModel { } } } + + private func startBusPolling() { + stopBusPolling() + + busPollingTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + if Task.isCancelled { break } + await self.refreshAllBusRealTime() + } + } + } + + private func stopBusPolling() { + busPollingTask?.cancel() + busPollingTask = nil + } + + @MainActor + private func refreshAllBusRealTime() async { + guard !busRoutes.isEmpty else { return } + + for route in busRoutes { + do { + let response = try await busInfoUseCase.getBusRealTimeInfo(route) + busRealTimeMap[route] = response + } catch { + // 실패 시 기존 값 유지 + print("실시간 버스 조회 실패: \(route), \(error)") + } + } + + busRealTimeInfos = busRoutes.compactMap { busRealTimeMap[$0] } + } + + // MARK: - Subway Polling + + private func startSubwayPolling() { + stopSubwayPolling() + + subwayPollingTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + if Task.isCancelled { break } + await self.refreshAllSubwayRealTime() + } + } + } + + private func stopSubwayPolling() { + subwayPollingTask?.cancel() + subwayPollingTask = nil + } + + @MainActor + private func refreshAllSubwayRealTime() async { + guard !subwayRoutes.isEmpty else { return } + + for route in subwayRoutes { + do { + let infos = try await subwayInfoUseCase.subwayRealTimeInfo(.init(routeName: route)) + + // 성공한 route만 교체 (실패하면 기존 유지) + subwayRealTimeInfos.removeAll { $0.routeName == route } + subwayRealTimeInfos.append(contentsOf: infos) + } catch { + // 실패 시 기존 유지 + print("실시간 지하철 조회 실패: \(route), \(error)") + } + } + } } diff --git a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift index e47f850f..d0a095e3 100644 --- a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift +++ b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift @@ -30,8 +30,15 @@ final class TMapContainerView: UIView { setupTMap() } + override func layoutSubviews() { + super.layoutSubviews() + // SDK가 초기 frame 기반으로 렌더링 잡는 경우 대비 + tMapWrapper.mapView.frame = bounds + } + + private func setupTMap() { - tMapWrapper = TMapWrapper(frame: bounds) + tMapWrapper = TMapWrapper(frame: .zero) tMapWrapper.delegate = delegate addSubview(tMapWrapper.mapView) tMapWrapper.mapView.snp.makeConstraints { @@ -45,7 +52,7 @@ final class TMapContainerView: UIView { func setupZoomCenter(location: CLLocationCoordinate2D) { tMapWrapper.mapView.setCenter(location) - tMapWrapper.mapView.setZoom(18) + tMapWrapper.mapView.setZoom(16) } func updateUserMarker(location: CLLocationCoordinate2D) { @@ -85,3 +92,9 @@ extension TMapContainerView { setupTMap() } } + +extension TMapContainerView { + func setupCenterWithRouteInsetOffset(location: CLLocationCoordinate2D, points: CGFloat = 50) { + tMapWrapper.centerUserWithRouteInset(location, yOffsetUp: points) + } +} diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmBottomView.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmBottomView.swift index f2df6cfe..0cb43316 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmBottomView.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmBottomView.swift @@ -13,7 +13,7 @@ import MediaPlayer final class PushAlarmBottomView: UIView { private var currentVolume: Float = AVAudioSession.sharedInstance().outputVolume private var volumeObservation: NSKeyValueObservation? - private var volume: Float = 0.7 + private var volume: Float = 0.3 private let volumeTitleLabel: UILabel = UILabel() private let volumeSubTitleLabel: UILabel = UILabel() @@ -61,11 +61,11 @@ final class PushAlarmBottomView: UIView { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(sliderTapped(_:))) volumeSlider.addGestureRecognizer(tapGesture) - volumeSlider.setValue(0.7, animated: true) + volumeSlider.setValue(0.3, animated: true) let savedVolume = UserDefaultsWrapper.shared.float( forKey: UserDefaultsWrapper.Key.alarmVolume.rawValue - ) ?? 0.7 + ) ?? 0.3 volumeSlider.setValue(savedVolume, animated: false) diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift index a2b33495..f4fdba59 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift @@ -218,11 +218,11 @@ final class PushAlarmViewController: BaseViewController { case .onlySound: self.toggleBottomView(show: true) - AlarmManager.shared.previewAlarmVolume(0.7) + AlarmManager.shared.previewAlarmVolume(0.3) case .both: self.toggleBottomView(show: true) - AlarmManager.shared.previewAlarmVolume(0.7) + AlarmManager.shared.previewAlarmVolume(0.3) } }