diff --git a/Projects/DVDesign/Resources/Assets.xcassets/color/gray300.colorset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/color/gray300.colorset/Contents.json index dbd79ae..c31e976 100644 --- a/Projects/DVDesign/Resources/Assets.xcassets/color/gray300.colorset/Contents.json +++ b/Projects/DVDesign/Resources/Assets.xcassets/color/gray300.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xEE", - "green" : "0xF0", - "red" : "0xEB" + "blue" : "0xE4", + "green" : "0xE6", + "red" : "0xE2" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xEE", - "green" : "0xF0", - "red" : "0xEB" + "blue" : "0xE4", + "green" : "0xE6", + "red" : "0xE2" } }, "idiom" : "universal" diff --git a/Projects/DVDesign/Sources/Components/DVCategory.swift b/Projects/DVDesign/Sources/Components/DVCategory.swift index 3f104ae..d3223ca 100644 --- a/Projects/DVDesign/Sources/Components/DVCategory.swift +++ b/Projects/DVDesign/Sources/Components/DVCategory.swift @@ -64,21 +64,21 @@ extension DVCategory { private var iconView: some View { Image(systemName: systemImage) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray700)) - .frame(width: 28, height: 28) + .dvFont(.headingLG) + .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray800)) + .frame(width: 24, height: 24) } private var titleLabel: some View { Text(title) .dvFont(.bodyLG) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray800)) + .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray900)) } private var countLabel: some View { Text(count > 999 ? "999+" : "\(count)") .dvFont(.bodyMD) - .foregroundStyle(isSelected ? Color.dv(.white).opacity(0.8) : Color.dv(.gray600)) + .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray600)) } } @@ -97,8 +97,8 @@ private struct DVCategoryButtonStyle: ButtonStyle { private func backgroundColor(isPressed: Bool) -> Color { if isSelected { return Color.dv(.vaultGreen) } - if isPressed { return Color.dv(.gray300) } - if isHovered { return Color.dv(.gray200) } - return Color.dv(.gray100) + if isPressed { return Color.dv(.gray400) } + if isHovered { return Color.dv(.gray300) } + return Color.dv(.gray200) } } diff --git a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift index 1c563e5..0c91707 100644 --- a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift @@ -27,7 +27,7 @@ public struct DVProjectContainer: View { Spacer(minLength: 8) countLabel } - .padding(6) + .padding(2) .frame(minWidth: 120, alignment: .leading) } } @@ -39,13 +39,13 @@ extension DVProjectContainer { private var projectIcon: some View { Image(systemName: "tray") .dvFont(.captionLG) - .foregroundStyle(.primary) + .foregroundStyle(Color.dv(.gray900)) } private var nameLabel: some View { Text(name) .dvFont(.bodyMD) - .foregroundStyle(.primary) + .foregroundStyle(Color.dv(.gray900)) .lineLimit(1) .truncationMode(.tail) .frame(minWidth: 40, alignment: .leading) @@ -54,7 +54,7 @@ extension DVProjectContainer { private var countLabel: some View { Text(count > 999 ? "999+" : "\(count)") .dvFont(.bodyMD) - .foregroundStyle(.secondary) + .foregroundStyle(Color.dv(.gray400)) .fixedSize() } } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 1dfa69f..446f49c 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -14,6 +14,7 @@ public struct MainFeature { @ObservableState public struct State: Equatable { public var columnVisibility: NavigationSplitViewVisibility = .all + var sidebar: SidebarFeature.State = .init() public init() {} } @@ -27,6 +28,10 @@ public struct MainFeature { case binding(BindingAction) case task + // MARK: - Child + + case sidebar(SidebarFeature.Action) + // MARK: - Delegate case delegate(Delegate) @@ -42,6 +47,9 @@ public struct MainFeature { public var body: some ReducerOf { BindingReducer() + Scope(state: \.sidebar, action: \.sidebar) { + SidebarFeature() + } Reduce { state, action in switch action { case .binding: @@ -50,9 +58,39 @@ public struct MainFeature { case .task: return .none + case .sidebar(.delegate(let delegate)): + return handleSidebarDelegate(&state, delegate: delegate) + + case .sidebar: + return .none + case .delegate: return .none } } } } + +// MARK: - Private + +extension MainFeature { + + private func handleSidebarDelegate( + _ state: inout State, + delegate: SidebarFeature.Action.Delegate + ) -> Effect { + switch delegate { + case .selectionChanged: + return .none + + case .addButtonTapped: + return .none + + case .settingsButtonTapped: + return .none + + case .addProjectTapped: + return .none + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index dbfe0ec..7bcf8f3 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -37,7 +37,8 @@ extension MainView { } private var sidebar: some View { - Text("Sidebar") + SidebarView(store: store.scope(state: \.sidebar, action: \.sidebar)) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 270) } private var contentColumn: some View { diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift new file mode 100644 index 0000000..178e91c --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -0,0 +1,115 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import ComposableArchitecture + +// MARK: - SidebarFilter + +public enum SidebarFilter: Equatable, CaseIterable, Hashable { + case all + case starred + case expired + case deleted + + var title: String { + switch self { + case .all: "All" + case .starred: "Star" + case .expired: "Expired" + case .deleted: "Deleted" + } + } + + var icon: String { + switch self { + case .all: "square.grid.2x2.fill" + case .starred: "star.fill" + case .expired: "exclamationmark" + case .deleted: "trash" + } + } +} + +// MARK: - SidebarSelection + +public enum SidebarSelection: Equatable { + case filter(SidebarFilter) + case project(Int) +} + +// MARK: - SidebarFeature + +@Reducer +public struct SidebarFeature { + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public internal(set) var selection: SidebarSelection = .filter(.all) + public internal(set) var isProjectSectionExpanded: Bool = true + + public init() {} + } + + // MARK: - Action + + public enum Action: Equatable { + + // MARK: - View + + case task + case didSelect(SidebarSelection) + case didTapAddButton + case didTapSettingsButton + case didTapAddProject + case didToggleProjectSection + + // MARK: - Delegate + + case delegate(Delegate) + + public enum Delegate: Equatable { + case selectionChanged(SidebarSelection) + case addButtonTapped + case settingsButtonTapped + case addProjectTapped + } + } + + // MARK: - Init + + public init() {} + + // MARK: - Body + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .task: + return .none + + case .didSelect(let selection): + state.selection = selection + return .send(.delegate(.selectionChanged(selection))) + + case .didTapAddButton: + return .send(.delegate(.addButtonTapped)) + + case .didTapSettingsButton: + return .send(.delegate(.settingsButtonTapped)) + + case .didTapAddProject: + return .send(.delegate(.addProjectTapped)) + + case .didToggleProjectSection: + state.isProjectSectionExpanded.toggle() + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift new file mode 100644 index 0000000..37cb2a9 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -0,0 +1,193 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import ComposableArchitecture +import DVDesign + +// MARK: - SidebarView + +struct SidebarView: View { + + // MARK: - Properties + + @Bindable var store: StoreOf + + // MARK: - Body + + var body: some View { + content + .task { store.send(.task) } + } +} + +// MARK: - Subviews + +extension SidebarView { + + private var content: some View { + VStack(spacing: 10) { + logoView + .padding(.leading, 4) + filterGrid + .padding(.bottom, 10) + projectSection + .frame(maxHeight: .infinity) + bottomBar + .padding(.bottom, 10) + } + .padding(.horizontal, 12) + } + + // 임시 로고뷰 입니다. + private var logoView: some View { + HStack { + Rectangle() + .fill(Color.red) + .frame(width: 52, height: 28) + .overlay( + Text("LOGO") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + ) + Spacer() + } + } + + private var filterGrid: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 12 + ) { + ForEach(SidebarFilter.allCases, id: \.self) { filter in + DVCategory( + title: filter.title, + count: 0, + systemImage: filter.icon, + isSelected: store.selection == .filter(filter) + ) { + store.send(.didSelect(.filter(filter))) + } + } + } + } + + private var projectSection: some View { + VStack(spacing: 12) { + projectSectionHeader + .padding(.horizontal, 8) + if store.isProjectSectionExpanded { + projectList + } else { + Spacer(minLength: 0) + } + } + } + + private var projectSectionHeader: some View { + HStack(spacing: 11) { + Text("Project") + .dvFont(.captionMDSemibold) + .foregroundStyle(Color.dv(.vaultGreen)) + Spacer() + projectHeaderButton(icon: "plus.circle") { store.send(.didTapAddProject) } + .accessibilityLabel("Add Project") + projectHeaderButton( + icon: store.isProjectSectionExpanded ? "chevron.down" : "chevron.up" + ) { + store.send(.didToggleProjectSection) + } + .accessibilityLabel(store.isProjectSectionExpanded ? "Collapse Projects" : "Expand Projects") + } + } + + // TODO: - contextMenu 확정 후 추후에 Action 추가 + private var projectList: some View { + let projects = ["Project A", "Project B", "Project C"] + // List(selection:)은 Int?를 요구하지만 store.selection은 SidebarSelection(enum)이라 + // 타입 불일치로 $store 직접 바인딩 불가 → 의도적 manual Binding 사용 + return List( + selection: Binding( + get: { + if case .project(let index) = store.selection { return index } + return nil + }, + set: { index in + if let index { store.send(.didSelect(.project(index))) } + // TODO: nil(deselect) 처리 — content 컬럼 연결 시 결정 + } + ) + ) { + ForEach(projects.indices, id: \.self) { index in + DVProjectContainer(name: projects[index], count: 0) + .tag(index) + .contextMenu { + Button("Rename") {} + Button("New Secret") {} + Divider() + Button("Delete Project", role: .destructive) {} + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .padding(.horizontal, -12) + } + + private var bottomBar: some View { + HStack { + circleIconButton(icon: "gearshape") { store.send(.didTapSettingsButton) } + .accessibilityLabel("Settings") + Spacer() + circleIconButton(icon: "plus") { store.send(.didTapAddButton) } + .accessibilityLabel("Add Secret") + } + } + + private func projectHeaderButton(icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .dvFont(.captionLG) + .foregroundStyle(Color.dv(.gray500)) + } + .buttonStyle(.plain) + } + + private func circleIconButton(icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .dvFont(.captionLG) + .frame(width: 32, height: 32) + .background(Color.dv(.vaultGreen), in: Circle()) + .foregroundStyle(Color.dv(.white)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Preview + +#Preview { + Group { + SidebarView( + store: Store(initialState: SidebarFeature.State()) { + SidebarFeature() + } + ) + .frame(width: 200) + + SidebarView( + store: Store(initialState: SidebarFeature.State()) { + SidebarFeature() + } + ) + .frame(width: 250) + + SidebarView( + store: Store(initialState: SidebarFeature.State()) { + SidebarFeature() + } + ) + .frame(width: 270) + } +}