diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 2e31f08d..fc63bd62 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -68,5 +68,12 @@ final class DataAssembler: Assembler { metadataService: container.resolve(WebPageMetadataService.self) ) } + + container.register(UserPreferencesRepository.self) { + UserPreferencesRepositoryImpl( + store: container.resolve(UserDefaultsStore.self), + themeStore: container.resolve(ThemeStore.self) + ) + } } } diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 44e5b088..67b70444 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -7,14 +7,18 @@ final class DomainAssembler: Assembler { func assemble(_ container: DIContainer) { - container.register(FetchPinnedTodosUseCase.self) { - FetchPinnedTodosUseCaseImpl(container.resolve(TodoRepository.self)) - } - - container.register(FetchTodoByIDUseCase.self) { - FetchTodoByIDUseCaseImpl(container.resolve(TodoRepository.self)) - } + registerAuthUseCases(container) + registerAuthProviderUseCases(container) + registerTodoUseCases(container) + registerUserDataUseCases(container) + registerPushNotificationUseCases(container) + registerWebPageUseCases(container) + registerUserPreferencesUseCases(container) + } +} +private extension DomainAssembler { + func registerAuthUseCases(_ container: DIContainer) { container.register(SignInUseCase.self) { SignInUseCaseImpl(container.resolve(AuthenticationRepository.self)) } @@ -27,42 +31,84 @@ final class DomainAssembler: Assembler { DeleteAuthUseCaseImpl(container.resolve(AuthenticationRepository.self)) } + container.register(AuthSessionUseCase.self) { + AuthSessionUseCaseImpl(container.resolve(AuthSessionRepository.self)) + } + } + + func registerAuthProviderUseCases(_ container: DIContainer) { + container.register(FetchAuthProvidersUseCase.self) { + FetchAuthProvidersUseCaseImpl(container.resolve(AuthDataRepository.self)) + } + + container.register(LinkAuthProviderUseCase.self) { + LinkAuthProviderUseCaseImpl(container.resolve(AuthDataRepository.self)) + } + + container.register(UnlinkAuthProviderUseCase.self) { + UnlinkAuthProviderUseCaseImpl(container.resolve(AuthDataRepository.self)) + } + } + + func registerTodoUseCases(_ container: DIContainer) { + container.register(FetchPinnedTodosUseCase.self) { + FetchPinnedTodosUseCaseImpl(container.resolve(TodoRepository.self)) + } + + container.register(FetchTodoByIDUseCase.self) { + FetchTodoByIDUseCaseImpl(container.resolve(TodoRepository.self)) + } + + container.register(FetchTodosByKindUseCase.self) { + FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self)) + } + + container.register(FetchTodosByKeywordUseCase.self) { + FetchTodosByKeywordUseCaseImpl(container.resolve(TodoRepository.self)) + } + container.register(UpsertTodoUseCase.self) { UpsertTodoUseCaseImpl(container.resolve(TodoRepository.self)) } - + container.register(DeleteTodoUseCase.self) { DeleteTodoUseCaseImpl(container.resolve(TodoRepository.self)) } + } - container.register(AuthSessionUseCase.self) { - AuthSessionUseCaseImpl(container.resolve(AuthSessionRepository.self)) - } - + func registerUserDataUseCases(_ container: DIContainer) { container.register(FetchUserDataUseCase.self) { FetchUserDataUseCaseImpl(container.resolve(UserDataRepository.self)) } - container.register(FetchPushSettingsUseCase.self) { - FetchPushNotificationSettingsUseCaseImpl(container.resolve(PushNotificationRepository.self)) - } - container.register(UpsertStatusMessageUseCase.self) { UpsertStatusMessageUseCaseImpl(container.resolve(UserDataRepository.self)) } + } + + func registerPushNotificationUseCases(_ container: DIContainer) { + container.register(FetchPushSettingsUseCase.self) { + FetchPushNotificationSettingsUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } container.register(UpdatePushSettingsUseCase.self) { UpdatePushSettingsUseCaseImpl(container.resolve(PushNotificationRepository.self)) } - container.register(FetchTodosByKindUseCase.self) { - FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self)) + container.register(DeletePushNotificationUseCase.self) { + DeletePushNotificationUseCaseImpl(container.resolve(PushNotificationRepository.self)) } - container.register(FetchTodosByKeywordUseCase.self) { - FetchTodosByKeywordUseCaseImpl(container.resolve(TodoRepository.self)) + container.register(FetchPushNotificationsUseCase.self) { + FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self)) } + container.register(TogglePushNotificationReadUseCase.self) { + TogglePushNotificationReadUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } + } + + func registerWebPageUseCases(_ container: DIContainer) { container.register(FetchWebPagesUseCase.self) { FetchWebPagesUseCaseImpl(container.resolve(WebPageRepository.self)) } @@ -74,29 +120,39 @@ final class DomainAssembler: Assembler { container.register(DeleteWebPageUseCase.self) { DeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) } + } - container.register(DeletePushNotificationUseCase.self) { - DeletePushNotificationUseCaseImpl(container.resolve(PushNotificationRepository.self)) + func registerUserPreferencesUseCases(_ container: DIContainer) { + container.register(ObserveSystemThemeUseCase.self) { + ObserveSystemThemeUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } - container.register(FetchPushNotificationsUseCase.self) { - FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self)) + container.register(UpdateSystemThemeUseCase.self) { + UpdateSystemThemeUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } - container.register(TogglePushNotificationReadUseCase.self) { - TogglePushNotificationReadUseCaseImpl(container.resolve(PushNotificationRepository.self)) + container.register(FetchFirstLaunchUseCase.self) { + FetchFirstLaunchUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } - - container.register(FetchAuthProvidersUseCase.self) { - FetchAuthProvidersUseCaseImpl(container.resolve(AuthDataRepository.self)) + + container.register(UpdateFirstLaunchUseCase.self) { + UpdateFirstLaunchUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } - - container.register(LinkAuthProviderUseCase.self) { - LinkAuthProviderUseCaseImpl(container.resolve(AuthDataRepository.self)) + + container.register(FetchRecentSearchQueriesUseCase.self) { + FetchRecentSearchQueriesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } - - container.register(UnlinkAuthProviderUseCase.self) { - UnlinkAuthProviderUseCaseImpl(container.resolve(AuthDataRepository.self)) + + container.register(UpdateRecentSearchQueriesUseCase.self) { + UpdateRecentSearchQueriesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(FetchPushNotificationQueryUseCase.self) { + FetchPushNotificationQueryUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(UpdatePushNotificationQueryUseCase.self) { + UpdatePushNotificationQueryUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } } } diff --git a/DevLog/App/Assembler/InfraAssembler.swift b/DevLog/App/Assembler/InfraAssembler.swift index 9a9d76eb..d898d9ef 100644 --- a/DevLog/App/Assembler/InfraAssembler.swift +++ b/DevLog/App/Assembler/InfraAssembler.swift @@ -51,5 +51,13 @@ final class InfraAssembler: Assembler { container.register(WebPageMetadataService.self) { WebPageMetadataService() } + + container.register(UserDefaultsStore.self) { + UserDefaultsStore() + } + + container.register(ThemeStore.self) { + ThemeStore() + } } } diff --git a/DevLog/App/DevLogApp.swift b/DevLog/App/DevLogApp.swift index 030c6b0d..049cb8ea 100644 --- a/DevLog/App/DevLogApp.swift +++ b/DevLog/App/DevLogApp.swift @@ -10,7 +10,6 @@ import SwiftUI @main struct DevLogApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @AppStorage("theme") var theme: SystemTheme = .automatic @Environment(\.diContainer) var container: DIContainer init() { @@ -19,12 +18,14 @@ struct DevLogApp: App { var body: some Scene { WindowGroup { - RootView( - viewModel: RootViewModel( - sessionUseCase: container.resolve(AuthSessionUseCase.self), - signOutUseCase: container.resolve(SignOutUseCase.self) - )) - .preferredColorScheme(theme.colorScheme) + RootView(viewModel: RootViewModel( + sessionUseCase: container.resolve(AuthSessionUseCase.self), + signOutUseCase: container.resolve(SignOutUseCase.self), + fetchFirstLaunchUseCase: container.resolve(FetchFirstLaunchUseCase.self), + updateFirstLaunchUseCase: container.resolve(UpdateFirstLaunchUseCase.self), + observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), + updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) + )) } } } diff --git a/DevLog/App/RootView.swift b/DevLog/App/RootView.swift index 81c823b9..fa9e1bdc 100644 --- a/DevLog/App/RootView.swift +++ b/DevLog/App/RootView.swift @@ -23,12 +23,12 @@ struct RootView: View { signOutUseCase: container.resolve(SignOutUseCase.self), sessionUseCase: container.resolve(AuthSessionUseCase.self)) ) - .onAppear { - if viewModel.state.isFirstLaunch { - viewModel.send(.setFirstLaunch(false)) - viewModel.send(.signOutAuto) - } + .onAppear { + if viewModel.state.isFirstLaunch { + viewModel.send(.setFirstLaunch(false)) + viewModel.send(.signOutAuto) } + } } } else { Color.clear.onAppear { @@ -41,6 +41,7 @@ struct RootView: View { } } } + .preferredColorScheme(viewModel.state.theme.colorScheme) .alert(viewModel.state.alertTitle, isPresented: Binding( get: { viewModel.state.showAlert }, set: { viewModel.send(.setAlert($0)) } diff --git a/DevLog/Data/Protocol/UserPreferencesRepository.swift b/DevLog/Data/Protocol/UserPreferencesRepository.swift new file mode 100644 index 00000000..850593d8 --- /dev/null +++ b/DevLog/Data/Protocol/UserPreferencesRepository.swift @@ -0,0 +1,30 @@ +// +// UserPreferencesRepository.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Foundation +import Combine + +protocol UserPreferencesRepository { + var systemThemePublisher: AnyPublisher { get } + func systemTheme() -> SystemTheme + func setSystemTheme(_ theme: SystemTheme) + + func isFirstLaunch() -> Bool + func setFirstLaunch(_ value: Bool) + + func recentSearchQueries() -> [String] + func setRecentSearchQueries(_ queries: [String]) + + func pushNotificationSortOrder() -> PushNotificationQuery.SortOrder + func setPushNotificationSortOrder(_ order: PushNotificationQuery.SortOrder) + + func pushNotificationTimeFilter() -> PushNotificationQuery.TimeFilter + func setPushNotificationTimeFilter(_ filter: PushNotificationQuery.TimeFilter) + + func pushNotificationUnreadOnly() -> Bool + func setPushNotificationUnreadOnly(_ value: Bool) +} diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift new file mode 100644 index 00000000..d4afa396 --- /dev/null +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -0,0 +1,95 @@ +// +// UserPreferencesRepositoryImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Foundation +import Combine + +final class UserPreferencesRepositoryImpl: UserPreferencesRepository { + private enum Key { + static let theme = "theme" + static let firstLaunch = "isFirstLaunch" + static let recentQueries = "Search.recentQueries" + static let pushSortOrder = "PushNotification.sortOption" + static let pushTimeFilter = "PushNotification.timeFilter" + static let pushUnreadOnly = "PushNotification.showUnreadOnly" + } + + private let store: UserDefaultsStore + private let themeStore: ThemeStore + + init( + store: UserDefaultsStore, + themeStore: ThemeStore + ) { + self.store = store + self.themeStore = themeStore + themeStore.send(systemTheme()) + } + + var systemThemePublisher: AnyPublisher { + themeStore.themePublisher + } + + func systemTheme() -> SystemTheme { + guard let rawValue = store.string(forKey: Key.theme), + let theme = SystemTheme(rawValue: rawValue) else { + return .automatic + } + return theme + } + + func setSystemTheme(_ theme: SystemTheme) { + store.setString(theme.rawValue, forKey: Key.theme) + themeStore.send(theme) + } + + func isFirstLaunch() -> Bool { + if store.string(forKey: Key.firstLaunch) == nil { + return true + } + return store.bool(forKey: Key.firstLaunch) + } + + func setFirstLaunch(_ value: Bool) { + store.setBool(value, forKey: Key.firstLaunch) + } + + func recentSearchQueries() -> [String] { + store.stringArray(forKey: Key.recentQueries) + } + + func setRecentSearchQueries(_ queries: [String]) { + store.setStringArray(queries, forKey: Key.recentQueries) + } + + func pushNotificationSortOrder() -> PushNotificationQuery.SortOrder { + guard let rawValue = store.string(forKey: Key.pushSortOrder) else { return .latest } + return rawValue == "oldest" ? .oldest : .latest + } + + func setPushNotificationSortOrder(_ order: PushNotificationQuery.SortOrder) { + let value = order == .oldest ? "oldest" : "latest" + store.setString(value, forKey: Key.pushSortOrder) + } + + func pushNotificationTimeFilter() -> PushNotificationQuery.TimeFilter { + let id = store.string(forKey: Key.pushTimeFilter) ?? "none" + return PushNotificationQuery.TimeFilter(id: id) + } + + func setPushNotificationTimeFilter(_ filter: PushNotificationQuery.TimeFilter) { + store.setString(filter.id, forKey: Key.pushTimeFilter) + } + + func pushNotificationUnreadOnly() -> Bool { + store.bool(forKey: Key.pushUnreadOnly) + } + + func setPushNotificationUnreadOnly(_ value: Bool) { + store.setBool(value, forKey: Key.pushUnreadOnly) + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCase.swift new file mode 100644 index 00000000..b74ec202 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchFirstLaunchUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol FetchFirstLaunchUseCase { + func execute() -> Bool +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCaseImpl.swift new file mode 100644 index 00000000..dd9c043a --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Launch/FetchFirstLaunchUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchFirstLaunchUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class FetchFirstLaunchUseCaseImpl: FetchFirstLaunchUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> Bool { + repository.isFirstLaunch() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCase.swift new file mode 100644 index 00000000..37b1ffe8 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateFirstLaunchUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol UpdateFirstLaunchUseCase { + func execute(_ value: Bool) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCaseImpl.swift new file mode 100644 index 00000000..beb122a5 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Launch/UpdateFirstLaunchUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateFirstLaunchUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class UpdateFirstLaunchUseCaseImpl: UpdateFirstLaunchUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ value: Bool) { + repository.setFirstLaunch(value) + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCase.swift new file mode 100644 index 00000000..b3e42726 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchPushNotificationQueryUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol FetchPushNotificationQueryUseCase { + func execute() -> PushNotificationQuery +} diff --git a/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCaseImpl.swift new file mode 100644 index 00000000..3ba9e974 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/PushNotification/FetchPushNotificationQueryUseCaseImpl.swift @@ -0,0 +1,23 @@ +// +// FetchPushNotificationQueryUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class FetchPushNotificationQueryUseCaseImpl: FetchPushNotificationQueryUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> PushNotificationQuery { + PushNotificationQuery( + sortOrder: repository.pushNotificationSortOrder(), + timeFilter: repository.pushNotificationTimeFilter(), + unreadOnly: repository.pushNotificationUnreadOnly(), + pageSize: 20 + ) + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCase.swift new file mode 100644 index 00000000..8170daef --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdatePushNotificationQueryUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol UpdatePushNotificationQueryUseCase { + func execute(_ query: PushNotificationQuery) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCaseImpl.swift new file mode 100644 index 00000000..9de023d0 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/PushNotification/UpdatePushNotificationQueryUseCaseImpl.swift @@ -0,0 +1,20 @@ +// +// UpdatePushNotificationQueryUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class UpdatePushNotificationQueryUseCaseImpl: UpdatePushNotificationQueryUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ query: PushNotificationQuery) { + repository.setPushNotificationSortOrder(query.sortOrder) + repository.setPushNotificationTimeFilter(query.timeFilter) + repository.setPushNotificationUnreadOnly(query.unreadOnly) + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCase.swift new file mode 100644 index 00000000..1be065c6 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchRecentSearchQueriesUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol FetchRecentSearchQueriesUseCase { + func execute() -> [String] +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCaseImpl.swift new file mode 100644 index 00000000..4645bd67 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Search/FetchRecentSearchQueriesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchRecentSearchQueriesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class FetchRecentSearchQueriesUseCaseImpl: FetchRecentSearchQueriesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> [String] { + repository.recentSearchQueries() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCase.swift new file mode 100644 index 00000000..5b7c4fc6 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateRecentSearchQueriesUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol UpdateRecentSearchQueriesUseCase { + func execute(_ queries: [String]) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCaseImpl.swift new file mode 100644 index 00000000..beefb4c4 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Search/UpdateRecentSearchQueriesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateRecentSearchQueriesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class UpdateRecentSearchQueriesUseCaseImpl: UpdateRecentSearchQueriesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ queries: [String]) { + repository.setRecentSearchQueries(queries) + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCase.swift new file mode 100644 index 00000000..463cfbf2 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCase.swift @@ -0,0 +1,12 @@ +// +// ObserveSystemThemeUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Combine + +protocol ObserveSystemThemeUseCase { + var publisher: AnyPublisher { get } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCaseImpl.swift new file mode 100644 index 00000000..932cb369 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Theme/ObserveSystemThemeUseCaseImpl.swift @@ -0,0 +1,20 @@ +// +// ObserveSystemThemeUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Combine + +final class ObserveSystemThemeUseCaseImpl: ObserveSystemThemeUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + var publisher: AnyPublisher { + repository.systemThemePublisher + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCase.swift new file mode 100644 index 00000000..351ed3c5 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateSystemThemeUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +protocol UpdateSystemThemeUseCase { + func execute(_ theme: SystemTheme) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCaseImpl.swift new file mode 100644 index 00000000..b96cd9b2 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Theme/UpdateSystemThemeUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateSystemThemeUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +final class UpdateSystemThemeUseCaseImpl: UpdateSystemThemeUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ theme: SystemTheme) { + repository.setSystemTheme(theme) + } +} diff --git a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift index 7479c554..ed6c29a5 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift @@ -64,26 +64,23 @@ final class PushNotificationViewModel: Store { private let fetchUseCase: FetchPushNotificationsUseCase private let deleteUseCase: DeletePushNotificationUseCase private let toggleReadUseCase: TogglePushNotificationReadUseCase - private let userDefaults: UserDefaults - - private enum DefaultsKey { - static let sortOption = "PushNotification.sortOption" - static let timeFilter = "PushNotification.timeFilter" - static let showUnreadOnly = "PushNotification.showUnreadOnly" - } + private let fetchQueryUseCase: FetchPushNotificationQueryUseCase + private let updateQueryUseCase: UpdatePushNotificationQueryUseCase init( fetchUseCase: FetchPushNotificationsUseCase, deleteUseCase: DeletePushNotificationUseCase, toggleReadUseCase: TogglePushNotificationReadUseCase, - userDefaults: UserDefaults = .standard + fetchQueryUseCase: FetchPushNotificationQueryUseCase, + updateQueryUseCase: UpdatePushNotificationQueryUseCase ) { self.fetchUseCase = fetchUseCase self.deleteUseCase = deleteUseCase self.toggleReadUseCase = toggleReadUseCase - self.userDefaults = userDefaults + self.fetchQueryUseCase = fetchQueryUseCase + self.updateQueryUseCase = updateQueryUseCase self.state = State( - query: Self.loadQuery(userDefaults: userDefaults) + query: fetchQueryUseCase.execute() ) } @@ -182,24 +179,22 @@ private extension PushNotificationViewModel { setAlert(&state, isPresented: isPresented, for: type) case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest - saveSortOrder(state.query.sortOrder) + updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] case .setTimeFilter(let filter): state.query.timeFilter = filter - saveTimeFilter(filter) + updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] case .toggleUnreadOnly: state.query.unreadOnly.toggle() - userDefaults.set(state.query.unreadOnly, forKey: DefaultsKey.showUnreadOnly) + updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] case .resetFilters: state.query = .default - saveSortOrder(.latest) - saveTimeFilter(.none) - userDefaults.set(false, forKey: DefaultsKey.showUnreadOnly) + updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] case .tapNotification(let notification): @@ -288,40 +283,6 @@ private extension PushNotificationViewModel { } state.showToast = isPresented } - - static func loadQuery(userDefaults: UserDefaults) -> PushNotificationQuery { - let sortOrder = loadSortOrder(userDefaults: userDefaults) - let timeFilter = loadTimeFilter(userDefaults: userDefaults) - let unreadOnly = userDefaults.bool(forKey: DefaultsKey.showUnreadOnly) - - return PushNotificationQuery( - sortOrder: sortOrder, - timeFilter: timeFilter, - unreadOnly: unreadOnly, - pageSize: 20 - ) - } - - static func loadSortOrder(userDefaults: UserDefaults) -> PushNotificationQuery.SortOrder { - guard let rawValue = userDefaults.string(forKey: DefaultsKey.sortOption) else { - return .latest - } - return rawValue == "oldest" ? .oldest : .latest - } - - static func loadTimeFilter(userDefaults: UserDefaults) -> PushNotificationQuery.TimeFilter { - let id = userDefaults.string(forKey: DefaultsKey.timeFilter) ?? "none" - return PushNotificationQuery.TimeFilter(id: id) - } - - func saveSortOrder(_ order: PushNotificationQuery.SortOrder) { - let value = order == .oldest ? "oldest" : "latest" - userDefaults.set(value, forKey: DefaultsKey.sortOption) - } - - func saveTimeFilter(_ filter: PushNotificationQuery.TimeFilter) { - userDefaults.set(filter.id, forKey: DefaultsKey.timeFilter) - } } extension PushNotificationQuery.SortOrder { diff --git a/DevLog/Presentation/ViewModel/RootViewModel.swift b/DevLog/Presentation/ViewModel/RootViewModel.swift index 457abed9..0ff64a04 100644 --- a/DevLog/Presentation/ViewModel/RootViewModel.swift +++ b/DevLog/Presentation/ViewModel/RootViewModel.swift @@ -16,12 +16,14 @@ final class RootViewModel: Store { var isNetworkConnected: Bool = true var isFirstLaunch: Bool var signIn: Bool? + var theme: SystemTheme = .automatic } enum Action { case setAlert(Bool) case networkStatusChanged(Bool) case setFirstLaunch(Bool) + case setTheme(SystemTheme) case signOutAuto case didLogined(Bool) } @@ -33,22 +35,33 @@ final class RootViewModel: Store { @Published private(set) var state: State private let connectivityProvider = NWPathConnectivityProvider() private var cancellables = Set() - private let userDefaults = UserDefaults.standard - private let firstLaunchKey = "isFirstLaunch" private let sessionUseCase: AuthSessionUseCase private let signOutUseCase: SignOutUseCase + private let fetchFirstLaunchUseCase: FetchFirstLaunchUseCase + private let updateFirstLaunchUseCase: UpdateFirstLaunchUseCase + private let observeSystemThemeUseCase: ObserveSystemThemeUseCase + private let updateSystemThemeUseCase: UpdateSystemThemeUseCase init( sessionUseCase: AuthSessionUseCase, - signOutUseCase: SignOutUseCase + signOutUseCase: SignOutUseCase, + fetchFirstLaunchUseCase: FetchFirstLaunchUseCase, + updateFirstLaunchUseCase: UpdateFirstLaunchUseCase, + observeSystemThemeUseCase: ObserveSystemThemeUseCase, + updateSystemThemeUseCase: UpdateSystemThemeUseCase ) { - let isFirstLaunch = userDefaults.object(forKey: firstLaunchKey) as? Bool ?? true + let isFirstLaunch = fetchFirstLaunchUseCase.execute() self.sessionUseCase = sessionUseCase self.signOutUseCase = signOutUseCase - self.state = State(isFirstLaunch: isFirstLaunch, signIn: nil) + self.fetchFirstLaunchUseCase = fetchFirstLaunchUseCase + self.updateFirstLaunchUseCase = updateFirstLaunchUseCase + self.observeSystemThemeUseCase = observeSystemThemeUseCase + self.updateSystemThemeUseCase = updateSystemThemeUseCase + self.state = State(isFirstLaunch: isFirstLaunch) setupNetworkMonitoring() setupSessionMonitoring() + setupThemeMonitoring() } func reduce(with action: Action) -> [SideEffect] { @@ -57,22 +70,19 @@ final class RootViewModel: Store { switch action { case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) - case .networkStatusChanged(let isConnected): let wasConnected = state.isNetworkConnected state.isNetworkConnected = isConnected - if wasConnected && !isConnected { setAlert(&state, isPresented: true) } - case .setFirstLaunch(let value): state.isFirstLaunch = value - userDefaults.set(value, forKey: firstLaunchKey) - + updateFirstLaunchUseCase.execute(value) + case .setTheme(let theme): + state.theme = theme case .signOutAuto: return [.signOut] - case .didLogined(let result): state.signIn = result } @@ -85,18 +95,26 @@ final class RootViewModel: Store { switch effect { case .signOut: Task { - do { - try await signOutUseCase.execute() - send(.didLogined(false)) - sessionUseCase.execute(false) - } catch { - // Silent fail for auto sign out - } + try? await signOutUseCase.execute() + send(.didLogined(false)) + sessionUseCase.execute(false) } } } - - private func setupNetworkMonitoring() { +} + +// MARK: - Helper Methods +private extension RootViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "네트워크 연결 끊김" + state.alertMessage = "인터넷 연결을 확인해주세요." + state.showAlert = isPresented + } + + func setupNetworkMonitoring() { connectivityProvider.isConnectedPublisher .dropFirst() .removeDuplicates() @@ -106,8 +124,8 @@ final class RootViewModel: Store { } .store(in: &cancellables) } - - private func setupSessionMonitoring() { + + func setupSessionMonitoring() { sessionUseCase.signedInPublisher .removeDuplicates() .receive(on: DispatchQueue.main) @@ -116,16 +134,14 @@ final class RootViewModel: Store { } .store(in: &cancellables) } -} -// MARK: - Helper Methods -private extension RootViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = "네트워크 연결 끊김" - state.alertMessage = "인터넷 연결을 확인해주세요." - state.showAlert = isPresented + func setupThemeMonitoring() { + observeSystemThemeUseCase.publisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + self?.send(.setTheme(theme)) + } + .store(in: &cancellables) } } diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 3b7e4fca..d810073d 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -45,13 +45,10 @@ final class SearchViewModel: Store { @Published private(set) var state: State = .init() private let fetchWebPagesUseCase: FetchWebPagesUseCase private let fetchTodosByKeywordUseCase: FetchTodosByKeywordUseCase - private let userDefaults: UserDefaults + private let fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase + private let updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase let contentsLimit: Int = 5 - private enum DefaultsKey { - static let recentQueries = "Search.recentQueries" - } - private let maxRecentQueries = 20 private let searchDebounceDelay: Double = 0.4 private var searchDebounceTask: Task? @@ -59,12 +56,14 @@ final class SearchViewModel: Store { init( fetchWebPagesUseCase: FetchWebPagesUseCase, fetchTodosByKeywordUseCase: FetchTodosByKeywordUseCase, - userDefaults: UserDefaults = .standard + fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase, + updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase ) { self.fetchWebPagesUseCase = fetchWebPagesUseCase self.fetchTodosByKeywordUseCase = fetchTodosByKeywordUseCase - self.userDefaults = userDefaults - self.state.recentQueries = Self.loadRecentQueries(userDefaults: userDefaults) + self.fetchRecentSearchQueriesUseCase = fetchRecentSearchQueriesUseCase + self.updateRecentSearchQueriesUseCase = updateRecentSearchQueriesUseCase + self.state.recentQueries = OrderedSet(fetchRecentSearchQueriesUseCase.execute()) } func reduce(with action: Action) -> [SideEffect] { @@ -176,11 +175,7 @@ private extension SearchViewModel { searchDebounceTask = nil } - static func loadRecentQueries(userDefaults: UserDefaults) -> OrderedSet { - OrderedSet(userDefaults.stringArray(forKey: DefaultsKey.recentQueries) ?? []) - } - func saveRecentQueries(_ queries: OrderedSet) { - userDefaults.set(Array(queries), forKey: DefaultsKey.recentQueries) + updateRecentSearchQueriesUseCase.execute(Array(queries)) } } diff --git a/DevLog/Presentation/ViewModel/SettingViewModel.swift b/DevLog/Presentation/ViewModel/SettingViewModel.swift index 32c262d0..7a688627 100644 --- a/DevLog/Presentation/ViewModel/SettingViewModel.swift +++ b/DevLog/Presentation/ViewModel/SettingViewModel.swift @@ -6,10 +6,11 @@ // import Foundation +import Combine final class SettingViewModel: Store { struct State { - var theme = "" + var theme: SystemTheme = .automatic var dirSize: Int64 = 0 var isLoading = false var showAlert: Bool = false @@ -23,7 +24,7 @@ final class SettingViewModel: Store { enum Action { case setAlert(isPresented: Bool, type: AlertType? = nil) case setLoading(Bool) - case setTheme(String) + case setTheme(SystemTheme) case updateDirSize case tapDeleteAuthButton case tapSignOutButton @@ -44,15 +45,23 @@ final class SettingViewModel: Store { private let deleteAuthuseCase: DeleteAuthUseCase private let signOutUseCase: SignOutUseCase private let sessionUseCase: AuthSessionUseCase + private let observeSystemThemeUseCase: ObserveSystemThemeUseCase + private let updateSystemThemeUseCase: UpdateSystemThemeUseCase + private var cancellables = Set() init( deleteAuthUseCase: DeleteAuthUseCase, signOutUseCase: SignOutUseCase, - sessionUseCase: AuthSessionUseCase + sessionUseCase: AuthSessionUseCase, + observeSystemThemeUseCase: ObserveSystemThemeUseCase, + updateSystemThemeUseCase: UpdateSystemThemeUseCase ) { self.deleteAuthuseCase = deleteAuthUseCase self.signOutUseCase = signOutUseCase self.sessionUseCase = sessionUseCase + self.observeSystemThemeUseCase = observeSystemThemeUseCase + self.updateSystemThemeUseCase = updateSystemThemeUseCase + setupThemeMonitoring() } func reduce(with action: Action) -> [SideEffect] { @@ -63,6 +72,7 @@ final class SettingViewModel: Store { state.isLoading = value case .setTheme(let value): state.theme = value + updateSystemThemeUseCase.execute(value) case .updateDirSize: state.dirSize = dirSizeInBytes() case .tapDeleteAuthButton: @@ -139,6 +149,16 @@ private extension SettingViewModel { state.alertType = type } + func setupThemeMonitoring() { + observeSystemThemeUseCase.publisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + self?.send(.setTheme(theme)) + } + .store(in: &cancellables) + } + func dirSizeInBytes() -> Int64 { do { let cachesDir = try FileManager.default.url( diff --git a/DevLog/Storage/Persistence/ThemeStore.swift b/DevLog/Storage/Persistence/ThemeStore.swift new file mode 100644 index 00000000..17970d12 --- /dev/null +++ b/DevLog/Storage/Persistence/ThemeStore.swift @@ -0,0 +1,20 @@ +// +// ThemeStore.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Combine + +final class ThemeStore { + private let subject = CurrentValueSubject(.automatic) + + var themePublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + + func send(_ theme: SystemTheme) { + subject.send(theme) + } +} diff --git a/DevLog/Storage/Persistence/UserDefaultsStore.swift b/DevLog/Storage/Persistence/UserDefaultsStore.swift new file mode 100644 index 00000000..4c964719 --- /dev/null +++ b/DevLog/Storage/Persistence/UserDefaultsStore.swift @@ -0,0 +1,40 @@ +// +// UserDefaultsStore.swift +// DevLog +// +// Created by 최윤진 on 2/25/26. +// + +import Foundation + +final class UserDefaultsStore { + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func string(forKey key: String) -> String? { + userDefaults.string(forKey: key) + } + + func setString(_ value: String?, forKey key: String) { + userDefaults.set(value, forKey: key) + } + + func stringArray(forKey key: String) -> [String] { + userDefaults.stringArray(forKey: key) ?? [] + } + + func setStringArray(_ value: [String], forKey key: String) { + userDefaults.set(value, forKey: key) + } + + func bool(forKey key: String) -> Bool { + userDefaults.bool(forKey: key) + } + + func setBool(_ value: Bool, forKey key: String) { + userDefaults.set(value, forKey: key) + } +} diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 66b8be49..c4e30a3c 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -26,7 +26,9 @@ struct MainView: View { PushNotificationView(viewModel: PushNotificationViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), - toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self) + toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), + fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), + updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) )) .tabItem { Image(systemName: "bell.fill") diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index f874e1c5..397fc155 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -90,7 +90,9 @@ struct HomeView: View { )) { SearchView(viewModel: SearchViewModel( fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - fetchTodosByKeywordUseCase: container.resolve(FetchTodosByKeywordUseCase.self) + fetchTodosByKeywordUseCase: container.resolve(FetchTodosByKeywordUseCase.self), + fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), + updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) )) } .alert( diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 5f10592f..7f6459e0 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -96,7 +96,9 @@ struct ProfileView: View { SettingView(viewModel: SettingViewModel( deleteAuthUseCase: container.resolve(DeleteAuthUseCase.self), signOutUseCase: container.resolve(SignOutUseCase.self), - sessionUseCase: container.resolve(AuthSessionUseCase.self) + sessionUseCase: container.resolve(AuthSessionUseCase.self), + observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), + updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) )) .environmentObject(router) } diff --git a/DevLog/UI/Setting/SettingView.swift b/DevLog/UI/Setting/SettingView.swift index 2e4b9745..b2d561c7 100644 --- a/DevLog/UI/Setting/SettingView.swift +++ b/DevLog/UI/Setting/SettingView.swift @@ -8,7 +8,6 @@ import SwiftUI struct SettingView: View { - @AppStorage("theme") var theme: SystemTheme = .automatic @Environment(\.diContainer) var container: DIContainer @StateObject var viewModel: SettingViewModel @EnvironmentObject var router: NavigationRouter @@ -23,7 +22,7 @@ struct SettingView: View { Text("테마") .foregroundStyle(Color.primary) Spacer() - Text(viewModel.state.theme) + Text(viewModel.state.theme.localizedName) .foregroundStyle(Color.gray) } } @@ -115,7 +114,12 @@ struct SettingView: View { .navigationDestination(for: Path.self) { path in switch path { case .theme: - ThemeView() + ThemeView( + theme: Binding( + get: { viewModel.state.theme }, + set: { viewModel.send(.setTheme($0)) } + ) + ) case .pushNotification: PushNotificationSettingsView( viewModel: PushNotificationSettingsViewModel( @@ -149,7 +153,6 @@ struct SettingView: View { } } .onAppear { - viewModel.send(.setTheme(theme.localizedName)) viewModel.send(.updateDirSize) } } diff --git a/DevLog/UI/Setting/ThemeView.swift b/DevLog/UI/Setting/ThemeView.swift index 390c4e96..2f7595b6 100644 --- a/DevLog/UI/Setting/ThemeView.swift +++ b/DevLog/UI/Setting/ThemeView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ThemeView: View { - @AppStorage("theme") var theme: SystemTheme = .automatic + @Binding var theme: SystemTheme var body: some View { List {