diff --git a/.github/workflows/snutt-ci.yml b/.github/workflows/snutt-ci.yml index df1766a1..86c4f8da 100644 --- a/.github/workflows/snutt-ci.yml +++ b/.github/workflows/snutt-ci.yml @@ -8,6 +8,9 @@ on: - "SNUTT/**" - ".github/workflows/SNUTT/**" +env: + XCODE_VERSION: "26.2" + jobs: check-and-test: runs-on: macos-latest @@ -24,7 +27,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.0.1" + xcode-version: ${{ env.XCODE_VERSION }} - name: Create XCConfig files from secrets run: | diff --git a/.github/workflows/snutt-deploy.yml b/.github/workflows/snutt-deploy.yml index 966690e9..abd0a4d7 100644 --- a/.github/workflows/snutt-deploy.yml +++ b/.github/workflows/snutt-deploy.yml @@ -7,6 +7,9 @@ on: - "testflight/v*" - "testflight/dev/v*" +env: + XCODE_VERSION: "26.2" + jobs: deploy: runs-on: macos-latest @@ -57,7 +60,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.0.1" + xcode-version: ${{ env.XCODE_VERSION }} - name: Setup mise (Tuist) uses: jdx/mise-action@v2 diff --git a/SNUTT/CLAUDE.md b/SNUTT/CLAUDE.md index 3e3a5175..89cd1631 100644 --- a/SNUTT/CLAUDE.md +++ b/SNUTT/CLAUDE.md @@ -1,7 +1,5 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - ## Development Commands This project uses **Tuist** for project generation and **Just** for task automation. @@ -633,4 +631,4 @@ Modules/Feature/FeatureName/ ## Widget Extension The project includes a widget extension (`SNUTTWidget`) with its own target and dependencies, primarily using timetable functionality. -- The app's Info.plist is stored in Tuist/ProjectDescriptionHelpers/InfoPlist.swift. \ No newline at end of file +- The app's Info.plist is stored in Tuist/ProjectDescriptionHelpers/InfoPlist.swift. diff --git a/SNUTT/Modules/Feature/Friends/Resources/Assets.xcassets/timetable.background.colorset/Contents.json b/SNUTT/Modules/Feature/Friends/Resources/Assets.xcassets/timetable.background.colorset/Contents.json new file mode 100644 index 00000000..90865fca --- /dev/null +++ b/SNUTT/Modules/Feature/Friends/Resources/Assets.xcassets/timetable.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x18", + "green" : "0x18", + "red" : "0x18" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SNUTT/Modules/Feature/Friends/Sources/UI/FriendsScene.swift b/SNUTT/Modules/Feature/Friends/Sources/UI/FriendsScene.swift index 5f0062ff..cdc22656 100644 --- a/SNUTT/Modules/Feature/Friends/Sources/UI/FriendsScene.swift +++ b/SNUTT/Modules/Feature/Friends/Sources/UI/FriendsScene.swift @@ -60,6 +60,7 @@ public struct FriendsScene: View { timetableView(friendContent: nil).opacity(0.5) } } + .background(FriendsAsset.timetableBackground.swiftUIColor) .animation(.defaultSpring, value: viewModel.selectedFriend?.id) .customPopup( isPresented: Binding( @@ -142,21 +143,24 @@ public struct FriendsScene: View { } private func timetableView(friendContent: FriendContent?) -> some View { - timetableUIProvider.timetableView( - timetable: friendContent?.timetableLoadState.timetable, - configuration: viewModel.timetableConfiguration, - preferredTheme: themeViewModel.selectedTheme, - availableThemes: themeViewModel.availableThemes - ) - .environment( - \.lectureTapAction, - LectureTapAction(action: { lecture in - guard let quarter = friendContent?.selectedQuarter else { return } - selectedLecture = SelectedLecture(lecture: lecture, quarter: quarter) - }) - ) - .id(friendContent?.friend.id) - .ignoresSafeArea(.keyboard) + VStack(spacing: 0) { + Divider() + timetableUIProvider.timetableView( + timetable: friendContent?.timetableLoadState.timetable, + configuration: viewModel.timetableConfiguration, + preferredTheme: themeViewModel.selectedTheme, + availableThemes: themeViewModel.availableThemes + ) + .environment( + \.lectureTapAction, + LectureTapAction(action: { lecture in + guard let quarter = friendContent?.selectedQuarter else { return } + selectedLecture = SelectedLecture(lecture: lecture, quarter: quarter) + }) + ) + .id(friendContent?.friend.id) + .ignoresSafeArea(.keyboard) + } } @ToolbarContentBuilder diff --git a/SNUTT/Modules/Feature/Settings/Sources/UI/Timetable/TimetableSettingView.swift b/SNUTT/Modules/Feature/Settings/Sources/UI/Timetable/TimetableSettingView.swift index 2916a11e..da54e18b 100644 --- a/SNUTT/Modules/Feature/Settings/Sources/UI/Timetable/TimetableSettingView.swift +++ b/SNUTT/Modules/Feature/Settings/Sources/UI/Timetable/TimetableSettingView.swift @@ -6,6 +6,7 @@ // import FoundationUtility +import SharedUIComponents import SwiftUI import ThemesInterface import TimetableInterface @@ -98,6 +99,7 @@ struct TimetableSettingView: View { .navigationTitle(SettingsStrings.displayTable) .navigationBarTitleDisplayMode(.inline) .onAppear { viewModel.loadInitialTimetable() } + .tint(SharedUIComponentsAsset.cyan.swiftUIColor) } } diff --git a/SNUTT/Modules/Feature/Themes/Sources/Composition/LiveDependencies.swift b/SNUTT/Modules/Feature/Themes/Sources/Composition/LiveDependencies.swift new file mode 100644 index 00000000..dd44bebf --- /dev/null +++ b/SNUTT/Modules/Feature/Themes/Sources/Composition/LiveDependencies.swift @@ -0,0 +1,13 @@ +// +// LiveDependencies.swift +// SNUTT +// +// Copyright © 2025 wafflestudio.com. All rights reserved. +// + +import Dependencies +import ThemesInterface + +extension ThemeLocalRepositoryKey: @retroactive DependencyKey { + public static let liveValue: any ThemeLocalRepository = ThemeUserDefaultsRepository() +} diff --git a/SNUTT/Modules/Feature/Themes/Sources/Infra/ThemeUserDefaultsRepository.swift b/SNUTT/Modules/Feature/Themes/Sources/Infra/ThemeUserDefaultsRepository.swift new file mode 100644 index 00000000..f1f3cd4f --- /dev/null +++ b/SNUTT/Modules/Feature/Themes/Sources/Infra/ThemeUserDefaultsRepository.swift @@ -0,0 +1,29 @@ +// +// ThemeUserDefaultsRepository.swift +// SNUTT +// +// Copyright © 2025 wafflestudio.com. All rights reserved. +// + +import Dependencies +import DependenciesUtility +import Foundation +import ThemesInterface + +struct ThemeUserDefaultsRepository: ThemeLocalRepository { + @Dependency(\.userDefaults) private var userDefaults + @Dependency(\.widgetReloader) private var widgetReloader + + func loadAvailableThemes() -> [Theme] { + guard let data = userDefaults.data(forKey: ThemeUserDefaultsKeys.availableThemes.rawValue), + let themes = try? JSONDecoder().decode([Theme].self, from: data) + else { return [] } + return themes + } + + func storeAvailableThemes(_ themes: [Theme]) { + guard let data = try? JSONEncoder().encode(themes) else { return } + userDefaults.set(data, forKey: ThemeUserDefaultsKeys.availableThemes.rawValue) + widgetReloader.reloadAll() + } +} diff --git a/SNUTT/Modules/Feature/Themes/Sources/Presentation/ThemeViewModel.swift b/SNUTT/Modules/Feature/Themes/Sources/Presentation/ThemeViewModel.swift index 78cac2d5..6f6e94f7 100644 --- a/SNUTT/Modules/Feature/Themes/Sources/Presentation/ThemeViewModel.swift +++ b/SNUTT/Modules/Feature/Themes/Sources/Presentation/ThemeViewModel.swift @@ -21,6 +21,9 @@ public final class ThemeViewModel: ThemeViewModelProtocol { @ObservationIgnored @Dependency(\.notificationCenter) private var notificationCenter + @ObservationIgnored + @Dependency(\.themeLocalRepository) private var themeLocalRepository + public private(set) var themes: [Theme] = [] public var availableThemes: [Theme] { themes @@ -48,6 +51,7 @@ public final class ThemeViewModel: ThemeViewModelProtocol { public func fetchThemes() async throws { themes = try await themeRepository.fetchThemes() + themeLocalRepository.storeAvailableThemes(themes) } public func selectTheme(_ theme: Theme?) { diff --git a/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeLocalRepository.swift b/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeLocalRepository.swift new file mode 100644 index 00000000..9602dacd --- /dev/null +++ b/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeLocalRepository.swift @@ -0,0 +1,30 @@ +// +// ThemeLocalRepository.swift +// SNUTT +// +// Copyright © 2025 wafflestudio.com. All rights reserved. +// + +import Dependencies +import Spyable + +@Spyable +public protocol ThemeLocalRepository: Sendable { + func loadAvailableThemes() -> [Theme] + func storeAvailableThemes(_ themes: [Theme]) +} + +public enum ThemeLocalRepositoryKey: TestDependencyKey { + public static let testValue: any ThemeLocalRepository = { + let spy = ThemeLocalRepositorySpy() + spy.loadAvailableThemesReturnValue = [.snutt, .fall, .modern] + return spy + }() +} + +extension DependencyValues { + public var themeLocalRepository: any ThemeLocalRepository { + get { self[ThemeLocalRepositoryKey.self] } + set { self[ThemeLocalRepositoryKey.self] = newValue } + } +} diff --git a/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeUserDefaultsKeys.swift b/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeUserDefaultsKeys.swift new file mode 100644 index 00000000..de3aefb0 --- /dev/null +++ b/SNUTT/Modules/Feature/ThemesInterface/Sources/ThemeUserDefaultsKeys.swift @@ -0,0 +1,10 @@ +// +// ThemeUserDefaultsKeys.swift +// SNUTT +// +// Copyright © 2026 wafflestudio.com. All rights reserved. +// + +public enum ThemeUserDefaultsKeys: String { + case availableThemes +} diff --git a/SNUTT/Modules/Feature/Timetable/Resources/Assets.xcassets/timetable.background.colorset/Contents.json b/SNUTT/Modules/Feature/Timetable/Resources/Assets.xcassets/timetable.background.colorset/Contents.json new file mode 100644 index 00000000..90865fca --- /dev/null +++ b/SNUTT/Modules/Feature/Timetable/Resources/Assets.xcassets/timetable.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x18", + "green" : "0x18", + "red" : "0x18" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SNUTT/Modules/Feature/Timetable/Sources/Infra/TimetableUserDefaultsRepository.swift b/SNUTT/Modules/Feature/Timetable/Sources/Infra/TimetableUserDefaultsRepository.swift index 15e2db42..2879faee 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/Infra/TimetableUserDefaultsRepository.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/Infra/TimetableUserDefaultsRepository.swift @@ -13,14 +13,16 @@ import TimetableUIComponents struct TimetableUserDefaultsRepository: TimetableLocalRepository { @Dependency(\.userDefaults) private var userDefaults + @Dependency(\.widgetReloader) private var widgetReloader func loadSelectedTimetable() throws -> Timetable { - try userDefaults.object(forKey: Keys.currentTimetable.rawValue, type: Timetable.self) + try userDefaults.object(forKey: TimetableUserDefaultsKeys.currentTimetable.rawValue, type: Timetable.self) } func storeSelectedTimetable(_ timetable: Timetable) throws { let data = try JSONEncoder().encode(timetable) - userDefaults.set(data, forKey: Keys.currentTimetable.rawValue) + userDefaults.set(data, forKey: TimetableUserDefaultsKeys.currentTimetable.rawValue) + widgetReloader.reloadAll() } func loadTimetableConfiguration() -> TimetableConfiguration { @@ -29,22 +31,22 @@ struct TimetableUserDefaultsRepository: TimetableLocalRepository { func storeTimetableConfiguration(_ configuration: TimetableConfiguration) { userDefaults[\.timetableConfiguration] = configuration + widgetReloader.reloadAll() } func configurationValues() -> AsyncStream { - userDefaults.dataValues(forKey: "timetableConfiguration").compactMap { + userDefaults.dataValues(forKey: TimetableUserDefaultsKeys.timetableConfiguration.rawValue).compactMap { guard let data = $0 else { return nil } return try? JSONDecoder().decode(TimetableConfiguration.self, from: data) }.eraseToStream() } - - private enum Keys: String { - case currentTimetable - } } extension UserDefaultsEntryDefinitions { var timetableConfiguration: UserDefaultsEntry { - UserDefaultsEntry(key: "timetableConfiguration", defaultValue: TimetableConfiguration()) + UserDefaultsEntry( + key: TimetableUserDefaultsKeys.timetableConfiguration.rawValue, + defaultValue: TimetableConfiguration() + ) } } diff --git a/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureEditDetail/LectureEditDetailScene+Toolbar.swift b/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureEditDetail/LectureEditDetailScene+Toolbar.swift index d13a7ef0..9ff678f5 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureEditDetail/LectureEditDetailScene+Toolbar.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureEditDetail/LectureEditDetailScene+Toolbar.swift @@ -161,12 +161,14 @@ extension LectureEditDetailScene { Button(TimetableStrings.editEdit, systemImage: "pencil") { editMode = .active } + .tint(SharedUIComponentsAsset.cyan.swiftUIColor) } } else if toolbarOptions.contains(.saveButton) { ToolbarItem(placement: .confirmationAction) { Button(TimetableStrings.editSave, systemImage: "checkmark") { handleSaveAction() } + .tint(SharedUIComponentsAsset.cyan.swiftUIColor) } } } diff --git a/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureSearchScene.swift b/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureSearchScene.swift index d191b6bf..331b7cfd 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureSearchScene.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/UI/LectureSearchScene.swift @@ -16,13 +16,14 @@ public struct LectureSearchScene: View { @State private var searchViewModel: LectureSearchViewModel @Environment(\.themeViewModel) private var themeViewModel @Environment(\.errorAlertHandler) private var errorAlertHandler - @FocusState private var searchFocus public init(timetableViewModel: TimetableViewModel) { self.timetableViewModel = timetableViewModel - _searchViewModel = State(initialValue: LectureSearchViewModel( - timetableViewModel: timetableViewModel - )) + _searchViewModel = State( + initialValue: LectureSearchViewModel( + timetableViewModel: timetableViewModel + ) + ) } public var body: some View { @@ -39,7 +40,7 @@ public struct LectureSearchScene: View { Rectangle() .fill(Color(UIColor.quaternaryLabel.withAlphaComponent(0.1))) .frame(height: 1) - TimetableZStack( + TimetableView( painter: timetableViewModel.makePainter( selectedLecture: searchViewModel.isSearchingDifferentQuarter ? nil @@ -91,34 +92,14 @@ public struct LectureSearchScene: View { searchViewModel.searchingQuarter = newValue } .searchable(text: $searchViewModel.searchQuery, prompt: TimetableStrings.searchInputPlaceholder) - .searchFocused($searchFocus) .onSubmit(of: .search) { errorAlertHandler.withAlert { try await searchViewModel.fetchInitialSearchResult() } } - .onAppear { searchFocus = true } .navigationTitle(navigationTitle) .searchPresentationToolbarBehavior(.avoidHidingContent) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Menu { - ForEach(timetableViewModel.availableQuarters.prefix(12), id: \.self) { quarter in - Button { - searchViewModel.searchingQuarter = quarter - } label: { - HStack { - Text(quarter.localizedDescription) - if searchViewModel.searchingQuarter == quarter { - Image(systemName: "checkmark") - } - } - } - } - } label: { - Text(searchViewModel.searchingQuarter?.localizedDescription ?? "-") - } - } ToolbarItem(placement: .topBarTrailing) { Button { searchViewModel.isSearchFilterOpen = true diff --git a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableMenu/TimetableMenuSection.swift b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableMenu/TimetableMenuSection.swift index ad172d73..ff8957cf 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableMenu/TimetableMenuSection.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableMenu/TimetableMenuSection.swift @@ -5,6 +5,7 @@ // Copyright © 2024 wafflestudio.com. All rights reserved. // +import SharedUIComponents import SwiftUI import TimetableInterface diff --git a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableScene.swift b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableScene.swift index bd07b26f..ca71ca4e 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableScene.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableScene.swift @@ -41,6 +41,7 @@ public struct TimetableScene: View { } } .ignoresSafeArea(.keyboard) + .background(TimetableAsset.timetableBackground.swiftUIColor) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: TimetableDetailSceneTypes.self) { TimetableDetails(pathType: $0, timetableViewModel: timetableViewModel) @@ -69,10 +70,8 @@ public struct TimetableScene: View { private var timetable: some View { VStack(spacing: 0) { - Rectangle() - .fill(Color(UIColor.quaternaryLabel.withAlphaComponent(0.1))) - .frame(height: 1) - TimetableZStack( + TimetableGridLayer.Divider() + TimetableView( painter: timetableViewModel.makePainter( selectedLecture: nil, selectedTheme: themeViewModel.selectedTheme, @@ -95,37 +94,6 @@ public struct TimetableScene: View { } } -struct CircleBadge: View { - let color: Color - - var body: some View { - Circle() - .fill(color) - .frame(width: 4, height: 4) - } -} - -struct CircleBadgeModifier: ViewModifier { - let condition: Bool - let color: Color - - func body(content: Content) -> some View { - ZStack(alignment: .topTrailing) { - content - - if condition { - CircleBadge(color: .red) - } - } - } -} - -extension View { - func circleBadge(condition: Bool, color: Color = .red) -> some View { - modifier(CircleBadgeModifier(condition: condition, color: color)) - } -} - @available(iOS 26, *) #Preview("Default") { TabView { diff --git a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableUIProvider.swift b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableUIProvider.swift index 0d42ef7d..c110b3ff 100644 --- a/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableUIProvider.swift +++ b/SNUTT/Modules/Feature/Timetable/Sources/UI/TimetableUIProvider.swift @@ -26,7 +26,7 @@ public struct TimetableUIProvider: TimetableUIProvidable { availableThemes: [Theme] ) -> AnyView { AnyView( - TimetableZStack( + TimetableView( painter: TimetablePainter( currentTimetable: timetable, selectedLecture: nil, diff --git a/SNUTT/Modules/Feature/TimetableInterface/Sources/TimetableUserDefaultsKeys.swift b/SNUTT/Modules/Feature/TimetableInterface/Sources/TimetableUserDefaultsKeys.swift new file mode 100644 index 00000000..686b4a52 --- /dev/null +++ b/SNUTT/Modules/Feature/TimetableInterface/Sources/TimetableUserDefaultsKeys.swift @@ -0,0 +1,11 @@ +// +// TimetableUserDefaultsKeys.swift +// SNUTT +// +// Copyright © 2026 wafflestudio.com. All rights reserved. +// + +public enum TimetableUserDefaultsKeys: String { + case currentTimetable + case timetableConfiguration +} diff --git a/SNUTT/Modules/Feature/TimetableUIComponents/Resources/Assets.xcassets/Contents.json b/SNUTT/Modules/Feature/TimetableUIComponents/Resources/Assets.xcassets/Contents.json index 97a8662e..73c00596 100644 --- a/SNUTT/Modules/Feature/TimetableUIComponents/Resources/Assets.xcassets/Contents.json +++ b/SNUTT/Modules/Feature/TimetableUIComponents/Resources/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info": { - "version": 1, - "author": "xcode" + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter+Theme.swift b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter+Theme.swift index 5f9e5ffd..859aeb02 100644 --- a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter+Theme.swift +++ b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter+Theme.swift @@ -13,7 +13,7 @@ extension TimetablePainter { public func resolveColor(for lecture: Lecture) -> LectureColor { guard let lectures = currentTimetable?.lectures, let lectureIndex = lectures.firstIndex(where: { $0.lectureID == lecture.lectureID }) - else { return Theme.snutt.colors.first ?? .temporary } + else { return lecture.customColor ?? Theme.snutt.colors.first ?? .temporary } if let preferredTheme { return preferredTheme.color(at: lectureIndex) } diff --git a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter.swift b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter.swift index 8c726dc0..347067ef 100644 --- a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter.swift +++ b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/Presentation/TimetablePainter.swift @@ -40,6 +40,19 @@ extension TimetablePainter { return max((containerSize.height - weekdayHeight) / CGFloat(hourCount), 0) } + /// 시간표 그리드의 시간 행 렌더링을 위한 높이와 개수를 계산한다. + /// + /// 1. `geometry.size`를 기준으로 이상적인 한 시간의 높이(`hourHeight`)를 계산한다. + /// 2. `geometry.extendedContainerSize`의 가용 높이(weekdayHeight 제외)를 `hourHeight`로 나누어 + /// 실제 화면에 표시할 시간 행의 개수(`displayHourCount`)를 계산한다. + func getDisplayGridMetrics(in geometry: TimetableGeometry) -> (hourHeight: CGFloat, displayHourCount: Int) { + let calculatedHourHeight = getHourHeight(in: geometry.size) + let effectiveHourHeight = max(10, calculatedHourHeight) + let availableHeight = max(0, geometry.extendedContainerSize.height - weekdayHeight) + let displayHourCount = Int(availableHeight / effectiveHourHeight) + return (hourHeight: effectiveHourHeight, displayHourCount: displayHourCount) + } + /// 주어진 `TimePlace` 블록의 좌표(오프셋)를 구한다. /// /// 주어진 `TimePlace`를 시간표에 표시할 수 없는 경우(e.g. 시간이나 요일 범위에서 벗어난 경우 등)에는 `nil`을 리턴한다. diff --git a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/UI/TimetableGridLayer.swift b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/UI/TimetableGridLayer.swift index 26770151..637d5838 100644 --- a/SNUTT/Modules/Feature/TimetableUIComponents/Sources/UI/TimetableGridLayer.swift +++ b/SNUTT/Modules/Feature/TimetableUIComponents/Sources/UI/TimetableGridLayer.swift @@ -11,6 +11,12 @@ import TimetableInterface public struct TimetableGridLayer: View { public let painter: TimetablePainter let geometry: TimetableGeometry + @Environment(\.displayScale) private var displayScale + + enum Design { + static let dividerColor = Color(UIColor.tertiaryLabel).opacity(0.8) + static let dividerHalfColor = Color(UIColor.quaternaryLabel).opacity(0.7) + } public init(painter: TimetablePainter, geometry: TimetableGeometry) { self.painter = painter @@ -22,14 +28,18 @@ public struct TimetableGridLayer: View { weeksHStack hoursVStack verticalPaths - .stroke(Color(UIColor.quaternaryLabel.withAlphaComponent(0.1))) + .stroke(Design.dividerColor, lineWidth: hairlineWidth) horizontalHourlyPaths - .stroke(Color(UIColor.quaternaryLabel.withAlphaComponent(0.1))) + .stroke(Design.dividerColor, lineWidth: hairlineWidth) horizontalHalfHourlyPaths - .stroke(Color(UIColor.quaternaryLabel.withAlphaComponent(0.05))) + .stroke(Design.dividerHalfColor, lineWidth: hairlineWidth) } } + private var hairlineWidth: CGFloat { + 1.0 / displayScale + } + // MARK: Grid Paths /// 하루 간격의 수직선 @@ -46,11 +56,10 @@ public struct TimetableGridLayer: View { /// 한 시간 간격의 수평선 var horizontalHourlyPaths: Path { - let hourHeight = painter.getHourHeight(in: geometry.size) - let hourCount = Int(geometry.extendedContainerSize.height / max(10, hourHeight)) + let metrics = painter.getDisplayGridMetrics(in: geometry) return Path { path in - for i in 0...hourCount { - let y = painter.weekdayHeight + CGFloat(i) * hourHeight + for i in 0...metrics.displayHourCount { + let y = painter.weekdayHeight + CGFloat(i) * metrics.hourHeight guard y <= geometry.extendedContainerSize.height else { break } path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: geometry.size.width, y: y)) @@ -60,11 +69,10 @@ public struct TimetableGridLayer: View { /// 30분 간격의 수평선 var horizontalHalfHourlyPaths: Path { - let hourHeight = painter.getHourHeight(in: geometry.size) - let hourCount = Int(geometry.extendedContainerSize.height / max(10, hourHeight)) + let metrics = painter.getDisplayGridMetrics(in: geometry) return Path { path in - for i in 0...hourCount { - let y = painter.weekdayHeight + CGFloat(i) * hourHeight + hourHeight / 2 + for i in 0...metrics.displayHourCount { + let y = painter.weekdayHeight + CGFloat(i) * metrics.hourHeight + metrics.hourHeight / 2 guard y <= geometry.extendedContainerSize.height else { break } path.move(to: CGPoint(x: 0 + painter.hourWidth, y: y)) path.addLine(to: CGPoint(x: geometry.size.width, y: y)) @@ -92,17 +100,29 @@ public struct TimetableGridLayer: View { /// 시간표 맨 왼쪽, 시간들을 나타내는 행 var hoursVStack: some View { let minHour = painter.startingHour - let hourHeight = painter.getHourHeight(in: geometry.size) - let hourCount = Int(geometry.extendedContainerSize.height / max(10, hourHeight)) + let metrics = painter.getDisplayGridMetrics(in: geometry) return VStack(spacing: 0) { - ForEach(0.. some View { + ZStack(alignment: .topTrailing) { + content + + if condition { + CircleBadge(color: color) + } + } + } +} + +extension View { + public func circleBadge(condition: Bool, color: Color = .red) -> some View { + modifier(CircleBadgeModifier(condition: condition, color: color)) + } +} diff --git a/SNUTT/Modules/Shared/SharedUIComponents/Sources/Sheet/SheetTopBar.swift b/SNUTT/Modules/Shared/SharedUIComponents/Sources/Sheet/SheetTopBar.swift index 9a81a6f5..3459dd6f 100644 --- a/SNUTT/Modules/Shared/SharedUIComponents/Sources/Sheet/SheetTopBar.swift +++ b/SNUTT/Modules/Shared/SharedUIComponents/Sources/Sheet/SheetTopBar.swift @@ -73,6 +73,7 @@ public struct SheetTopBar: View { } .padding(.top, 15) .padding(.horizontal, 15) + .tint(SharedUIComponentsAsset.cyan.swiftUIColor) } } @@ -84,5 +85,4 @@ public struct SheetTopBar: View { } ) .border(.gray, width: 1) - .tint(.label) } diff --git a/SNUTT/Modules/Utility/DependenciesUtility/Sources/WidgetReloaderDependency.swift b/SNUTT/Modules/Utility/DependenciesUtility/Sources/WidgetReloaderDependency.swift new file mode 100644 index 00000000..b9a31e98 --- /dev/null +++ b/SNUTT/Modules/Utility/DependenciesUtility/Sources/WidgetReloaderDependency.swift @@ -0,0 +1,32 @@ +// +// WidgetReloaderDependency.swift +// SNUTT +// +// Copyright © 2026 wafflestudio.com. All rights reserved. +// + +import Dependencies +import WidgetKit + +public struct WidgetReloader: Sendable { + public let reloadAll: @Sendable () -> Void + + public init(reloadAll: @escaping @Sendable () -> Void) { + self.reloadAll = reloadAll + } +} + +public enum WidgetReloaderKey: DependencyKey { + public static let liveValue: WidgetReloader = .init( + reloadAll: { WidgetCenter.shared.reloadAllTimelines() } + ) + + public static let testValue: WidgetReloader = .init(reloadAll: {}) +} + +extension DependencyValues { + public var widgetReloader: WidgetReloader { + get { self[WidgetReloaderKey.self] } + set { self[WidgetReloaderKey.self] = newValue } + } +} diff --git a/SNUTT/Project.swift b/SNUTT/Project.swift index a350b356..79d1e32a 100644 --- a/SNUTT/Project.swift +++ b/SNUTT/Project.swift @@ -23,6 +23,7 @@ let project = Project.app( .target(name: "SwiftUIUtility"), .target(name: "SwiftUtility"), .target(name: "FoundationUtility"), + .target(name: "DependenciesUtility"), .target(name: "SharedUIComponents"), .target(name: "SharedUIMapKit"), .external(name: "Dependencies"), @@ -337,6 +338,7 @@ let project = Project.app( dependencies: [ .external(name: "Dependencies"), .external(name: "DependenciesAdditions"), + .sdk(name: "WidgetKit", type: .framework), ] ), .module( diff --git a/SNUTT/SNUTTWidget/Sources/SNUTTWidget.swift b/SNUTT/SNUTTWidget/Sources/SNUTTWidget.swift index cc4a8a0f..cda27316 100644 --- a/SNUTT/SNUTTWidget/Sources/SNUTTWidget.swift +++ b/SNUTT/SNUTTWidget/Sources/SNUTTWidget.swift @@ -8,6 +8,7 @@ import DependenciesAdditions import MemberwiseInit import SwiftUI +import ThemesInterface import TimetableInterface import TimetableUIComponents import WidgetKit @@ -21,7 +22,8 @@ struct TimelineProvider: AppIntentTimelineProvider { date: Date(), configuration: ConfigurationAppIntent(), currentTimetable: dataSource.currentTimetable, - timetableConfiguration: dataSource.timetableConfiguration + timetableConfiguration: dataSource.timetableConfiguration, + availableThemes: dataSource.availableThemes ) } @@ -30,7 +32,8 @@ struct TimelineProvider: AppIntentTimelineProvider { date: Date(), configuration: configuration, currentTimetable: dataSource.currentTimetable, - timetableConfiguration: dataSource.timetableConfiguration + timetableConfiguration: dataSource.timetableConfiguration, + availableThemes: dataSource.availableThemes ) } @@ -39,6 +42,7 @@ struct TimelineProvider: AppIntentTimelineProvider { var dates: [Date] = [now] let currentTimetable = dataSource.currentTimetable let timetableConfiguration = dataSource.timetableConfiguration + let availableThemes = dataSource.availableThemes if let remainingLectureTimes = currentTimetable?.getRemainingLectureTimes(on: now, by: .startTime) { dates.append(contentsOf: remainingLectureTimes.map { $0.timePlace.toDates() }.flatMap { $0 }) @@ -51,7 +55,8 @@ struct TimelineProvider: AppIntentTimelineProvider { date: $0, configuration: configuration, currentTimetable: currentTimetable, - timetableConfiguration: timetableConfiguration + timetableConfiguration: timetableConfiguration, + availableThemes: availableThemes ) } @@ -64,41 +69,30 @@ struct TimetableEntry: TimelineEntry { let configuration: ConfigurationAppIntent let currentTimetable: Timetable? let timetableConfiguration: TimetableConfiguration + let availableThemes: [Theme] init( date: Date, configuration: ConfigurationAppIntent, currentTimetable: Timetable?, - timetableConfiguration: TimetableConfiguration + timetableConfiguration: TimetableConfiguration, + availableThemes: [Theme] ) { self.date = date self.configuration = configuration self.currentTimetable = currentTimetable self.timetableConfiguration = timetableConfiguration + self.availableThemes = availableThemes } func makeTimetablePainter() -> TimetablePainter { - fatalError() - // TimetablePainter( - // currentTimetable: currentTimetable, - // selectedLecture: nil, - // resolvedTheme: .snutt, - // configuration: timetableConfiguration - // ) - } -} - -struct SNUTTWidgetEntryView: View { - var entry: TimelineProvider.Entry - - var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Favorite Emoji:") - Text(entry.configuration.favoriteEmoji) - } + TimetablePainter( + currentTimetable: currentTimetable, + selectedLecture: nil, + preferredTheme: nil, + availableThemes: availableThemes, + configuration: timetableConfiguration + ) } } @@ -137,13 +131,15 @@ func makePreviewTimeline() -> [TimetableEntry] { date: .now, configuration: ConfigurationAppIntent(), currentTimetable: nil, - timetableConfiguration: .init() + timetableConfiguration: .init(), + availableThemes: [] ), TimetableEntry( date: .now, configuration: ConfigurationAppIntent(), currentTimetable: PreviewHelpers.preview(id: "1"), - timetableConfiguration: .init() + timetableConfiguration: .init(), + availableThemes: [] ), ] } @@ -164,7 +160,7 @@ func makePreviewTimeline() -> [TimetableEntry] { } } -#Preview("SystemExtraLarge", as: .systemExtraLarge) { +#Preview("SystemLarge", as: .systemLarge) { SNUTTWidget() } timeline: { for timeline in makePreviewTimeline() { diff --git a/SNUTT/SNUTTWidget/Sources/SNUTTWidgetDataSource.swift b/SNUTT/SNUTTWidget/Sources/SNUTTWidgetDataSource.swift index 6a5b15fa..70769e5d 100644 --- a/SNUTT/SNUTTWidget/Sources/SNUTTWidgetDataSource.swift +++ b/SNUTT/SNUTTWidget/Sources/SNUTTWidgetDataSource.swift @@ -9,6 +9,7 @@ import Dependencies import DependenciesAdditions import DependenciesUtility import Foundation +import ThemesInterface import TimetableInterface import TimetableUIComponents @@ -21,16 +22,23 @@ final class SNUTTWidgetDataSource { } var currentTimetable: Timetable? { - guard let data = userDefaults.data(forKey: "currentTimetable"), + guard let data = userDefaults.data(forKey: TimetableUserDefaultsKeys.currentTimetable.rawValue), let timetable = try? JSONDecoder().decode(Timetable.self, from: data) else { return nil } return timetable } var timetableConfiguration: TimetableConfiguration { - guard let data = userDefaults.data(forKey: "timetableConfiguration"), + guard let data = userDefaults.data(forKey: TimetableUserDefaultsKeys.timetableConfiguration.rawValue), let configuration = try? JSONDecoder().decode(TimetableConfiguration.self, from: data) else { return .init() } return configuration } + + var availableThemes: [Theme] { + guard let data = userDefaults.data(forKey: ThemeUserDefaultsKeys.availableThemes.rawValue), + let themes = try? JSONDecoder().decode([Theme].self, from: data) + else { return [] } + return themes + } } diff --git a/SNUTT/SNUTTWidget/Sources/UI/TimetableAccessoryRectangularView.swift b/SNUTT/SNUTTWidget/Sources/UI/TimetableAccessoryRectangularView.swift index 25077e03..34ce91aa 100644 --- a/SNUTT/SNUTTWidget/Sources/UI/TimetableAccessoryRectangularView.swift +++ b/SNUTT/SNUTTWidget/Sources/UI/TimetableAccessoryRectangularView.swift @@ -14,10 +14,11 @@ struct TimetableAccessoryRectangularView: View { var body: some View { VStack(alignment: .leading) { Spacer() + let painter = entry.makeTimetablePainter() if let lectureTimes = entry.currentTimetable?.getRemainingLectureTimes(on: entry.date, by: .startTime), let firstLectureTime = lectureTimes.get(at: 0) { - TimePlaceListItem(items: [firstLectureTime], showTime: true, showPlace: true) + TimePlaceListItem(items: [firstLectureTime], painter: painter, showTime: true, showPlace: true) } else if isLoginRequired { loginRequiredView } else if isTimetableEmpty { diff --git a/SNUTT/SNUTTWidget/Sources/UI/TimetableCompactWidgetView.swift b/SNUTT/SNUTTWidget/Sources/UI/TimetableCompactWidgetView.swift index d7a713fa..687975d3 100644 --- a/SNUTT/SNUTTWidget/Sources/UI/TimetableCompactWidgetView.swift +++ b/SNUTT/SNUTTWidget/Sources/UI/TimetableCompactWidgetView.swift @@ -26,7 +26,6 @@ struct TimetableCompactWidgetView: View { } } .padding(.horizontal, 16) - // .background(STColor.systemBackground) } } @@ -60,11 +59,12 @@ struct TimetableCompactLeftView: View { @MainActor private var timetableBody: some View { GeometryReader { reader in + let painter = entry.makeTimetablePainter() let lectureTimes = entry.currentTimetable?.getRemainingLectureTimes(on: entry.date, by: .endTime) if let lectureTimes, !lectureTimes.isEmpty { VStack(alignment: .leading, spacing: 5) { if let item = lectureTimes.get(at: 0) { - TimePlaceListItem(items: [item]) + TimePlaceListItem(items: [item], painter: painter) } if let item = lectureTimes.get(at: 1) { @@ -74,6 +74,7 @@ struct TimetableCompactLeftView: View { .isEmpty == true TimePlaceListItem( items: [item], + painter: painter, showPlace: hasEnoughSpace ) } @@ -84,6 +85,7 @@ struct TimetableCompactLeftView: View { } TimePlaceListItem( items: Array(lectureTimes.dropFirst(2)), + painter: painter, showTime: hasEnoughSpace, showPlace: false ) @@ -162,6 +164,7 @@ struct TimetableCompactRightView: View { var body: some View { VStack { GeometryReader { _ in + let painter = entry.makeTimetablePainter() if let upcomingLectureTimesResult = entry.currentTimetable?.getUpcomingLectureTimes() { VStack(alignment: .leading, spacing: 5) { Text(upcomingLectureTimesResult.date.localizedDateString(dateStyle: .long, timeStyle: .none)) @@ -171,12 +174,12 @@ struct TimetableCompactRightView: View { .lineLimit(1) ForEach(upcomingLectureTimesResult.lectureTimes.prefix(2), id: \.timePlace.id) { item in - TimePlaceListItem(items: [item], showPlace: false) + TimePlaceListItem(items: [item], painter: painter, showPlace: false) } let remainingItems = Array(upcomingLectureTimesResult.lectureTimes.dropFirst(2)) if !remainingItems.isEmpty { - TimePlaceListItem(items: remainingItems, showPlace: false) + TimePlaceListItem(items: remainingItems, painter: painter, showPlace: false) } } } @@ -188,6 +191,7 @@ struct TimetableCompactRightView: View { struct TimePlaceListItem: View { let items: [Timetable.LectureTime] + let painter: TimetablePainter? var showTime: Bool = true var showPlace: Bool = true @@ -239,13 +243,14 @@ struct TimePlaceListItem: View { HStack(alignment: .firstTextBaseline, spacing: 0) { ZStack { ForEach(0..