From 0000819888d1e542333a77d14a1507400af1b74a Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 25 Sep 2026 13:16:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ui:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 40 ++- .../PushNotificationSettingsView.swift | 337 +++++++++++++++--- 2 files changed, 318 insertions(+), 59 deletions(-) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 1969d187..b5d2e8bf 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -1548,7 +1548,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "알람" + "value" : "알림" } } } @@ -2053,6 +2053,23 @@ } } }, + "push_settings_custom_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a custom time in 5-minute intervals." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 설정 시간은 5분 단위로 선택할 수 있어요." + } + } + } + }, "push_settings_enable" : { "extractionState" : "manual", "localizations" : { @@ -2065,7 +2082,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "푸시 알람" + "value" : "푸시 알림" } } } @@ -2082,7 +2099,24 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "설정의 푸시 알람 권한과는 별개예요.\n기기 알림 권한을 꺼도 알림 리스트는 생성돼요." + "value" : "기기 알림 권한과는 별개예요. 권한을 꺼도 앱의 알림 목록은 생성돼요." + } + } + } + }, + "push_settings_time_section" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification Time" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림 시간" } } } diff --git a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift index 60746640..6d68bc7c 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift @@ -5,72 +5,41 @@ // Created by opfic on 5/14/25. // +import Domain import SwiftUI import PresentationShared struct PushNotificationSettingsView: View { + @Environment(\.dismiss) private var dismiss @Environment(\.isTabContentActive) private var isTabContentActive @State var store: StoreOf + var fetchesSettingsOnAppear = true var body: some View { - List { - Section(content: { - HStack { - Text(String(localized: "push_settings_enable", bundle: PresentationResources.bundle)) - Spacer() - if store.isLoading && store.activeLoadingRow == .enable { - ProgressView() - } else { - Toggle("", isOn: $store.pushNotificationEnable) - .labelsHidden() - .tint(.blue) - .disabled(store.activeLoadingRow != nil) - } - } - }, footer: { - Text(String(localized: "push_settings_footer", bundle: PresentationResources.bundle)) - .multilineTextAlignment(.leading) - }) - Section { - ForEach([9, 15, 18, 21], id: \.self) { hour in - if let date = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: Date()) { - let loadingRow = PushNotificationSettingsFeature.activeLoadingRow(for: date) - HStack { - Text(formattedTimeString(date)) - Spacer() - if let loadingRow, - store.isLoading && store.activeLoadingRow == loadingRow { - ProgressView() - } else if store.activeLoadingRow != loadingRow - && store.pushNotificationHour == hour - && store.pushNotificationMinute == 0 { - Image(systemName: "checkmark") - .foregroundStyle(Color.blue) - } - } - .contentShape(Rectangle()) - .onTapGesture { store.send(.selectPresetTime(date)) } - } - } - HStack { - Text(String(localized: "push_settings_custom", bundle: PresentationResources.bundle)) - Spacer() - Text(formattedTimeString(store.viewPushNotificationTime)) - .foregroundStyle(.secondary) - if store.pushNotificationMinute != 0 { - Image(systemName: "checkmark") - .foregroundStyle(Color.blue) - } + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + EnableCard(store: store) + + VStack(alignment: .leading, spacing: 12) { + Text("push_settings_time_section", bundle: PresentationResources.bundle) + .font(.title3.weight(.semibold)) + TimeCard(store: store) } - .contentShape(Rectangle()) - .onTapGesture { store.send(.tapCustomTime) } + + CustomTimeTipCard() + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 24) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground) + .toolbarVisibility(.hidden, for: .navigationBar) + .onAppear { + if fetchesSettingsOnAppear { + store.send(.fetchSettings) } - .disabled(!store.pushNotificationEnable || store.activeLoadingRow != nil) - .opacity(store.pushNotificationEnable ? 1.0 : 0.2) } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_push_settings", bundle: PresentationResources.bundle)) - .onAppear { store.send(.fetchSettings) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet( item: $store.scope(state: \.timePicker, action: \.timePicker) @@ -83,8 +52,174 @@ struct PushNotificationSettingsView: View { } } + private var topBar: some View { + ZStack { + Text(String(localized: "nav_push_settings", bundle: PresentationResources.bundle)) + .font(.headline) + + HStack { + NavigationBackButton(action: { dismiss() }) + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) + } +} + +private struct EnableCard: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + Image(systemName: "bell") + .font(.headline) + .frame(width: 36, height: 36) + .iconStyle(color: .accent, in: Circle()) + + Text("push_settings_enable", bundle: PresentationResources.bundle) + .font(.title3) + + Spacer(minLength: 8) + + if store.isLoading && store.activeLoadingRow == .enable { + ProgressView() + } else { + Toggle("", isOn: $store.pushNotificationEnable) + .labelsHidden() + .tint(.accent) + .disabled(store.activeLoadingRow != nil) + } + } + + Divider() + + Text("push_settings_footer", bundle: PresentationResources.bundle) + .font(.footnote) + .foregroundStyle(Color.textSecondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surface, in: .rect(cornerRadius: 16)) + } +} + +private struct TimeCard: View { + @Environment(\.locale) private var locale + let store: StoreOf + + private static let presetHours = [9, 15, 18, 21] + + var body: some View { + VStack(spacing: 0) { + ForEach(Self.presetHours, id: \.self) { hour in + if let date = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: Date()) { + presetTimeButton(date, hour: hour) + Divider() + } + } + customTimeButton + } + .background(Color.surface, in: .rect(cornerRadius: 16)) + .disabled(!store.pushNotificationEnable || store.activeLoadingRow != nil) + .opacity(store.pushNotificationEnable ? 1.0 : 0.7) + } + + private func presetTimeButton(_ date: Date, hour: Int) -> some View { + let loadingRow = PushNotificationSettingsFeature.activeLoadingRow(for: date) + let isSelected = store.activeLoadingRow != loadingRow + && store.pushNotificationHour == hour + && store.pushNotificationMinute == 0 + + return Button { + store.send(.selectPresetTime(date)) + } label: { + HStack { + Text(formattedTimeString(date)) + .foregroundStyle(Color.primary) + Spacer() + if let loadingRow, + store.isLoading && store.activeLoadingRow == loadingRow { + ProgressView() + } else { + selectionIndicator(isSelected: isSelected) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var customTimeButton: some View { + Button { + store.send(.tapCustomTime) + } label: { + HStack(spacing: 8) { + Text("push_settings_custom", bundle: PresentationResources.bundle) + .foregroundStyle(Color.primary) + Spacer(minLength: 8) + if store.isLoading && store.activeLoadingRow == .customTime { + ProgressView() + } else { + selectionIndicator(isSelected: store.pushNotificationMinute != 0) + } + Text(formattedTimeString(store.viewPushNotificationTime)) + .foregroundStyle(Color.textSecondary) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.textSecondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func selectionIndicator(isSelected: Bool) -> some View { + if isSelected { + ZStack { + Image(systemName: "circle.fill") + .foregroundStyle(Color.accent) + Image(systemName: "checkmark") + .font(.caption2.bold()) + .foregroundStyle(Color.white) + } + .font(.title2) + } else { + Image(systemName: "circle") + .font(.title2) + .foregroundStyle(Color.border) + } + } + private func formattedTimeString(_ date: Date) -> String { - date.formatted(.dateTime.hour().minute()) + date.formatted(.dateTime.hour().minute().locale(locale)) + } +} + +private struct CustomTimeTipCard: View { + var body: some View { + HStack(spacing: 12) { + Image(systemName: "info") + .font(.caption.weight(.semibold)) + .frame(width: 28, height: 28) + .background(Color.surface, in: Circle()) + Text("push_settings_custom_hint", bundle: PresentationResources.bundle) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(Color.accent) + .padding(16) + .background(Color.accent.opacity(0.08), in: .rect(cornerRadius: 16)) + .padding(.bottom, 16) + .background(Color.appBackground) } } @@ -135,3 +270,93 @@ private struct TimePickerView: View { .presentationDetents([.height(store.height)]) } } + +#if DEBUG +#Preview("알림, 밝게") { + NavigationStack { + PushNotificationSettingsView( + store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0), + fetchesSettingsOnAppear: false + ) + } + .environment(\.locale, Locale(identifier: "ko")) + .preferredColorScheme(.light) +} + +#Preview("알림, 어둡게") { + NavigationStack { + PushNotificationSettingsView( + store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0), + fetchesSettingsOnAppear: false + ) + } + .environment(\.locale, Locale(identifier: "ko")) + .preferredColorScheme(.dark) +} + +#Preview("알림, 사용자 설정") { + NavigationStack { + PushNotificationSettingsView( + store: pushNotificationSettingsPreviewStore(hour: 19, minute: 35), + fetchesSettingsOnAppear: false + ) + } + .environment(\.locale, Locale(identifier: "ko")) + .preferredColorScheme(.light) +} + +#Preview("알림, 꺼짐") { + NavigationStack { + PushNotificationSettingsView( + store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0, isEnabled: false), + fetchesSettingsOnAppear: false + ) + } + .environment(\.locale, Locale(identifier: "ko")) + .preferredColorScheme(.light) +} + +@MainActor +private func pushNotificationSettingsPreviewStore( + hour: Int, + minute: Int, + isEnabled: Bool = true +) -> StoreOf { + var state = PushNotificationSettingsFeature.State() + state.pushNotificationEnable = isEnabled + state.viewPushNotificationTime = Calendar.current.date( + bySettingHour: hour, + minute: minute, + second: 0, + of: Date() + ) ?? Date() + + return Store(initialState: state) { + PushNotificationSettingsFeature() + } withDependencies: { + $0.fetchPushSettingsUseCase = PushNotificationSettingsPreviewFetchUseCase( + hour: hour, + minute: minute, + isEnabled: isEnabled + ) + $0.updatePushSettingsUseCase = PushNotificationSettingsPreviewUpdateUseCase() + } +} + +private struct PushNotificationSettingsPreviewFetchUseCase: FetchPushSettingsUseCase { + let hour: Int + let minute: Int + let isEnabled: Bool + + func execute() async throws -> PushNotificationSettings { + PushNotificationSettings( + isEnabled: isEnabled, + scheduledTime: DateComponents(hour: hour, minute: minute) + ) + } +} + +private struct PushNotificationSettingsPreviewUpdateUseCase: UpdatePushSettingsUseCase { + func execute(_ settings: PushNotificationSettings) async throws { } +} +#endif From 16f4e6d161a109fa1d099611f91d3803a2df0de4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 25 Sep 2026 13:44:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationSettingsView.swift | 98 +------------------ 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift index 6d68bc7c..f56d6516 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift @@ -5,7 +5,6 @@ // Created by opfic on 5/14/25. // -import Domain import SwiftUI import PresentationShared @@ -13,7 +12,6 @@ struct PushNotificationSettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.isTabContentActive) private var isTabContentActive @State var store: StoreOf - var fetchesSettingsOnAppear = true var body: some View { ScrollView { @@ -35,11 +33,7 @@ struct PushNotificationSettingsView: View { .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground) .toolbarVisibility(.hidden, for: .navigationBar) - .onAppear { - if fetchesSettingsOnAppear { - store.send(.fetchSettings) - } - } + .onAppear { store.send(.fetchSettings) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet( item: $store.scope(state: \.timePicker, action: \.timePicker) @@ -270,93 +264,3 @@ private struct TimePickerView: View { .presentationDetents([.height(store.height)]) } } - -#if DEBUG -#Preview("알림, 밝게") { - NavigationStack { - PushNotificationSettingsView( - store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0), - fetchesSettingsOnAppear: false - ) - } - .environment(\.locale, Locale(identifier: "ko")) - .preferredColorScheme(.light) -} - -#Preview("알림, 어둡게") { - NavigationStack { - PushNotificationSettingsView( - store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0), - fetchesSettingsOnAppear: false - ) - } - .environment(\.locale, Locale(identifier: "ko")) - .preferredColorScheme(.dark) -} - -#Preview("알림, 사용자 설정") { - NavigationStack { - PushNotificationSettingsView( - store: pushNotificationSettingsPreviewStore(hour: 19, minute: 35), - fetchesSettingsOnAppear: false - ) - } - .environment(\.locale, Locale(identifier: "ko")) - .preferredColorScheme(.light) -} - -#Preview("알림, 꺼짐") { - NavigationStack { - PushNotificationSettingsView( - store: pushNotificationSettingsPreviewStore(hour: 18, minute: 0, isEnabled: false), - fetchesSettingsOnAppear: false - ) - } - .environment(\.locale, Locale(identifier: "ko")) - .preferredColorScheme(.light) -} - -@MainActor -private func pushNotificationSettingsPreviewStore( - hour: Int, - minute: Int, - isEnabled: Bool = true -) -> StoreOf { - var state = PushNotificationSettingsFeature.State() - state.pushNotificationEnable = isEnabled - state.viewPushNotificationTime = Calendar.current.date( - bySettingHour: hour, - minute: minute, - second: 0, - of: Date() - ) ?? Date() - - return Store(initialState: state) { - PushNotificationSettingsFeature() - } withDependencies: { - $0.fetchPushSettingsUseCase = PushNotificationSettingsPreviewFetchUseCase( - hour: hour, - minute: minute, - isEnabled: isEnabled - ) - $0.updatePushSettingsUseCase = PushNotificationSettingsPreviewUpdateUseCase() - } -} - -private struct PushNotificationSettingsPreviewFetchUseCase: FetchPushSettingsUseCase { - let hour: Int - let minute: Int - let isEnabled: Bool - - func execute() async throws -> PushNotificationSettings { - PushNotificationSettings( - isEnabled: isEnabled, - scheduledTime: DateComponents(hour: hour, minute: minute) - ) - } -} - -private struct PushNotificationSettingsPreviewUpdateUseCase: UpdatePushSettingsUseCase { - func execute(_ settings: PushNotificationSettings) async throws { } -} -#endif