diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3758b56..fdb10186 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,8 +16,8 @@ jobs: matrix: # see https://github.com/actions/virtual-environments/blob/main/images/macos/macos-13-Readme.md for available versions xcode: - - "15.1" - macos: ["macos-14"] + - "16.2" + macos: ["macos-15"] command: ["build", "test"] scheme: ["SNUTT"] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8643a4ae..4d572739 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,9 +23,9 @@ jobs: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} GIT_PAT_READONLY: ${{ secrets.GIT_PAT_READONLY }} GIT_TAG_NAME: ${{ github.ref_name }} - XCODE_VERSION: "15.1" + XCODE_VERSION: "16.2" - runs-on: macos-14 + runs-on: macos-15 steps: - name: Parse tag name diff --git a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj index 61378099..f1632a22 100644 --- a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj +++ b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 731DA003297BC5740027BA25 /* BookmarkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731DA002297BC5740027BA25 /* BookmarkRouter.swift */; }; 731DA005297BC8990027BA25 /* BookmarkScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731DA004297BC8990027BA25 /* BookmarkScene.swift */; }; 734A831F2C2FD41200D6CB95 /* KakaoLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 734A831E2C2FD41200D6CB95 /* KakaoLogin.swift */; }; + 734B0DE02D2CF23E00A0BAB9 /* View+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 734B0DDF2D2CF23600A0BAB9 /* View+Gesture.swift */; }; 736AF84C2C2F275E00ED9C1A /* GoogleLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736AF84B2C2F275E00ED9C1A /* GoogleLogin.swift */; }; 736AF84F2C2F279900ED9C1A /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 736AF84E2C2F279900ED9C1A /* GoogleSignIn */; }; 736AF8512C2F279900ED9C1A /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 736AF8502C2F279900ED9C1A /* GoogleSignInSwift */; }; @@ -365,6 +366,7 @@ 731DA002297BC5740027BA25 /* BookmarkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRouter.swift; sourceTree = ""; }; 731DA004297BC8990027BA25 /* BookmarkScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkScene.swift; sourceTree = ""; }; 734A831E2C2FD41200D6CB95 /* KakaoLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KakaoLogin.swift; sourceTree = ""; }; + 734B0DDF2D2CF23600A0BAB9 /* View+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Gesture.swift"; sourceTree = ""; }; 736AF84B2C2F275E00ED9C1A /* GoogleLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleLogin.swift; sourceTree = ""; }; 738406ED2B57107C00007E62 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 738406F02B5710C200007E62 /* ThemeDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeDto.swift; sourceTree = ""; }; @@ -748,6 +750,7 @@ BE419B8B288B8C4900FA9590 /* Extensions */ = { isa = PBXGroup; children = ( + 734B0DDF2D2CF23600A0BAB9 /* View+Gesture.swift */, BE419B8C288B8C5A00FA9590 /* View+Debug.swift */, BEB3B6B028D4D4D900E56062 /* View+ResignResponder.swift */, B87DF6F229152649008BB95B /* View+Screenshot.swift */, @@ -1552,6 +1555,7 @@ BE1D2B3A28014527008F9134 /* Weekday.swift in Sources */, BEDE34CA28754F3100525014 /* Sheet.swift in Sources */, BEB3B6B128D4D4D900E56062 /* View+ResignResponder.swift in Sources */, + 734B0DE02D2CF23E00A0BAB9 /* View+Gesture.swift in Sources */, CE4777F72A6AE41C00E03253 /* VacancyScene.swift in Sources */, B87DF6F329152649008BB95B /* View+Screenshot.swift in Sources */, DCD41A6027E5CC7700CF380E /* Timetable.swift in Sources */, diff --git a/SNUTT-2022/SNUTT/AppState/AppEnvironment.swift b/SNUTT-2022/SNUTT/AppState/AppEnvironment.swift index 3dc2bdbd..f19a108b 100644 --- a/SNUTT-2022/SNUTT/AppState/AppEnvironment.swift +++ b/SNUTT-2022/SNUTT/AppState/AppEnvironment.swift @@ -129,7 +129,7 @@ extension AppEnvironment { let timetableService = TimetableService(appState: appState, webRepositories: webRepositories, localRepositories: localRepositories) let userService = UserService(appState: appState, webRepositories: webRepositories, localRepositories: localRepositories) let lectureService = LectureService(appState: appState, webRepositories: webRepositories, localRepositories: localRepositories) - let searchService = SearchService(appState: appState, webRepositories: webRepositories) + let searchService = SearchService(appState: appState, webRepositories: webRepositories, localRepositories: localRepositories) let globalUIService = GlobalUIService(appState: appState, localRepositories: localRepositories, webRepositories: webRepositories) let courseBookService = CourseBookService(appState: appState, webRepositories: webRepositories) let authService = AuthService(appState: appState, webRepositories: webRepositories, localRepositories: localRepositories) diff --git a/SNUTT-2022/SNUTT/AppState/States/SearchState.swift b/SNUTT-2022/SNUTT/AppState/States/SearchState.swift index 6ac43aec..069601ae 100644 --- a/SNUTT-2022/SNUTT/AppState/States/SearchState.swift +++ b/SNUTT-2022/SNUTT/AppState/States/SearchState.swift @@ -12,6 +12,7 @@ class SearchState { @Published var isFilterOpen = false @Published var searchTagList: SearchTagList? @Published var selectedTagList: [SearchTag] = [] + @Published var pinnedTagList: [SearchTag] = [] @Published var selectedTimeRange: [SearchTimeMaskDto] = [] @Published var displayMode: SearchDisplayMode = .search diff --git a/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/Contents.json b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/Contents.json new file mode 100644 index 00000000..ac4cff11 --- /dev/null +++ b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "exit@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "exit@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@2x.png b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@2x.png new file mode 100644 index 00000000..921e8dd3 Binary files /dev/null and b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@2x.png differ diff --git a/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@3x.png b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@3x.png new file mode 100644 index 00000000..49f01989 Binary files /dev/null and b/SNUTT-2022/SNUTT/Assets/Assets.xcassets/Icons/department.xmark.imageset/exit@3x.png differ diff --git a/SNUTT-2022/SNUTT/Extensions/View+Gesture.swift b/SNUTT-2022/SNUTT/Extensions/View+Gesture.swift new file mode 100644 index 00000000..ceb7a118 --- /dev/null +++ b/SNUTT-2022/SNUTT/Extensions/View+Gesture.swift @@ -0,0 +1,71 @@ +// +// View+Gesture.swift +// SNUTT +// +// Created by 이채민 on 1/7/25. +// + +import SwiftUI + +extension View { + @ViewBuilder + func sheetGesture(_ translation: Binding, dismiss: @escaping @MainActor () -> Void) -> some View { + if #available(iOS 18.0, *) { + gesture(SheetGestureRecognizer(translation: translation, dismiss: dismiss)) + } else { + highPriorityGesture(DragGesture().onChanged { value in + translation.wrappedValue = value.translation.height + }.onEnded { value in + translation.wrappedValue = 0 + if value.velocity.height < -500 || value.translation.height < -100 { + dismiss() + } + }) + } + } +} + +@available(iOS 18.0, *) +private struct SheetGestureRecognizer: UIGestureRecognizerRepresentable { + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator(translation: $translation, dismiss: dismiss) + } + + @Binding var translation: CGFloat + let dismiss: () -> Void + + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let recognizer = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan)) + return recognizer + } + + final class Coordinator: NSObject { + @Binding var translation: CGFloat + let dismiss: () -> Void + + init(translation: Binding, dismiss: @escaping () -> Void) { + _translation = translation + self.dismiss = dismiss + } + + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .changed: + translation = recognizer.translation(in: recognizer.view).y + case .ended: + if shouldDismiss(recognizer) { + dismiss() + } + translation = 0 + default: + break + } + } + + private func shouldDismiss(_ recognizer: UIPanGestureRecognizer) -> Bool { + let velocity = recognizer.velocity(in: recognizer.view).y + let translation = recognizer.translation(in: recognizer.view).y + return velocity < -500 || translation < -100 + } + } +} diff --git a/SNUTT-2022/SNUTT/Repositories/UserDefaultsRepository.swift b/SNUTT-2022/SNUTT/Repositories/UserDefaultsRepository.swift index 3ee71518..81680ff6 100644 --- a/SNUTT-2022/SNUTT/Repositories/UserDefaultsRepository.swift +++ b/SNUTT-2022/SNUTT/Repositories/UserDefaultsRepository.swift @@ -20,6 +20,7 @@ enum STDefaultsKey: String { case userDto case fcmToken case preferredColorScheme + case recentDepartmentTags case currentTimetable case timetableConfig diff --git a/SNUTT-2022/SNUTT/Services/SearchService.swift b/SNUTT-2022/SNUTT/Services/SearchService.swift index 485420e3..f866042a 100644 --- a/SNUTT-2022/SNUTT/Services/SearchService.swift +++ b/SNUTT-2022/SNUTT/Services/SearchService.swift @@ -28,11 +28,16 @@ protocol SearchServiceProtocol: Sendable { struct SearchService: SearchServiceProtocol { var appState: AppState var webRepositories: AppEnvironment.WebRepositories + var localRepositories: AppEnvironment.LocalRepositories var searchRepository: SearchRepositoryProtocol { webRepositories.searchRepository } + var userDefaultsRepository: UserDefaultsRepositoryProtocol { + localRepositories.userDefaultsRepository + } + var searchState: SearchState { appState.search } @@ -61,11 +66,27 @@ struct SearchService: SearchServiceProtocol { let dto = try await searchRepository.fetchTags(quarter: quarter) let model = SearchTagList(from: dto) appState.search.searchTagList = model + guard let recentTagNames = userDefaultsRepository.get([String].self, key: .recentDepartmentTags) else { return } + appState.search.pinnedTagList = model?.tagList.filter { $0.type == .department && recentTagNames.contains($0.text) } ?? [] + } + + private func _saveDepartmentTagsToUserDefaults(from tagList: [SearchTag]) async throws { + let departmentTags = tagList.filter { tag in tag.type == .department && + !appState.search.pinnedTagList.contains { $0.text == tag.text } + } + var updatedTags = departmentTags + appState.search.pinnedTagList + if updatedTags.count > 5 { + updatedTags = Array(updatedTags.suffix(5)) + } + appState.search.pinnedTagList = updatedTags + let savedTags = updatedTags.map { $0.text } + userDefaultsRepository.set([String].self, key: .recentDepartmentTags, value: savedTags) } private func _fetchSearchResult() async throws { guard let currentTimetable = timetableState.current else { return } let tagList = searchState.selectedTagList + try await _saveDepartmentTagsToUserDefaults(from: tagList) let timeList = tagList.contains(where: { $0.type == .time && TimeType(rawValue: $0.text) == .range }) ? searchState.selectedTimeRange : nil let excludedTimeList = tagList.contains(where: { $0.type == .time && TimeType(rawValue: $0.text) == .empty }) ? currentTimetable.timeMask : nil let offset = searchState.perPage * searchState.pageNum diff --git a/SNUTT-2022/SNUTT/ViewModels/FilterSheetViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/FilterSheetViewModel.swift index 7d5e9292..212ef0aa 100644 --- a/SNUTT-2022/SNUTT/ViewModels/FilterSheetViewModel.swift +++ b/SNUTT-2022/SNUTT/ViewModels/FilterSheetViewModel.swift @@ -10,6 +10,7 @@ import SwiftUI class FilterSheetViewModel: BaseViewModel, ObservableObject { @Published var selectedTagList: [SearchTag] = [] @Published var searchTagList: SearchTagList? + @Published var pinnedTagList: [SearchTag] = [] @Published private var _selectedTimeRange: [SearchTimeMaskDto] = [] @Published private var _isFilterOpen: Bool = false @@ -36,6 +37,7 @@ class FilterSheetViewModel: BaseViewModel, ObservableObject { appState.search.$selectedTagList.assign(to: &$selectedTagList) appState.search.$selectedTimeRange.assign(to: &$_selectedTimeRange) appState.search.$searchTagList.assign(to: &$searchTagList) + appState.search.$pinnedTagList.assign(to: &$pinnedTagList) appState.search.$isFilterOpen.assign(to: &$_isFilterOpen) } @@ -63,4 +65,9 @@ class FilterSheetViewModel: BaseViewModel, ObservableObject { func isSelected(tag: SearchTag) -> Bool { return appState.search.selectedTagList.contains(where: { $0.id == tag.id }) } + + func removePin(tag: SearchTag) { + guard let index = appState.search.pinnedTagList.firstIndex(where: { $0.id == tag.id }) else { return } + appState.search.pinnedTagList.remove(at: index) + } } diff --git a/SNUTT-2022/SNUTT/Views/Components/FilterSheetContent.swift b/SNUTT-2022/SNUTT/Views/Components/FilterSheetContent.swift index 5a3870dc..7e4faa3e 100644 --- a/SNUTT-2022/SNUTT/Views/Components/FilterSheetContent.swift +++ b/SNUTT-2022/SNUTT/Views/Components/FilterSheetContent.swift @@ -11,6 +11,7 @@ struct FilterSheetContent: View { @ObservedObject var viewModel: FilterSheetViewModel @State private var selectedCategory: SearchTagType = .sortCriteria @State private var isTimeRangeSheetOpen: Bool = false + @State private var dragTranslation: CGFloat = 0 struct FilterButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { @@ -33,8 +34,8 @@ struct FilterSheetContent: View { Text(tag.typeStr) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(isSelected ? Color(uiColor: .label) : Color(uiColor: .label.withAlphaComponent(0.5))) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(isSelected ? Color(uiColor: .label) : STColor.assistive) } .buttonStyle(.plain) } @@ -43,53 +44,24 @@ struct FilterSheetContent: View { .padding(.horizontal, 10) .padding(.vertical, 5) - Divider() - ScrollView { - LazyVStack { - Group { - ForEach(viewModel.filterTags(with: selectedCategory)) { tag in - Button { - viewModel.toggle(tag) - } label: { - HStack(alignment: .top) { - Image("checkmark.circle.\(viewModel.isSelected(tag: tag) ? "tick" : "untick")") - .resizable() - .scaledToFit() - .frame(width: 16) - .padding(.trailing, 3) - VStack(alignment: .leading, spacing: 6) { - Text(tag.text) - .font(STFont.regular14.font) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - - if tag.text == TimeType.range.rawValue { - Group { - if !$viewModel.selectedTimeRange.isEmpty { - Text(viewModel.selectedTimeRange.map { - $0.preciseTimeString - }.joined(separator: "\n")) - .underline() - .lineSpacing(4) - } else { - Text("눌러서 선택하기") - .underline() - } - } - .font(STFont.regular12.font) - .foregroundColor(STColor.darkGray) - .onTapGesture { isTimeRangeSheetOpen = true } - } - } - } + LazyVStack(alignment: .leading) { + if selectedCategory == .department { + if !viewModel.pinnedTagList.isEmpty { + Text("최근 찾아본 학과") + .font(STFont.regular13.font) + .foregroundColor(STColor.alternative) .padding(.horizontal, 20) .padding(.vertical, 7) + ForEach(viewModel.pinnedTagList) { + tag in FilterTagButton(tag: tag, isPinned: true, viewModel: viewModel, isTimeRangeSheetOpen: $isTimeRangeSheetOpen) } - .buttonStyle(.plain) + Divider() } } - .frame(maxWidth: .infinity) + ForEach(viewModel.filterTags(with: selectedCategory)) { tag in + FilterTagButton(tag: tag, isPinned: false, viewModel: viewModel, isTimeRangeSheetOpen: $isTimeRangeSheetOpen) + } } } .id(selectedCategory) // rerender on change of category @@ -100,6 +72,9 @@ struct FilterSheetContent: View { .init(color: .black, location: 0.97), .init(color: .clear, location: 1), ]), startPoint: .top, endPoint: .bottom)) + .sheetGesture($dragTranslation) { + dismissView() + } } Button { @@ -131,6 +106,62 @@ struct FilterSheetContent: View { } } } + + @MainActor private func dismissView() { + viewModel.isFilterOpen = false + } +} + +struct FilterTagButton: View { + let tag: SearchTag + let isPinned: Bool + @ObservedObject var viewModel: FilterSheetViewModel + @Binding var isTimeRangeSheetOpen: Bool + + var body: some View { + Button { + viewModel.toggle(tag) + } label: { + HStack(alignment: .top) { + Image("checkmark.circle.\(viewModel.isSelected(tag: tag) ? "tick" : "untick")") + .resizable() + .scaledToFit() + .frame(width: 16) + .padding(.trailing, 3) + VStack(alignment: .leading, spacing: 6) { + Text(tag.text) + .font(STFont.regular14.font) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + + if tag.text == TimeType.range.rawValue { + Group { + if !viewModel.selectedTimeRange.isEmpty { + Text(viewModel.selectedTimeRange.map { + $0.preciseTimeString + }.joined(separator: "\n")) + .underline() + .lineSpacing(4) + } else { + Text("눌러서 선택하기") + .underline() + } + } + .font(STFont.regular12.font) + .foregroundColor(STColor.darkGray) + .onTapGesture { isTimeRangeSheetOpen = true } + } + } + if isPinned { + Image("department.xmark") + .onTapGesture { viewModel.removePin(tag: tag) } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 7) + } + .buttonStyle(.plain) + } } #if DEBUG