diff --git a/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift index 0a9da6c6..e81d9e53 100644 --- a/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift +++ b/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift @@ -42,16 +42,16 @@ struct SystemTodoCategoryItem: Identifiable, Hashable { } } - var color: Color { + var color: UIColor { switch systemTodoCategory { - case .issue: return .red - case .feature: return .green - case .improvement: return .cyan - case .review: return .orange - case .test: return .purple - case .doc: return .yellow - case .research: return .teal - case .etc: return .gray + case .issue: return .systemRed + case .feature: return .systemGreen + case .improvement: return .systemCyan + case .review: return .systemOrange + case .test: return .systemPurple + case .doc: return .systemYellow + case .research: return .systemTeal + case .etc: return .systemGray } } } diff --git a/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift index 4c3c4374..af9da76c 100644 --- a/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift @@ -64,7 +64,7 @@ struct TodoCategoryItem: Identifiable, Hashable { var color: Color { switch category { case .system(let systemTodoCategory): - return SystemTodoCategoryItem(from: systemTodoCategory).color + return Color(SystemTodoCategoryItem(from: systemTodoCategory).color) case .user(let userTodoCategory): return UserTodoCategoryItem(from: userTodoCategory).color } diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index f369bab4..a5af7766 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -21,6 +21,7 @@ final class PushNotificationListViewModel: Store { var hasMore: Bool = false var nextCursor: PushNotificationCursor? var query: PushNotificationQuery + var selectedNotificationId: String? var selectedTodoId: TodoIdItem? } @@ -42,8 +43,7 @@ final class PushNotificationListViewModel: Store { case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly case resetFilters - case tapNotification(PushNotificationItem) - case setSelectedTodoId(TodoIdItem?) + case selectNotification(String?) } enum SideEffect { @@ -97,10 +97,10 @@ final class PushNotificationListViewModel: Store { switch action { case .deleteNotification, .toggleRead, .undoDelete, .setAlert, .toggleSortOption, - .setTimeFilter, .toggleUnreadOnly, .resetFilters, .tapNotification: + .setTimeFilter, .toggleUnreadOnly, .resetFilters, .selectNotification: effects = reduceByUser(action, state: &state) - case .fetchNotifications, .setToast, .setSelectedTodoId, .loadNextPage: + case .fetchNotifications, .setToast, .loadNextPage: effects = reduceByView(action, state: &state) case .setLoading, .appendNotifications, .resetPagination, .setHasMore, @@ -221,9 +221,19 @@ private extension PushNotificationListViewModel { updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] - case .tapNotification(let item): + case .selectNotification(let notificationId): + state.selectedNotificationId = notificationId + guard let notificationId else { + state.selectedTodoId = nil + return [] + } + guard let index = state.notifications.firstIndex(where: { $0.id == notificationId }) else { + state.selectedTodoId = nil + return [] + } + let item = state.notifications[index] state.selectedTodoId = TodoIdItem(id: item.todoId) - if let index = state.notifications.firstIndex(where: { $0.id == item.id }), !item.isRead { + if !item.isRead { state.notifications[index].isRead.toggle() return [.toggleRead(item.todoId)] } @@ -247,8 +257,6 @@ private extension PushNotificationListViewModel { state.notifications.removeAll { $0.isHidden } self.undoNotificationId = nil } - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId default: break } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 5de1745e..6469af01 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -425,6 +425,23 @@ } } }, + "home_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select an item." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 선택해주세요." + } + } + } + }, "home_recent_title" : { "extractionState" : "manual", "localizations" : { @@ -1064,6 +1081,23 @@ } } }, + "push_notifications_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a notification." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림을 선택해주세요." + } + } + } + }, "push_period" : { "extractionState" : "manual", "localizations" : { @@ -1897,6 +1931,23 @@ } } }, + "today_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a todo." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 선택해주세요." + } + } + } + }, "today_due_overdue" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index c0d717ae..8191e8fc 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -9,80 +9,446 @@ import SwiftUI struct MainView: View { @Environment(\.diContainer) var container: DIContainer + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel + @State private var homeNavigationRouter = NavigationRouter() + @State private var todayNavigationRouter = NavigationRouter() + @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab var body: some View { - TabView(selection: $selectedTab) { - HomeView(viewModel: HomeViewModel( - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), - addWebPageUseCase: container.resolve(AddWebPageUseCase.self), - deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), - undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) - )) - .tabItem { - Image(systemName: "house.fill") - Text(String(localized: "nav_home")) + content + .onAppear { + viewModel.send(.onAppear) } - .tag(MainTab.home) - TodayView(viewModel: TodayViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), - updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) - )) - .tabItem { - Image(systemName: "sun.max.fill") - Text(String(localized: "nav_today")) + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) } - .tag(MainTab.today) - PushNotificationListView(viewModel: PushNotificationListViewModel( - fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), - deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), - undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), - toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), - fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), - updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) - )) - .tabItem { - Image(systemName: "bell.fill") - Text(String(localized: "nav_notifications")) + } + + @ViewBuilder + private var content: some View { + if isCompactLayout { + tabView + } else { + sidebarView + } + } + + private var isCompactLayout: Bool { + horizontalSizeClass == .compact + } + + private var sidebarSelection: Binding { + Binding( + get: { selectedTab }, + set: { tab in + if let tab { + selectedTab = tab + } + } + ) + } + + private var tabView: some View { + TabView(selection: $selectedTab) { + homeView + .tabItem { + tabLabel(.home) + } + .tag(MainTab.home) + + todayView + .tabItem { + tabLabel(.today) + } + .tag(MainTab.today) + + notificationView + .tabItem { + tabLabel(.notification) + } + .badge(viewModel.state.unreadPushCount) + .tag(MainTab.notification) + + profileView + .tabItem { + tabLabel(.profile) + } + .tag(MainTab.profile) + } + } + + @ViewBuilder + private var sidebarView: some View { + switch selectedTab.mainTabSplitStyle { + case .detailOnly: + NavigationSplitView { + mainSidebar + } detail: { + selectedTabView } - .badge(viewModel.state.unreadPushCount) - .tag(MainTab.notification) - ProfileView(viewModel: ProfileViewModel( - fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), - fetchHeatmapActivityTypesUseCase: container.resolve(FetchHeatmapActivityTypesUseCase.self), - updateHeatmapActivityTypesUseCase: container.resolve(UpdateHeatmapActivityTypesUseCase.self) - )) - .tabItem { - Image(systemName: "person.crop.circle.fill") - Text(String(localized: "nav_profile")) + case .contentDetail: + switch selectedTab { + case .home: + NavigationSplitView { + mainSidebar + } content: { + homeView + } detail: { + homeRegularDetailView + } + .environment(homeNavigationRouter) + case .today: + NavigationSplitView { + mainSidebar + } content: { + todayView + } detail: { + todayRegularDetailView + } + .environment(todayNavigationRouter) + case .notification: + let viewModel = makePushNotificationListViewModel() + NavigationSplitView { + mainSidebar + } content: { + PushNotificationListView( + viewModel: viewModel, + todoIdToPresent: $todoIdToPresent, + isCompactLayout: isCompactLayout + ) + } detail: { + Group { + if let todoId = todoIdToPresent?.id { + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId, + showEditButton: false + )) + .id(todoId) + } else { + ContentUnavailableView( + String(localized: "push_notifications_select_detail"), + systemImage: "bell.badge" + ) + } + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + } + case .profile: + NavigationSplitView { + mainSidebar + } detail: { + selectedTabView + } } - .tag(MainTab.profile) } - .onAppear { - viewModel.send(.onAppear) + } + + private var mainSidebar: some View { + List(selection: sidebarSelection) { + sidebarRow(.home) + sidebarRow(.today) + sidebarRow(.notification) + sidebarRow(.profile) + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var selectedTabView: some View { + switch selectedTab { + case .home: + homeView + case .today: + todayView + case .notification: + notificationView + case .profile: + profileView + } + } + + @ViewBuilder + private func sidebarRow(_ tab: MainTab) -> some View { + if tab == .notification { + tabLabel(tab) + .badge(viewModel.state.unreadPushCount) + .tag(tab) + } else { + tabLabel(tab) + .tag(tab) + } + } + + private func tabLabel(_ tab: MainTab) -> some View { + Label { + Text(tab.title) + } icon: { + Image(systemName: tab.symbolName) + } + } + + @ViewBuilder + private var homeView: some View { + Group { + if isCompactLayout { + NavigationStack(path: $homeNavigationRouter.path) { + homeContentView + .navigationDestination(for: HomeRoute.self) { homeRoute in + homeDestinationView(homeRoute) + } + } + } else { + homeContentView + } + } + .environment(homeNavigationRouter) + } + + private var homeContentView: some View { + HomeView( + viewModel: makeHomeViewModel(), + isCompactLayout: isCompactLayout + ) + } + + private func makeHomeViewModel() -> HomeViewModel { + HomeViewModel( + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), + addWebPageUseCase: container.resolve(AddWebPageUseCase.self), + deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), + undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), + networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) + ) + } + + private var homeDetailPath: Binding<[HomeRoute]> { + Binding( + get: { homeNavigationRouter.detailPath }, + set: { homeNavigationRouter.detailPath = $0 } + ) + } + + @ViewBuilder + private var homeRegularDetailView: some View { + NavigationStack(path: homeDetailPath) { + Group { + if let homeRoute = homeNavigationRouter.root { + homeDestinationView(homeRoute) + } else { + ContentUnavailableView( + String(localized: "home_select_detail"), + systemImage: "house" + ) + } + } + .navigationDestination(for: HomeRoute.self) { homeRoute in + homeDestinationView(homeRoute) + } } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + } + + @ViewBuilder + private func homeDestinationView(_ homeRoute: HomeRoute) -> some View { + switch homeRoute { + case .category(let item): + TodoListView( + viewModel: makeTodoListViewModel(category: item.todoCategory) ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + .id(item.id) + case .todo(let item): + TodoDetailView(viewModel: makeTodoDetailViewModel(todoId: item.id)) + .id(item.id) + case .webPage(let item): + WebView(url: item.url) + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea() + .toolbar(.hidden, for: .tabBar) + .toolbar { + ToolbarItem(placement: .principal) { + Text(item.title) + .bold() + } + } + } + } + + private func makeTodoListViewModel(category: TodoCategory) -> TodoListViewModel { + TodoListViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), + undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), + category: category + ) + } + + private func makeTodoDetailViewModel(todoId: String) -> TodoDetailViewModel { + TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId + ) + } + + @ViewBuilder + private var todayView: some View { + Group { + if isCompactLayout { + NavigationStack(path: $todayNavigationRouter.path) { + todayContentView + .navigationDestination(for: TodayRoute.self) { todayRoute in + todayDestinationView(todayRoute) + } + } + } else { + todayContentView + } + } + .environment(todayNavigationRouter) + } + + private var todayContentView: some View { + TodayView( + viewModel: makeTodayViewModel(), + isCompactLayout: isCompactLayout + ) + } + + private func makeTodayViewModel() -> TodayViewModel { + TodayViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), + updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) + ) + } + + private var todayDetailPath: Binding<[TodayRoute]> { + Binding( + get: { todayNavigationRouter.detailPath }, + set: { todayNavigationRouter.detailPath = $0 } + ) + } + + @ViewBuilder + private var todayRegularDetailView: some View { + NavigationStack(path: todayDetailPath) { + Group { + if let todayRoute = todayNavigationRouter.root { + todayDestinationView(todayRoute) + } else { + ContentUnavailableView( + String(localized: "today_select_detail"), + systemImage: "sun.max" + ) + } + } + .navigationDestination(for: TodayRoute.self) { todayRoute in + todayDestinationView(todayRoute) + } + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + } + + @ViewBuilder + private func todayDestinationView(_ todayRoute: TodayRoute) -> some View { + switch todayRoute { + case .todo(let item): + TodoDetailView(viewModel: makeTodoDetailViewModel(todoId: item.id)) + .id(item.id) + } + } + + private var notificationView: some View { + PushNotificationListView( + viewModel: makePushNotificationListViewModel(), + todoIdToPresent: $todoIdToPresent, + isCompactLayout: isCompactLayout + ) + } + + private func makePushNotificationListViewModel() -> PushNotificationListViewModel { + PushNotificationListViewModel( + fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), + deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), + undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), + toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), + fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), + updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) + ) + } + + private var profileView: some View { + ProfileView(viewModel: ProfileViewModel( + fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), + networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), + fetchHeatmapActivityTypesUseCase: container.resolve(FetchHeatmapActivityTypesUseCase.self), + updateHeatmapActivityTypesUseCase: container.resolve(UpdateHeatmapActivityTypesUseCase.self) + )) + } +} + +private enum MainTabSplitStyle { + case detailOnly + case contentDetail +} + +private extension MainTab { + var mainTabSplitStyle: MainTabSplitStyle { + switch self { + case .home, .today, .notification: + .contentDetail + case .profile: + .detailOnly + } + } + + var title: String { + switch self { + case .home: + String(localized: "nav_home") + case .today: + String(localized: "nav_today") + case .notification: + String(localized: "nav_notifications") + case .profile: + String(localized: "nav_profile") + } + } + + var symbolName: String { + switch self { + case .home: + "house.fill" + case .today: + "sun.max.fill" + case .notification: + "bell.fill" + case .profile: + "person.crop.circle.fill" } } } diff --git a/DevLog/UI/Common/NavigationRouter.swift b/DevLog/UI/Common/NavigationRouter.swift index 1713ed69..956ab997 100644 --- a/DevLog/UI/Common/NavigationRouter.swift +++ b/DevLog/UI/Common/NavigationRouter.swift @@ -8,12 +8,31 @@ import SwiftUI @Observable -final class NavigationRouter { - var path = NavigationPath() +final class NavigationRouter { + var path: [Route] = [] - func push(_ element: any Hashable) { - Task { @MainActor in - path.append(element) + var root: Route? { + path.first + } + + var detailPath: [Route] { + get { + Array(path.dropFirst()) } + set { + if let route = root { + path = [route] + newValue + } else { + path = newValue + } + } + } + + func show(_ route: Route) { + path = [route] + } + + func push(_ route: Route) { + path.append(route) } } diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 64a6e655..a8199bf1 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -9,128 +9,95 @@ import SwiftUI struct HomeView: View { @Environment(\.diContainer) var container: any DIContainer - @State private var router = NavigationRouter() + @Environment(NavigationRouter.self) private var router @State var viewModel: HomeViewModel + let isCompactLayout: Bool @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) var body: some View { - NavigationStack(path: $router.path) { - List { - todoSection - recentTodoSection - webPageSection - } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_home")) - .navigationDestination(for: Path.self) { path in - switch path { - case .category(let item): - TodoListView(viewModel: TodoListViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), - undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - category: item.todoCategory - )) - .environment(router) - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) - case .web(let page): - WebView(url: page.url) - .navigationBarTitleDisplayMode(.inline) - .ignoresSafeArea() - .toolbar(.hidden, for: .tabBar) - .toolbar { - ToolbarItem(placement: .principal) { - Text(page.title) - .bold() - } - } - } - } - .toolbar { toolbar } - .sheet(isPresented: Binding( - get: { viewModel.state.reorderTodo }, - set: { viewModel.send(.setPresentation(.reorderTodo, $0)) } - )) { - TodoManageView( - viewModel: TodoManageViewModel(viewModel.state.preferences), - onDismiss: { array in - viewModel.send(.setPresentation(.reorderTodo, false)) - withAnimation { - viewModel.send(.orderTodoCategory(array)) - } + List { + todoSection + recentTodoSection + webPageSection + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_home")) + .toolbar { toolbar } + .sheet(isPresented: Binding( + get: { viewModel.state.reorderTodo }, + set: { viewModel.send(.setPresentation(.reorderTodo, $0)) } + )) { + TodoManageView( + viewModel: TodoManageViewModel(viewModel.state.preferences), + onDismiss: { array in + viewModel.send(.setPresentation(.reorderTodo, false)) + withAnimation { + viewModel.send(.orderTodoCategory(array)) } - ) - } - .sheet(isPresented: Binding( - get: { viewModel.state.showContentPicker }, - set: { _, _ in } - )) { - contentPicker - } - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showTodoEditor }, - set: { viewModel.send(.setPresentation(.todoEditor, $0)) } - )) { - if let selectedCategory = viewModel.state.selectedTodoCategory { - TodoEditorView( - viewModel: TodoEditorViewModel( - category: selectedCategory, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) - ), - onSubmit: { viewModel.send(.addTodo($0)) } - ) } - } - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showSearchView }, - set: { viewModel.send(.setPresentation(.searchView, $0)) } - )) { - SearchView(viewModel: SearchViewModel( - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), - updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) - )) - } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } + ) + } + .sheet(isPresented: Binding( + get: { viewModel.state.showContentPicker }, + set: { _, _ in } + )) { + contentPicker + } + .fullScreenCover(isPresented: Binding( + get: { viewModel.state.showTodoEditor }, + set: { viewModel.send(.setPresentation(.todoEditor, $0)) } + )) { + if let selectedCategory = viewModel.state.selectedTodoCategory { + TodoEditorView( + viewModel: TodoEditorViewModel( + category: selectedCategory, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) + ), + onSubmit: { viewModel.send(.addTodo($0)) } ) - ) { - alertButtons - } message: { - Text(viewModel.state.alertMessage) - } - .toast( - isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) } - ), - duration: 5, - action: { viewModel.send(.undoDeleteWebPage) } - ) { - Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - .font(.caption) - .multilineTextAlignment(.center) } - .onAppear { - viewModel.send(.onAppear) - } - .overlay { - if viewModel.state.isAppending { - LoadingView() - } + } + .fullScreenCover(isPresented: Binding( + get: { viewModel.state.showSearchView }, + set: { viewModel.send(.setPresentation(.searchView, $0)) } + )) { + SearchView(viewModel: SearchViewModel( + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), + updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) + )) + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + ) + ) { + alertButtons + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) } + ), + duration: 5, + action: { viewModel.send(.undoDeleteWebPage) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + .font(.caption) + .multilineTextAlignment(.center) + } + .onAppear { + viewModel.send(.onAppear) + } + .overlay { + if viewModel.state.isAppending { + LoadingView() } } } @@ -168,13 +135,7 @@ struct HomeView: View { } else { let preferences = viewModel.state.preferences ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in - NavigationLink(value: Path.category(item)) { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } + todoCategoryRow(item) } } }, header: { @@ -209,9 +170,7 @@ struct HomeView: View { } } else { ForEach(viewModel.state.recentTodos, id: \.id) { todo in - NavigationLink(value: Path.detail(todo.id)) { - RecentTodoRow(todo: todo) - } + recentTodoRow(todo) } } } header: { @@ -290,9 +249,63 @@ struct HomeView: View { } } + @ViewBuilder + private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { + if isCompactLayout { + NavigationLink(value: HomeRoute.category(item)) { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + } else { + Button { + router.show(.category(item)) + } label: { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private func recentTodoRow(_ item: RecentTodoItem) -> some View { + if isCompactLayout { + NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) { + RecentTodoRow(todo: item) + } + } else { + Button { + router.show(.todo(TodoIdItem(id: item.id))) + } label: { + RecentTodoRow(todo: item) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder private func webResultRow(_ item: WebPageItem) -> some View { - NavigationLink(value: Path.web(item)) { - WebItemRow(item: item, showsChevron: false) + Group { + if isCompactLayout { + NavigationLink(value: HomeRoute.webPage(item)) { + WebItemRow(item: item, showsChevron: false) + } + } else { + Button { + router.show(.webPage(item)) + } label: { + WebItemRow(item: item, showsChevron: false) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { @@ -381,13 +394,15 @@ struct HomeView: View { Spacer() } .padding(.vertical, -6) + .contentShape(.rect) } - private enum Path: Hashable { - case category(TodoCategoryItem) - case detail(String) - case web(WebPageItem) - } +} + +enum HomeRoute: Hashable { + case category(TodoCategoryItem) + case todo(TodoIdItem) + case webPage(WebPageItem) } private struct RecentTodoRow: View { diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 2b2d59c8..9dfcb9d8 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -8,13 +8,13 @@ import SwiftUI struct TodoListView: View { - @State var viewModel: TodoListViewModel - @Environment(NavigationRouter.self) var router + @Environment(NavigationRouter.self) private var router @Environment(\.diContainer) var container: DIContainer @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @State private var headerOffset: CGFloat = .zero @State private var isScrollTrackingEnabled = false + @State var viewModel: TodoListViewModel var body: some View { Group { @@ -51,17 +51,6 @@ struct TodoListView: View { ) } } - .navigationDestination(for: Path.self) { path in - switch path { - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) - } - } .alert( viewModel.state.alertTitle, isPresented: Binding( @@ -121,10 +110,11 @@ struct TodoListView: View { .task { viewModel.send(.onAppear) } } + @ViewBuilder private var todoListContent: some View { let visibleTodos = viewModel.state.todos.filter { !$0.isHidden } - return ZStack { + ZStack { List { Group { if visibleTodos.isEmpty, !viewModel.state.isLoading { @@ -138,7 +128,7 @@ struct TodoListView: View { } else { ForEach(Array(zip(visibleTodos.indices, visibleTodos)), id: \.1.id) { idx, todo in Button { - router.push(Path.detail(todo.id)) + selectTodo(todo.id) } label: { TodoItemRow(todo) } @@ -268,7 +258,7 @@ struct TodoListView: View { LazyVStack(spacing: 0) { ForEach(displayedTodos) { todo in Button { - router.push(Path.detail(todo.id)) + selectTodo(todo.id) } label: { VStack(spacing: 0) { TodoItemRow(todo) @@ -418,7 +408,7 @@ struct TodoListView: View { .background(Circle().fill(backgroundColor)) } -private enum Path: Hashable { - case detail(String) + private func selectTodo(_ todoId: String) { + router.push(.todo(TodoIdItem(id: todoId))) } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 947f612f..6d2e64dc 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -9,7 +9,7 @@ import SwiftUI struct ProfileView: View { @State var viewModel: ProfileViewModel - @State private var router = NavigationRouter() + @State private var router = NavigationRouter() @Environment(\.diContainer) private var container @FocusState private var focused: Bool @@ -88,14 +88,14 @@ struct ProfileView: View { ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 0) { Button { - router.push(Path.settings) + router.push(.settings) } label: { Image(systemName: "gearshape") } } } } - .navigationDestination(for: Path.self) { path in + .navigationDestination(for: ProfileRoute.self) { path in switch path { case .settings: SettingView(viewModel: SettingViewModel( @@ -116,6 +116,8 @@ struct ProfileView: View { todoId: todoId, showEditButton: false )) + case .theme, .pushNotification, .account: + EmptyView() } } .onAppear { viewModel.send(.onAppear) } @@ -350,7 +352,7 @@ struct ProfileView: View { ForEach(activities) { activity in Button { if !activity.isDeleted { - router.push(Path.activity(activity.todoId)) + router.push(.activity(activity.todoId)) } } label: { let item = TodoCategoryItem(from: activity.category) @@ -395,8 +397,12 @@ struct ProfileView: View { .padding(.top, 4) } - private enum Path: Hashable { - case settings - case activity(String) - } +} + +enum ProfileRoute: Hashable { + case settings + case activity(String) + case theme + case pushNotification + case account } diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 7b5b433a..25cc63e1 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -8,124 +8,160 @@ import SwiftUI struct PushNotificationListView: View { - @State private var router = NavigationRouter() - @State var viewModel: PushNotificationListViewModel @Environment(\.colorScheme) private var colorScheme @Environment(\.diContainer) private var container: DIContainer @ScaledMetric(relativeTo: .body) private var headerHeight = 41 + @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 + @State var viewModel: PushNotificationListViewModel @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + @Binding var todoIdToPresent: TodoIdItem? + let isCompactLayout: Bool var body: some View { - NavigationStack(path: $router.path) { - notificationList - .listStyle(.plain) + NavigationStack { + notificationListContent + } + .listStyle(.sidebar) + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + .alert( + "", + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + )) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) }), + duration: 5, + action: { viewModel.send(.undoDelete) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + .font(.caption) + .multilineTextAlignment(.center) + .lineLimit(3) + } + .sheet(item: Binding( + get: { isCompactLayout ? todoIdToPresent : nil }, + set: { item in + if item == nil { + selectNotification(nil) + } + } + )) { item in + NavigationStack { + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: item.id, + showEditButton: false + )) + .id(item.id) + .toolbar { + ToolbarLeadingButton { + selectNotification(nil) + } + } + } + .background(Color(.secondarySystemBackground)) + .presentationDragIndicator(.visible) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } + } + + private var notificationListContent: some View { + notificationList .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) .onScrollOffsetChange { offset in guard isScrollTrackingEnabled else { return } headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .background(Color(.secondarySystemBackground)) .onAppear { viewModel.send(.fetchNotifications) } .refreshable { viewModel.send(.fetchNotifications) } .navigationTitle(String(localized: "nav_push_notifications")) - .alert( - "", - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + } + + @ViewBuilder + private var notificationList: some View { + let notifications = viewModel.state.notifications.filter { !$0.isHidden } + if notifications.isEmpty { + HStack { + Spacer() + Text(String(localized: "push_notifications_empty")) + .foregroundStyle(Color.gray) + Spacer() } - .toast( - isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) }), - duration: 5, - action: { viewModel.send(.undoDelete) } - ) { - Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - .font(.caption) - .multilineTextAlignment(.center) - .lineLimit(3) + } else { + List( + Array(zip(notifications.indices, notifications)), + id: \.1.id + ) { index, notification in + notificationListRow(notification, index: index, notifications: notifications) + .listRowInsets(EdgeInsets()) + .listSectionSeparator(.hidden, edges: .top) + .listRowBackground(Color.clear) } - .sheet(item: Binding( - get: { viewModel.state.selectedTodoId }, - set: { viewModel.send(.setSelectedTodoId($0)) } - )) { item in - NavigationStack { - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: item.id, - showEditButton: false - )) - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setSelectedTodoId(nil)) - } - } - } - .background(Color(.secondarySystemBackground)) - .presentationDragIndicator(.visible) + } + } + + @ViewBuilder + private func notificationListRow( + _ notification: PushNotificationItem, + index: Int, + notifications: [PushNotificationItem] + ) -> some View { + if isCompactLayout { + Button { + selectNotification(notification.id) + } label: { + notificationRowContent(notification, index: index, notifications: notifications) } - .overlay { - if viewModel.state.isLoading { - LoadingView() + .buttonStyle(.plain) + } else { + notificationRowContent(notification, index: index, notifications: notifications) + .onTapGesture { + selectNotification(notification.id) + } + .accessibilityAddTraits(.isButton) + .accessibilityAction { + selectNotification(notification.id) } - } } } - private var notificationList: some View { - let visibleNotifications = viewModel.state.notifications.filter { !$0.isHidden } - return List { - Group { - if visibleNotifications.isEmpty { - HStack { - Spacer() - Text(String(localized: "push_notifications_empty")) - .foregroundStyle(Color.gray) - Spacer() - } - .listRowSeparator(.hidden) - } else { - ForEach( - Array(zip(visibleNotifications.indices, visibleNotifications)), - id: \.1.id - ) { index, notification in - Button { - viewModel.send(.tapNotification(notification)) - } label: { - notificationRow(notification) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .onAppear { - let lastId = visibleNotifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) - } - } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) - } - } - } - } + private func notificationRowContent( + _ notification: PushNotificationItem, + index: Int, + notifications: [PushNotificationItem] + ) -> some View { + notificationRow( + notification, + isSelected: !isCompactLayout && viewModel.state.selectedNotificationId == notification.id + ) + .onAppear { + let lastId = notifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { + viewModel.send(.loadNextPage) + } + } + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if index == 0 { + Divider() + .padding(.horizontal, -16) } } - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) } } @@ -260,7 +296,10 @@ struct PushNotificationListView: View { } // swiftlint:disable function_body_length - private func notificationRow(_ item: PushNotificationItem) -> some View { + private func notificationRow( + _ item: PushNotificationItem, + isSelected: Bool + ) -> some View { HStack { VStack { let todoCategoryItem = TodoCategoryItem(from: item.todoCategory) @@ -281,10 +320,11 @@ struct PushNotificationListView: View { VStack(alignment: .leading, spacing: 5) { Text(item.title) .font(.headline) + .foregroundStyle(isSelected ? Color.white : Color(.label)) .lineLimit(1) Text(item.body) .font(.subheadline) - .foregroundStyle(Color.gray) + .foregroundStyle(isSelected ? Color.white : .gray) .lineLimit(1) } @@ -293,11 +333,15 @@ struct PushNotificationListView: View { TimelineView(.periodic(from: .now, by: 1.0)) { context in Text(timeAgoText(from: item.receivedAt, now: context.date)) .font(.caption2) - .foregroundStyle(Color.gray) + .foregroundStyle(isSelected ? Color.white : .gray) } } - .padding(.vertical, 5) - .contentShape(.rect) + .padding(8) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.blue : .clear) + } .swipeActions(edge: .leading) { Button { viewModel.send(.toggleRead(item)) @@ -347,4 +391,9 @@ struct PushNotificationListView: View { ) } } + + private func selectNotification(_ notificationId: String?) { + viewModel.send(.selectNotification(notificationId)) + todoIdToPresent = viewModel.state.selectedTodoId + } } diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 8db33875..fc43299a 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -10,7 +10,7 @@ import SwiftUI struct SearchView: View { @Environment(\.dismiss) private var dismiss @Environment(\.diContainer) private var container: DIContainer - @State private var router = NavigationRouter() + @State private var router = NavigationRouter() @State var viewModel: SearchViewModel var body: some View { diff --git a/DevLog/UI/Setting/SettingView.swift b/DevLog/UI/Setting/SettingView.swift index e0e1d542..2d9f8baa 100644 --- a/DevLog/UI/Setting/SettingView.swift +++ b/DevLog/UI/Setting/SettingView.swift @@ -9,15 +9,15 @@ import SwiftUI struct SettingView: View { @Environment(\.diContainer) var container: DIContainer + @Environment(NavigationRouter.self) private var router @State var viewModel: SettingViewModel - @Environment(NavigationRouter.self) var router var body: some View { let connected = viewModel.state.isNetworkConnected Form { Section { Button { - router.push(Path.theme) + router.push(.theme) } label: { HStack { Text(String(localized: "settings_theme")) @@ -29,7 +29,7 @@ struct SettingView: View { } Button { - router.push(Path.pushNotification) + router.push(.pushNotification) } label: { Text(String(localized: "settings_notifications")) .foregroundStyle(connected ? Color.primary : Color.secondary) @@ -83,7 +83,7 @@ struct SettingView: View { Section { Button { - router.push(Path.account) + router.push(.account) } label: { Text(String(localized: "settings_account")) } @@ -110,7 +110,7 @@ struct SettingView: View { } .navigationTitle(String(localized: "nav_settings")) .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: Path.self) { path in + .navigationDestination(for: ProfileRoute.self) { path in switch path { case .theme: ThemeView( @@ -134,6 +134,8 @@ struct SettingView: View { unlinkProviderUseCase: container.resolve(UnlinkAuthProviderUseCase.self) ) ) + case .settings, .activity: + EmptyView() } } .alert( @@ -156,10 +158,6 @@ struct SettingView: View { } } - private enum Path: Hashable { - case theme, pushNotification, account - } - @ViewBuilder private var alertButtons: some View { switch viewModel.state.alertType { diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index fff76f17..ab7fc181 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -8,54 +8,41 @@ import SwiftUI struct TodayView: View { - @Environment(\.diContainer) private var container: any DIContainer - @State private var router = NavigationRouter() + @Environment(NavigationRouter.self) private var router @State var viewModel: TodayViewModel + let isCompactLayout: Bool var body: some View { - NavigationStack(path: $router.path) { - List { - summarySection - if viewModel.sections.isEmpty, !viewModel.state.isLoading { - emptySection - } else { - ForEach(viewModel.sections) { section in - todoSection(section.title, items: section.items) - } - } - } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_today")) - .toolbar { toolbarContent } - .navigationDestination(for: Path.self) { path in - switch path { - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) + List { + summarySection + if viewModel.sections.isEmpty, !viewModel.state.isLoading { + emptySection + } else { + ForEach(viewModel.sections) { section in + todoSection(section.title, items: section.items) } } - .background(NavigationBarConfigurator()) - .refreshable { viewModel.send(.refresh) } - .onAppear { viewModel.send(.onAppear) } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } - .overlay { - if viewModel.state.isLoading { - LoadingView() - } + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_today")) + .toolbar { toolbarContent } + .background(NavigationBarConfigurator()) + .refreshable { viewModel.send(.refresh) } + .onAppear { viewModel.send(.onAppear) } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() } } } @@ -145,10 +132,7 @@ struct TodayView: View { if !items.isEmpty { Section { ForEach(items) { item in - NavigationLink(value: Path.detail(item.id)) { - TodayTodoRow(item: item) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - } + todoRow(item) .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { viewModel.send(.togglePinned(item)) @@ -173,6 +157,25 @@ struct TodayView: View { } } + @ViewBuilder + private func todoRow(_ item: TodayTodoItem) -> some View { + if isCompactLayout { + NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { + TodayTodoRow(item: item) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + } else { + Button { + router.show(.todo(TodoIdItem(id: item.id))) + } label: { + TodayTodoRow(item: item) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .buttonStyle(.plain) + } + } + private var emptyStateContent: EmptyStateContent { switch viewModel.state.selectedSectionScope { case .all: @@ -208,10 +211,10 @@ struct TodayView: View { let title: String let message: String } +} - private enum Path: Hashable { - case detail(String) - } +enum TodayRoute: Hashable { + case todo(TodoIdItem) } private extension TodayDisplayOptions.DueDateVisibility {