diff --git a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift index 654a2f9e..036b5f19 100644 --- a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift +++ b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift @@ -33,6 +33,7 @@ public extension ModulePaths { case SchoolMajorSheetFeature case RootFeature case NoticeFeature + case OnboardingFeature case ModifyTimeTableFeature case MealFeature case MainFeature diff --git a/Projects/App/iOS/Sources/Application/TodayWhatApp.swift b/Projects/App/iOS/Sources/Application/TodayWhatApp.swift index f174d873..a827ca85 100644 --- a/Projects/App/iOS/Sources/Application/TodayWhatApp.swift +++ b/Projects/App/iOS/Sources/Application/TodayWhatApp.swift @@ -33,13 +33,24 @@ struct TodayWhatApp: App { RootCore() } ) - ) + ) { + siriSection + } .onOpenURL { url in guard let route = TodayWhatAppRoute.from(url: url) else { return } TodayWhatAppRouteStore.shared.request(route) } } } + + @ViewBuilder + private var siriSection: some View { + if #available(iOS 17, *) { + SiriTipView(intent: GetMealIntent(mealTime: .all, daySelection: .today)) + } else if #available(iOS 16, *) { + ShortcutsLink() + } + } } extension UINavigationController: ObservableObject, UIGestureRecognizerDelegate { diff --git a/Projects/App/iOS/Support/Info.plist b/Projects/App/iOS/Support/Info.plist index 617fb560..e8e06b7a 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 - 13.0 + 14.0 CFBundleURLTypes @@ -34,7 +34,7 @@ CFBundleVersion - 92 + 98 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Projects/Feature/OnboardingFeature/Project.swift b/Projects/Feature/OnboardingFeature/Project.swift new file mode 100644 index 00000000..1c1d0f20 --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Project.swift @@ -0,0 +1,18 @@ +import DependencyPlugin +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.module( + name: ModulePaths.Feature.OnboardingFeature.rawValue, + targets: [ + .implements(module: .feature(.OnboardingFeature), dependencies: [ + .feature(target: .BaseFeature), + .feature(target: .SchoolSettingFeature), + .shared(target: .DateUtil), + .shared(target: .Entity), + .shared(target: .MealClient), + .shared(target: .TimeTableClient), + .userInterface(target: .DesignSystem) + ]) + ] +) diff --git a/Projects/Feature/OnboardingFeature/Sources/EventLog/OnboardingCompleteEventLog.swift b/Projects/Feature/OnboardingFeature/Sources/EventLog/OnboardingCompleteEventLog.swift new file mode 100644 index 00000000..e98c6222 --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/EventLog/OnboardingCompleteEventLog.swift @@ -0,0 +1,6 @@ +import TWLog + +struct OnboardingCompleteEventLog: EventLog { + let name: String = "onboarding_complete" + let params: [String: String] = [:] +} diff --git a/Projects/Feature/OnboardingFeature/Sources/OnboardingCore.swift b/Projects/Feature/OnboardingFeature/Sources/OnboardingCore.swift new file mode 100644 index 00000000..ddafbeb3 --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/OnboardingCore.swift @@ -0,0 +1,246 @@ +import BaseFeature +import ComposableArchitecture +import DateUtil +import Entity +import Foundation +import MealClient +import SchoolSettingFeature +import TimeTableClient +import TWLog + +public struct OnboardingCore: Reducer { + public init() {} + + public enum Step: Int, CaseIterable, Equatable { + case school + case meal + case timetable + case widget + case ecosystem + + public var index: Int { rawValue + 1 } + public var totalCount: Int { Self.allCases.count } + } + + public struct State: Equatable { + public var step: Step + public var schoolSettingCore: SchoolSettingCore.State + public var schoolName: String + public var meal: Meal? + public var mealUsesFallback: Bool + public var isMealLoading: Bool + public var mealDisplayDate: Date + public var timeTables: [TimeTable] + public var timeTableUsesFallback: Bool + public var isTimeTableLoading: Bool + public var timeTableDisplayDate: Date + + public init( + step: Step = .school, + schoolSettingCore: SchoolSettingCore.State = .init(), + schoolName: String = "", + meal: Meal? = nil, + mealUsesFallback: Bool = false, + isMealLoading: Bool = false, + mealDisplayDate: Date = Date(), + timeTables: [TimeTable] = [], + timeTableUsesFallback: Bool = false, + isTimeTableLoading: Bool = false, + timeTableDisplayDate: Date = Date() + ) { + self.step = step + self.schoolSettingCore = schoolSettingCore + self.schoolName = schoolName + self.meal = meal + self.mealUsesFallback = mealUsesFallback + self.isMealLoading = isMealLoading + self.mealDisplayDate = mealDisplayDate + self.timeTables = timeTables + self.timeTableUsesFallback = timeTableUsesFallback + self.isTimeTableLoading = isTimeTableLoading + self.timeTableDisplayDate = timeTableDisplayDate + } + + public var progressText: String { + "\(step.index) / \(step.totalCount)" + } + + public var progressValue: Double { + Double(step.index) / Double(step.totalCount) + } + } + + @CasePathable + public enum Action: Equatable { + case onAppear + case schoolSettingCore(SchoolSettingCore.Action) + case backButtonDidTap + case nextButtonDidTap + case fetchPreviewData + case fetchMeal(Date) + case mealResponse(Date, TaskResult) + case fetchTimeTable(Date) + case timeTableResponse(Date, TaskResult<[TimeTable]>) + case onboardingFinished + } + + @Dependency(\.mealClient) var mealClient + @Dependency(\.timeTableClient) var timeTableClient + + public var body: some ReducerOf { + Scope(state: \.schoolSettingCore, action: \.schoolSettingCore) { + SchoolSettingCore() + } + + Reduce { state, action in + switch action { + case .onAppear: + self.logStepShowed(step: state.step) + return .none + + case .schoolSettingCore(.schoolSettingFinished): + state.schoolName = state.schoolSettingCore.school + state.step = .meal + state.isMealLoading = true + state.isTimeTableLoading = true + self.logStepShowed(step: state.step) + return .send(.fetchPreviewData) + + case .schoolSettingCore: + return .none + + case .backButtonDidTap: + switch state.step { + case .school: + return .none + case .meal: + state.step = .school + case .timetable: + state.step = .meal + case .widget: + state.step = .timetable + case .ecosystem: + state.step = .widget + } + self.logStepShowed(step: state.step) + return .none + + case .nextButtonDidTap: + switch state.step { + case .school: + return .none + case .meal: + state.step = .timetable + case .timetable: + state.step = .widget + case .widget: + state.step = .ecosystem + case .ecosystem: + return .send(.onboardingFinished) + } + self.logStepShowed(step: state.step) + return .none + + + case .fetchPreviewData: + let targetDate = schoolDay(from: Date()) + state.mealDisplayDate = targetDate + state.timeTableDisplayDate = targetDate + return .merge( + .send(.fetchMeal(targetDate)), + .send(.fetchTimeTable(targetDate)) + ) + + case let .fetchMeal(targetDate): + return .run { send in + await send( + .mealResponse( + targetDate, + TaskResult { + try await mealClient.fetchMeal(targetDate) + } + ) + ) + } + + case let .mealResponse(targetDate, .success(meal)): + state.mealDisplayDate = targetDate + state.meal = meal.isEmpty ? nil : meal + state.mealUsesFallback = meal.isEmpty + state.isMealLoading = false + return .none + + case let .mealResponse(targetDate, .failure): + state.mealDisplayDate = targetDate + state.meal = nil + state.mealUsesFallback = true + state.isMealLoading = false + return .none + + case let .fetchTimeTable(targetDate): + return .run { send in + await send( + .timeTableResponse( + targetDate, + TaskResult { + try await timeTableClient.fetchTimeTable(targetDate) + } + ) + ) + } + + case let .timeTableResponse(targetDate, .success(timeTables)): + let sorted = timeTables + .filter { !$0.content.isEmpty } + .sorted { $0.perio < $1.perio } + state.timeTableDisplayDate = targetDate + state.timeTables = sorted + state.timeTableUsesFallback = sorted.isEmpty + state.isTimeTableLoading = false + return .none + + case let .timeTableResponse(targetDate, .failure): + state.timeTableDisplayDate = targetDate + state.timeTables = [] + state.timeTableUsesFallback = true + state.isTimeTableLoading = false + return .none + + case .onboardingFinished: + TWLog.event(OnboardingCompleteEventLog()) + return .none + } + } + } + + private func schoolDay(from date: Date) -> Date { + switch date.weekday { + case 7: + return date.adding(by: .day, value: 2) + case 1: + return date.adding(by: .day, value: 1) + default: + return date + } + } + + private func logStepShowed(step: Step) { + let log = PageShowedEventLog(pageName: onboardingPageName(for: step)) + TWLog.event(log) + } + + private func onboardingPageName(for step: Step) -> String { + switch step { + case .school: + return "onboarding_school_page" + case .meal: + return "onboarding_meal_page" + case .timetable: + return "onboarding_timetable_page" + case .widget: + return "onboarding_widget_page" + case .ecosystem: + return "onboarding_ecosystem_page" + } + } +} diff --git a/Projects/Feature/OnboardingFeature/Sources/OnboardingView.swift b/Projects/Feature/OnboardingFeature/Sources/OnboardingView.swift new file mode 100644 index 00000000..6afdd220 --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/OnboardingView.swift @@ -0,0 +1,699 @@ +import ComposableArchitecture +import DesignSystem +import Entity +import Lottie +import SchoolSettingFeature +import SwiftUI + +public struct OnboardingView: View { + private let store: StoreOf + private let siriSection: SiriSection + @State private var isWidgetGuidePresented = false + @State private var hasSeenWidgetGuide = false + @State private var showPageContent = false + + public init(store: StoreOf, @ViewBuilder siriSection: () -> SiriSection) { + self.store = store + self.siriSection = siriSection() + } + + public var body: some View { + navigationContainer { + WithViewStore(store, observe: { $0 }) { viewStore in + let pageRail = GeometryReader { proxy in + HStack(spacing: 0) { + schoolPage(pageWidth: proxy.size.width) + .frame(width: proxy.size.width) + + onboardingPage( + title: "오늘 급식, 바로 확인해요", + message: "입력한 학교 정보를 바탕으로 점심 메뉴를 바로 보여드릴게요.", + pageWidth: proxy.size.width + ) { + mealPreview(viewStore: viewStore) + } + .frame(width: proxy.size.width) + + onboardingPage( + title: "오늘 시간표도 한눈에", + message: "수업 순서를 빠르게 확인하고 하루를 더 가볍게 시작해보세요.", + pageWidth: proxy.size.width + ) { + timetablePreview(viewStore: viewStore) + } + .frame(width: proxy.size.width) + + onboardingPage( + title: "위젯으로 더 빠르게", + message: "홈 화면에서 오늘 급식을 앱 열지 않고도 바로 볼 수 있어요.", + pageWidth: proxy.size.width + ) { + widgetPreview(viewStore: viewStore) + } + .frame(width: proxy.size.width) + + onboardingPage( + title: "애플 생태계 어디서나", + message: "Siri, Apple Watch, Mac, iPad에서도 오늘뭐임을 자연스럽게 이어서 사용할 수 있어요.", + pageWidth: proxy.size.width + ) { + ecosystemPreview + } + .frame(width: proxy.size.width) + } + .frame( + width: proxy.size.width * CGFloat(OnboardingCore.Step.allCases.count), + alignment: .leading + ) + .offset(x: -proxy.size.width * CGFloat(viewStore.step.rawValue)) + .animation( + .interactiveSpring(response: 0.34, dampingFraction: 0.86), + value: viewStore.step + ) + } + + let content = VStack(spacing: 0) { + pageRail + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + showPageContent = true + viewStore.send(.onAppear) + } + .onChange(of: viewStore.step) { _ in + showPageContent = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + showPageContent = true + } + } + // .toolbar(.hidden, for: .navigationBar) + + if #available(iOS 26.0, *) { + content + .safeAreaBar(edge: .top) { + topBar(viewStore: viewStore) + } + .safeAreaBar(edge: .bottom) { + bottomCTA(viewStore: viewStore) + } + } else { + content + .safeAreaInset(edge: .top) { + topBar(viewStore: viewStore) + } + .safeAreaInset(edge: .bottom) { + bottomCTA(viewStore: viewStore) + } + } + } + } + } + + @ViewBuilder + private func navigationContainer(@ViewBuilder content: () -> Content) -> some View + { + if #available(iOS 16.0, *) { + NavigationStack { + content() + } + } else { + NavigationView { + content() + } + .navigationViewStyle(.stack) + } + } + + private func schoolPage(pageWidth: CGFloat) -> some View { + SchoolSettingView( + store: store.scope(state: \.schoolSettingCore, action: \.schoolSettingCore), + progressText: nil, + hidesInternalButton: true + ) + .frame(width: pageWidth) + } + + private func onboardingPage( + title: String, + message: String, + pageWidth: CGFloat, + @ViewBuilder content: () -> some View + ) -> some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .twFont(.headline2, color: .extraBlack) + .accessibilityAddTraits(.isHeader) + + Text(message) + .twFont(.body2, color: .textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + content() + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 40) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(width: pageWidth) + .frame(maxHeight: .infinity, alignment: .top) + .scrollClipDisabledIfAvailable() + } + + private func topBar(viewStore: ViewStore) + -> some View + { + HStack(spacing: 12) { + if viewStore.step != .school { + let button = Button { + viewStore.send(.backButtonDidTap, animation: .default) + } label: { + Image(systemName: "chevron.left") + .frame(width: 36, height: 36) + .contentShape(Circle()) + } + .frame(width: 44, height: 44) + .buttonStyle(.plain) + .contentShape(Circle()) + .accessibilityLabel("이전") + + if #available(iOS 26.0, *) { + button + .glassEffect(.regular.interactive(), in: .circle) + } else { + button + } + } + + progressSection(step: viewStore.step) + .frame(minHeight: 44) + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + + @ViewBuilder + private func bottomCTA(viewStore: ViewStore) + -> some View + { + if let buttonTitle = buttonTitle(for: viewStore) { + let button = TWButton(title: buttonTitle, style: .wide) { + switch viewStore.step { + case .school: + viewStore.send(.schoolSettingCore(.nextButtonDidTap), animation: .default) + case .widget: + if hasSeenWidgetGuide { + viewStore.send(.nextButtonDidTap, animation: .default) + } else { + hasSeenWidgetGuide = true + isWidgetGuidePresented = true + } + default: + viewStore.send(.nextButtonDidTap, animation: .default) + } + } + .disabled(isBottomCTADisabled(viewStore: viewStore)) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 8) + + if #available(iOS 16.0, *) { + button + .contentTransition(.numericText()) + .animation(.default, value: buttonTitle) + } else { + button + } + } + } + + private func buttonTitle(for viewStore: ViewStore) -> String? { + switch viewStore.step { + case .school: + return isBottomCTADisabled(viewStore: viewStore) ? nil : viewStore.schoolSettingCore.nextButtonTitle + case .meal: + return "시간표 보기" + case .timetable: + return "위젯 보기" + case .widget: + return "추가하기" + case .ecosystem: + return "시작하기" + } + } + + private func isBottomCTADisabled(viewStore: ViewStore) -> Bool { + switch viewStore.step { + case .school: + return viewStore.schoolSettingCore.class.isEmpty + || viewStore.schoolSettingCore.grade.isEmpty + || viewStore.schoolSettingCore.isFocusedSchool + || viewStore.schoolSettingCore.school.isEmpty + case .meal, .timetable, .widget, .ecosystem: + return false + } + } + + private func progressSection(step: OnboardingCore.Step) -> some View { + VStack(alignment: .leading, spacing: 10) { + let text = Text("\(step.index) / \(step.totalCount)") + .twFont(.caption1, color: .textSecondary) + + if #available(iOS 16.0, *) { + text + .contentTransition(.numericText()) + .animation(.default, value: step) + } else { + text + } + + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.cardBackgroundSecondary) + .frame(height: 8) + + Capsule() + .fill(Color.extraBlack) + .frame( + width: proxy.size.width * (Double(step.index) / Double(step.totalCount)), + height: 8 + ) + } + } + .frame(height: 8) + } + } + + private func mealPreview(viewStore: ViewStore) + -> some View + { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(viewStore.schoolName.isEmpty ? "오늘의 점심" : "\(viewStore.schoolName) 점심") + .twFont(.headline4, color: .extraBlack) + .staggered(showPageContent, order: 0) + Text("앱을 열자마자 가장 먼저 보이는 핵심 정보예요") + .twFont(.caption1, color: .textSecondary) + .staggered(showPageContent, order: 1) + } + Spacer() + Text("급식") + .twFont(.caption1, color: .extraWhite) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.extraBlack) + .clipShape(Capsule()) + .staggered(showPageContent, order: 2) + } + + if viewStore.isMealLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 24) + } else { + let meals = mealLines(from: viewStore.meal) + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(meals.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(Color.extraBlack) + .frame(width: 6, height: 6) + .padding(.top, 7) + Text(line) + .twFont(.body2, color: .textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + .staggered(showPageContent, order: 3 + index) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.cardBackground, in: .rect(cornerRadius: 28)) + + if viewStore.mealUsesFallback { + infoFootnote(text: "해당 날짜의 실제 급식 정보가 아직 없어서 예시 메뉴를 보여드리고 있어요.") + .staggered(showPageContent, order: 8) + } + } + } + + private func timetablePreview(viewStore: ViewStore) + -> some View + { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("오늘 시간표") + .twFont(.headline4, color: .extraBlack) + .staggered(showPageContent, order: 0) + Text("등교 전에도 빠르게 확인할 수 있어요") + .twFont(.caption1, color: .textSecondary) + .staggered(showPageContent, order: 1) + } + Spacer() + Text("시간표") + .twFont(.caption1, color: .extraWhite) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.extraBlack) + .clipShape(Capsule()) + .staggered(showPageContent, order: 2) + } + + if viewStore.isTimeTableLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 24) + } else { + let lines = timetableLines(from: viewStore.timeTables) + VStack(spacing: 10) { + ForEach(Array(lines.enumerated()), id: \.element.0) { index, item in + HStack(spacing: 12) { + Text("\(item.0)교시") + .twFont(.body3, color: .textSecondary) + .frame(width: 42, alignment: .leading) + + Text(item.1) + .twFont(.body2, color: .textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.unselectedPrimary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color.backgroundMain) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .staggered(showPageContent, order: 3 + index) + } + } + .padding(.top, 2) + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.cardBackground, in: .rect(cornerRadius: 28)) + + if viewStore.timeTableUsesFallback { + infoFootnote(text: "해당 날짜의 실제 시간표 정보가 아직 없어서 예시 시간표를 보여드리고 있어요.") + .staggered(showPageContent, order: 9) + } + } + } + + private func widgetPreview(viewStore: ViewStore) + -> some View + { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(viewStore.schoolName.isEmpty ? "오늘뭐임" : viewStore.schoolName) + .twFont(.caption1, color: .extraWhite) + .lineLimit(1) + Spacer() + Image(systemName: "sparkles") + .foregroundColor(.extraWhite) + } + + Text("점심") + .twFont(.headline4, color: .extraWhite) + + VStack(alignment: .leading, spacing: 6) { + ForEach(mealLines(from: viewStore.meal).prefix(4), id: \.self) { line in + Text(line) + } + } + .twFont(.body3, color: .extraWhite) + .lineLimit(1) + } + .padding(22) + .frame(maxWidth: .infinity, alignment: .topLeading) + .aspectRatio(360.0 / 170.0, contentMode: .fit) + .background(Color.extraBlack, in: .rect(cornerRadius: 30)) + .shadow(color: .black.opacity(0.12), radius: 18, y: 8) + .staggered(showPageContent, order: 0) + + guideRow(number: 1, text: "홈 화면 빈 공간을 길게 눌러 편집 모드로 들어가요") + .staggered(showPageContent, order: 2) + guideRow(number: 2, text: "왼쪽 위 + 버튼을 누른 뒤 ‘오늘뭐임’을 검색해요") + .staggered(showPageContent, order: 3) + guideRow(number: 3, text: "급식 위젯을 선택해서 홈 화면에 추가하면 끝이에요") + .staggered(showPageContent, order: 4) + + if viewStore.mealUsesFallback { + infoFootnote(text: "위젯 미리보기는 현재 예시 급식 데이터로 보여주고 있어요.") + .staggered(showPageContent, order: 5) + } + } + .sheet(isPresented: $isWidgetGuidePresented) { + viewStore.send(.nextButtonDidTap, animation: .default) + } content: { + widgetGuideSheet + } + } + + @ViewBuilder + private var widgetGuideSheet: some View { + if #available(iOS 16.0, *) { + WidgetGuideSheetView() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } else { + WidgetGuideSheetView() + } + } + + private var ecosystemPreview: some View { + VStack(spacing: 20) { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), + spacing: 12 + ) { + ecosystemCard(symbol: siriSymbolName, title: "Siri", message: "말로 급식과 시간표를 빠르게 확인") + .staggered(showPageContent, order: 0) + ecosystemCard(symbol: "applewatch", title: "Apple Watch", message: "손목에서 바로 오늘 정보 확인") + .staggered(showPageContent, order: 1) + ecosystemCard(symbol: "laptopcomputer", title: "Mac", message: "공부 중에도 데스크에서 자연스럽게") + .staggered(showPageContent, order: 2) + ecosystemCard(symbol: "ipad.landscape", title: "iPad", message: "큰 화면에서도 편하게 이어보기") + .staggered(showPageContent, order: 3) + } + + siriSection + .staggered(showPageContent, order: 4) + } + } + + private var siriSymbolName: String { + if #available(iOS 26.0, *) { + return "siri" + } else { + return "waveform" + } + } + + private func ecosystemCard(symbol: String, title: String, message: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: symbol) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.extraBlack) + .frame(width: 42, height: 42) + .background(Color.backgroundMain) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Text(title) + .twFont(.headline4, color: .extraBlack) + + Text(message) + .twFont(.body3, color: .textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading) + .padding(18) + .background(Color.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + + private func infoFootnote(text: String) -> some View { + Text(text) + .twFont(.caption1, color: .textSecondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + } + + private func guideRow(number: Int, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text("\(number)") + .twFont(.caption1, color: .extraWhite) + .frame(width: 24, height: 24) + .background(Color.extraBlack.opacity(0.75)) + .overlay { + Circle().stroke(Color.extraWhite.opacity(0.2), lineWidth: 1) + } + .clipShape(Circle()) + + Text(text) + .twFont(.body3, color: .textPrimary) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + } + .padding(14) + .background(Color.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + + private func mealLines(from meal: Meal?) -> [String] { + let lunch = meal?.lunch.meals.filter { !$0.isEmpty } + if let lunch, !lunch.isEmpty { + return Array(lunch.prefix(5)) + } + return ["찹쌀밥", "돈까스", "양배추샐러드", "우동국물", "요구르트"] + } + + private func timetableLines(from timeTables: [TimeTable]) -> [(Int, String)] { + let lines = + timeTables + .filter { !$0.content.isEmpty } + .sorted { $0.perio < $1.perio } + .prefix(6) + .map { ($0.perio, $0.content) } + + if lines.isEmpty { + return [ + (1, "국어"), + (2, "수학"), + (3, "영어"), + (4, "과학"), + (5, "체육"), + ] + } + + return Array(lines) + } +} + +extension View { + @ViewBuilder + fileprivate func scrollClipDisabledIfAvailable() -> some View { + if #available(iOS 17.0, *) { + self.scrollClipDisabled() + } else { + self + } + } + + fileprivate func staggered(_ show: Bool, order: Int) -> some View { + self + .opacity(show ? 1 : 0) + .animation( + show ? .easeOut(duration: 0.3).delay(Double(order) * 0.07) : nil, + value: show + ) + } +} + +extension OnboardingView where SiriSection == EmptyView { + public init(store: StoreOf) { + self.store = store + self.siriSection = EmptyView() + } +} + +private struct WidgetGuideSheetView: View { + @State private var elapsedTime: Double = 0 + @State private var timerActive = true + @State private var showDismissButton = false + @Environment(\.dismiss) private var dismiss + private let cycleTime: Double = 10.0 + + private var currentGuideText: String { + let cyclicTime = elapsedTime.truncatingRemainder(dividingBy: cycleTime) + + if cyclicTime < 4.0 { + return "1. 홈 화면에서 아무 곳이나 길게 눌러주세요." + } else if cyclicTime < 6.5 { + return "2. 왼쪽 위의 '+' 혹은 '편집'을 눌러주세요." + } else { + return "3. 오늘뭐임을 찾아 홈 화면에 추가해주세요." + } + } + + var body: some View { + VStack(spacing: 16) { + if let animation = LottieAnimation.named( + "add_home_screen_widget", + bundle: DesignSystemResources.bundle + ) { + LottieView { + .lottieAnimation(animation) + } + .looping() + .frame(height: 260) + .padding(.top, 40) + } + + Text(currentGuideText) + .twFont(.headline4, color: .textPrimary) + .lineSpacing(5) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .animation(.easeInOut, value: currentGuideText) + .onReceive( + Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + ) { _ in + if timerActive { + elapsedTime += 0.5 + } + } + + if showDismissButton { + TWButton(title: "확인했어요", style: .wide) { + dismiss() + } + .padding(.top, 8) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .background(Color.backgroundMain) + .onDisappear { + timerActive = false + } + .onAppear { + timerActive = true + elapsedTime = 0 + showDismissButton = false + + DispatchQueue.main.asyncAfter(deadline: .now() + cycleTime) { + guard timerActive else { return } + timerActive = false + withAnimation(.easeOut(duration: 0.25)) { + showDismissButton = true + } + } + } + } +} diff --git a/Projects/Feature/RootFeature/Project.swift b/Projects/Feature/RootFeature/Project.swift index 20af6a11..afba3d5b 100644 --- a/Projects/Feature/RootFeature/Project.swift +++ b/Projects/Feature/RootFeature/Project.swift @@ -7,7 +7,7 @@ let project = Project.module( targets: [ .implements(module: .feature(.RootFeature), dependencies: [ .feature(target: .BaseFeature), - .feature(target: .SchoolSettingFeature), + .feature(target: .OnboardingFeature), .feature(target: .MainFeature), .feature(target: .SplashFeature), .shared(target: .UserDefaultsClient) diff --git a/Projects/Feature/RootFeature/Sources/RootCore.swift b/Projects/Feature/RootFeature/Sources/RootCore.swift index 70108d66..ca7cdd0b 100644 --- a/Projects/Feature/RootFeature/Sources/RootCore.swift +++ b/Projects/Feature/RootFeature/Sources/RootCore.swift @@ -1,14 +1,15 @@ import ComposableArchitecture import MainFeature -import SchoolSettingFeature +import OnboardingFeature import SplashFeature import UserDefaultsClient public struct RootCore: Reducer { public init() {} + public enum State: Equatable { case splashCore(SplashCore.State) - case schoolSettingCore(SchoolSettingCore.State) + case onboardingCore(OnboardingCore.State) case mainCore(MainCore.State) public init() { self = .splashCore(.init()) } @@ -16,7 +17,7 @@ public struct RootCore: Reducer { public enum Action { case splashCore(SplashCore.Action) - case schoolSettingCore(SchoolSettingCore.Action) + case onboardingCore(OnboardingCore.Action) case mainCore(MainCore.Action) } @@ -26,13 +27,13 @@ public struct RootCore: Reducer { Reduce { state, action in switch action { case .splashCore(.userInfoIsRequired): - state = .schoolSettingCore(.init()) + state = .onboardingCore(.init()) case .splashCore(.userInfoIsExist): - state = .mainCore(.init()) + state = .mainCore(.init()) - case .schoolSettingCore(.schoolSettingFinished): - state = .mainCore(.init()) + case .onboardingCore(.onboardingFinished): + state = .mainCore(.init()) default: return .none @@ -42,8 +43,8 @@ public struct RootCore: Reducer { .ifCaseLet(/State.splashCore, action: /Action.splashCore) { SplashCore() } - .ifCaseLet(/State.schoolSettingCore, action: /Action.schoolSettingCore) { - SchoolSettingCore() + .ifCaseLet(/State.onboardingCore, action: /Action.onboardingCore) { + OnboardingCore() } .ifCaseLet(/State.mainCore, action: /Action.mainCore) { MainCore() diff --git a/Projects/Feature/RootFeature/Sources/RootView.swift b/Projects/Feature/RootFeature/Sources/RootView.swift index 88d5dd7e..899087ab 100644 --- a/Projects/Feature/RootFeature/Sources/RootView.swift +++ b/Projects/Feature/RootFeature/Sources/RootView.swift @@ -1,14 +1,16 @@ import ComposableArchitecture import MainFeature -import SchoolSettingFeature +import OnboardingFeature import SplashFeature import SwiftUI -public struct RootView: View { +public struct RootView: View { private let store: StoreOf + private let siriSection: SiriSection - public init(store: StoreOf) { + public init(store: StoreOf, @ViewBuilder siriSection: () -> SiriSection) { self.store = store + self.siriSection = siriSection() } public var body: some View { @@ -19,9 +21,9 @@ public struct RootView: View { SplashView(store: store) } - case .schoolSettingCore: - CaseLet(/RootCore.State.schoolSettingCore, action: RootCore.Action.schoolSettingCore) { store in - SchoolSettingView(store: store) + case .onboardingCore: + CaseLet(/RootCore.State.onboardingCore, action: RootCore.Action.onboardingCore) { store in + OnboardingView(store: store) { siriSection } } case .mainCore: @@ -32,3 +34,10 @@ public struct RootView: View { } } } + +extension RootView where SiriSection == EmptyView { + public init(store: StoreOf) { + self.store = store + self.siriSection = EmptyView() + } +} diff --git a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift index 3a0c0411..2e8a6788 100644 --- a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift +++ b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift @@ -15,16 +15,22 @@ public struct SchoolSettingView: View { let store: StoreOf private let isNavigationPushed: Bool + private let progressText: String? + private let hidesInternalButton: Bool @FocusState private var focusField: FocusField? @Environment(\.dismiss) var dismiss @Environment(\.accessibilityReduceMotion) private var reduceMotion public init( store: StoreOf, - isNavigationPushed: Bool = false + isNavigationPushed: Bool = false, + progressText: String? = nil, + hidesInternalButton: Bool = false ) { self.store = store self.isNavigationPushed = isNavigationPushed + self.progressText = progressText + self.hidesInternalButton = hidesInternalButton } public var body: some View { @@ -36,6 +42,14 @@ public struct SchoolSettingView: View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 34) { + if let progressText { + HStack { + Text(progressText) + .twFont(.caption1, color: .textSecondary) + Spacer() + } + } + if !viewStore.isFocusedSchool { headerSection(viewStore: viewStore) majorTextField(viewStore: viewStore) @@ -46,12 +60,13 @@ public struct SchoolSettingView: View { schoolTextField(viewStore: viewStore) schoolSearchResults(viewStore: viewStore) } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 40) } .animation(reduceMotion ? .none : .default, value: viewStore.grade) .animation(reduceMotion ? .none : .default, value: viewStore.class) .animation(reduceMotion ? .none : .default, value: viewStore.school) - .padding(.horizontal, 16) - .padding(.top, 24) .onChange(of: focusField) { newValue in viewStore.send(.schoolFocusedChanged(newValue == .school), animation: .default) } @@ -61,7 +76,9 @@ public struct SchoolSettingView: View { } } .safeAreaInset(edge: .bottom) { - nextButton(viewStore: viewStore) + if !hidesInternalButton { + nextButton(viewStore: viewStore) + } } .onLoad { viewStore.send(.onLoad) @@ -243,6 +260,9 @@ public struct SchoolSettingView: View { viewStore.send(.nextButtonDidTap, animation: .default) focusField = nil } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 8) .accessibilityLabel(viewStore.nextButtonTitle) .accessibilityHint("입력한 정보로 설정을 완료 혹은 다음 단계로 넘어갑니다") } diff --git a/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift b/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift index 2a7ad7f1..a66179a2 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift @@ -87,12 +87,12 @@ private extension TWButtonStyle { if #available(iOS 26.0, watchOS 26.0, *) { contentView - .glassEffect(.clear.tint(background).interactive(), in: .rect) - .contentShape(.rect) + .glassEffect(.clear.tint(background).interactive(), in: .rect(cornerRadius: 16)) + .contentShape(.rect(cornerRadius: 16)) } else { contentView - .background(background) - .contentShape(.rect) + .background(background, in: .rect(cornerRadius: 16)) + .contentShape(.rect(cornerRadius: 16)) } } }