diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b417fec0..35a2ee7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: env: WORKSPACE: DevLog.xcworkspace SCHEME: App - XCODE_VERSION: "26.5" + XCODE_VERSION: "27.0" MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} @@ -19,7 +19,7 @@ permissions: jobs: build: name: Build - runs-on: macos-latest + runs-on: xcode-27 timeout-minutes: 30 steps: - uses: actions/checkout@v5 @@ -241,7 +241,7 @@ jobs: test: name: Test (${{ matrix.name }}) - runs-on: macos-latest + runs-on: xcode-27 timeout-minutes: 30 strategy: fail-fast: false diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 64722690..6914781d 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -323,7 +323,6 @@ private struct GoalCreateSaveBar: View { Group { if isSaving { ProgressView() - .tint(Color.white) } else { Text(RecordPresentation.text("development_goal_save")) } diff --git a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift index daa11523..ff49fa63 100644 --- a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift +++ b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift @@ -19,7 +19,6 @@ struct GoalDescriptionCard: View { if isLoading { ProgressView() - .tint(Color.accent) .frame(maxWidth: .infinity) .padding(.vertical, 16) } else if description.isEmpty { diff --git a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift index 40fa205f..e084f1d3 100644 --- a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift +++ b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift @@ -34,7 +34,6 @@ struct GoalCreateTodoSelectionSheet: View { if store.isLoading, store.todos.isEmpty { ProgressView() - .tint(Color.accent) .frame(maxWidth: .infinity) .padding(.vertical, 40) } else if store.hasLoadFailure, store.todos.isEmpty { diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index eaa4e369..b3a18dff 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -201,7 +201,6 @@ public struct RecordEditorView: View { Group { if store.isLoading { ProgressView() - .tint(Color.white) } else { Text(RecordPresentation.text( store.isCorrection diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 3127c28b..5a5edc04 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -197,7 +197,6 @@ public struct GoalDetailView: View { } } else if !store.hasLoaded || store.isLoading, store.items.isEmpty { ProgressView() - .tint(Color.accent) .frame(maxWidth: .infinity) .padding(.vertical, 32) } else if store.items.isEmpty { diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift index 9a2f0d97..97579de8 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift @@ -51,7 +51,6 @@ struct GoalLinkedTodoCard: View { private var content: some View { if isLoading, todos.isEmpty { ProgressView() - .tint(Color.accent) .frame(maxWidth: .infinity) .padding(.vertical, 20) } else if hasLoadFailure, todos.isEmpty { diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift index 5d373b7c..1702ec93 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift @@ -136,7 +136,6 @@ struct GoalTodoLinkSheet: View { private var sheetContent: some View { if store.isLoading, store.todos.isEmpty { ProgressView() - .tint(Color.accent) .frame(maxWidth: .infinity) .padding(.vertical, 40) } else if store.hasLoadFailure, store.todos.isEmpty { diff --git a/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift b/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift index 227d3c6d..6df0a902 100644 --- a/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift +++ b/Application/Presentation/Entry/Sources/Common/Component/LoginButton.swift @@ -33,7 +33,6 @@ struct LoginButton: View { Group { if showsProgressView { ProgressView() - .tint(Color.accent) } else { Text(text) .foregroundStyle(Color.textSecondary) diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index 03b83ae5..ebbe16ab 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -224,7 +224,6 @@ private struct SearchResults: View { .padding(.vertical, 32) } else if store.isLoading && todos.isEmpty { ProgressView() - .tint(Color.accent) } else if todos.isEmpty { Text(String( localized: "todo_list_search_empty", diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index 73dccc6e..0d72b22e 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -10,10 +10,7 @@ import Core import PresentationShared public struct PushNotificationListView: View { - @ScaledMetric(relativeTo: .body) private var headerHeight = 41 - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 - @State private var headerOffset: CGFloat = 0 - @State private var isScrollTrackingEnabled = false + @ScaledMetric(relativeTo: .headline) private var iconSize = 36 @State private var store: StoreOf private let isSelected: Bool @@ -32,20 +29,13 @@ public struct PushNotificationListView: View { public var body: some View { NavigationStack { notificationListContent - .listStyle(.plain) - .background(Color(.systemGroupedBackground)) - .background(NavigationBarConfigurator(alwaysVisible: true)) + .background(Color.appBackground) .refreshable(isEnabled: PullToRefreshAvailability.isEnabled) { let task = store.send(.view(.refresh)) await task.finish() store.send(.view(.startObserving)) } - .onScrollOffsetChange { offset in - guard isScrollTrackingEnabled else { return } - headerOffset = max(0, -offset) - } - .safeAreaInset(edge: .top) { safeAreaHeader } - .navigationTitle(String(localized: "nav_push_notifications", bundle: PresentationResources.bundle)) + .toolbarVisibility(.hidden, for: .navigationBar) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet(item: sheetStore.activePresentation(when: isSelected)) { store in @@ -69,125 +59,172 @@ public struct PushNotificationListView: View { @ViewBuilder private var notificationListContent: some View { let notifications = store.notifications.filter { !$0.isHidden } - List { - Group { - if notifications.isEmpty { - HStack { - Spacer() + let recent = notifications.filter { Calendar.autoupdatingCurrent.isDateInToday($0.receivedAt) } + let previous = notifications.filter { !Calendar.autoupdatingCurrent.isDateInToday($0.receivedAt) } + 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)) - .foregroundStyle(Color.gray) - Spacer() - } - .listRowSeparator(.hidden) - } else { - ForEach( - Array(zip(notifications.indices, notifications)), - id: \.1.id - ) { index, notification in - notificationListRow(notification, index: index, notifications: notifications) - .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) - } - } - } - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) - } - .scrollDisabled(notifications.isEmpty || store.isLoading) - } + .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", + bundle: PresentationResources.bundle + )) + .font(.title3.bold()) + Text("\(items.count)") + .font(.callout.bold()) + .foregroundStyle(Color.accent) + Spacer() + } - @ViewBuilder - private func notificationListRow( - _ notification: PushNotificationItem, - index: Int, - notifications: [PushNotificationItem] - ) -> some View { - Button { - store.send(.view(.selectNotification(notification.id))) - } label: { - notificationRowContent(notification, index: index, notifications: notifications) - } - .buttonStyle(.plain) - } + LazyVStack(spacing: 12) { + ForEach(items) { item in + let category = TodoCategoryItem(from: item.todoCategory) - private func notificationRowContent( - _ notification: PushNotificationItem, - index: Int, - notifications: [PushNotificationItem] - ) -> some View { - notificationRow( - notification, - isSelected: false - ) - .onAppear { - let lastId = notifications.last?.id - if notification.id == lastId, store.nextCursor != nil { - store.send(.view(.loadNextPage)) - } - } - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) - } - } - } - } + HStack(alignment: .top, spacing: 12) { + Image(systemName: category.symbolName) + .font(.headline) + .foregroundStyle(Color.white) + .frame(width: iconSize, height: iconSize) + .background(category.color, in: .rect(cornerRadius: 10)) - private var safeAreaHeader: some View { - VStack(spacing: 4) { - ScrollView(.horizontal) { - headerContent - } - .scrollIndicators(.never) - .contentMargins(.leading, 16, for: .scrollContent) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: headerHeight) - .onAppear { - headerOffset = 0 - isScrollTrackingEnabled = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isScrollTrackingEnabled = true + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(item.title) + .font(.headline) + .foregroundStyle( + item.isRead ? Color.textSecondary : .primary + ) + .lineLimit(2) + + Spacer(minLength: 4) + + TimelineView(.periodic(from: .now, by: 1.0)) { context in + Text(timeAgoText(from: item.receivedAt, now: context.date)) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .lineLimit(1) + } + + if !item.isRead { + Circle() + .fill(Color.accent) + .frame(width: 7, height: 7) + } + } + + Text(item.body) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(3) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surface, in: .rect(cornerRadius: 16)) + .contentShape(.rect(cornerRadius: 16)) + .onTapGesture { + store.send(.view(.selectNotification(item.id))) + } + .itemActions { + Button { + store.send(.view(.toggleRead(item))) + } label: { + HStack(spacing: 6) { + Image(systemName: item.isRead + ? "circle.badge.xmark" : "checkmark.circle") + Text(String( + localized: item.isRead + ? "push_mark_unread" : "push_mark_read", + bundle: PresentationResources.bundle + )) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + Button(role: .destructive) { + store.send(.view(.deleteNotification(item))) + presentDeleteNotificationToast(item.id) + } label: { + HStack(spacing: 6) { + Image(systemName: "trash") + Text(String( + localized: "common_delete", + bundle: PresentationResources.bundle + )) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } + .clipShape(.rect(cornerRadius: 16)) + .onAppear { + if item.id == notifications.last?.id, store.nextCursor != nil { + store.send(.view(.loadNextPage)) + } + } + } + } + } + } + } + } + } 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(.bottom, 8) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) } } - if #unavailable(iOS 26) { - Divider() - .padding(.horizontal, -16) - } - } - .background { - if #available(iOS 26.0, *) { - Color.clear - } else { - Color(.systemGroupedBackground) - } + .padding(.horizontal, 16) } - .offset(y: headerOffset) + .scrollDisabled(notifications.isEmpty || store.isLoading) } private var headerContent: some View { - HStack(spacing: 8) { - if 0 < store.appliedFilterCount { - Menu { - Text( - String.localizedStringWithFormat( - String(localized: "push_filters_applied_format", bundle: PresentationResources.bundle), - Int64(store.appliedFilterCount) - ) - ) - Button(role: .destructive) { - store.send(.view(.resetFilters)) - } label: { - Text(String(localized: "push_clear_all_filters", bundle: PresentationResources.bundle)) - } - } label: { - HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - .foregroundStyle(Color.onControlBackground) + LazyHStack(spacing: 8) { + Button { + if 0 < store.appliedFilterCount { + store.send(.view(.resetFilters)) + } + } label: { + HStack(spacing: 6) { + Text(String(localized: "push_timefilter_all", bundle: PresentationResources.bundle)) + if 0 < store.appliedFilterCount { filterBadge } - .adaptiveButtonStyle(color: .controlBackground) } + .font(.callout) + .foregroundStyle(store.appliedFilterCount == 0 ? Color.onPrimaryContainer : .onControlBackground) + .adaptiveButtonStyle( + color: store.appliedFilterCount == 0 ? .primaryContainer : .controlBackground + ) } Button { @@ -202,10 +239,10 @@ public struct PushNotificationListView: View { store.query.sortOrder.title ) ) + .font(.callout) .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } - .frame(height: headerHeight) Menu { Picker(selection: $store.query.timeFilter) { @@ -221,6 +258,7 @@ public struct PushNotificationListView: View { Text(String(localized: "push_period", bundle: PresentationResources.bundle)) Image(systemName: "chevron.down") } + .font(.callout) .foregroundStyle(condition ? Color.onControlBackground : .onPrimaryContainer) .adaptiveButtonStyle(color: condition ? .controlBackground : .primaryContainer) } @@ -232,119 +270,21 @@ public struct PushNotificationListView: View { } label: { let condition = store.query.unreadOnly Text(String(localized: "push_unread", bundle: PresentationResources.bundle)) + .font(.callout) .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } - .frame(height: headerHeight) } } private var filterBadge: some View { Text("\(store.appliedFilterCount)") .font(.caption2.weight(.bold)) - .foregroundStyle(Color.onPrimaryContainer) + .foregroundStyle(Color.accent) .lineLimit(1) .minimumScaleFactor(0.6) .frame(width: 20, height: 20) - .background(Circle().fill(Color.primaryContainer)) - } - - // swiftlint:disable function_body_length - private func notificationRow( - _ item: PushNotificationItem, - isSelected: Bool - ) -> some View { - HStack { - VStack { - let todoCategoryItem = TodoCategoryItem(from: item.todoCategory) - RoundedRectangle(cornerRadius: 8) - .fill(todoCategoryItem.color) - .frame(width: labelWidth, height: labelWidth) - .overlay { - Image(systemName: todoCategoryItem.symbolName) - .foregroundStyle(Color.white) - .font(.title3) - } - Circle() - .fill(Color.blue) - .frame(width: 8, height: 8) - .opacity(item.isRead ? 0 : 1) - } - - VStack(alignment: .leading, spacing: 5) { - Text(item.title) - .font(.headline) - .foregroundStyle(isSelected ? Color.white : Color(.label)) - .lineLimit(1) - Text(item.body) - .font(.subheadline) - .foregroundStyle(isSelected ? Color.white : .gray) - .lineLimit(1) - } - - Spacer() - - TimelineView(.periodic(from: .now, by: 1.0)) { context in - Text(timeAgoText(from: item.receivedAt, now: context.date)) - .font(.caption2) - .foregroundStyle(isSelected ? Color.white : .gray) - } - } - .padding(8) - .contentShape(RoundedRectangle(cornerRadius: 8)) - .background { - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.blue : .clear) - } - .swipeActions(edge: .leading) { - Button { - store.send(.view(.toggleRead(item))) - } label: { - Image(systemName: "checkmark.circle\(item.isRead ? ".badge.xmark" : "")") - .tint(.blue) - } - } - .swipeActions(edge: .trailing) { - Button( - role: .destructive, - action: { - store.send(.view(.deleteNotification(item))) - presentDeleteNotificationToast(item.id) - } - ) { - Image(systemName: "trash") - } - } - } - // swiftlint:enable function_body_length - - private func timeAgoText(from date: Date, now: Date) -> String { - let seconds = Int(now.timeIntervalSince(date)) - - if seconds < 60 { - return String.localizedStringWithFormat( - String(localized: "push_time_seconds_ago_format", bundle: PresentationResources.bundle), - Int64(max(0, seconds)) - ) - } else if seconds < 3600 { - let minutes = seconds / 60 - return String.localizedStringWithFormat( - String(localized: "push_time_minutes_ago_format", bundle: PresentationResources.bundle), - Int64(minutes) - ) - } else if seconds < 86400 { - let hours = seconds / 3600 - return String.localizedStringWithFormat( - String(localized: "push_time_hours_ago_format", bundle: PresentationResources.bundle), - Int64(hours) - ) - } else { - let days = seconds / 86400 - return String.localizedStringWithFormat( - String(localized: "push_time_days_ago_format", bundle: PresentationResources.bundle), - Int64(days) - ) - } + .background(Circle().fill(Color.accent.opacity(0.1))) } @ViewBuilder @@ -393,4 +333,33 @@ public struct PushNotificationListView: View { } ) } + + private func timeAgoText(from date: Date, now: Date) -> String { + let seconds = Int(now.timeIntervalSince(date)) + + if seconds < 60 { + return String.localizedStringWithFormat( + String(localized: "push_time_seconds_ago_format", bundle: PresentationResources.bundle), + Int64(max(0, seconds)) + ) + } else if seconds < 3600 { + let minutes = seconds / 60 + return String.localizedStringWithFormat( + String(localized: "push_time_minutes_ago_format", bundle: PresentationResources.bundle), + Int64(minutes) + ) + } else if seconds < 86400 { + let hours = seconds / 3600 + return String.localizedStringWithFormat( + String(localized: "push_time_hours_ago_format", bundle: PresentationResources.bundle), + Int64(hours) + ) + } else { + let days = seconds / 86400 + return String.localizedStringWithFormat( + String(localized: "push_time_days_ago_format", bundle: PresentationResources.bundle), + Int64(days) + ) + } + } } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 52482774..505ad0d7 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -1524,7 +1524,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "받은 푸시 알람" + "value" : "알림" } } } @@ -1903,6 +1903,40 @@ } } }, + "push_mark_read" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "읽음" + } + } + } + }, + "push_mark_unread" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unread" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "안 읽음" + } + } + } + }, "push_notifications_empty" : { "extractionState" : "manual", "localizations" : { @@ -1920,6 +1954,40 @@ } } }, + "push_notifications_new" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로운 알림" + } + } + } + }, + "push_notifications_previous" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 알림" + } + } + } + }, "push_notifications_select_detail" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift new file mode 100644 index 00000000..b8bdc055 --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift @@ -0,0 +1,195 @@ +// +// View+ItemActions.swift +// PresentationShared +// +// Created by opfic on 9/23/26. +// + +import SwiftUI +import ComposableArchitecture + +public extension View { + func itemActions(@ViewBuilder content: () -> Actions) -> some View { + modifier(ItemActionsModifier(actions: content())) + } +} + +private struct ItemActionsModifier: ViewModifier { + @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + @State private var store = Store(initialState: ItemActionsFeature.State()) { + ItemActionsFeature() + } + let actions: Actions + + @ViewBuilder + func body(content: Content) -> some View { + if isiOSAppOnMac { + content.contextMenu { + actions + } + } else { + let trayWidth = store.trayWidth + + ZStack(alignment: .trailing) { + Group(subviews: actions) { subviews in + HStack(spacing: 8) { + ForEach(subviews) { subview in + subview + .background { + GeometryReader { geometry in + Color.clear.preference( + key: ItemActionWidthsPreferenceKey.self, + value: [geometry.size.width] + ) + } + } + } + } + } + .buttonStyle(ItemActionButtonStyle()) + .padding(.leading, 12) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + store.send(.view(.setTrayWidth(width))) + } + .onPreferenceChange(ItemActionWidthsPreferenceKey.self) { widths in + store.send(.view(.setViewWidths(widths))) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .allowsHitTesting(store.isRevealed && !store.isDragging) + .simultaneousGesture(TapGesture().onEnded { + withAnimation(.snappy) { + store.send(.view(.close)) + } + }) + + content + .offset(x: max( + -trayWidth, + min(0, (store.isRevealed ? -trayWidth : 0) + store.dragTranslation) + )) + .highPriorityGesture( + TapGesture().onEnded { + withAnimation(.snappy) { + store.send(.view(.close)) + } + }, + including: store.isRevealed ? .all : .none + ) + .simultaneousGesture( + DragGesture(minimumDistance: 12) + .onChanged { value in + store.send(.view(.changed(value.translation))) + } + .onEnded { value in + withAnimation(.snappy) { + store.send(.view(.ended(value.translation.width))) + } + } + ) + } + .clipped() + } + } +} + +private struct ItemActionButtonStyle: ButtonStyle { + @ScaledMetric(relativeTo: .body) private var width = 88 + @ScaledMetric(relativeTo: .body) private var height = 44 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout) + .foregroundStyle(Color.white) + .frame(width: width, height: height) + .background( + configuration.role == .destructive ? Color.red : Color.accent, + in: Capsule() + ) + } +} + +private struct ItemActionWidthsPreferenceKey: PreferenceKey { + static var defaultValue = [CGFloat]() + + static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) { + value.append(contentsOf: nextValue()) + } +} + +@Reducer +private struct ItemActionsFeature { + @ObservableState + struct State: Equatable { + var trayWidth = CGFloat.zero + var viewWidths = [CGFloat]() + var isRevealed = false + var isDragging = false + var isScrolling = false + var dragTranslation = CGFloat.zero + } + + enum Action { + case view(ViewAction) + + enum ViewAction { + case setTrayWidth(CGFloat) + case setViewWidths([CGFloat]) + case changed(CGSize) + case ended(CGFloat) + case close + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .view(.setTrayWidth(let width)): + state.trayWidth = width + case .view(.setViewWidths(let widths)): + state.viewWidths = widths + case .view(.changed(let translation)): + guard !state.isScrolling else { return .none } + if !state.isDragging { + if abs(translation.width) <= abs(translation.height) { + state.isScrolling = true + return .none + } + state.isDragging = true + } + state.dragTranslation = translation.width + case .view(.ended(let translation)): + if state.isScrolling { + state.isScrolling = false + return .none + } + guard state.isDragging else { return .none } + let wasRevealed = state.isRevealed + let base = wasRevealed ? -state.trayWidth : 0 + let releasedOffset = max(-state.trayWidth, min(0, base + translation)) + let revealedWidth = -releasedOffset + if wasRevealed { + var fullyHiddenCount = 0 + var trailingDistance = CGFloat.zero + for width in state.viewWidths.reversed() { + if revealedWidth <= trailingDistance { + fullyHiddenCount += 1 + } + trailingDistance += width + 8 + } + let requiredHiddenCount = (state.viewWidths.count + 1) / 2 + state.isRevealed = !state.viewWidths.isEmpty + && fullyHiddenCount < requiredHiddenCount + } else { + let firstVisibleWidth = state.viewWidths.last ?? 0 + state.isRevealed = 0 < firstVisibleWidth + && firstVisibleWidth / 2 <= revealedWidth + } + state.isDragging = false + state.dragTranslation = 0 + case .view(.close): + state.isRevealed = false + } + return .none + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift index 6a8e2d3e..55b5dd38 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift @@ -96,7 +96,6 @@ struct SettingsView: View { } label: { if store.activeLoadingRow == .deleteAuth { ProgressView() - .tint(.red) } else { Text(String(localized: "settings_delete_account", bundle: PresentationResources.bundle)) .foregroundStyle(.red) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 5d1b8ac5..1566453a 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -124,7 +124,6 @@ public struct TodayView: View { } ProgressView(value: store.todayAchievement?.progress ?? 0) - .tint(Color.accent) } .padding(20) .background { diff --git a/README.md b/README.md index dcda360b..6fa54d28 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - Todo 유형별 정리 및 빠른 탐색 - Markdown, 태그, 마감일, 중요 표시를 포함한 Todo 작성 - 오늘 기준 우선 확인 Todo 요약 +- 개발 목표에 Todo와 기록을 연결해 진행 과정 관리 - 받은 푸시 알림 확인 및 Todo 연계 - 분기별 활동 히트맵 제공 - Google, GitHub, Apple 로그인 및 계정 연동 @@ -70,6 +71,7 @@ - `Presentation` target은 `App`의 기존 import를 유지하는 re-export 역할 - `Entry`: root/auth/tab shell/window 흐름 소유 - `PresentationShared`: 공통 Todo/Search/Loading 흐름 소유 + - `Development`: 개발 목표와 개발 기록 흐름 소유 - `HomeTab`, `TodayTab`, `NotificationTab`, `ProfileTab`: 탭 단위 흐름 소유 - `MarkdownRenderer` target은 SwiftUI 공개 화면과 참조 값, 내부 `WKWebView` 연결, HTML/JavaScript/CSS 자원과 TypeScript Tooling을 소유함 - `PresentationShared`에서는 `TodoMarkdownContentView`만 `MarkdownRenderer`를 직접 import하며 재노출하지 않음 @@ -108,6 +110,12 @@ - Markdown, 태그, 마감일, 중요 표시 기반 Todo 작성 및 수정 - 상세 화면에서 생성일, 완료일, 마감일, 태그 확인 +### 개발 목표와 기록 + +- 개발 목표 생성과 상태 전환 +- 개발 목표에 연결한 Todo의 진행률 확인 +- 개발 기록 작성, 확정, 버전 이력과 되돌리기 + ### Today - 남은 일, 집중 Todo, 지연 Todo, 7일 내 마감 Todo 요약 카드 제공 @@ -143,7 +151,7 @@ | 구분 | 스택 | | --- | --- | -| Deployment Target | iOS / iPadOS 17.0+ | +| Deployment Target | iOS / iPadOS 18.0+ | | Platform Support | iPhone, iPad, Apple Silicon Mac (App Store, Designed for iPad) | | Architecture | Tuist Modular based Clean Architecture | | UI | SwiftUI, WidgetKit, AppIntents | @@ -166,8 +174,8 @@ | 항목 | 버전 | | --- | --- | -| Xcode | 26.5 | -| iOS Deployment Target | 17.0 | +| Xcode | 27.0 | +| iOS Deployment Target | 18.0 | | Swift | 5.0 | | Tuist | 4.194.4 | | SwiftLint | 0.63.3 | @@ -245,6 +253,7 @@ DevLog_iOS/ │ │ ├── Sources/ # Presentation target re-export source │ │ ├── Entry/ # root/auth/tab shell/window target │ │ ├── PresentationShared/ # 공통 Todo/Search/Loading UI, 공통 presentation structure +│ │ ├── Development/ # 개발 목표, 개발 기록 화면과 테스트 │ │ ├── HomeTab/ # Home 탭 화면, feature, coordinator, 테스트 │ │ ├── TodayTab/ # Today 탭 화면, feature, coordinator, 테스트 │ │ ├── NotificationTab/ # Notification 탭 화면, feature, coordinator, 테스트 diff --git a/docs/home.png b/docs/home.png index 93f6dae6..55e9ac14 100644 Binary files a/docs/home.png and b/docs/home.png differ diff --git a/docs/iPad_Login.png b/docs/iPad_Login.png index 938a5c4c..e369e1f7 100644 Binary files a/docs/iPad_Login.png and b/docs/iPad_Login.png differ