diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 6914781d..446ab5e7 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -39,7 +39,7 @@ public struct GoalCreateView: View { .safeAreaInset(edge: .top, spacing: 0) { GoalCreateTopBar( isSaving: store.isSaving, - onClose: dismiss.callAsFunction + onClose: { dismiss() } ) } .safeAreaInset(edge: .bottom, spacing: 0) { diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index 995c1345..8bfb3706 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -79,7 +79,7 @@ public struct RecordDetailView: View { onCompletion: finishEditing ) } - .navigationDestination(isPresented: $isHistoryPresented) { + .navigationDestination(isPresented: $isHistoryPresented, interactivePop: true) { RecordVersionHistoryView(store: store) } .overlay { @@ -95,7 +95,7 @@ public struct RecordDetailView: View { private var topBar: some View { HStack { - NavigationBackButton(action: dismiss.callAsFunction) + NavigationBackButton(action: { dismiss() }) .disabled(store.isRestoring) Spacer() } diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 5a5edc04..4c397c1a 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -34,27 +34,24 @@ public struct GoalDetailView: View { private var mainContent: some View { ScrollView { - LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { - Section { - GoalDescriptionCard( - description: store.goal?.description ?? "", - isLoading: !store.hasLoaded && store.isLoading - ) - timelineCard - GoalLinkedTodoCard( - todos: store.linkedTodos, - isLoading: store.isTodoLoading, - hasLoadFailure: store.hasTodoLoadFailure, - allowsManagement: store.hasLoaded && store.allowsTodoLinkMutation, - onManage: { store.send(.view(.manageTodos)) }, - onRetry: { store.send(.view(.retryTodos)) }, - onSelect: { store.send(.view(.selectTodo($0))) } - ) - } header: { - titleBar - } - .padding(.horizontal) + LazyVStack(spacing: 12) { + titleBar + GoalDescriptionCard( + description: store.goal?.description ?? "", + isLoading: !store.hasLoaded && store.isLoading + ) + timelineCard + GoalLinkedTodoCard( + todos: store.linkedTodos, + isLoading: store.isTodoLoading, + hasLoadFailure: store.hasTodoLoadFailure, + allowsManagement: store.hasLoaded && store.allowsTodoLinkMutation, + onManage: { store.send(.view(.manageTodos)) }, + onRetry: { store.send(.view(.retryTodos)) }, + onSelect: { store.send(.view(.selectTodo($0))) } + ) } + .padding(.horizontal) } .safeAreaInset(edge: .top, spacing: 0) { topBar } .safeAreaInset(edge: .bottom, spacing: 0) { recordActionBar } @@ -79,7 +76,10 @@ public struct GoalDetailView: View { .sheet(item: $store.scope(state: \.todoLinkSheet, action: \.todoLinkSheet)) { GoalTodoLinkSheet(store: $0) } - .navigationDestination(item: $store.scope(state: \.recordDetail, action: \.recordDetail)) { destination in + .navigationDestination( + item: $store.scope(state: \.recordDetail, action: \.recordDetail), + interactivePop: true + ) { destination in RecordDetailView( goalTitle: store.goalTitle, record: destination.record, @@ -104,13 +104,13 @@ public struct GoalDetailView: View { private var topBar: some View { HStack { if #available(iOS 26.0, *) { - Button(action: dismiss.callAsFunction) { + Button(action: { dismiss() }) { Image(systemName: "xmark") } .topBarButtonStyle(tint: Color.accent) .disabled(store.isTransitioning) } else { - Button(action: dismiss.callAsFunction) { + Button(action: { dismiss() }) { Text(RecordPresentation.text("common_close")) } .topBarButtonStyle(tint: Color.accent) diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift index 494592a6..9c96a150 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift @@ -51,7 +51,7 @@ struct RecordVersionDetailView: View { private var topBar: some View { HStack { - NavigationBackButton(action: dismiss.callAsFunction) + NavigationBackButton(action: { dismiss() }) .disabled(store.isRestoring) Spacer() } diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift index fdac4f37..8774b402 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -37,7 +37,7 @@ struct RecordVersionHistoryView: View { .safeAreaInset(edge: .bottom, spacing: 0) { footer } .background(Color.appBackground) .toolbarVisibility(.hidden, for: .navigationBar) - .navigationDestination(item: $destination) { destination in + .navigationDestination(item: $destination, interactivePop: true) { destination in RecordVersionDetailView( store: store, version: destination.version @@ -47,7 +47,7 @@ struct RecordVersionHistoryView: View { private var topBar: some View { HStack { - NavigationBackButton(action: dismiss.callAsFunction) + NavigationBackButton(action: { dismiss() }) Spacer() } .padding(.horizontal) diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift index 0542e85c..9a67715e 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift @@ -55,5 +55,11 @@ struct GoalDetailDestinationTests { await store.send(.view(.selectRecord(item))) { $0.recordDetail = RecordDetailDestination(record: record) } + await store.send(.recordDetail(.dismiss)) { + $0.recordDetail = nil + } + await store.send(.view(.selectRecord(item))) { + $0.recordDetail = RecordDetailDestination(record: record) + } } } diff --git a/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift b/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift index ffd1b7f0..11a7f6f9 100644 --- a/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift +++ b/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift @@ -100,11 +100,7 @@ struct DevelopmentGoalSection: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.textSecondary) } - .accessibilityLabel( - String(localized: "development_goal_create_title", bundle: PresentationResources.bundle) - ) } - .accessibilityElement(children: .combine) } } @@ -160,7 +156,6 @@ private struct DevelopmentGoalCard: View { .padding(18) .background(Color.surface, in: .rect(cornerRadius: 24)) .contentShape(.rect) - .accessibilityElement(children: .combine) } } @@ -212,7 +207,6 @@ struct DevelopmentTodoProgressView: View { } .frame(height: 8) } - .accessibilityElement(children: .combine) } } } diff --git a/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift b/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift index 4e7e0e21..1170703e 100644 --- a/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift +++ b/Application/Presentation/HomeTab/Sources/Home/DevelopmentSummaryCard.swift @@ -36,7 +36,6 @@ struct DevelopmentSummaryCard: View { } .padding(20) .background(Color.surface, in: .rect(cornerRadius: 28)) - .accessibilityElement(children: .combine) } @ViewBuilder diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index 939822a1..082e9c2d 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -46,19 +46,14 @@ public struct HomeView: View { public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { - Section { - DevelopmentSummaryCard( - items: store.developmentGoalItems, - isLoading: store.isDevelopmentGoalsLoading, - hasLoaded: store.hasDevelopmentGoalsLoaded, - hasLoadFailure: store.hasDevelopmentGoalsLoadFailure - ) - todoSection - } header: { - topBar - .toolbarBackground(Color.appBackground) - } + LazyVStack(alignment: .leading, spacing: 16) { + DevelopmentSummaryCard( + items: store.developmentGoalItems, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure + ) + todoSection DevelopmentGoalSection( items: store.developmentGoalItems, isLoading: store.isDevelopmentGoalsLoading, @@ -71,6 +66,7 @@ public struct HomeView: View { } .padding(.horizontal, 16) } + .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground) .toolbarVisibility(.hidden, for: .navigationBar) .navigationDestination(for: HomeRoute.self, destination: destinationView) @@ -158,8 +154,10 @@ public struct HomeView: View { .disabled(!store.isNetworkConnected) } } + .padding(.horizontal, 16) .padding(.bottom, 8) .background(Color.appBackground) + .toolbarBackground(Color.appBackground) } @ViewBuilder @@ -347,7 +345,6 @@ public struct HomeView: View { color: item.color, in: RoundedRectangle(cornerRadius: 20) ) - .accessibilityHidden(true) Text(item.localizedName) .font(.subheadline) .foregroundStyle(Color.textSecondary) diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index ebbe16ab..eed20697 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -17,27 +17,29 @@ struct SearchView: View { var body: some View { NavigationStack(path: $router.path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { - Section { - if !store.searchQuery.isEmpty { - SearchResults( - store: store, - onSelectTodo: { router.push(.todo($0)) } - ) - .padding(.bottom, 8) - } - RecentSearchQuries(store: store) - .padding(.bottom, 8) - instruction - } header: { - tipCard - .padding(.bottom, 8) - .background(Color.appBackground) + LazyVStack(alignment: .leading, spacing: 16) { + if !store.searchQuery.isEmpty { + SearchResults( + store: store, + onSelectTodo: { router.push(.todo($0)) } + ) + .padding(.bottom, 8) } + RecentSearchQuries(store: store) + .padding(.bottom, 8) + instruction } .padding(.horizontal) } - .safeAreaInset(edge: .top, spacing: 0) { topBar } + .safeAreaInset(edge: .top, spacing: 0) { + VStack(spacing: 0) { + topBar + tipCard + .padding(.horizontal) + .padding(.bottom, 8) + } + .background(Color.appBackground) + } .background(Color.appBackground.ignoresSafeArea()) .prominentAlert(store, state: \.alert, action: \.alert) .navigationDestination(for: Path.self) { path in diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index 0d72b22e..c39bbae5 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -64,21 +64,20 @@ public struct PushNotificationListView: View { let sections = store.query.sortOrder == .latest ? [recent, previous] : [previous, recent] ScrollView { - LazyVStack(alignment: .leading, spacing: 20, pinnedViews: [.sectionHeaders]) { - Section { - if notifications.isEmpty { - Text(String(localized: "push_notifications_empty", bundle: PresentationResources.bundle)) - .font(.callout) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 32) - .background(Color.surface, in: .rect(cornerRadius: 16)) - } else { - ForEach(sections.indices, id: \.self) { index in - let items = sections[index] - let isRecent = (index == 0) == (store.query.sortOrder == .latest) - if !items.isEmpty { - VStack(alignment: .leading, spacing: 12) { + LazyVStack(alignment: .leading, spacing: 20) { + if notifications.isEmpty { + Text(String(localized: "push_notifications_empty", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(Color.surface, in: .rect(cornerRadius: 16)) + } else { + ForEach(sections.indices, id: \.self) { index in + let items = sections[index] + let isRecent = (index == 0) == (store.query.sortOrder == .latest) + if !items.isEmpty { + VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Text(String( localized: isRecent ? "push_notifications_new" : "push_notifications_previous", @@ -181,28 +180,32 @@ public struct PushNotificationListView: View { } } } - } } } } - } header: { - VStack(alignment: .leading, spacing: 12) { - Text(String(localized: "nav_push_notifications", bundle: PresentationResources.bundle)) - .font(.largeTitle.bold()) - .padding(.bottom, 8) - ScrollView(.horizontal) { - headerContent - } - .scrollIndicators(.hidden) - .padding(.horizontal, -16) - .contentMargins(.horizontal, 16, for: .scrollContent) - } + } + } + .padding(.horizontal, 16) + } + .safeAreaInset(edge: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "nav_push_notifications", bundle: PresentationResources.bundle)) + .font(.largeTitle.bold()) .padding(.bottom, 8) - .background(Color.appBackground) - .toolbarBackground(Color.appBackground) + + ScrollView(.horizontal) { + headerContent } + .scrollIndicators(.hidden) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, -16) + .contentMargins(.horizontal, 16, for: .scrollContent) } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) + .padding(.bottom, 8) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) } .scrollDisabled(notifications.isEmpty || store.isLoading) } diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift new file mode 100644 index 00000000..c028743d --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift @@ -0,0 +1,173 @@ +// +// View+NavigationDestination.swift +// PresentationShared +// +// Created by opfic on 9/25/26. +// + +import SwiftUI +import SwiftUINavigation +import UIComposable + +public extension View { + func navigationDestination( + item: Binding, + interactivePop: Bool, + @ViewBuilder destination: @escaping (Item) -> Destination + ) -> some View { + navigationDestination(item: item) { value in + destination(value) + .background { + if interactivePop { + NavigationPopGestureController() + .composable { controller in + controller.onInteractivePopCompleted = { + guard item.wrappedValue != nil else { return } + item.wrappedValue = nil + } + } + } + } + } + } + + func navigationDestination( + isPresented: Binding, + interactivePop: Bool, + @ViewBuilder destination: @escaping () -> Destination + ) -> some View { + navigationDestination(isPresented: isPresented) { + destination() + .background { + if interactivePop { + NavigationPopGestureController() + .composable { controller in + controller.onInteractivePopCompleted = { + guard isPresented.wrappedValue else { return } + isPresented.wrappedValue = false + } + } + } + } + } + } + + func navigationDestination( + for type: Item.Type, + interactivePop: @escaping (Item) -> Bool, + @ViewBuilder destination: @escaping (Item) -> Destination + ) -> some View { + navigationDestination(for: type) { value in + destination(value) + .background { + if interactivePop(value) { + NavigationPopGestureController().composable() + } + } + } + } +} + +@MainActor +private final class NavigationPopGestureController: UIViewController, UICoordinatedComposable { + var onInteractivePopCompleted: () -> Void = { } + private weak var coordinator: Coordinator? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + coordinator?.configure(navigationController) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + coordinator?.configure(navigationController) + } + + func makeCoordinator() -> Coordinator { + Coordinator(completion: onInteractivePopCompleted) + } + + func connect(coordinator: Coordinator) { + self.coordinator = coordinator + } + + func update(coordinator: Coordinator) { + coordinator.completion = onInteractivePopCompleted + coordinator.configure(navigationController) + } + + func disconnect(coordinator: Coordinator) { + self.coordinator = nil + coordinator.restore() + } + + @MainActor + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + private weak var recognizer: UIGestureRecognizer? + private weak var controller: UINavigationController? + private weak var delegate: (any UIGestureRecognizerDelegate)? + private var isObserving = false + var completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + func configure(_ controller: UINavigationController?) { + guard let controller, + let gesture = controller.interactivePopGestureRecognizer else { return } + + if recognizer !== gesture { + restore() + recognizer = gesture + delegate = gesture.delegate + gesture.addTarget(self, action: #selector(handlePopGesture(_:))) + } + + self.controller = controller + gesture.delegate = self + gesture.isEnabled = 1 < controller.viewControllers.count + } + + func restore() { + guard let recognizer else { return } + + recognizer.removeTarget(self, action: #selector(handlePopGesture(_:))) + if recognizer.delegate === self { + recognizer.delegate = delegate + } + self.recognizer = nil + controller = nil + delegate = nil + isObserving = false + } + + func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { + guard let controller else { return false } + return 1 < controller.viewControllers.count + && controller.transitionCoordinator == nil + } + + @objc + private func handlePopGesture(_ recognizer: UIGestureRecognizer) { + guard recognizer.state == .changed, + !isObserving, + let transition = controller?.transitionCoordinator else { + return + } + + isObserving = true + transition.notifyWhenInteractionChanges { context in + self.isObserving = false + guard !context.isCancelled else { return } + self.completion() + } + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 1a293234..4387af8c 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -36,34 +36,39 @@ public struct ProfileView: View { public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { - Section { - UserInfoCard(store: store, isSelected: isSelected) - ActivityCard(store: store) { todoId in - path.append(.activity(todoId)) - } - GoalSummaryCard( - goals: store.developmentGoals, - isLoading: store.isDevelopmentGoalsLoading, - hasLoaded: store.hasDevelopmentGoalsLoaded, - hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, - onRetry: { store.send(.retryDevelopmentGoals) } - ) - RecentActivityCard(store: store) { todoId in - path.append(.recentTodo(todoId)) - } - } header: { - titleBar - .toolbarBackground(Color.appBackground) + LazyVStack(alignment: .leading, spacing: 16) { + UserInfoCard(store: store, isSelected: isSelected) + ActivityCard(store: store) { todoId in + path.append(.activity(todoId)) + } + GoalSummaryCard( + goals: store.developmentGoals, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, + onRetry: { store.send(.retryDevelopmentGoals) } + ) + RecentActivityCard(store: store) { todoId in + path.append(.recentTodo(todoId)) } } .padding(.horizontal, 16) .padding(.bottom, 24) } + .safeAreaInset(edge: .top, spacing: 0) { titleBar } .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar) .background(Color.appBackground) - .navigationDestination(for: ProfileRoute.self, destination: destinationView) + .navigationDestination( + for: ProfileRoute.self, + interactivePop: { + switch $0 { + case .settings, .theme: true + default: false + } + }, + destination: destinationView + ) } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { @@ -99,8 +104,10 @@ public struct ProfileView: View { .topBarButtonStyle() } } + .padding(.horizontal, 16) .padding(.bottom, 8) .background(Color.appBackground) + .toolbarBackground(Color.appBackground) } @ViewBuilder diff --git a/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift index b561148d..0e22d72a 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/RecentActivityCard.swift @@ -60,7 +60,6 @@ private struct RecentTodoCard: View { color: category.color, in: RoundedRectangle(cornerRadius: 12) ) - .accessibilityHidden(true) VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { @@ -101,7 +100,6 @@ private struct RecentTodoCard: View { Image(systemName: "chevron.right") .font(.callout.bold()) .foregroundStyle(Color.textTertiary) - .accessibilityHidden(true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) diff --git a/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift index d9ad180e..37a142f6 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/UserInfoCard.swift @@ -75,7 +75,7 @@ struct UserInfoCard: View { .padding(8) .background( RoundedRectangle(cornerRadius: 10) - .fill(Color.accent.opacity(0.08)) + .fill(Color.surfaceSecondary) ) if store.showDoneButton { Button { diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountCard.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountCard.swift index f1eab7a6..8519bc9a 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountCard.swift @@ -29,7 +29,6 @@ struct AccountCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) Text(String(localized: "settings_account", bundle: PresentationResources.bundle)) .foregroundStyle(Color.primary) @@ -39,13 +38,11 @@ struct AccountCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .opacity(isNetworkConnected ? 1 : 0.5) - .accessibilityElement(children: .combine) } .buttonStyle(.plain) .disabled(!isNetworkConnected) @@ -53,7 +50,6 @@ struct AccountCard: View { Divider() .padding(.leading, 64) .padding(.trailing, 16) - .accessibilityHidden(true) Button(action: onSignOut) { HStack(spacing: 12) { @@ -61,7 +57,6 @@ struct AccountCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .danger, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) Text(String(localized: "settings_sign_out", bundle: PresentationResources.bundle)) .foregroundStyle(Color.danger) @@ -75,13 +70,11 @@ struct AccountCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .opacity(isNetworkConnected ? 1 : 0.5) - .accessibilityElement(children: .combine) } .buttonStyle(.plain) .disabled(!isNetworkConnected || isLoading) diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AppInformationCard.swift b/Application/Presentation/ProfileTab/Sources/Settings/AppInformationCard.swift index c867129d..46a74a47 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AppInformationCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AppInformationCard.swift @@ -27,8 +27,6 @@ struct AppInformationCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) - Text(String(localized: "settings_version", bundle: PresentationResources.bundle)) .foregroundStyle(Color.primary) @@ -40,7 +38,6 @@ struct AppInformationCard: View { } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) } if let privacyPolicyURL { @@ -48,7 +45,6 @@ struct AppInformationCard: View { Divider() .padding(.leading, 64) .padding(.trailing, 16) - .accessibilityHidden(true) } Link(destination: privacyPolicyURL) { @@ -57,7 +53,6 @@ struct AppInformationCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) Text(String( localized: "settings_privacy_policy", @@ -70,12 +65,10 @@ struct AppInformationCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .accessibilityElement(children: .combine) } .buttonStyle(.plain) } @@ -85,7 +78,6 @@ struct AppInformationCard: View { Divider() .padding(.leading, 64) .padding(.trailing, 16) - .accessibilityHidden(true) } Link(destination: betaTestURL) { @@ -94,7 +86,6 @@ struct AppInformationCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(String(localized: "settings_join_beta", bundle: PresentationResources.bundle)) @@ -112,12 +103,10 @@ struct AppInformationCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .accessibilityElement(children: .combine) + .contentShape(.rect) } .buttonStyle(.plain) } diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AppSettingsCard.swift b/Application/Presentation/ProfileTab/Sources/Settings/AppSettingsCard.swift index b255fb25..aee936fa 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AppSettingsCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AppSettingsCard.swift @@ -28,8 +28,6 @@ struct AppSettingsCard: View { .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) - Text(String(localized: "settings_theme", bundle: PresentationResources.bundle)) .foregroundStyle(Color.primary) @@ -42,28 +40,22 @@ struct AppSettingsCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .accessibilityElement(children: .combine) } .buttonStyle(.plain) Divider() .padding(.leading, 64) .padding(.trailing, 16) - .accessibilityHidden(true) - Button(action: onNotifications) { HStack(spacing: 12) { Image(systemName: "bell") .font(.headline) .frame(width: iconSize, height: iconSize) .iconStyle(color: .accent, in: RoundedRectangle(cornerRadius: 10)) - .accessibilityHidden(true) - Text(String(localized: "settings_notifications", bundle: PresentationResources.bundle)) .foregroundStyle(Color.primary) @@ -72,13 +64,11 @@ struct AppSettingsCard: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .accessibilityHidden(true) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .opacity(isNetworkConnected ? 1 : 0.5) - .accessibilityElement(children: .combine) } .buttonStyle(.plain) .disabled(!isNetworkConnected) diff --git a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift index e77642ae..771787d4 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift @@ -11,7 +11,6 @@ import PresentationShared struct SettingsView: View { @Environment(\.dismiss) private var dismiss - @ScaledMetric(relativeTo: .body) private var deleteAccountButtonHeight = CGFloat(22) @Bindable var store: StoreOf let onNavigate: (ProfileRoute) -> Void @@ -22,41 +21,38 @@ struct SettingsView: View { var body: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { - Section { - AppSettingsCard( - themeName: store.theme.localizedName(in: PresentationResources.bundle), - isNetworkConnected: store.isNetworkConnected, - onTheme: { onNavigate(.theme) }, - onNotifications: { onNavigate(.pushNotification) } - ) - - if store.appVersion != nil || privacyPolicyURL != nil || store.betaTestURL != nil { - AppInformationCard( - appVersion: store.appVersion, - privacyPolicyURL: privacyPolicyURL, - betaTestURL: store.betaTestURL - ) - } + LazyVStack(alignment: .leading, spacing: 24) { + AppSettingsCard( + themeName: store.theme.localizedName(in: PresentationResources.bundle), + isNetworkConnected: store.isNetworkConnected, + onTheme: { onNavigate(.theme) }, + onNotifications: { onNavigate(.pushNotification) } + ) - AccountCard( - isNetworkConnected: store.isNetworkConnected, - isLoading: store.isLoading, - showsSignOutProgress: store.activeLoadingRow == .signOut, - onAccount: { onNavigate(.account) }, - onSignOut: { store.send(.setAlert(.signOut)) } + if store.appVersion != nil || privacyPolicyURL != nil || store.betaTestURL != nil { + AppInformationCard( + appVersion: store.appVersion, + privacyPolicyURL: privacyPolicyURL, + betaTestURL: store.betaTestURL ) - - deleteAccountContent - } header: { - topBar } + + AccountCard( + isNetworkConnected: store.isNetworkConnected, + isLoading: store.isLoading, + showsSignOutProgress: store.activeLoadingRow == .signOut, + onAccount: { onNavigate(.account) }, + onSignOut: { store.send(.setAlert(.signOut)) } + ) + + deleteAccountContent } - .toolbarVisibility(.hidden, for: .navigationBar) .padding(.horizontal, 16) .padding(.bottom, 24) } + .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground) + .toolbarVisibility(.hidden, for: .navigationBar) .prominentAlert(store, state: \.alert, action: \.alert) } @@ -66,10 +62,11 @@ struct SettingsView: View { .font(.headline) HStack { - NavigationBackButton(action: dismiss.callAsFunction) + NavigationBackButton(action: { dismiss() }) Spacer() } } + .padding(.horizontal, 16) .padding(.bottom, 12) .background(Color.appBackground) .toolbarBackground(Color.appBackground) @@ -91,10 +88,10 @@ struct SettingsView: View { } } .font(.system(.body)) - .contentShape(.rect(cornerRadius: 12)) .frame(maxWidth: .infinity) - .frame(height: deleteAccountButtonHeight + 24) - .background(Color.surface, in: .rect(cornerRadius: 12)) + .padding(.vertical, 24) + .contentShape(.rect(cornerRadius: 16)) + .background(Color.surface, in: .rect(cornerRadius: 16)) } .buttonStyle(.plain) .disabled(!store.isNetworkConnected || store.isLoading) diff --git a/Application/Presentation/ProfileTab/Sources/Settings/ThemeView.swift b/Application/Presentation/ProfileTab/Sources/Settings/ThemeView.swift index 9fb90be5..bc39e871 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/ThemeView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/ThemeView.swift @@ -10,53 +10,96 @@ import Core import PresentationShared struct ThemeView: View { + @Environment(\.dismiss) private var dismiss @Binding var theme: SystemTheme var body: some View { - List { - Button(action: { - theme = .automatic - }) { - HStack { - Text(SystemTheme.automatic.localizedName(in: PresentationResources.bundle)) - .foregroundStyle(Color.primary) - Spacer() - if theme == .automatic { - Image(systemName: "checkmark") - } - } - } - Button(action: { - theme = .light - }) { - HStack { - Text(SystemTheme.light.localizedName(in: PresentationResources.bundle)) - .foregroundStyle(Color.primary) - Spacer() - if theme == .light { - Image(systemName: "checkmark") - } - } - } - Button(action: { - theme = .dark - }) { - HStack { - Text(SystemTheme.dark.localizedName(in: PresentationResources.bundle)) - .foregroundStyle(Color.primary) - Spacer() - if theme == .dark { - Image(systemName: "checkmark") + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + VStack(spacing: 0) { + ForEach(Self.themes) { option in + themeButton(option) + + if option != Self.themes.last { + Divider() + .padding(.leading, 68) + .padding(.trailing, 16) + } } } + .background(Color.surface, in: .rect(cornerRadius: 16)) } + .padding(.horizontal, 16) + .padding(.bottom, 24) } - .listStyle(.insetGrouped) - .toolbar { - ToolbarItem(placement: .principal) { - Text(String(localized: "nav_theme", bundle: PresentationResources.bundle)) - .bold() + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground) + .toolbarVisibility(.hidden, for: .navigationBar) + } + + private static let themes: [SystemTheme] = [.automatic, .light, .dark] + + private var topBar: some View { + ZStack { + Text(String(localized: "nav_theme", bundle: PresentationResources.bundle)) + .font(.headline) + HStack { + NavigationBackButton(action: { dismiss() }) + Spacer() } } + .padding(.horizontal, 16) + .padding(.bottom, 12) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) + } + + private func themeButton(_ option: SystemTheme) -> some View { + HStack(spacing: 12) { + Image(systemName: option.symbolName) + .font(.headline) + .frame(width: 36, height: 36) + .iconStyle(color: option.iconColor, in: Circle()) + + Text(option.localizedName(in: PresentationResources.bundle)) + .foregroundStyle(Color.primary) + + Spacer(minLength: 8) + + Image(systemName: theme == option ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(theme == option ? Color.accent : Color.border) + + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .onTapGesture { + theme = option + } + } +} + +private extension SystemTheme { + var symbolName: String { + switch self { + case .automatic: + return "circle.lefthalf.filled" + case .light: + return "sun.max.fill" + case .dark: + return "moon.fill" + } + } + + var iconColor: Color { + switch self { + case .automatic: + return .accent + case .light: + return .warning + case .dark: + return .onControlBackground + } } } diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 1566453a..3a1f5b63 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -34,35 +34,39 @@ public struct TodayView: View { NavigationStack(path: $path) { ScrollView { let sections = store.sections - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if sections.isEmpty, !store.isLoading { - emptyContent - } else { - ForEach(sections) { section in - TodoSection( - section: section, - isNavigationEnabled: !store.isTodoInspectorPresented, - onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, - onInspect: { store.send(.showTodoInspector($0)) } - ) - .padding(.bottom, section.id == sections.last?.id ? 0 : 8) - } - .padding(.bottom, 12) - } - } header: { - VStack(alignment: .leading) { - topBar - achievementCard - filterBar - } + LazyVStack(alignment: .leading, spacing: 0) { + achievementCard .padding(.bottom, 8) - .background(Color.appBackground) - .toolbarBackground(Color.appBackground) + + if sections.isEmpty, !store.isLoading { + emptyContent + } else { + ForEach(sections) { section in + TodoSection( + section: section, + isNavigationEnabled: !store.isTodoInspectorPresented, + onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, + onInspect: { store.send(.showTodoInspector($0)) } + ) + .padding(.bottom, section.id == sections.last?.id ? 0 : 8) + } + .padding(.bottom, 12) } } .padding(.horizontal) } + .safeAreaInset(edge: .top, spacing: 0) { + VStack(alignment: .leading) { + topBar + filterBar + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 8) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) + } .background(Color.appBackground) .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar)