Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/snutt-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
- "SNUTT/**"
- ".github/workflows/SNUTT/**"

env:
XCODE_VERSION: "26.2"

jobs:
check-and-test:
runs-on: macos-latest
Expand All @@ -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: |
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/snutt-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- "testflight/v*"
- "testflight/dev/v*"

env:
XCODE_VERSION: "26.2"

jobs:
deploy:
runs-on: macos-latest
Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions SNUTT/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
- The app's Info.plist is stored in Tuist/ProjectDescriptionHelpers/InfoPlist.swift.
Original file line number Diff line number Diff line change
@@ -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
}
}
34 changes: 19 additions & 15 deletions SNUTT/Modules/Feature/Friends/Sources/UI/FriendsScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import FoundationUtility
import SharedUIComponents
import SwiftUI
import ThemesInterface
import TimetableInterface
Expand Down Expand Up @@ -98,6 +99,7 @@ struct TimetableSettingView: View {
.navigationTitle(SettingsStrings.displayTable)
.navigationBarTitleDisplayMode(.inline)
.onAppear { viewModel.loadInitialTimetable() }
.tint(SharedUIComponentsAsset.cyan.swiftUIColor)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// ThemeUserDefaultsKeys.swift
// SNUTT
//
// Copyright © 2026 wafflestudio.com. All rights reserved.
//

public enum ThemeUserDefaultsKeys: String {
case availableThemes
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment on lines 22 to 26
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserDefaults 저장 시마다 widgetReloader.reloadAll()로 전체 위젯 타임라인을 리로드하고 있습니다. 저장이 빈번할 수 있는 경로라면, (1) 특정 kind만 리로드하거나, (2) 변경을 debounce/throttle하는 방식으로 리로드 비용/레이트리밋 리스크를 줄이는 것을 권장합니다.

Copilot uses AI. Check for mistakes.

func loadTimetableConfiguration() -> TimetableConfiguration {
Expand All @@ -29,22 +31,22 @@ struct TimetableUserDefaultsRepository: TimetableLocalRepository {

func storeTimetableConfiguration(_ configuration: TimetableConfiguration) {
userDefaults[\.timetableConfiguration] = configuration
widgetReloader.reloadAll()
}

func configurationValues() -> AsyncStream<TimetableConfiguration> {
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<TimetableConfiguration> {
UserDefaultsEntry(key: "timetableConfiguration", defaultValue: TimetableConfiguration())
UserDefaultsEntry(
key: TimetableUserDefaultsKeys.timetableConfiguration.rawValue,
defaultValue: TimetableConfiguration()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Loading
Loading