From c86b766bbd87afba3d792a78a83f13b04d236dd4 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:00:01 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 36 ++++++++++--------- .../ViewModels/BundleViewModel+Filter.swift | 31 ++++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 56bf3993..675b2675 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 38D17A512EBBF88C00F52A88 /* CollectionViewModel+Edit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D17A502EBBF88C00F52A88 /* CollectionViewModel+Edit.swift */; }; 38DD90622ED594C00042EB45 /* FestivalKeyringContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DD90612ED594C00042EB45 /* FestivalKeyringContextMenu.swift */; }; 38DD909C2EF1679D0042EB45 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DD909B2EF1679D0042EB45 /* Color+Extension.swift */; }; + 38DE8C432F38ECEF00C87924 /* BundleViewModel+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */; }; 38F832CB2EC9067300D3A248 /* WidgetOnboardingStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CA2EC9067300D3A248 /* WidgetOnboardingStepView.swift */; }; 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CC2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift */; }; 38F832CF2EC914C900D3A248 /* InvenExpandPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CE2EC914C900D3A248 /* InvenExpandPopup.swift */; }; @@ -359,9 +360,6 @@ AA2146B72F15E5B60048D40E /* BundleEditView+SelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */; }; AA2146BB2F161D0C0048D40E /* BundleEditView+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */; }; AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */; }; - BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */; }; - BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */; }; - BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */; }; AA3909462EC9F29500D87EEC /* UIApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */; }; AA39098E2ECA061700D87EEC /* GridItemSpacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */; }; AA390CE52ECC60A700D87EEC /* BundleRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA390CE42ECC60A700D87EEC /* BundleRoute.swift */; }; @@ -396,6 +394,9 @@ BC0002102F35F00200000004 /* KeyringEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC00020E2F35F00200000002 /* KeyringEmptyStateView.swift */; }; BC0002132F35F00200000006 /* KeyringSelectionContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0002112F35F00200000005 /* KeyringSelectionContent.swift */; }; BC0002152F35F00200000008 /* BundleKeyringCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0002142F35F00200000007 /* BundleKeyringCellView.swift */; }; + BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */; }; + BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */; }; + BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */; }; C645AE9F2EB1055C004BFE69 /* CategoryTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */; }; C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C645AEA22EB1B8FC004BFE69 /* DataInitializer.swift */; }; C665DDE82EAEFA8700CE4495 /* CoinChargeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C665DDE72EAEFA8700CE4495 /* CoinChargeView.swift */; }; @@ -548,6 +549,7 @@ 38D17A502EBBF88C00F52A88 /* CollectionViewModel+Edit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+Edit.swift"; sourceTree = ""; }; 38DD90612ED594C00042EB45 /* FestivalKeyringContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalKeyringContextMenu.swift; sourceTree = ""; }; 38DD909B2EF1679D0042EB45 /* Color+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; + 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleViewModel+Filter.swift"; sourceTree = ""; }; 38F832CA2EC9067300D3A248 /* WidgetOnboardingStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOnboardingStepView.swift; sourceTree = ""; }; 38F832CC2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WidgetOnboardingStepView+Helpers.swift"; sourceTree = ""; }; 38F832CE2EC914C900D3A248 /* InvenExpandPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvenExpandPopup.swift; sourceTree = ""; }; @@ -822,9 +824,6 @@ AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+SelectSheet.swift"; sourceTree = ""; }; AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Initialization.swift"; sourceTree = ""; }; AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleDetailView+SaveImage.swift"; sourceTree = ""; }; - BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleCompleteView.swift; sourceTree = ""; }; - BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+VideoGen.swift"; sourceTree = ""; }; - BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+SaveImage.swift"; sourceTree = ""; }; AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extension.swift"; sourceTree = ""; }; AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridItemSpacing.swift; sourceTree = ""; }; AA390CE42ECC60A700D87EEC /* BundleRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleRoute.swift; sourceTree = ""; }; @@ -859,6 +858,9 @@ BC00020E2F35F00200000002 /* KeyringEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringEmptyStateView.swift; sourceTree = ""; }; BC0002112F35F00200000005 /* KeyringSelectionContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringSelectionContent.swift; sourceTree = ""; }; BC0002142F35F00200000007 /* BundleKeyringCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleKeyringCellView.swift; sourceTree = ""; }; + BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleCompleteView.swift; sourceTree = ""; }; + BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+VideoGen.swift"; sourceTree = ""; }; + BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+SaveImage.swift"; sourceTree = ""; }; C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryTabBar.swift; sourceTree = ""; }; C645AEA22EB1B8FC004BFE69 /* DataInitializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInitializer.swift; sourceTree = ""; }; C665DDE72EAEFA8700CE4495 /* CoinChargeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinChargeView.swift; sourceTree = ""; }; @@ -2172,6 +2174,7 @@ 61SOUIL06G2NM0OODQ5MLB0A /* BundleViewModel+Fetch.swift */, AA69DD232F14C56F00C0A41C /* BundleViewModel+CRUD.swift */, AA69DD252F14C60000C0A41C /* BundleViewModel+Edit.swift */, + 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */, QM5GFF2WHZX9U24OOWPTMM4S /* BundleViewModel+Purchase.swift */, 40QZ1H4Y8EH2YZZUOT7WN7MX /* BundleViewModel+Helpers.swift */, DTQCH5OWZZJ8N03KVZERHJKR /* BundleViewModel+Cache.swift */, @@ -2227,6 +2230,16 @@ path = Edit; sourceTree = ""; }; + BC0CMPLT2F3B123400000000 /* Complete */ = { + isa = PBXGroup; + children = ( + BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */, + BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */, + BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */, + ); + path = Complete; + sourceTree = ""; + }; C665DDE92EAEFAA800CE4495 /* Coin */ = { isa = PBXGroup; children = ( @@ -2321,16 +2334,6 @@ path = Detail; sourceTree = ""; }; - BC0CMPLT2F3B123400000000 /* Complete */ = { - isa = PBXGroup; - children = ( - BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */, - BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */, - BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */, - ); - path = Complete; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -2857,6 +2860,7 @@ BC00010B2F35F0010000000B /* BundleCreateView+Capture.swift in Sources */, BC00010C2F35F0010000000C /* BundleEditView+Capture.swift in Sources */, 38C3C2922EC1F787003C5DE1 /* CollectionKeyringDetailView+Lifecycle.swift in Sources */, + 38DE8C432F38ECEF00C87924 /* BundleViewModel+Filter.swift in Sources */, 4C8426642ED375840050B6FE /* ColorPalette.swift in Sources */, C6C402A32EB40ACA006B58DF /* AlarmView.swift in Sources */, C6B56F0B2EBC43AC0049F969 /* StoreProduct.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift new file mode 100644 index 00000000..3241366f --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift @@ -0,0 +1,31 @@ +// +// BundleViewModel+Filter.swift +// Keychy +// +// Created by Jini on 2/9/26. +// + +import SwiftUI + +extension BundleViewModel { + // MARK: - 검색 키워드 필터링 + /// 검색어로 뭉치 필터링 (이름 기준) + func getFilteredBundles(searchText: String = "") -> [KeyringBundle] { + var result = bundles + + // 검색 필터 + if !searchText.isEmpty { + result = result.filter { bundle in + bundle.name.localizedCaseInsensitiveContains(searchText) + } + } + + // 정렬 적용 (메인 뭉치 우선, 그 다음 최신순) + return result.sorted { a, b in + if a.isMain != b.isMain { + return a.isMain + } + return a.createdAt > b.createdAt + } + } +} From 8cd68c4ae993333405e752bd6869f90fd8259ad1 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:00:21 +0900 Subject: [PATCH 02/13] =?UTF-8?q?style:=20=ED=97=A4=EB=8D=94=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=ED=8B=80=20=ED=8C=A8=EB=94=A9=EA=B0=92=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Collection/Views/Main/CollectionView+NormalMode.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift index 7d68c8ce..526a4053 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift @@ -77,8 +77,7 @@ extension CollectionView { // 고정 오버레이 헤더 VStack(spacing: 0) { headerSection - .padding(.horizontal, Spacing.margin) - .padding(.top, 2) + .padding(.horizontal, Spacing.md) .padding(.bottom, 10) tagSection @@ -110,7 +109,6 @@ extension CollectionView { VStack(spacing: 0) { headerSection .padding(.horizontal, Spacing.margin) - .padding(.top, 2) .padding(.bottom, 10) collectionHeader From 862aee6af2dca87b7fdb2f4b34fe0db3c4768681 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:01:50 +0900 Subject: [PATCH 03/13] =?UTF-8?q?style:=20=EB=AD=89=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=82=B4=20=EA=B2=80=EC=83=89=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Shared/BundleGridItem.swift | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift index 351d2cc8..ec381a71 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift @@ -12,6 +12,7 @@ import FirebaseFirestore struct BundleGridItem: View { let bundle: KeyringBundle + var searchKeyword: String = "" // 검색 키워드 (기본값 빈 문자열) @State private var cachedImage: Image? @State private var isCapturing: Bool = false @@ -46,16 +47,60 @@ struct BundleGridItem: View { .padding(10) } } - HStack { + // 번들 이름 (검색 키워드 하이라이트 적용) + bundleNameView + } //: VSTACK + .onAppear { + loadBundleImage() + } + } + + // MARK: - Bundle Name View + private var bundleNameView: some View { + HStack { + if !searchKeyword.isEmpty { + // 검색 모드: 하이라이트 적용 + Text(highlightedText(text: bundle.name, keyword: searchKeyword)) + } else { + // 일반 모드 Text(bundle.name) .typography(.notosans14M) .foregroundStyle(.black100) } - } //: VSTACK - .onAppear { - loadBundleImage() } } + + // MARK: - 검색 키워드 Highlighted Text + private func highlightedText(text: String, keyword: String) -> AttributedString { + var attributedString = AttributedString(text) + + guard !keyword.isEmpty else { + attributedString.font = .notosans14M + return attributedString + } + + attributedString.font = .notosans14M + attributedString.foregroundColor = .black100 + + let lowerText = text.lowercased() + let lowerKeyword = keyword.lowercased() + + var searchRange = lowerText.startIndex.. Date: Mon, 9 Feb 2026 02:02:19 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EC=84=B8=EA=B7=B8=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC=EC=9A=A9=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Collection/Views/Main/CollectionView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift index 65d8b536..5637b416 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift @@ -8,6 +8,11 @@ import SwiftUI import SpriteKit +enum SearchSegment { + case keyring + case bundle +} + struct CollectionView: View { @Bindable var router: NavigationRouter @State var collectionViewModel: CollectionViewModel @@ -32,6 +37,7 @@ struct CollectionView: View { @State var newCategoryName: String = "" @State var showingMenuFor: String? @State var menuPosition: CGRect = .zero + @State var searchSegment: SearchSegment = .keyring // 디버그용 @State var showCachedImagesDebug: Bool = false @@ -59,6 +65,11 @@ struct CollectionView: View { ) } + // 필터링된 뭉치 (검색) + var filteredBundles: [KeyringBundle] { + bundleViewModel.getFilteredBundles(searchText: isSearching ? searchText : "") + } + let columns: [GridItem] = [ GridItem(.flexible(), spacing: Spacing.gap), GridItem(.flexible(), spacing: Spacing.gap) From 2e0a459fe30ba8acfee3d7922b711b65ca49cbe2 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:02:48 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EB=B7=B0=20?= =?UTF-8?q?=EB=82=B4=20=ED=82=A4=EB=A7=81/=EB=AD=89=EC=B9=98=20=EC=84=B8?= =?UTF-8?q?=EA=B7=B8=EB=A8=BC=ED=8A=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20UI?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/CollectionView+SearchMode.swift | 277 +++++++++++++----- 1 file changed, 197 insertions(+), 80 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+SearchMode.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+SearchMode.swift index 0fdf6112..a6b8c8a7 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+SearchMode.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+SearchMode.swift @@ -14,78 +14,23 @@ extension CollectionView { Group { if collectionViewModel.hasNetworkError { // 네트워크 에러: 오버레이 형태 - ZStack(alignment: .top) { - Color.white - .ignoresSafeArea() - - NoInternetView(topPadding: getSafeAreaTop() + 90, onRetry: { - Task { - guard let uid = UserDefaults.standard.string(forKey: "userUID") else { - print("UID를 찾을 수 없습니다") - return - } - await collectionViewModel.retryFetchData(userId: uid) - } - }) - .ignoresSafeArea() - - VStack(spacing: 10) { - VStack(spacing: 10) { - Spacer() - .frame(height: 60) - - HStack { - Spacer() - - Text("\(filteredKeyrings.count)개 발견됨") - .typography(.suit14M) - .foregroundColor(.gray500) - .padding(.top, 22) - .padding(.trailing, 22) - } - - HStack { - Text("키링") - .typography(.suit16B) - .foregroundColor(.gray500) - .padding(.leading, 20) - - Spacer() - } - .opacity(!filteredKeyrings.isEmpty ? 1 : 0) - } - .background(Color.white) - - Spacer() - } - } + networkErrorView } else { // 정상 상태: 기존 VStack 형태 VStack(spacing: 10) { - Spacer() - .frame(height: 60) - - HStack { - Spacer() - - Text("\(filteredKeyrings.count)개 발견됨") - .typography(.suit14M) - .foregroundColor(.gray500) - .padding(.top, 22) - .padding(.trailing, 22) - } - - HStack { - Text("키링") - .typography(.suit16B) - .foregroundColor(.gray500) - .padding(.leading, 20) - - Spacer() - } - .opacity(!filteredKeyrings.isEmpty ? 1 : 0) - - searchCollectionSection + // 헤더 + searchHeaderSection + + // 세그먼트 컨트롤 + searchSegmentControl + .padding(.horizontal, Spacing.margin) + + // 결과 카운트 + searchResultCount + + // 컨텐츠 + searchContentSection + .padding(.top, -4) } .contentShape(Rectangle()) .onTapGesture { @@ -95,10 +40,88 @@ extension CollectionView { } } } - } - var searchCollectionSection: some View { + // 검색 헤더 + private var searchHeaderSection: some View { + HStack { + Text("검색") + .typography(.nanum24EB) + .foregroundColor(.black100) + + Spacer() + } + .padding(.vertical, Spacing.sm) + .padding(.top, 60) + .padding(.leading, 16) + } + + // 세그먼트 컨트롤 + private var searchSegmentControl: some View { + HStack(spacing: 0) { + segmentButton(title: "키링", segment: .keyring) + segmentButton(title: "뭉치", segment: .bundle) + } + .frame(height: 46) + .background(Color.gray50) + .cornerRadius(100) + } + + private func segmentButton(title: String, segment: SearchSegment) -> some View { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + searchSegment = segment + } + } label: { + Text(title) + .typography(.suit16M) + .foregroundColor(.black100) + .frame(maxWidth: .infinity) + .frame(height: 38) + .background( + searchSegment == segment ? + .white100 : .clear + ) + .cornerRadius(20) + } + .buttonStyle(.plain) + .padding(4) + } + + // 결과 카운트 + private var searchResultCount: some View { + HStack { + Text("\(currentSearchResultCount)개 발견됨") + .typography(.suit14M) + .foregroundColor(.gray500) + .padding(.top, 10) + .padding(.leading, 22) + + Spacer() + } + } + + private var currentSearchResultCount: Int { + switch searchSegment { + case .keyring: + return filteredKeyrings.count + case .bundle: + return filteredBundles.count + } + } + + var searchContentSection: some View { + Group { + if searchSegment == .keyring { + keyringSearchResults + } else { + bundleSearchResults + } + } + } + + // 키링 검색 결과 + private var keyringSearchResults: some View { ScrollView { VStack(spacing: 0) { if filteredKeyrings.isEmpty { @@ -110,20 +133,107 @@ extension CollectionView { .padding(.horizontal, Spacing.xs) } .scrollIndicators(.hidden) - .simultaneousGesture( - DragGesture().onChanged { _ in - if showSearchBar { - isSearchFieldFocused = false + .simultaneousGesture(scrollGesture) + } + + // 뭉치 검색 결과 + private var bundleSearchResults: some View { + ScrollView { + VStack(spacing: 0) { + if filteredBundles.isEmpty { + searchEmptyView + } else { + bundleSearchGrid + } + } + .padding(.horizontal, Spacing.xs) + } + .scrollIndicators(.hidden) + .simultaneousGesture(scrollGesture) + } + + private var bundleSearchGrid: some View { + LazyVGrid(columns: columns, spacing: 11) { + ForEach(filteredBundles, id: \.documentId) { bundle in + Button { + guard NetworkManager.shared.isConnected else { + ToastManager.shared.show() + return + } + + // 키보드 내리기 + if isSearchFieldFocused { + isSearchFieldFocused = false + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + bundleViewModel.selectedBundle = bundle + bundleViewModel.selectedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) + bundleViewModel.selectedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + router.push(.bundleDetailView) + } + } label: { + BundleGridItem( + bundle: bundle, + searchKeyword: searchText + ) + + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, Spacing.gap) + .padding(.vertical, 4) + .padding(.bottom, 90) + } + + // 스크롤 제스처 + private var scrollGesture: some Gesture { + DragGesture().onChanged { _ in + if showSearchBar { + isSearchFieldFocused = false + + if !isSearching { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSearchBar = false + } + } + } + } + } - if !isSearching { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSearchBar = false - } + private var networkErrorView: some View { + ZStack(alignment: .top) { + Color.white + .ignoresSafeArea() + + NoInternetView(topPadding: getSafeAreaTop() + 90, onRetry: { + Task { + guard let uid = UserDefaults.standard.string(forKey: "userUID") else { + print("UID를 찾을 수 없습니다") + return } + await collectionViewModel.retryFetchData(userId: uid) } + }) + .ignoresSafeArea() + + VStack(spacing: 10) { + VStack(spacing: 10) { + searchHeaderSection + + searchSegmentControl + .padding(.horizontal, Spacing.margin) + + searchResultCount + + Spacer() + } + .background(Color.white) } - ) + } } + var searchEmptyView: some View { VStack { @@ -222,3 +332,10 @@ extension CollectionView { return attributedString } } + +extension UISegmentedControl { + override open func didMoveToSuperview() { + super.didMoveToSuperview() + self.setContentHuggingPriority(.defaultLow, for: .vertical) + } +} From ad8a3ecc0d2802cac51ad079fe072a78aecac822 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:16:40 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++ .../ViewModels/BundleViewModel+Filter.swift | 9 +--- .../ViewModels/BundleViewModel+Sort.swift | 43 +++++++++++++++++++ .../Bundle/ViewModels/BundleViewModel.swift | 11 +++-- .../Main/CollectionView+NormalMode.swift | 9 +++- .../Views/Main/CollectionView.swift | 7 ++- 6 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Sort.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 675b2675..6053b661 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 38DD90622ED594C00042EB45 /* FestivalKeyringContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DD90612ED594C00042EB45 /* FestivalKeyringContextMenu.swift */; }; 38DD909C2EF1679D0042EB45 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DD909B2EF1679D0042EB45 /* Color+Extension.swift */; }; 38DE8C432F38ECEF00C87924 /* BundleViewModel+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */; }; + 38DE8C452F38FAA700C87924 /* BundleViewModel+Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DE8C442F38FAA700C87924 /* BundleViewModel+Sort.swift */; }; 38F832CB2EC9067300D3A248 /* WidgetOnboardingStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CA2EC9067300D3A248 /* WidgetOnboardingStepView.swift */; }; 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CC2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift */; }; 38F832CF2EC914C900D3A248 /* InvenExpandPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F832CE2EC914C900D3A248 /* InvenExpandPopup.swift */; }; @@ -550,6 +551,7 @@ 38DD90612ED594C00042EB45 /* FestivalKeyringContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalKeyringContextMenu.swift; sourceTree = ""; }; 38DD909B2EF1679D0042EB45 /* Color+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleViewModel+Filter.swift"; sourceTree = ""; }; + 38DE8C442F38FAA700C87924 /* BundleViewModel+Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleViewModel+Sort.swift"; sourceTree = ""; }; 38F832CA2EC9067300D3A248 /* WidgetOnboardingStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOnboardingStepView.swift; sourceTree = ""; }; 38F832CC2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WidgetOnboardingStepView+Helpers.swift"; sourceTree = ""; }; 38F832CE2EC914C900D3A248 /* InvenExpandPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvenExpandPopup.swift; sourceTree = ""; }; @@ -2174,6 +2176,7 @@ 61SOUIL06G2NM0OODQ5MLB0A /* BundleViewModel+Fetch.swift */, AA69DD232F14C56F00C0A41C /* BundleViewModel+CRUD.swift */, AA69DD252F14C60000C0A41C /* BundleViewModel+Edit.swift */, + 38DE8C442F38FAA700C87924 /* BundleViewModel+Sort.swift */, 38DE8C422F38ECEF00C87924 /* BundleViewModel+Filter.swift */, QM5GFF2WHZX9U24OOWPTMM4S /* BundleViewModel+Purchase.swift */, 40QZ1H4Y8EH2YZZUOT7WN7MX /* BundleViewModel+Helpers.swift */, @@ -2673,6 +2676,7 @@ 4C4733BF2F1FA388005D2376 /* PolaroidVM.swift in Sources */, 4C4733C02F1FA388005D2376 /* ClearSketchPreview.swift in Sources */, 4C4733C12F1FA388005D2376 /* ClearSketchVM+Drawing.swift in Sources */, + 38DE8C452F38FAA700C87924 /* BundleViewModel+Sort.swift in Sources */, 4C4733C22F1FA388005D2376 /* PixelVM+Effect.swift in Sources */, 4C4733C32F1FA388005D2376 /* CropModels.swift in Sources */, 4C4733C42F1FA388005D2376 /* SpeechBubbleVM+Effect.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift index 3241366f..aeba6940 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Filter.swift @@ -20,12 +20,7 @@ extension BundleViewModel { } } - // 정렬 적용 (메인 뭉치 우선, 그 다음 최신순) - return result.sorted { a, b in - if a.isMain != b.isMain { - return a.isMain - } - return a.createdAt > b.createdAt - } + // 정렬 적용 + return sortBundles(result) } } diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Sort.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Sort.swift new file mode 100644 index 00000000..a0488bc5 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Sort.swift @@ -0,0 +1,43 @@ +// +// BundleViewModel+Sort.swift +// Keychy +// +// Created by Jini on 2/9/26. +// + +import SwiftUI + +extension BundleViewModel { + // MARK: - 정렬 방식 + + /// 정렬 기준 변경 및 즉시 적용 + func updateSortOrder(_ newSort: String) { + selectedSort = newSort + } + + /// 뭉치 배열을 정렬 (메인 뭉치는 항상 최상단 유지) + func sortBundles(_ bundles: [KeyringBundle]) -> [KeyringBundle] { + // 메인 뭉치와 일반 뭉치 분리 + let mainBundles = bundles.filter { $0.isMain } + let normalBundles = bundles.filter { !$0.isMain } + + // 일반 뭉치를 선택된 정렬 기준으로 정렬 + let sortedNormalBundles: [KeyringBundle] + + switch selectedSort { + case "최신순": + sortedNormalBundles = normalBundles.sorted { $0.createdAt > $1.createdAt } + case "오래된순": + sortedNormalBundles = normalBundles.sorted { $0.createdAt < $1.createdAt } + case "이름순": + sortedNormalBundles = normalBundles.sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + default: + sortedNormalBundles = normalBundles.sorted { $0.createdAt > $1.createdAt } + } + + // 메인 뭉치(최신순 정렬) + 정렬된 일반 뭉치 + return mainBundles.sorted { $0.createdAt > $1.createdAt } + sortedNormalBundles + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift index 9e568b45..f9c57f96 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift @@ -75,6 +75,10 @@ class BundleViewModel { var isLoading = false var isPurchasing = false + // MARK: - 정렬 상태 + + var selectedSort: String = "최신순" // 기본값 + // MARK: - 시트 필터/정렬 상태 var sheetSortOrder: String = "최신순" @@ -150,12 +154,7 @@ class BundleViewModel { // MARK: - 정렬된 뭉치 var sortedBundles: [KeyringBundle] { - bundles.sorted { a, b in - if a.isMain != b.isMain { - return a.isMain - } - return a.createdAt > b.createdAt - } + sortBundles(bundles) } // MARK: - 구성 ID 저장소 (편집 → 상세 화면 전환용) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift index 526a4053..91cc1648 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift @@ -380,7 +380,7 @@ extension CollectionView { showSortSheet = true }) { HStack(spacing: 2) { - Text(collectionViewModel.selectedSort) + Text(currentSortText) .typography(.suit14SB18) .foregroundColor(.gray500) @@ -399,6 +399,13 @@ extension CollectionView { .buttonStyle(PlainButtonStyle()) } + // 현재 탭에 따른 정렬 텍스트 + private var currentSortText: String { + collectionViewModel.collectionToggle + ? collectionViewModel.selectedSort + : bundleViewModel.selectedSort + } + var emptyView: some View { VStack { Spacer() diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift index 5637b416..ed7a29d0 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView.swift @@ -305,7 +305,12 @@ struct CollectionView: View { title: sort, isSelected: collectionViewModel.selectedSort == sort ) { - collectionViewModel.updateSortOrder(sort) + // 현재 탭에 따라 정렬 적용 + if collectionViewModel.collectionToggle { + collectionViewModel.updateSortOrder(sort) + } else { + bundleViewModel.updateSortOrder(sort) + } showSortSheet = false } } From 78daacc51d0f8e170b274fcd24d0145d6a71bd25 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 02:17:02 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=ED=82=A4=EB=A7=81/=EB=AD=89?= =?UTF-8?q?=EC=B9=98=20=ED=83=AD=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=B0=A9=EC=8B=9D=20=EB=8F=85=EB=A6=BD?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Collection/ViewModels/CollectionViewModel.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift b/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift index 4b45c075..383ea360 100644 --- a/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift +++ b/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift @@ -29,11 +29,7 @@ class CollectionViewModel { // MARK: - 탭 토글 (true = 키링, false = 뭉치) var collectionToggle: Bool = true { didSet { - // 탭 전환 시 초기화 - if !collectionToggle { - // 뭉치 탭으로 전환 시 - selectedSort = "최신순" - } + // 탭 전환해도 정렬 상태는 유지 (각 탭이 독립적으로 정렬방식 유지) } } From da5af0f77a3691f3a94a3401569fb09154a91c6d Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 03:01:40 +0900 Subject: [PATCH 08/13] =?UTF-8?q?style:=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=ED=83=AD=EB=B3=84=20=ED=8C=A8=EB=94=A9?= =?UTF-8?q?=EA=B0=92=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Collection/Views/Main/CollectionView+NormalMode.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift index 91cc1648..69d77aba 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift @@ -55,7 +55,6 @@ extension CollectionView { VStack { headerSection .padding(.horizontal, Spacing.margin) - .padding(.top, 2) if collectionViewModel.collectionToggle { tagSection @@ -77,7 +76,7 @@ extension CollectionView { // 고정 오버레이 헤더 VStack(spacing: 0) { headerSection - .padding(.horizontal, Spacing.md) + .padding(.horizontal, Spacing.margin) .padding(.bottom, 10) tagSection @@ -307,6 +306,7 @@ extension CollectionView { } } .padding(.bottom, 90) + .padding(.horizontal, Spacing.gap) } private var bundleEmptyView: some View { From a76bcb12d004622cda4134ec335c05910ac97d89 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 03:18:41 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=ED=82=A4=EB=A7=81=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=ED=9B=84=20=EB=B3=B4=EA=B4=80?= =?UTF-8?q?=ED=95=A8=20=EB=8C=80=EC=8B=A0=20=EC=83=81=EC=84=B8=EB=B7=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Detail/CollectionKeyringDetailView.swift | 45 ++++++++++++++++++- .../Views/Detail/KeyringEditView.swift | 6 +-- .../Tab/Views/CollectionTab.swift | 2 +- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift index cded4c62..76a583f7 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift @@ -53,7 +53,21 @@ struct CollectionKeyringDetailView: View { let isSearchMode: Bool // 검색모드 여부 - let keyring: Keyring + // 키링 정보 + @State var keyring: Keyring + + // 초기화 시 keyring을 받아서 State에 저장 + init( + router: NavigationRouter, + viewModel: CollectionViewModel, + keyring: Keyring, + isSearchMode: Bool = false + ) { + self.router = router + self.viewModel = viewModel + self._keyring = State(initialValue: keyring) + self.isSearchMode = isSearchMode + } var body: some View { GeometryReader { geometry in @@ -151,7 +165,10 @@ struct CollectionKeyringDetailView: View { .onPreferenceChange(MenuButtonPreferenceKey.self) { frame in menuPosition = frame } - + .task { + // 뷰가 다시 나타날 때 키링 데이터 새로고침 + await refreshKeyringData() + } } private var shouldApplyBlur: Bool { @@ -176,6 +193,30 @@ struct CollectionKeyringDetailView: View { } } + /// 키링 데이터 새로고침 (편집 후 돌아왔을 때) + private func refreshKeyringData() async { + guard let documentId = keyring.documentId else { return } + + // ViewModel에서 최신 키링 데이터 찾기 + if let updatedKeyring = viewModel.keyring.first(where: { $0.documentId == documentId }) { + await MainActor.run { + self.keyring = updatedKeyring + } + } else { + // 로컬에 없으면 Firebase에서 직접 가져오기 + await withCheckedContinuation { continuation in + viewModel.fetchKeyringById(keyringId: documentId) { fetchedKeyring in + if let fetchedKeyring = fetchedKeyring { + Task { @MainActor in + self.keyring = fetchedKeyring + } + } + continuation.resume() + } + } + } + } + /// 씬 스케일 (시트 최대화 시 작게, 최소화 시 크게) private var sceneScale: CGFloat { isSheetPresented == false ? 1.1 : 0.8 diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift index adff7a9f..a87498e4 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift @@ -159,11 +159,7 @@ extension KeyringEditView { tags: editedTags ) { success in if success { - router.reset() - TabBarManager.show() - - //TODO: 수정완료 후 pop (데이터 새로고침 로직 추가 예정) - //router.pop() + router.pop() } } } label: { diff --git a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift index 30c00b2c..f5617d5d 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift @@ -20,7 +20,7 @@ struct CollectionTab: View { switch route { case .collectionKeyringDetailView(let keyring, let isSearchMode): - CollectionKeyringDetailView(router: router, viewModel: collectionViewModel, isSearchMode: isSearchMode, keyring: keyring) + CollectionKeyringDetailView(router: router, viewModel: collectionViewModel, keyring: keyring, isSearchMode: isSearchMode) case .collectionKeyringPackageView(let keyring, let isSearchMode): CollectionKeyringPackageView(router: router, viewModel: collectionViewModel, isSearchMode: isSearchMode, keyring: keyring) case .keyringEditView(let keyring): From 7d4cd18fb5b6f1b6cdf8cf15c7d2220bba0ba007 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 03:41:15 +0900 Subject: [PATCH 10/13] =?UTF-8?q?chore:=20=EC=9C=84=EC=A0=AF=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=94=A9=EB=B7=B0=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Navigation/Routes/BundleRoute.swift | 1 + .../Core/Navigation/Routes/HomeRoute.swift | 3 + .../Navigation/Routes/WorkshopRoute.swift | 3 + .../Views/Detail/BundleDetailView+Menu.swift | 7 ++ .../Bundle/Views/Detail/BundleMenu.swift | 69 ++++++++++++++----- .../Views/Widget/WidgetSettingView.swift | 4 +- .../Presentation/Tab/Views/HomeTab.swift | 2 + .../Presentation/Tab/Views/WorkshopTab.swift | 4 ++ 8 files changed, 73 insertions(+), 20 deletions(-) diff --git a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift index 72577575..5e10ba0a 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift @@ -17,4 +17,5 @@ protocol BundleRoute: Hashable { static var bundleEditView: Self { get } static var bundleCompleteView: Self { get } static var coinCharge: Self { get } + static var widgetSettingView: Self { get } } diff --git a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index d0b33fae..047ca049 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift @@ -15,6 +15,9 @@ enum HomeRoute: Hashable, BundleRoute { case bundleNameEditView case bundleEditView case bundleCompleteView + + // Widget + case widgetSettingView // Home case coinCharge diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 869c7f76..39b187de 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -67,6 +67,9 @@ enum WorkshopRoute: Hashable, BundleRoute { // MARK: - 선물 포장 완료 case packageComplete(keyringDocumentId: String, postOfficeId: String, templateId: String, shareLink: String) + // MARK: - 위젯 가이딩 + case widgetSettingView + /// template.id 문자열을 WorkshopRoute로 변환 static func from(string: String) -> WorkshopRoute? { switch string { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+Menu.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+Menu.swift index 0d1335ff..fd7fae83 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+Menu.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+Menu.swift @@ -67,6 +67,13 @@ extension BundleDetailView { uiState.showDeleteAlert = true } }, + onWidget: { + uiState.showMenu = false + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + router.push(.widgetSettingView) + } + }, isMain: bundle.isMain ) .zIndex(50) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift index 9c98506a..d5903bfe 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift @@ -12,12 +12,11 @@ struct BundleMenu: View { let onNameEdit: () -> Void let onEdit: () -> Void let onDelete: () -> Void + let onWidget: () -> Void let isMain: Bool private let menuWidth: CGFloat = 185 - private var menuHeight: CGFloat { - isMain ? 120 : 175 // 메인 뭉치면 삭제 버튼 없음 - } + private let menuHeight: CGFloat = 218 @State private var isAppearing = false @@ -62,23 +61,57 @@ struct BundleMenu: View { .frame(maxWidth: .infinity, alignment: .leading) // 삭제 버튼 - if !isMain { - Button(action: onDelete) { - HStack(spacing: 8) { - Image(.trash) - - Text("삭제") - .typography(.suit16M) - .foregroundColor(.pink) - - Spacer() - } - .padding(.vertical, 10) - .padding(.horizontal, 10) - .contentShape(Rectangle()) + Button(action: onDelete) { + HStack(spacing: 8) { + Image(.trash) + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(isMain ? .gray300 : .pink) + + Text("삭제") + .typography(.suit16M) + .foregroundColor(isMain ? .gray300 : .pink) + + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + if !isMain { + onDelete() + } + } + + // 구분선 + Rectangle() + .fill(Color.gray100) + .padding(.horizontal, 10) + .frame(height: 1) + + // 위젯 버튼 + Button(action: onWidget) { + HStack(spacing: 8) { + Image(.widget) + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.gray600) + + Text("위젯 설정") + .typography(.suit16M) + .foregroundColor(.gray600) + + Spacer() } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal, 10) + .contentShape(Rectangle()) } + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 10) .padding(.vertical, 20) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Widget/WidgetSettingView.swift b/Keychy/Keychy/Presentation/Collection/Views/Widget/WidgetSettingView.swift index 362534b9..36f85cf4 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Widget/WidgetSettingView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Widget/WidgetSettingView.swift @@ -7,8 +7,8 @@ import SwiftUI -struct WidgetSettingView: View { - @Bindable var router: NavigationRouter +struct WidgetSettingView: View { + @Bindable var router: NavigationRouter private let steps = WidgetOnboardingStep.steps diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 2e98ba8d..7ba8247d 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift @@ -63,6 +63,8 @@ struct HomeTab: View { IntroView(viewModel: introViewModel) case .termsAndPolicy: TermsView(router: router) + case .widgetSettingView: + WidgetSettingView(router: router) // Festival case .festivalView: diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index 4872b23f..6b912980 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -62,6 +62,10 @@ struct WorkshopTab: View { // MARK: - 재화 구매뷰 case .coinCharge: CoinChargeView(router: router) + + // MARK: - 위젯 가이딩 + case .widgetSettingView: + WidgetSettingView(router: router) // MARK: - AcrylicPhoto case .acrylicPhotoPreview: From 269895b49edf5b20deb5b6883e3c0a58d2b3944f Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 03:46:43 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EB=AD=89?= =?UTF-8?q?=EC=B9=98=EC=9D=BC=20=EB=95=8C=20=EC=82=AD=EC=A0=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Detail/BundleMenu.swift | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift index d5903bfe..bc89b711 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleMenu.swift @@ -61,24 +61,22 @@ struct BundleMenu: View { .frame(maxWidth: .infinity, alignment: .leading) // 삭제 버튼 - Button(action: onDelete) { - HStack(spacing: 8) { - Image(.trash) - .renderingMode(.template) - .resizable() - .frame(width: 24, height: 24) - .foregroundColor(isMain ? .gray300 : .pink) - - Text("삭제") - .typography(.suit16M) - .foregroundColor(isMain ? .gray300 : .pink) - - Spacer() - } - .padding(.vertical, 10) - .padding(.horizontal, 10) - .contentShape(Rectangle()) + HStack(spacing: 8) { + Image(.trash) + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(isMain ? .gray300 : .pink) + + Text("삭제") + .typography(.suit16M) + .foregroundColor(isMain ? .gray300 : .pink) + + Spacer() } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .contentShape(Rectangle()) .frame(maxWidth: .infinity, alignment: .leading) .onTapGesture { if !isMain { From 39da8efba192ea2778b851f86f0374f976e964d4 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 04:16:41 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=B7=B0=EC=97=90=20=EA=B3=B5=EC=9C=A0=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Detail/BundleDetailView+VideoGen.swift | 50 ++++++++++++++ .../Views/Detail/BundleDetailView.swift | 68 ++++++++++++++----- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift index 1a1c4d20..531a6814 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift @@ -86,4 +86,54 @@ extension BundleDetailView { return nil } } + + /// 공유용 영상 생성 + @MainActor + func generateVideoForShare() async { + guard !uiState.isGeneratingVideo else { return } + + // 이미 캐시된 영상이 있으면 바로 공유 + if cachedVideoURL != nil { + showShareSheet = true + return + } + + uiState.isGeneratingVideo = true + + guard let bundle = bundleVM.selectedBundle, + let background = bundleVM.selectedBackground, + let carabiner = bundleVM.selectedCarabiner else { + uiState.isGeneratingVideo = false + return + } + + do { + // 배경 이미지 로드 + let backgroundImage = await loadImage(from: background.backgroundImage) + + // 영상 생성 (기존 generateVideo 함수 사용) + let videoURL = try await videoGenerator.generateVideo( + keyringDataList: keyringDataList, + backgroundImage: backgroundImage, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabiner.backImageURL, + carabinerFrontImageURL: carabiner.frontImageURL, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth, + carabinerType: carabiner.type, + bundleScale: 2.5 + ) + + cachedVideoURL = videoURL + uiState.isGeneratingVideo = false + + // 영상 생성 완료 후 공유 시트 표시 + showShareSheet = true + + } catch { + print("[BundleDetail] 영상 생성 실패: \(error.localizedDescription)") + uiState.isGeneratingVideo = false + } + } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift index 3b7768d5..5d4527cb 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -53,6 +53,10 @@ struct BundleDetailView: View { /// 영상 생성기 @State var videoGenerator = BundleVideoGenerator() + // 영상 공유 관련 + @State var cachedVideoURL: URL? + @State var showShareSheet: Bool = false + // MARK: - Body var body: some View { GeometryReader { geometry in @@ -110,19 +114,9 @@ struct BundleDetailView: View { VStack { Spacer() + bottomSection } - - // 영상 저장 버튼 - 이미지 저장 버튼 바로 위 - VStack { - Spacer() - HStack { - Spacer() - downloadVideoButton - } - .padding(.trailing, 16) - .padding(.bottom, 36 + 48 + 12) // bottomSection padding + button height + spacing - } } menuOverlay @@ -137,6 +131,13 @@ struct BundleDetailView: View { .ignoresSafeArea() .navigationBarBackButtonHidden(true) .withToast(position: .default) + .sheet(isPresented: $showShareSheet) { + if let url = cachedVideoURL { + ShareSheet(items: [url]) + .presentationDetents([.fraction(0.65)]) + .presentationDragIndicator(.visible) + } + } .onPreferenceChange(MenuButtonPreferenceKey.self) { frame in if frame != .zero { menuPosition = frame @@ -150,6 +151,7 @@ struct BundleDetailView: View { } .onDisappear { uiState.resetOverlays() + cleanupCachedVideo() // 진행 중인 작업들 취소 readyDelayTask?.cancel() @@ -174,6 +176,14 @@ struct BundleDetailView: View { } } } + + // MARK: - 캐시된 영상 정리 + private func cleanupCachedVideo() { + if let url = cachedVideoURL { + try? FileManager.default.removeItem(at: url) + cachedVideoURL = nil + } + } } // MARK: - Data Loading @@ -336,11 +346,18 @@ extension BundleDetailView { Text("\(bundle.name)") } } trailing: { - MenuToolbarButton { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - uiState.showMenu.toggle() + HStack(spacing: 10) { + // 이미지 다운 버튼 + downloadImageButton + + // 메뉴 버튼 + MenuToolbarButton { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + uiState.showMenu.toggle() + } } } + } } } @@ -371,7 +388,7 @@ extension BundleDetailView { Spacer() - downloadImageButton + shareButton } } .padding(EdgeInsets(top: 4, leading: 16, bottom: 36, trailing: 16)) @@ -409,6 +426,25 @@ extension BundleDetailView { } } + /// 공유 버튼 + private var shareButton: some View { + Button(action: { + if cachedVideoURL != nil { + showShareSheet = true + return + } + Task { + await generateVideoForShare() + } + }) { + Image(.share) + } + .disabled(uiState.isGeneratingVideo || uiState.isCapturing) + .frame(width: 48, height: 48) + .glassEffect(in: .circle) + .opacity((uiState.isGeneratingVideo || uiState.isCapturing) ? 0.5 : 1) + } + /// 영상 다운로드 버튼 private var downloadVideoButton: some View { Button(action: { @@ -435,7 +471,7 @@ extension BundleDetailView { Image(.imageDownload) } .disabled(uiState.isCapturing || uiState.isGeneratingVideo) - .frame(width: 48, height: 48) + .frame(width: 44, height: 44) .glassEffect(in: .circle) .opacity((uiState.isCapturing || uiState.isGeneratingVideo) ? 0.5 : 1) } From e6644f94f31627983069a964253048a806f7d038 Mon Sep 17 00:00:00 2001 From: Jini Date: Mon, 9 Feb 2026 04:55:20 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EB=B0=94=20=EB=AD=89=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=8F=A0=EB=A6=BC=20=ED=98=84=EC=83=81=20=ED=94=BD?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Detail/BundleDetailView.swift | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift index 5d4527cb..a9d2b55d 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -121,6 +121,7 @@ struct BundleDetailView: View { menuOverlay customnavigationBar + .adaptiveTopPadding() } .blur(radius: shouldShowAlertOverlay ? 15 : 0) .ignoresSafeArea() @@ -333,32 +334,50 @@ extension BundleDetailView { // MARK: - 커스텀 네비게이션 바 extension BundleDetailView { + private var safeAreaTop: CGFloat { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first(where: { $0.isKeyWindow }) else { + return 0 + } + return window.safeAreaInsets.top + } + private var customnavigationBar: some View { - CustomNavigationBar { - BackToolbarButton { - bundleVM.lastKeyringsIdForDetail = "" - bundleVM.lastCarabinerIdForDetail = "" - bundleVM.lastBackgroundIdForDetail = "" - router.pop() - } - } center: { + ZStack { if let bundle = bundleVM.selectedBundle { Text("\(bundle.name)") + .typography(.notosans17M) + .foregroundStyle(.gray600) } - } trailing: { - HStack(spacing: 10) { - // 이미지 다운 버튼 - downloadImageButton + + HStack { + BackToolbarButton { + bundleVM.lastKeyringsIdForDetail = "" + bundleVM.lastCarabinerIdForDetail = "" + bundleVM.lastBackgroundIdForDetail = "" + router.pop() + } + + Spacer() - // 메뉴 버튼 - MenuToolbarButton { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - uiState.showMenu.toggle() + HStack(spacing: 10) { + // 이미지 다운 버튼 + downloadImageButton + + // 메뉴 버튼 + MenuToolbarButton { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + uiState.showMenu.toggle() + } } } } - + .padding(.horizontal, 16) } + .frame(height: 44) + .padding(.top, safeAreaTop) } }