diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..1e2ed416 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,153 @@ +#!/bin/sh +set -eu + +remote=$1 +schema=$(dirname "$0")/review-result.schema.json +result_dir= + +trap 'if [ -n "$result_dir" ]; then rm -rf "$result_dir"; fi' 0 +trap 'exit 1' HUP INT TERM + +while read -r local_ref local_sha remote_ref remote_sha +do + case "$remote_ref" in + refs/heads/*) ;; + *) continue ;; + esac + + case "$local_sha" in + *[!0]*) ;; + *) continue ;; + esac + + if [ "$local_sha" = "$remote_sha" ]; then + continue + fi + + case "$remote_sha" in + *[!0]*) + base=$remote_sha + if ! git cat-file -e "$base^{commit}" 2>/dev/null; then + printf '%s\n' "리뷰 기준 커밋을 찾을 수 없습니다. git fetch $remote 실행 후 다시 푸시하세요." >&2 + exit 1 + fi + ;; + *) + if ! base_ref=$(git symbolic-ref --quiet "refs/remotes/$remote/HEAD"); then + printf '%s\n' "원격 기본 브랜치를 찾을 수 없습니다. git remote set-head $remote -a 실행 후 다시 푸시하세요." >&2 + exit 1 + fi + if ! base=$(git merge-base "$base_ref" "$local_sha"); then + printf '%s\n' '리뷰 기준이 될 공통 조상 커밋을 찾을 수 없습니다.' >&2 + exit 1 + fi + ;; + esac + + if git diff --quiet "$base" "$local_sha" --; then + continue + else + status=$? + if [ "$status" -ne 1 ]; then + exit "$status" + fi + fi + + if ! command -v claude >/dev/null 2>&1; then + printf '%s\n' 'Claude Code CLI를 찾을 수 없습니다. 설치와 로그인을 확인하세요.' >&2 + exit 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + printf '%s\n' '리뷰 결과 판독에 필요한 python3를 찾을 수 없습니다.' >&2 + exit 1 + fi + if [ -z "$result_dir" ]; then + result_dir=$(mktemp -d "${TMPDIR:-/tmp}/devlog-pre-push.XXXXXX") + fi + result=$result_dir/result.json + rm -f "$result" + schema_json=$(cat "$schema") + + printf '%s\n' "Claude 리뷰 시작: $local_ref ($base..$local_sha)" >&2 + if ! claude -p \ + --model claude-opus-5-5 \ + --effort high \ + --permission-mode plan \ + --permission-prompts none \ + --no-session-persistence \ + --output-format json \ + --json-schema "$schema_json" \ + < "$result" +You are code_reviewer for a user-authorized pre-push review in $(pwd). +This is a separate Claude Code CLI review. Perform the review yourself without spawning subagents or other agents. + +Read CLAUDE.md and fetch its DevLog Agent Policy index through the connected Notion MCP before inspecting code. Verify that the index is under the DevLog page and use only policies marked Active. Load the Active General, iOS, and Claude Code policies and every Active task-specific policy whose route matches this review. Apply the current code_reviewer contract. +If Notion MCP, the policy index, any required Active policy, or required code is unavailable, set verdict to "BLOCKED" and report the missing source. Do not use historical memory as a policy fallback. + +Review only the committed changes from $base to $local_sha for $local_ref being pushed to $remote_ref. +Use git diff $base $local_sha -- for the exact change set. Inspect supporting files at $local_sha with git show. Do not substitute HEAD or include unrelated staged, unstaged, or untracked changes. +Use the current request or issue and acceptance criteria when identifiable. Keep the review within the actual diff and applicable scope exclusions. + +Inspect correctness, regressions, error and cancellation handling, concurrency and resource lifetimes, persistence and event integrity, architecture and dependency boundaries, and meaningful test coverage against existing contracts. +Inspect layout that can break on compact iPhones such as mini and SE models, including hardcoded dimensions, fixed widths or heights, and fixed offsets that do not adapt to the available width, height, or Safe Area. Report a finding only with the concrete screen condition and the element that overflows, clips, or overlaps. +Read only. Do not create, edit, or delete files, policies, Git state, or external records. Do not request escalation, build, execute tests, or run, launch, install, boot, or open the app or Simulator. + +Return a JSON object matching the provided JSON schema, with only "verdict" and "review" fields. +Set verdict to "FAIL" when an actionable defect requires correction. Set verdict to "PASS" only when no blocking finding exists and every required policy and code source was available. Set verdict to "BLOCKED" if a required source or execution setting is unavailable. +In review, state the inspected scope, available verification evidence, unexecuted checks, and remaining verification limits. For PASS, explicitly state that there are no blocking findings. A PASS does not establish that tests passed or runtime behavior was verified. +Write review in Korean and preserve implementation names. Include file paths and line numbers for actionable findings. +EOF + then + printf '%s\n' 'Claude 리뷰 실행에 실패하여 푸시를 중단합니다.' >&2 + exit 1 + fi + + if ! python3 - "$result" <<'PY' +import json +import sys + + +def unique_object(pairs): + result = dict(pairs) + if len(result) != len(pairs): + raise ValueError("duplicate keys") + return result + + +try: + with open(sys.argv[1], encoding="utf-8") as file: + envelope = json.load(file, object_pairs_hook=unique_object) + if ( + not isinstance(envelope, dict) + or envelope.get("type") != "result" + or envelope.get("subtype") != "success" + or envelope.get("is_error") is not False + ): + raise ValueError("unsuccessful claude result") + result = envelope.get("structured_output") + if result is None: + text = envelope.get("result") + if not isinstance(text, str): + raise ValueError("missing review result") + result = json.loads(text, object_pairs_hook=unique_object) + if ( + not isinstance(result, dict) + or set(result) != {"verdict", "review"} + or not isinstance(result["verdict"], str) + or result["verdict"] not in {"PASS", "FAIL", "BLOCKED"} + or not isinstance(result["review"], str) + or not result["review"].strip() + ): + raise ValueError("invalid review result") +except (OSError, UnicodeError, ValueError): + print("리뷰 결과가 없거나 형식이 올바르지 않습니다.", file=sys.stderr) + sys.exit(1) + +print(result["review"], file=sys.stderr) +sys.exit(0 if result["verdict"] == "PASS" else 1) +PY + then + printf '%s\n' 'PASS 판정을 확인하지 못하여 푸시를 중단합니다.' >&2 + exit 1 + fi +done diff --git a/.githooks/review-result.schema.json b/.githooks/review-result.schema.json new file mode 100644 index 00000000..4c445a14 --- /dev/null +++ b/.githooks/review-result.schema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "verdict": { + "type": "string", + "enum": ["PASS", "FAIL", "BLOCKED"] + }, + "review": { + "type": "string" + } + }, + "required": ["verdict", "review"], + "additionalProperties": false +} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index be068c13..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,92 +0,0 @@ -# AGENTS.md - -## Scope - -- This file is the only source of rules for the Codex GitHub code review bot in this repository. It is self-contained; do not look for rules in external documents. -- It applies to code review comments on pull requests. It does not describe how to implement or run the project. - -## Code Review Rules - -### Language (highest priority) - -- Write every review comment, summary, and question in Korean (한국어). Do not write review text in English, even when the PR title, description, commit messages, or code comments are in English. - - Keep code identifiers, file paths, commands, and quoted source text in their original form. - - Keep the priority badge (`P0` to `P3`) as is, and write the explanation in Korean. - - Do not translate implementation names such as `StorePattern`, `ToastPresenter`, or `MainView`. -- Keep comments concise: one issue per comment, short sentences, and end sentences in Korean noun form where natural (명사형 종결), matching the existing PR style. -- Preferred shape of a comment: - - Title: `[P2] 인스펙터 안의 Todo 상세에서 뒤로가기가 옵션 페이지로 돌아가지 않음` - - Location, trigger, impact, and minimal fix, each on its own line in Korean. - -### Priorities - -- `P0`, `P1`: data loss, security, crashes, concurrency and resource lifetime problems, behavior regressions. -- `P2`: architecture violations, scope drift, missing meaningful tests. -- `P3`: low-impact violations of the repository conventions below. -- Do not report: - - Matters of taste and style that do not affect behavior. - - Formatting-only issues that SwiftLint would catch. - - Speculation that the code does not support. - -### What counts as a finding - -- Report only issues that the code supports and that have a concrete failure scenario. -- For each finding, include: - - The file path and line. - - The condition that triggers it. - - The impact on users or behavior. - - The minimum correction. -- Order findings by severity. Do not repeat the same finding in several places. -- Do not claim that tests or runtime behavior were verified if they were not run. -- Do not raise again threads that are already resolved or feedback that was already applied. - -### Checks for this repository (DevLog_iOS) - -- Behavior preservation: - - Behavior changes that were not requested, and cleanups outside the requested scope. - - New logic written where existing logic could have been reused. -- Architecture: - - No dependency injection between types of the same layer (initializer, stored property, environment, or runtime resolver). - - The only exception is a SwiftUI `View` in `Application/Presentation` receiving same-layer presentation objects such as a ViewModel, Coordinator, or Store. - - `Infra` depends on `Data` and `Core`, not on `Domain`. - - Do not move domain entities to `Core` only because several modules use them. - - Firebase-specific error detection belongs in `Infra`; `Data` handles domain-level errors after mapping. - - Widget UI consumes snapshot data only. `WidgetCore` must not depend on Domain, Data, Infra, Persistence, Presentation, or App. -- StorePattern: - - Keep `@MainActor`, `State`, `Action`, `SideEffect`, and `send -> reduce -> run`. - - Reducers compute state and return side effects; I/O belongs in `run` or injected services. - - Do not add task cancellation or async wrappers around operations that are not actually async. - - Remove reducer-era helper methods left behind after work moved into `run`. -- SwiftUI: - - No intermediate views, properties, or wrappers that only forward to another view. - - Feature-owned presentation state of a Store-backed view lives in the Feature's `State`; do not duplicate it as view-local `@State`. - - View-local `@State` is only for transient rendering and measurement that does not change the feature flow. - - Do not add accessibility code that was not requested, and do not remove existing accessibility behavior without a reason. -- Design tokens: - - UI changes must use the existing `ColorPalette` colors (`Color+Assets`), not hard-coded or system colors. -- Localization: - - Missing keys or translations for new user-facing strings. - - Large unrelated changes in `.xcstrings`. -- Swift conventions (`P3`): - - Explicit type annotations that are not required. - - New Swift file headers whose author is not `opfic`. - - Prefer `<` and `<=` over `>` and `>=` when the meaning is the same. -- Repository rules: - - SwiftUI preview code (`#Preview`, `PreviewProvider`, preview-only fixtures) included in a commit. - - Secrets, tokens, or private configuration files. - - Unnecessary changes to generated Xcode projects or `Package.resolved`. - - A PR title that differs from the title of the linked issue. - - A PR body with sections not declared in the PR template, or with verification results (build, test, lint). - - Tool attribution text (such as `Co-Authored-By` or `Generated with`) in commits or the PR body. - -### Review summary - -- Write the findings first, then, in order: - - Verdict: one of `Pass`, `Block`, `Needs Follow-up`. - - The scope that was inspected. - - Verification that could not be done. - - Limitations. -- `Pass` means no blocking finding in the inspected scope. It does not mean tests were run or runtime behavior was verified. -- If there are no findings, state in Korean that there are none ("지적 없음") together with the inspected scope. -- If required source files could not be read, state the missing prerequisite and do not give `Pass`. -- Final reminder: all of the above output must be written in Korean. diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 446ab5e7..aae0f955 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -140,7 +140,7 @@ private struct GoalCreateDescriptionEditor: View { Text(RecordPresentation.text("development_goal_create_markdown_hint")) .font(.caption) .foregroundStyle(Color.textTertiary) - UIKitTextEditor() + UIKitTextEditor .composable( update: { textEditor in textEditor.updateInput( diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index b3a18dff..a9b5745a 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -134,7 +134,7 @@ public struct RecordEditorView: View { Text(RecordPresentation.text("development_record_markdown_hint")) .font(.caption) .foregroundStyle(Color.textTertiary) - UIKitTextEditor() + UIKitTextEditor .composable( update: { textEditor in textEditor.updateInput( diff --git a/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift index 9ad1f99a..69fd1cb9 100644 --- a/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift +++ b/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift @@ -13,79 +13,144 @@ struct CategoryManageView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { - List { - ForEach(store.preferences, id: \.id) { item in - HStack(spacing: 0) { - CheckBox(isChecked: item.isVisible, font: .title3) - .padding(.horizontal) - .onTapGesture { - store.send(.tapItem(item)) - } - Text(item.localizedName) - .lineLimit(1) - Spacer() - if item.isUserCategory { - Button { - store.send(.tapEditUserCategory(item)) - } label: { - Image(systemName: "slider.horizontal.3") - } - .buttonStyle(.borderless) - .padding(.trailing, 8) - - Button(role: .destructive) { - store.send(.tapDeleteUserCategory(item)) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .padding(.trailing) + CardCollectionView + .composable { view in + view.updateContent( + items: store.preferences, + header: UIHostingConfiguration { + Text(String(localized: "todo_manage_description", bundle: PresentationResources.bundle)) + .font(.footnote) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .margins(.horizontal, 16) + .margins(.vertical, 8), + footer: UIHostingConfiguration { + Button { + store.send(.tapAddUserCategory) + } label: { + Label( + String(localized: "todo_manage_add_category_title", bundle: PresentationResources.bundle), + systemImage: "plus.circle.fill" + ) + .font(.headline) + .foregroundStyle(Color.onPrimaryContainer) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .primaryContainer) } - } - .onMove { source, destination in - store.send(.moveItem(from: source, target: destination)) - } - .listRowInsets(EdgeInsets()) + .margins(.horizontal, 16) + .margins(.vertical, 24), + row: { item in + UIHostingConfiguration { + CategoryManageRow( + item: item, + onToggle: { store.send(.tapItem(item)) }, + onEdit: { store.send(.tapEditUserCategory(item)) }, + onDelete: { store.send(.tapDeleteUserCategory(item)) } + ) + } + .margins(.horizontal, 0) + .margins(.vertical, 8) + }, + onMove: { source, destination in + store.send(.moveItem(from: source, target: destination)) + } + ) } - .environment(\.editMode, .constant(.active)) - .navigationTitle(String(localized: "nav_todo_manage", bundle: PresentationResources.bundle)) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden() + .ignoresSafeArea(edges: .bottom) + .safeAreaInset(edge: .top, spacing: 0) { toolBar } + .background(Color.appBackground.ignoresSafeArea()) .sheet( item: $store.scope(state: \.categorySheet, action: \.categorySheet) .activePresentation(when: isTabContentActive) ) { sheetStore in - sheetContent(sheetStore) + CategoryManageSheet(store: sheetStore) } .prominentAlert(store, state: \.alert, action: \.alert) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + .presentationDragIndicator(.visible) + } + + private var toolBar: some View { + ZStack { + Text(String(localized: "todo_category_manage", bundle: PresentationResources.bundle)) + .font(.headline) + HStack { + Spacer() + if #available(iOS 26.0, *) { Button { - store.send(.tapAddUserCategory) + store.send(.tapDoneButton, animation: .default) } label: { - Image(systemName: "plus") + Image(systemName: "checkmark") } - } - - ToolbarItem(placement: .navigationBarTrailing) { + .topBarButtonStyle() + } else { Button { store.send(.tapDoneButton, animation: .default) } label: { Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) } + .topBarButtonStyle(tint: Color.accent) } } } - .presentationDragIndicator(.visible) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } +} + +private struct CategoryManageRow: View { + let item: TodoCategoryItem + let onToggle: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + + var body: some View { + if item.isUserCategory { + content + .background(Color.surface) + .itemActions { + ItemActionButton( + color: Color.accent, + image: Image(systemName: "pencil"), + action: onEdit + ) + + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash"), + action: onDelete + ) + } + } else { + content + } } - @ViewBuilder - private func sheetContent( - _ sheetStore: Store - ) -> some View { - CategoryManageSheet(store: sheetStore) + private var content: some View { + HStack(spacing: 12) { + Image(systemName: item.symbolName) + .font(.headline) + .frame(width: 36, height: 36) + .iconStyle(color: item.color, in: Circle()) + + Text(item.localizedName) + .lineLimit(1) + + Spacer(minLength: 8) + + Toggle( + item.localizedName, + isOn: Binding(get: { item.isVisible }, set: { _ in onToggle() }) + ) + .labelsHidden() + .tint(Color.accent) + } + .padding(.trailing, 12) + .frame(minHeight: 44) } } diff --git a/Application/Presentation/HomeTab/Sources/Category/CheckBox.swift b/Application/Presentation/HomeTab/Sources/Category/CheckBox.swift deleted file mode 100644 index 8e0c694f..00000000 --- a/Application/Presentation/HomeTab/Sources/Category/CheckBox.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// CheckBox.swift -// HomeTab -// -// Created by opfic on 6/17/25. -// - -import SwiftUI - -struct CheckBox: View { - private let isChecked: Bool - @State private var font: Font - - init(isChecked: Bool, font: Font = .title2) { - self.isChecked = isChecked - self._font = State(initialValue: font) - } - - var body: some View { - Group { - if isChecked { - Image(systemName: "checkmark.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.blue) - } else { - Image(systemName: "circle") - .foregroundStyle(Color.gray) - } - } - .font(font) - } -} diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 73e2028a..fdc77602 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -2827,23 +2827,6 @@ } } }, - "nav_todo_manage" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edit TODO" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "TODO 편집" - } - } - } - }, "profile_activity_completed" : { "extractionState" : "manual", "localizations" : { @@ -5430,6 +5413,23 @@ } } }, + "todo_manage_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage the categories you use and change their order." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용할 카테고리를 관리하고, 순서를 변경할 수 있어요." + } + } + } + }, "todo_manage_edit_category_title" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/CardCollectionView.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/CardCollectionView.swift new file mode 100644 index 00000000..54e5104f --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/CardCollectionView.swift @@ -0,0 +1,296 @@ +// +// CardCollectionView.swift +// PresentationShared +// +// Created by opfic on 10/5/26. +// + +import SwiftUI +import UIComposable + +private let cardBackgroundElementKind = "CardCollectionView.cardBackground" +private let headerElementKind = "CardCollectionView.header" +private let footerElementKind = "CardCollectionView.footer" + +public final class CardCollectionView< + Item: Identifiable & Equatable +>: UICollectionView, UICoordinatedComposable where Item.ID: Sendable { + private var items: [Item] = [] + private var headerContent: (any UIContentConfiguration)? + private var footerContent: (any UIContentConfiguration)? + private var rowContent: (Item) -> any UIContentConfiguration = { _ in UIListContentConfiguration.cell() } + private var onMove: (IndexSet, Int) -> Void = { _, _ in } + + public init() { + super.init(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + public func connect(coordinator: Coordinator) { + coordinator.connect(self) + } + + public func update(coordinator: Coordinator) { + coordinator.apply() + } + + public func disconnect(coordinator: Coordinator) { + coordinator.disconnect() + } + + public func updateContent( + items: [Item], + header: any UIContentConfiguration, + footer: any UIContentConfiguration, + row: @escaping (Item) -> any UIContentConfiguration, + onMove: @escaping (IndexSet, Int) -> Void + ) { + self.items = items + headerContent = header + footerContent = footer + rowContent = row + self.onMove = onMove + } + + /// 드래그 결과(제거 위치와 삽입 위치)를 `Array.move(fromOffsets:toOffset:)`의 목적지 의미로 변환한다. + static func moveDestination(from source: Int, to target: Int) -> Int { + source < target ? target + 1 : target + } + + @MainActor + public final class Coordinator { + private weak var collectionView: CardCollectionView? + private var dataSource: UICollectionViewDiffableDataSource? + private var itemIDs: [Item.ID] = [] + private var itemsByID: [Item.ID: Item] = [:] + private var hasAppliedInitialSnapshot = false + + func connect(_ collectionView: CardCollectionView) { + self.collectionView = collectionView + collectionView.backgroundColor = .clear + collectionView.allowsSelection = false + collectionView.collectionViewLayout = makeLayout() + collectionView.dataSource = makeDataSource(for: collectionView) + } + + func disconnect() { + dataSource?.reorderingHandlers.canReorderItem = nil + dataSource?.reorderingHandlers.didReorder = nil + dataSource?.supplementaryViewProvider = nil + collectionView?.dataSource = nil + dataSource = nil + collectionView = nil + } + + func apply() { + guard let collectionView, let dataSource else { return } + + let newIDs = collectionView.items.map(\.id) + let newItemsByID = Dictionary( + collectionView.items.map { ($0.id, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + let changedIDs = newIDs.filter { id in + guard let oldItem = itemsByID[id], let newItem = newItemsByID[id] else { return false } + return oldItem != newItem + } + let isOrderChanged = newIDs != itemIDs + + itemIDs = newIDs + itemsByID = newItemsByID + + if isOrderChanged { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(newIDs) + snapshot.reconfigureItems(changedIDs) + dataSource.apply(snapshot, animatingDifferences: hasAppliedInitialSnapshot) + hasAppliedInitialSnapshot = true + } else if !changedIDs.isEmpty { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems(changedIDs) + dataSource.apply(snapshot, animatingDifferences: false) + } + } + + private func makeLayout() -> UICollectionViewCompositionalLayout { + let layoutConfiguration = UICollectionViewCompositionalLayoutConfiguration() + layoutConfiguration.boundarySupplementaryItems = [ + boundaryItem(kind: headerElementKind, alignment: .top), + boundaryItem(kind: footerElementKind, alignment: .bottom) + ] + + let layout = UICollectionViewCompositionalLayout( + sectionProvider: { [weak self] _, environment in + var listConfiguration = UICollectionLayoutListConfiguration(appearance: .plain) + listConfiguration.backgroundColor = .clear + listConfiguration.separatorConfiguration.color = UIColor(named: "Border", in: PresentationResources.bundle, compatibleWith: nil) + ?? .separator + // 시스템 reorder 핸들의 trailing 여백이 카드 안쪽 여백(16pt)보다 커서 셀을 그만큼 카드 밖으로 늘린다. + let handleOverhang: CGFloat = 7 + let separatorInsets = NSDirectionalEdgeInsets( + top: 0, + leading: 0, + bottom: 0, + trailing: 16 + handleOverhang + ) + listConfiguration.itemSeparatorHandler = { indexPath, separatorConfiguration in + var separatorConfiguration = separatorConfiguration + separatorConfiguration.topSeparatorInsets = separatorInsets + separatorConfiguration.bottomSeparatorInsets = separatorInsets + if indexPath.item == 0 { + separatorConfiguration.topSeparatorVisibility = .hidden + } + if indexPath.item == (self?.itemIDs.count ?? 0) - 1 { + separatorConfiguration.bottomSeparatorVisibility = .hidden + } + return separatorConfiguration + } + + let section = NSCollectionLayoutSection.list( + using: listConfiguration, + layoutEnvironment: environment + ) + section.contentInsetsReference = .none + section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 32, bottom: 8, trailing: 16 - handleOverhang) + + let background = NSCollectionLayoutDecorationItem.background(elementKind: cardBackgroundElementKind) + background.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + section.decorationItems = [background] + return section + }, + configuration: layoutConfiguration + ) + layout.register(CardBackgroundView.self, forDecorationViewOfKind: cardBackgroundElementKind) + return layout + } + + private func boundaryItem( + kind: String, + alignment: NSRectAlignment + ) -> NSCollectionLayoutBoundarySupplementaryItem { + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(44) + ), + elementKind: kind, + alignment: alignment + ) + } + + private func makeDataSource( + for collectionView: CardCollectionView + ) -> UICollectionViewDiffableDataSource { + let surface = UIColor(named: "Surface", in: PresentationResources.bundle, compatibleWith: nil) + ?? .secondarySystemGroupedBackground + let handle = UIColor(named: "TextTertiary", in: PresentationResources.bundle, compatibleWith: nil) + ?? .tertiaryLabel + + let cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, _, id in + guard let collectionView = self?.collectionView, let item = self?.itemsByID[id] else { return } + + cell.contentConfiguration = collectionView.rowContent(item) + + cell.configurationUpdateHandler = { cell, state in + var background = UIBackgroundConfiguration.clear() + if state.cellDragState != .none { + background.backgroundColor = surface + background.cornerRadius = 12 + } + cell.backgroundConfiguration = background + } + + cell.accessories = [ + .reorder( + displayed: .always, + options: UICellAccessory.ReorderOptions( + reservedLayoutWidth: .actual, + tintColor: handle, + showsVerticalSeparator: false + ) + ) + ] + } + + let headerRegistration = supplementaryRegistration(kind: headerElementKind) { $0.headerContent } + let footerRegistration = supplementaryRegistration(kind: footerElementKind) { $0.footerContent } + + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, id in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: id + ) + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + let registration = kind == headerElementKind ? headerRegistration : footerRegistration + return collectionView.dequeueConfiguredReusableSupplementary( + using: registration, + for: indexPath + ) + } + + dataSource.reorderingHandlers.canReorderItem = { _ in true } + dataSource.reorderingHandlers.didReorder = { [weak self] transaction in + self?.didReorder(transaction) + } + + self.dataSource = dataSource + return dataSource + } + + private func supplementaryRegistration( + kind: String, + content: @escaping (CardCollectionView) -> (any UIContentConfiguration)? + ) -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration( + elementKind: kind + ) { [weak self] view, _, _ in + guard let collectionView = self?.collectionView else { return } + + view.contentConfiguration = content(collectionView) + view.backgroundConfiguration = .clear() + } + } + + private func didReorder(_ transaction: NSDiffableDataSourceTransaction) { + guard let collectionView, + case .remove(let source, _, _)? = transaction.difference.removals.first, + case .insert(let target, _, _)? = transaction.difference.insertions.first else { return } + + itemIDs = transaction.finalSnapshot.itemIdentifiers + collectionView.onMove( + IndexSet(integer: source), + CardCollectionView.moveDestination(from: source, to: target) + ) + } + } +} + +private final class CardBackgroundView: UICollectionReusableView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(named: "Surface", in: PresentationResources.bundle, compatibleWith: nil) + ?? .secondarySystemGroupedBackground + layer.cornerRadius = 16 + layer.cornerCurve = .continuous + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift index c028743d..5967dc32 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+NavigationDestination.swift @@ -19,7 +19,7 @@ public extension View { destination(value) .background { if interactivePop { - NavigationPopGestureController() + NavigationPopGestureController .composable { controller in controller.onInteractivePopCompleted = { guard item.wrappedValue != nil else { return } @@ -40,7 +40,7 @@ public extension View { destination() .background { if interactivePop { - NavigationPopGestureController() + NavigationPopGestureController .composable { controller in controller.onInteractivePopCompleted = { guard isPresented.wrappedValue else { return } @@ -61,7 +61,7 @@ public extension View { destination(value) .background { if interactivePop(value) { - NavigationPopGestureController().composable() + NavigationPopGestureController.composable() } } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 4a421602..482b7bf2 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -331,7 +331,7 @@ private struct ContentView: View { if store.tabViewTag == .editor { TextEditorContentLayout(minimumHeight: minimumHeight) { markdownHint - UIKitTextEditor() + UIKitTextEditor .composable( update: { textEditor in textEditor.updateInput( diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index e805ceb9..33979392 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -39,7 +39,7 @@ let project = Project( ), .package( url: "https://github.com/opficdev/UIComposable.git", - .exact("0.2.0") + .exact("0.3.0") ), ], settings: .devlogProject(additionalBase: deploymentSettings),