diff --git a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift index 2e1b488..654a2f9 100644 --- a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift +++ b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift @@ -55,6 +55,7 @@ public extension ModulePaths { public extension ModulePaths { enum Shared: String, MicroTargetPathConvertable { + case AppRouteClient case FeatureFlagClient case KeychainClient case TutorialClient diff --git a/Projects/App/Intents/GetMealIntent.swift b/Projects/App/Intents/GetMealIntent.swift new file mode 100644 index 0000000..f2576c2 --- /dev/null +++ b/Projects/App/Intents/GetMealIntent.swift @@ -0,0 +1,280 @@ +import AppIntents +import Entity +import Foundation +import MealClient +import SwiftUI +import TWLog + +@available(iOS 16, macOS 13, *) +struct GetMealIntent: AppIntent { + static let title: LocalizedStringResource = "급식 조회" + static let description = IntentDescription("오늘 또는 내일의 급식 메뉴를 조회합니다") + static let openAppWhenRun: Bool = false + + @Parameter(title: "날짜", default: .today) + var daySelection: MealDaySelection + + @Parameter(title: "특정 날짜") + var specifyDate: Date? + + @Parameter(title: "식사 시간", default: .all) + var mealTime: MealTimeSelection + + static var parameterSummary: some ParameterSummary { + When(\Self.$daySelection, .equalTo, .specify) { + Summary("\(\.$daySelection) \(\.$mealTime) 급식 조회") { + \.$specifyDate + } + } otherwise: { + Summary("\(\.$daySelection) \(\.$mealTime) 급식 조회") + } + } + + init() { + self.daySelection = .today + self.mealTime = .all + } + + init( + mealTime: MealTimeSelection, + daySelection: MealDaySelection = .today, + specifyDate: Date? = nil + ) { + self.daySelection = daySelection + self.mealTime = mealTime + self.specifyDate = specifyDate + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView + & ReturnsValue + { + TWLog.enqueueEvent(DefaultEventLog( + name: "meal_intent_performed", + params: [ + "day_selection": daySelection.rawValue, + "meal_time": mealTime.rawValue, + ] + )) + + let targetDate = daySelection == .specify ? (specifyDate ?? Date()) : daySelection.targetDate + let meal = try await MealClient.liveValue.fetchMeal(targetDate) + let response = formatMealResponse( + meal: meal, + timeSelection: mealTime, + daySelection: daySelection + ) + + return .result( + value: response.entity, + dialog: IntentDialog(stringLiteral: response.dialog), + view: MealIntentView(title: response.title, mealData: response.mealData) + ) + } + + private func formatMealResponse( + meal: Meal, + timeSelection: MealTimeSelection, + daySelection: MealDaySelection + ) -> (dialog: String, title: String, entity: MealResultEntity, mealData: [MealData]) { + let breakfast: [String] + let lunch: [String] + let dinner: [String] + + switch timeSelection { + case .breakfast: + breakfast = meal.breakfast.meals + lunch = [] + dinner = [] + case .lunch: + breakfast = [] + lunch = meal.lunch.meals + dinner = [] + case .dinner: + breakfast = [] + lunch = [] + dinner = meal.dinner.meals + case .all: + breakfast = meal.breakfast.meals + lunch = meal.lunch.meals + dinner = meal.dinner.meals + } + + let entity = MealResultEntity(breakfast: breakfast, lunch: lunch, dinner: dinner) + let mealData: [MealData] + let dialog: String + let title: String + + switch timeSelection { + case .breakfast: + mealData = [MealData(name: "아침", subMeal: meal.breakfast)] + title = "\(daySelection.displayName) 아침" + dialog = breakfast.isEmpty + ? "\(daySelection.displayName) 아침 급식 정보가 없습니다" + : "\(daySelection.displayName) 아침은 \(breakfast.joined(separator: ", "))입니다" + case .lunch: + mealData = [MealData(name: "점심", subMeal: meal.lunch)] + title = "\(daySelection.displayName) 점심" + dialog = lunch.isEmpty + ? "\(daySelection.displayName) 점심 급식 정보가 없습니다" + : "\(daySelection.displayName) 점심은 \(lunch.joined(separator: ", "))입니다" + case .dinner: + mealData = [MealData(name: "저녁", subMeal: meal.dinner)] + title = "\(daySelection.displayName) 저녁" + dialog = dinner.isEmpty + ? "\(daySelection.displayName) 저녁 급식 정보가 없습니다" + : "\(daySelection.displayName) 저녁은 \(dinner.joined(separator: ", "))입니다" + case .all: + mealData = [ + MealData(name: "아침", subMeal: meal.breakfast), + MealData(name: "점심", subMeal: meal.lunch), + MealData(name: "저녁", subMeal: meal.dinner), + ] + title = "\(daySelection.displayName) 급식" + dialog = meal.isEmpty + ? "\(daySelection.displayName) 급식 정보가 없습니다" + : "\(daySelection.displayName) 급식을 조회했습니다" + } + + return (dialog: dialog, title: title, entity: entity, mealData: mealData) + } +} + +@available(iOS 16, macOS 13, *) +enum MealDaySelection: String, AppEnum { + case yesterday + case today + case tomorrow + case specify + + var displayName: String { + switch self { + case .yesterday: return "어제" + case .today: return "오늘" + case .tomorrow: return "내일" + case .specify: return "날짜 선택" + } + } + + var targetDate: Date { + switch self { + case .yesterday: + return Calendar.autoupdatingCurrent.date(byAdding: .day, value: -1, to: Date()) ?? Date() + case .today: + return Date() + case .tomorrow: + return Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1, to: Date()) ?? Date() + case .specify: + return Date() + } + } + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "날짜") + + static let caseDisplayRepresentations: [MealDaySelection: DisplayRepresentation] = [ + .yesterday: "어제", + .today: "오늘", + .tomorrow: "내일", + .specify: "날짜 선택", + ] +} + +@available(iOS 16, macOS 13, *) +enum MealTimeSelection: String, AppEnum { + case breakfast + case lunch + case dinner + case all + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "식사 시간") + + static let caseDisplayRepresentations: [MealTimeSelection: DisplayRepresentation] = [ + .breakfast: "아침", + .lunch: "점심", + .dinner: "저녁", + .all: "전체", + ] +} + +struct MealData { + let name: String + let subMeal: Meal.SubMeal +} + +@available(iOS 16, macOS 13, *) +struct MealResultEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "급식 결과") + static let defaultQuery = MealResultEntityQuery() + + var id: String + @Property(title: "아침") + var breakfast: [String] + @Property(title: "점심") + var lunch: [String] + @Property(title: "저녁") + var dinner: [String] + + var displayRepresentation: DisplayRepresentation { + let parts = [ + breakfast.isEmpty ? nil : "[아침] \(breakfast.joined(separator: ", "))", + lunch.isEmpty ? nil : "[점심] \(lunch.joined(separator: ", "))", + dinner.isEmpty ? nil : "[저녁] \(dinner.joined(separator: ", "))", + ].compactMap { $0 } + return DisplayRepresentation(title: "\(parts.joined(separator: "\n\n"))") + } + + init(id: String = UUID().uuidString, breakfast: [String], lunch: [String], dinner: [String]) { + self.id = id + self.breakfast = breakfast + self.lunch = lunch + self.dinner = dinner + } +} + +@available(iOS 16, macOS 13, *) +struct MealResultEntityQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [MealResultEntity] { + [] + } +} + +@available(iOS 16, macOS 13, *) +struct MealIntentView: View { + let title: String + let mealData: [MealData] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + + if mealData.isEmpty || mealData.allSatisfy({ $0.subMeal.meals.isEmpty }) { + Text("급식 정보가 없습니다") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + ForEach(Array(mealData.enumerated()), id: \.offset) { _, data in + if !data.subMeal.meals.isEmpty { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(data.name) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text("\(Int(data.subMeal.cal.rounded())) kcal") + .font(.caption) + .foregroundColor(.secondary) + } + + ForEach(data.subMeal.meals, id: \.self) { menu in + Text("• \(menu)") + .font(.caption) + } + } + } + } + } + } + .padding() + } +} diff --git a/Projects/App/Intents/GetTimeTableIntent.swift b/Projects/App/Intents/GetTimeTableIntent.swift new file mode 100644 index 0000000..5b7956c --- /dev/null +++ b/Projects/App/Intents/GetTimeTableIntent.swift @@ -0,0 +1,177 @@ +import AppIntents +import Entity +import Foundation +import SwiftUI +import TimeTableClient +import TWLog + +@available(iOS 16, macOS 13, *) +struct GetTimeTableIntent: AppIntent { + static let title: LocalizedStringResource = "시간표 조회" + static let description = IntentDescription("오늘 또는 내일의 시간표를 조회합니다") + static let openAppWhenRun: Bool = false + + @Parameter(title: "날짜", default: .today) + var daySelection: TimeTableDaySelection + + @Parameter(title: "특정 날짜") + var specifyDate: Date? + + static var parameterSummary: some ParameterSummary { + When(\Self.$daySelection, .equalTo, .specify) { + Summary("\(\.$daySelection) 시간표 조회") { + \.$specifyDate + } + } otherwise: { + Summary("\(\.$daySelection) 시간표 조회") + } + } + + init() { + self.daySelection = .today + } + + init(daySelection: TimeTableDaySelection = .today, specifyDate: Date? = nil) { + self.daySelection = daySelection + self.specifyDate = specifyDate + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView + & ReturnsValue<[TimeTableResultEntity]> + { + TWLog.enqueueEvent(DefaultEventLog( + name: "timetable_intent_performed", + params: ["day_selection": daySelection.rawValue] + )) + + let targetDate = daySelection == .specify ? (specifyDate ?? Date()) : daySelection.targetDate + let timeTables = try await TimeTableClient.liveValue.fetchTimeTable(targetDate) + let sortedTimeTables = timeTables.sorted { $0.perio < $1.perio } + let dialog = formatDialogText(timeTables: sortedTimeTables) + let value = sortedTimeTables.map { + TimeTableResultEntity(period: $0.perio, subject: $0.content) + } + + return .result( + value: value, + dialog: IntentDialog(stringLiteral: dialog), + view: TimeTableIntentView( + title: "\(daySelection.displayName) 시간표", + timeTables: sortedTimeTables + ) + ) + } + + private func formatDialogText(timeTables: [TimeTable]) -> String { + guard !timeTables.isEmpty else { + return "\(daySelection.displayName) 시간표 정보가 없습니다" + } + + let summary = timeTables.map { "\($0.perio)교시 \($0.content)" }.joined(separator: ", ") + return "\(daySelection.displayName) 시간표는 \(summary)입니다" + } +} + +@available(iOS 16, macOS 13, *) +enum TimeTableDaySelection: String, AppEnum { + case yesterday + case today + case tomorrow + case specify + + var displayName: String { + switch self { + case .yesterday: return "어제" + case .today: return "오늘" + case .tomorrow: return "내일" + case .specify: return "날짜 선택" + } + } + + var targetDate: Date { + switch self { + case .yesterday: + return Calendar.autoupdatingCurrent.date(byAdding: .day, value: -1, to: Date()) ?? Date() + case .today: + return Date() + case .tomorrow: + return Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1, to: Date()) ?? Date() + case .specify: + return Date() + } + } + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "날짜") + + static let caseDisplayRepresentations: [TimeTableDaySelection: DisplayRepresentation] = [ + .yesterday: "어제", + .today: "오늘", + .tomorrow: "내일", + .specify: "날짜 선택", + ] +} + +@available(iOS 16, macOS 13, *) +struct TimeTableResultEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "시간표") + static let defaultQuery = TimeTableResultEntityQuery() + + var id: String + @Property(title: "교시") + var period: Int + @Property(title: "과목") + var subject: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(period)교시 \(subject)") + } + + init(period: Int, subject: String) { + self.id = "\(period)-\(subject)" + self.period = period + self.subject = subject + } +} + +@available(iOS 16, macOS 13, *) +struct TimeTableResultEntityQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [TimeTableResultEntity] { + [] + } +} + +@available(iOS 16, macOS 13, *) +struct TimeTableIntentView: View { + let title: String + let timeTables: [TimeTable] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + + if timeTables.isEmpty { + Text("시간표 정보가 없습니다") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + ForEach(timeTables, id: \.self) { timeTable in + HStack(spacing: 10) { + Text("\(timeTable.perio)") + .font(.caption) + .fontWeight(.bold) + .frame(width: 24, height: 24) + .clipShape(Circle()) + + Text(timeTable.content) + .font(.subheadline) + + Spacer() + } + } + } + } + .padding() + } +} diff --git a/Projects/App/Intents/MealPartTimeSelectionIntent.swift b/Projects/App/Intents/MealPartTimeSelectionIntent.swift index ae87dd5..326ce94 100644 --- a/Projects/App/Intents/MealPartTimeSelectionIntent.swift +++ b/Projects/App/Intents/MealPartTimeSelectionIntent.swift @@ -6,7 +6,7 @@ enum PartTimeIntentUserDefaultsKeys: Sendable { static let latestSelectedDate = "LATEST_MEAL_PART_TIME_SELECTED_DATE" } -@available(iOS 16, *) +@available(iOS 16.0, macOS 13.0, *) struct MealPartTimeSelectionIntent: AppIntent { static var title: LocalizedStringResource = "Meal 조회 시간대 Intent" @@ -30,7 +30,7 @@ struct MealPartTimeSelectionIntent: AppIntent { } } -@available(iOS 16, *) +@available(iOS 16.0, macOS 13.0, *) extension MealPartTime: AppEnum { static var typeDisplayRepresentation: TypeDisplayRepresentation = "Meal Part Time" diff --git a/Projects/App/Intents/TodayWhatAppOpenIntent.swift b/Projects/App/Intents/TodayWhatAppOpenIntent.swift index 4bc7176..d960d9c 100644 --- a/Projects/App/Intents/TodayWhatAppOpenIntent.swift +++ b/Projects/App/Intents/TodayWhatAppOpenIntent.swift @@ -1,27 +1,52 @@ import AppIntents +import AppRouteClient -@available(iOS 16, *) +@available(iOS 16.0, macOS 13.0, *) struct TodayWhatAppOpenIntent: OpenIntent { - static let title: LocalizedStringResource = "오늘 급식/시간표 보러가기" - static let description = IntentDescription("오늘뭐임 앱 열기") + static let title: LocalizedStringResource = "오늘뭐임 열기" + static let description = IntentDescription("오늘뭐임 앱을 원하는 화면으로 엽니다") static var openAppWhenRun: Bool = true - @Parameter(title: "화면 위치", default: .home) + @AppDependency + private var routeStore: TodayWhatAppRouteStore + + @Parameter(title: "화면", default: .home) var target: TodayWhatAppOpenControlAppEnum + init() { + self.target = .home + } + + init(target: TodayWhatAppOpenControlAppEnum) { + self.target = target + } + func perform() async throws -> some IntentResult { + await routeStore.request(target.route) return .result() } } -@available(iOS 16, *) +@available(iOS 16.0, macOS 13.0, *) enum TodayWhatAppOpenControlAppEnum: String, AppEnum { case home + case meal + case timetable + + var route: TodayWhatAppRoute { + switch self { + case .home: .home + case .meal: .meal + case .timetable: .timetable + } + } - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "홈") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "화면") static let caseDisplayRepresentations: [TodayWhatAppOpenControlAppEnum: DisplayRepresentation] = [ - .home: "Home" + .home: "홈", + .meal: "급식", + .timetable: "시간표" ] } diff --git a/Projects/App/Intents/TodayWhatAppShortcuts.swift b/Projects/App/Intents/TodayWhatAppShortcuts.swift new file mode 100644 index 0000000..3ff7b9e --- /dev/null +++ b/Projects/App/Intents/TodayWhatAppShortcuts.swift @@ -0,0 +1,116 @@ +import AppIntents + +@available(iOS 16, macOS 13, *) +struct TodayWhatAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: GetMealIntent(mealTime: .all, daySelection: .today), + phrases: [ + "\(.applicationName) 오늘 급식", + "오늘 \(.applicationName) 급식", + "\(.applicationName)에서 오늘 급식 보여줘" + ], + shortTitle: "오늘 급식", + systemImageName: "fork.knife" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .breakfast, daySelection: .today), + phrases: [ + "\(.applicationName) 오늘 아침 급식", + "오늘 \(.applicationName) 아침 급식", + "\(.applicationName)에서 오늘 아침 급식 보여줘" + ], + shortTitle: "오늘 아침 급식", + systemImageName: "sunrise" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .lunch, daySelection: .today), + phrases: [ + "\(.applicationName) 오늘 점심 급식", + "오늘 \(.applicationName) 점심 급식", + "\(.applicationName)에서 오늘 점심 급식 보여줘" + ], + shortTitle: "오늘 점심 급식", + systemImageName: "sun.max" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .dinner, daySelection: .today), + phrases: [ + "\(.applicationName) 오늘 저녁 급식", + "오늘 \(.applicationName) 저녁 급식", + "\(.applicationName)에서 오늘 저녁 급식 보여줘" + ], + shortTitle: "오늘 저녁 급식", + systemImageName: "moon.stars" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .all, daySelection: .tomorrow), + phrases: [ + "\(.applicationName) 내일 급식", + "내일 \(.applicationName) 급식", + "\(.applicationName)에서 내일 급식 보여줘" + ], + shortTitle: "내일 급식", + systemImageName: "fork.knife" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .breakfast, daySelection: .tomorrow), + phrases: [ + "\(.applicationName) 내일 아침 급식", + "내일 \(.applicationName) 아침 급식", + "\(.applicationName)에서 내일 아침 급식 보여줘" + ], + shortTitle: "내일 아침 급식", + systemImageName: "sunrise" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .lunch, daySelection: .tomorrow), + phrases: [ + "\(.applicationName) 내일 점심 급식", + "내일 \(.applicationName) 점심 급식", + "\(.applicationName)에서 내일 점심 급식 보여줘" + ], + shortTitle: "내일 점심 급식", + systemImageName: "sun.max" + ) + + AppShortcut( + intent: GetMealIntent(mealTime: .dinner, daySelection: .tomorrow), + phrases: [ + "\(.applicationName) 내일 저녁 급식", + "내일 \(.applicationName) 저녁 급식", + "\(.applicationName)에서 내일 저녁 급식 보여줘" + ], + shortTitle: "내일 저녁 급식", + systemImageName: "moon.stars" + ) + + AppShortcut( + intent: GetTimeTableIntent(daySelection: .today), + phrases: [ + "\(.applicationName) 오늘 시간표", + "오늘 \(.applicationName) 시간표", + "\(.applicationName)에서 오늘 시간표 보여줘" + ], + shortTitle: "오늘 시간표", + systemImageName: "calendar" + ) + + AppShortcut( + intent: GetTimeTableIntent(daySelection: .tomorrow), + phrases: [ + "\(.applicationName) 내일 시간표", + "내일 \(.applicationName) 시간표", + "\(.applicationName)에서 내일 시간표 보여줘" + ], + shortTitle: "내일 시간표", + systemImageName: "calendar" + ) + } +} diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 57d699d..d16c171 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -36,8 +36,12 @@ let targets: [Target] = [ entitlements: .file(path: "iOS/Support/TodayWhat.entitlements"), scripts: generateEnvironment.iOSScripts, dependencies: [ + .shared(target: .AppRouteClient), .feature(target: .RootFeature), + .shared(target: .Entity), .shared(target: .KeychainClient), + .shared(target: .MealClient), + .shared(target: .TimeTableClient), .target(name: "\(env.name)Widget"), .target(name: "\(env.name)WatchApp"), .shared(target: .TWLog) @@ -56,6 +60,7 @@ let targets: [Target] = [ entitlements: .file(path: "iOS-Widget/Support/TodayWhatWidget.entitlements"), scripts: scripts, dependencies: [ + .shared(target: .AppRouteClient), .shared(target: .ComposableArchitectureWrapper), .shared(target: .UserDefaultsClient), .shared(target: .TimeTableClient), @@ -98,12 +103,13 @@ let targets: [Target] = [ bundleId: "\(env.organizationName).\(env.name)", deploymentTargets: .macOS("12.0"), infoPlist: .file(path: "macOS/Support/Info.plist"), - sources: ["macOS/Sources/**"], + sources: ["macOS/Sources/**", "Intents/**"], resources: ["macOS/Resources/**"], entitlements: .file(path: "macOS/Support/TodayWhat_Mac_App.entitlements"), scripts: generateEnvironment.macOSScripts, dependencies: [ .SPM.LaunchAtScreen, + .shared(target: .AppRouteClient), .shared(target: .ComposableArchitectureWrapper), .shared(target: .FirebaseWrapper), .shared(target: .Entity), diff --git a/Projects/App/iOS/Sources/Application/AppDelegate.swift b/Projects/App/iOS/Sources/Application/AppDelegate.swift index e29ead2..325644f 100644 --- a/Projects/App/iOS/Sources/Application/AppDelegate.swift +++ b/Projects/App/iOS/Sources/Application/AppDelegate.swift @@ -37,6 +37,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } DesignSystemFontFamily.Suit.all.forEach { $0.register() } initializeAnalyticsUserID() + TWLog.flushPendingEvents() sendUserPropertyWidget() session = WCSession.default if WCSession.isSupported() { diff --git a/Projects/App/iOS/Sources/Application/TodayWhatApp.swift b/Projects/App/iOS/Sources/Application/TodayWhatApp.swift index b2dbd70..f174d87 100644 --- a/Projects/App/iOS/Sources/Application/TodayWhatApp.swift +++ b/Projects/App/iOS/Sources/Application/TodayWhatApp.swift @@ -1,3 +1,5 @@ +import AppIntents +import AppRouteClient import ComposableArchitecture import Firebase import FirebaseCore @@ -7,6 +9,7 @@ import UIKit import UserDefaultsClient @main +@MainActor struct TodayWhatApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Dependency(\.userDefaultsClient) var userDefaultsClient @@ -14,6 +17,11 @@ struct TodayWhatApp: App { init() { let appOpenCount = (userDefaultsClient.getValue(.appOpenCount) as? Int) ?? 0 userDefaultsClient.setValue(.appOpenCount, appOpenCount + 1) + + if #available(iOS 16, *) { + AppDependencyManager.shared.add(dependency: TodayWhatAppRouteStore.shared) + TodayWhatAppShortcuts.updateAppShortcutParameters() + } } var body: some Scene { @@ -26,6 +34,10 @@ struct TodayWhatApp: App { } ) ) + .onOpenURL { url in + guard let route = TodayWhatAppRoute.from(url: url) else { return } + TodayWhatAppRouteStore.shared.request(route) + } } } } diff --git a/Projects/App/iOS/Support/Info.plist b/Projects/App/iOS/Support/Info.plist index 113a31a..617fb56 100644 --- a/Projects/App/iOS/Support/Info.plist +++ b/Projects/App/iOS/Support/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 12.5 + 13.0 CFBundleURLTypes @@ -34,7 +34,7 @@ CFBundleVersion - 91 + 92 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Projects/Feature/MainFeature/Project.swift b/Projects/Feature/MainFeature/Project.swift index 8abf0fa..5256d2e 100644 --- a/Projects/Feature/MainFeature/Project.swift +++ b/Projects/Feature/MainFeature/Project.swift @@ -11,6 +11,7 @@ let project = Project.module( .feature(target: .NoticeFeature), .feature(target: .TimeTableFeature), .feature(target: .SettingsFeature), + .shared(target: .AppRouteClient), .shared(target: .FeatureFlagClient), .shared(target: .NoticeClient), .shared(target: .UserDefaultsClient), diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift index 0931482..7d93017 100644 --- a/Projects/Feature/MainFeature/Sources/MainView.swift +++ b/Projects/Feature/MainFeature/Sources/MainView.swift @@ -1,3 +1,5 @@ +import AppRouteClient +import Combine import ComposableArchitecture import DesignSystem import FirebaseRemoteConfig @@ -10,9 +12,11 @@ import UIKit import TipKit import TWLog +@MainActor public struct MainView: View { let store: StoreOf @ObservedObject var viewStore: ViewStoreOf + @ObservedObject var routeStore: TodayWhatAppRouteStore @Environment(\.openURL) var openURL @Environment(\.calendar) var calendar @Environment(\.accessibilityReduceMotion) var reduceMotion @@ -21,6 +25,7 @@ public struct MainView: View { public init(store: StoreOf) { self.store = store self.viewStore = ViewStore(store, observe: { $0 }) + self.routeStore = TodayWhatAppRouteStore.shared } public var body: some View { @@ -225,6 +230,10 @@ public struct MainView: View { } .onAppear { viewStore.send(.onAppear, animation: .default) + applyPendingRouteIfNeeded() + } + .onReceive(routeStore.pendingRoutePublisher.compactMap { $0 }) { _ in + applyPendingRouteIfNeeded() } .onLoad { viewStore.send(.onLoad) @@ -259,6 +268,22 @@ public struct MainView: View { } } +private extension MainView { + func applyPendingRouteIfNeeded() { + guard let route = routeStore.consumePendingRoute() else { return } + apply(route: route) + } + + func apply(route: TodayWhatAppRoute) { + switch route { + case .timetable: + viewStore.send(.tabTapped(1), animation: .default) + case .home, .meal: + viewStore.send(.tabTapped(0), animation: .default) + } + } +} + private extension Date { private static let displayFormatter: DateFormatter = { let f = DateFormatter() diff --git a/Projects/Shared/AppRouteClient/Project.swift b/Projects/Shared/AppRouteClient/Project.swift new file mode 100644 index 0000000..dcb15d3 --- /dev/null +++ b/Projects/Shared/AppRouteClient/Project.swift @@ -0,0 +1,10 @@ +import DependencyPlugin +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.module( + name: ModulePaths.Shared.AppRouteClient.rawValue, + targets: [ + .implements(module: .shared(.AppRouteClient), dependencies: []) + ] +) diff --git a/Projects/Shared/AppRouteClient/Sources/TodayWhatAppRoute.swift b/Projects/Shared/AppRouteClient/Sources/TodayWhatAppRoute.swift new file mode 100644 index 0000000..296c86c --- /dev/null +++ b/Projects/Shared/AppRouteClient/Sources/TodayWhatAppRoute.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation + +public enum TodayWhatAppRoute: String, Sendable { + case home + case meal + case timetable + + public var url: URL { + URL(string: "todaywhat://\(rawValue)")! + } + + public static func from(url: URL) -> TodayWhatAppRoute? { + guard url.scheme?.lowercased() == "todaywhat" else { return nil } + + if let host = url.host?.lowercased(), let route = TodayWhatAppRoute(rawValue: host) { + return route + } + + let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() + if let route = TodayWhatAppRoute(rawValue: path) { + return route + } + + return nil + } +} + +@MainActor +public final class TodayWhatAppRouteStore: ObservableObject, @unchecked Sendable { + public static let shared = TodayWhatAppRouteStore() + + @Published public private(set) var pendingRoute: TodayWhatAppRoute? + + public var pendingRoutePublisher: Published.Publisher { + $pendingRoute + } + + public init(pendingRoute: TodayWhatAppRoute? = nil) { + self.pendingRoute = pendingRoute + } + + public func request(_ route: TodayWhatAppRoute) { + pendingRoute = route + } + + public func consumePendingRoute() -> TodayWhatAppRoute? { + defer { pendingRoute = nil } + return pendingRoute + } +} diff --git a/Projects/Shared/TWLog/Sources/TWLog.swift b/Projects/Shared/TWLog/Sources/TWLog.swift index 6e89570..9cdeb0c 100644 --- a/Projects/Shared/TWLog/Sources/TWLog.swift +++ b/Projects/Shared/TWLog/Sources/TWLog.swift @@ -1,5 +1,5 @@ import AmplitudeSwift -#if os(iOS) +#if canImport(AmplitudeSwiftSessionReplayPlugin) import AmplitudeSwiftSessionReplayPlugin #endif import FirebaseAnalytics @@ -39,7 +39,7 @@ public enum TWLog { ) #endif - #if os(iOS) + #if canImport(AmplitudeSwiftSessionReplayPlugin) amplitude.add(plugin: AmplitudeSwiftSessionReplayPlugin(sampleRate: 0.03)) #endif @@ -120,6 +120,37 @@ public extension TWLog { } } +// MARK: - Pending Event Queue (for AppIntent / Extension) +public extension TWLog { + private static let appGroupID = "group.baegteun.TodayWhat" + private static let pendingEventsKey = "tw_pending_analytics_events" + + private static var appGroupDefaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + static func enqueueEvent(_ eventLog: any EventLog) { + guard let defaults = appGroupDefaults else { return } + var queue = defaults.array(forKey: pendingEventsKey) as? [[String: String]] ?? [] + var entry = eventLog.params + entry["__event_name"] = eventLog.name + queue.append(entry) + defaults.set(queue, forKey: pendingEventsKey) + } + + static func flushPendingEvents() { + guard let defaults = appGroupDefaults else { return } + guard let queue = defaults.array(forKey: pendingEventsKey) as? [[String: String]], !queue.isEmpty else { return } + defaults.removeObject(forKey: pendingEventsKey) + + for var entry in queue { + guard let name = entry.removeValue(forKey: "__event_name") else { continue } + let log = DefaultEventLog(name: name, params: entry) + TWLog.event(log) + } + } +} + private extension TWLog { static func log(_ message: Any, level: Level) { #if DEV || STAGE