diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift index 5837d661..20a646ce 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift @@ -17,6 +17,7 @@ struct HomeFeature { @Presents var sheet: SheetState? @Presents var fullScreenCover: FullScreenCoverState? var preferences = [TodoCategoryItem]() + var isTodoCategoryExpanded = false var isNetworkConnected = true var selectedTodoCategory: TodoCategory? var loading = LoadingFeature.State() @@ -52,6 +53,7 @@ struct HomeFeature { case fetchData case todoEditorCreated case tapManageTodoCategory + case tapTodoCategoryExpansionButton case tapTodoCategory(TodoCategory) } @@ -211,6 +213,8 @@ private extension HomeFeature { ) case .tapManageTodoCategory: state.sheet = .reorderTodo(CategoryManageFeature.State(preferences: state.preferences)) + case .tapTodoCategoryExpansionButton: + state.isTodoCategoryExpanded.toggle() case .tapTodoCategory(let category): state.selectedTodoCategory = category state.sheet = nil diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index 2f8469b1..cef29b41 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -11,9 +11,12 @@ import Domain import PresentationShared public struct HomeView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + @ScaledMetric(relativeTo: .title2) private var categoryIconSize = CGFloat(64) @State private var path = [HomeRoute]() @State private var searchStore: StoreOf @State private var store: StoreOf @@ -41,14 +44,21 @@ public struct HomeView: View { public var body: some View { NavigationStack(path: $path) { - List { - todoSection + ScrollView { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { + Section { + todoSection + } header: { + topBar + } + } + .padding(.horizontal, 16) } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_home", bundle: PresentationResources.bundle)) + .background(Color.appBackground) + .toolbarVisibility(.hidden, for: .navigationBar) .navigationDestination(for: HomeRoute.self, destination: destinationView) - .toolbar { toolbar } } + .toolbarBackground(Color.appBackground) .onAppear { store.send(.view(.startObserving)) } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { @@ -73,56 +83,129 @@ public struct HomeView: View { ) } + @ViewBuilder + private var topBar: some View { + HStack(spacing: 12) { + Text("DevLog") + .font(.largeTitle.weight(.bold)) + Spacer() + Button { + store.send(.store(.setPresentation(.searchView, true))) + } label: { + Image(systemName: "magnifyingglass") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + Button { + store.send(.store(.setPresentation(.contentPicker, true))) + } label: { + Image(systemName: "plus") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + .disabled(!store.isNetworkConnected) + } + .padding(.bottom, 8) + .background(Color.appBackground) + } + + @ViewBuilder private var todoSection: some View { - Section(content: { + let visibleCategoryCount = store.preferences.filter(\.isVisible).count + let collapsedCategoryCount = min(visibleCategoryCount, 8) + let columnCount = dynamicTypeSize.isAccessibilitySize ? 2 : 4 + let showsExpansionButton = 8 < visibleCategoryCount + || 0 < collapsedCategoryCount && collapsedCategoryCount.isMultiple(of: columnCount) + + VStack(alignment: .leading, spacing: 0) { if store.isPreferencesLoading { LoadingView() + .frame(maxWidth: .infinity) } else { - let preferences = store.preferences - ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in - todoCategoryRow(item) - .listRowInsets((EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))) - } + todoCategoryGrid } - }, header: { - HStack { - Text("TODO", bundle: PresentationResources.bundle) - .foregroundStyle(Color.primary) - .font(.title2) - .bold() - Spacer() - Button(action: { - store.send(.view(.tapManageTodoCategory)) - }) { - Image(systemName: "ellipsis") - .font(.title2) - .foregroundStyle(Color.gray) + + if !store.isPreferencesLoading, showsExpansionButton { + HStack { + Spacer() + Button { + store.send( + .view(.tapTodoCategoryExpansionButton), + animation: .easeInOut(duration: 0.2) + ) + } label: { + Image(systemName: store.isTodoCategoryExpanded ? "chevron.up" : "chevron.down") + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.textTertiary) + .frame(width: 32, height: 32) + } + Spacer() } } - .listRowInsets(EdgeInsets()) // 헤더의 padding 제거 - }) + } + .padding(.bottom, 12) + .background(Color.surface, in: RoundedRectangle(cornerRadius: 28)) } - @ToolbarContentBuilder - private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - store.send(.store(.setPresentation(.contentPicker, true))) - } label: { - Image(systemName: "plus") + @ViewBuilder + private var todoCategoryGrid: some View { + let preferences = store.preferences.filter(\.isVisible) + let visiblePreferences = store.isTodoCategoryExpanded + ? preferences + : Array(preferences.prefix(8)) + let columnCount = dynamicTypeSize.isAccessibilitySize ? 2 : 4 + let hasAvailableSlot = visiblePreferences.isEmpty + || !visiblePreferences.count.isMultiple(of: columnCount) + + TodoCategoryGridLayout( + columnCount: columnCount, + itemWidth: categoryIconSize + ) { + ForEach(visiblePreferences) { item in + todoCategoryRow(item) + } + if store.isTodoCategoryExpanded || hasAvailableSlot { + editTodoCategoryButton } - .disabled(!store.isNetworkConnected) - } - if #available(iOS 26.0, *) { - ToolbarSpacer(.fixed, placement: .topBarTrailing) } - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - store.send(.store(.setPresentation(.searchView, true))) - } label: { - Image(systemName: "magnifyingglass") + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var editTodoCategoryButton: some View { + Button { + store.send(.view(.tapManageTodoCategory)) + } label: { + VStack(spacing: 10) { + RoundedRectangle(cornerRadius: 20) + .strokeBorder( + Color.textTertiary, + style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]) + ) + .frame(width: categoryIconSize, height: categoryIconSize) + .overlay { + Image(systemName: "square.grid.2x2") + .font(.title2.bold()) + .foregroundStyle(Color.textTertiary) + } + Text("todo_edit", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textTertiary) } + .frame(maxWidth: .infinity) + .contentShape(.rect) } + .buttonStyle(.plain) } @ViewBuilder @@ -211,15 +294,28 @@ public struct HomeView: View { } } - @ViewBuilder private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { NavigationLink(value: HomeRoute.category(item)) { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) + VStack(spacing: 10) { + RoundedRectangle(cornerRadius: 20) + .fill(categoryIconBackground(item)) + .frame(width: categoryIconSize, height: categoryIconSize) + .overlay { + Image(systemName: item.symbolName) + .font(.title2.bold()) + .foregroundStyle(categoryIconForeground(item)) + } + Text(item.localizedName) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .contentShape(.rect) } + .buttonStyle(.plain) } private func labelImage( @@ -255,6 +351,21 @@ public struct HomeView: View { } } + private func categoryIconBackground(_ item: TodoCategoryItem) -> Color { + if colorScheme == .dark { + return item.color + } + + return item.color.opacity(0.12) + } + + private func categoryIconForeground(_ item: TodoCategoryItem) -> Color { + if colorScheme == .dark { + return .white + } + + return item.color + } } public enum HomeRoute: Hashable { diff --git a/Application/Presentation/HomeTab/Sources/Home/TodoCategoryGridLayout.swift b/Application/Presentation/HomeTab/Sources/Home/TodoCategoryGridLayout.swift new file mode 100644 index 00000000..75a3d860 --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/TodoCategoryGridLayout.swift @@ -0,0 +1,79 @@ +// +// TodoCategoryGridLayout.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import SwiftUI + +struct TodoCategoryGridLayout: Layout { + let columnCount: Int + let itemWidth: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let width = proposal.width ?? itemWidth * CGFloat(columnCount) + let spacing = spacing(for: width) + let rowHeights = rowHeights(subviews: subviews) + let rowSpacing = spacing * CGFloat(max(0, rowHeights.count - 1)) + + return CGSize( + width: width, + height: spacing + rowHeights.reduce(0, +) + rowSpacing + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let spacing = spacing(for: bounds.width) + let rowHeights = rowHeights(subviews: subviews) + var currentY = bounds.minY + spacing + + for rowIndex in rowHeights.indices { + let rowStartIndex = rowIndex * columnCount + let rowEndIndex = min(rowStartIndex + columnCount, subviews.count) + + for itemIndex in rowStartIndex.. CGFloat { + max(0, (width - itemWidth * CGFloat(columnCount)) / CGFloat(columnCount + 1)) + } + + private func rowHeights(subviews: Subviews) -> [CGFloat] { + stride(from: 0, to: subviews.count, by: columnCount).map { rowStartIndex in + let rowEndIndex = min(rowStartIndex + columnCount, subviews.count) + + return (rowStartIndex.. var preferences: [TodoCategoryItem] { store.state.preferences } + var isTodoCategoryExpanded: Bool { store.state.isTodoCategoryExpanded } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } var showCategoryManage: Bool { @@ -78,6 +79,10 @@ struct HomeStoreTestAdapter { await drainReceivedActions() } + func tapTodoCategoryExpansionButton() async { + await store.send(.view(.tapTodoCategoryExpansionButton)) + } + func orderTodoCategory(_ items: [TodoCategoryItem]) async { await store.send(.sheet(.presented(.categoryManage(.delegate(.done(items)))))) await drainReceivedActions() diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index 163185ee..fb3188a2 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -29,6 +29,15 @@ struct HomeFeatureTests { try await verifyHomeTapTodoCategory(adapter: adapter) } + @Test("HomeFeature 카테고리 펼침 버튼은 펼침 상태를 전환한다") + func HomeFeature_카테고리_펼침_버튼은_펼침_상태를_전환한다() async { + let adapter = HomeStoreTestAdapter() + + await adapter.tapTodoCategoryExpansionButton() + + #expect(adapter.isTodoCategoryExpanded) + } + @Test("TodoEditor 생성 delegate는 editor를 닫고 홈 데이터를 다시 조회한다") func TodoEditor_생성_delegate는_editor를_닫고_홈_데이터를_다시_조회한다() async throws { let context = makeHomeFetchDataContext() diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index fdbe50ef..aede72b2 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -3200,6 +3200,23 @@ } } }, + "todo_category_manage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Categories" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카테고리 관리" + } + } + } + }, "todo_category_doc" : { "comment" : "Todo category: Documentation", "extractionState" : "manual",