diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift
index da0f2e27..e1f3cc85 100644
--- a/Projects/App/Project.swift
+++ b/Projects/App/Project.swift
@@ -206,6 +206,10 @@ let schemes: [Scheme] = [
let project = Project(
name: env.name,
organizationName: env.organizationName,
+ options: .options(
+ defaultKnownRegions: ["ko"],
+ developmentRegion: "ko"
+ ),
settings: settings,
targets: targets,
schemes: schemes
diff --git a/Projects/App/iOS/Sources/Application/AppDelegate.swift b/Projects/App/iOS/Sources/Application/AppDelegate.swift
index dc668b90..cc382170 100644
--- a/Projects/App/iOS/Sources/Application/AppDelegate.swift
+++ b/Projects/App/iOS/Sources/Application/AppDelegate.swift
@@ -1,4 +1,5 @@
import Dependencies
+import DesignSystem
import Entity
import EnumUtil
import FeatureFlagClient
@@ -29,11 +30,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
FirebaseApp.configure()
Task {
do {
- try await featureFlagClient.activate()
+ try await RemoteConfig.remoteConfig().fetchAndActivate()
} catch {
TWLog.error(error)
}
}
+ DesignSystemFontFamily.Suit.all.forEach { $0.register() }
initializeAnalyticsUserID()
sendUserPropertyWidget()
session = WCSession.default
diff --git a/Projects/App/iOS/Support/Info.plist b/Projects/App/iOS/Support/Info.plist
index 5ec1af8e..6614bafe 100644
--- a/Projects/App/iOS/Support/Info.plist
+++ b/Projects/App/iOS/Support/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 9.0
+ 10.0
CFBundleURLTypes
@@ -32,7 +32,7 @@
CFBundleVersion
- 72
+ 79
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift
index 2d00d99c..00ca6805 100644
--- a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift
+++ b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift
@@ -8,7 +8,7 @@ struct ReviewToast: View {
let onTap: () -> Void
var body: some View {
- Button {
+ let baseButton = Button {
onTap()
} label: {
HStack(spacing: 8) {
@@ -21,9 +21,18 @@ struct ReviewToast: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
- .background(Color.extraWhite)
- .cornerRadius(20)
- .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4)
+ }
+
+ if #available(iOS 26.0, *) {
+ baseButton
+ .glassEffect(.regular.interactive(), in: .capsule)
+ } else {
+ baseButton
+ .background {
+ RoundedRectangle(cornerRadius: 20)
+ .fill(Color.extraWhite)
+ }
+ .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4)
}
}
}
diff --git a/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift b/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift
index 04863150..14943ece 100644
--- a/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift
+++ b/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import DesignSystem
+import FirebaseRemoteConfig
import SwiftUI
struct SchoolInfoCardView: View {
let store: StoreOf
@ObservedObject var viewStore: ViewStoreOf
+ @RemoteConfigProperty(key: "enable_weekly", fallback: false) private var enableWeeklyView
init(store: StoreOf) {
self.store = store
@@ -20,9 +22,15 @@ struct SchoolInfoCardView: View {
let gradeClassString = "\(viewStore.grade)학년 \(viewStore.class)반"
let dateString = "\(viewStore.displayDate.toString())"
- Text("\(gradeClassString) • \(dateString)")
- .twFont(.body2, color: .textSecondary)
- .accessibilitySortPriority(3)
+ if enableWeeklyView {
+ Text(gradeClassString)
+ .twFont(.body2, color: .textSecondary)
+ .accessibilitySortPriority(3)
+ } else {
+ Text("\(gradeClassString) • \(dateString)")
+ .twFont(.body2, color: .textSecondary)
+ .accessibilitySortPriority(3)
+ }
}
Spacer()
diff --git a/Projects/Feature/MainFeature/Sources/DatePolicy.swift b/Projects/Feature/MainFeature/Sources/DatePolicy.swift
index 5911a499..06a8b971 100644
--- a/Projects/Feature/MainFeature/Sources/DatePolicy.swift
+++ b/Projects/Feature/MainFeature/Sources/DatePolicy.swift
@@ -32,6 +32,29 @@ public struct DatePolicy: Sendable {
return formatter.string(from: date)
}
+ public func weekDisplayText(for weekStartDate: Date, baseDate: Date) -> String {
+ let calendar = Calendar.current
+ let currentWeekStart = startOfWeek(for: baseDate)
+ if calendar.isDate(weekStartDate, inSameDayAs: currentWeekStart) {
+ return "이번주"
+ }
+
+ if let previousWeek = calendar.date(byAdding: .day, value: -7, to: currentWeekStart),
+ calendar.isDate(weekStartDate, inSameDayAs: previousWeek) {
+ return "저번주"
+ }
+
+ if let nextWeek = calendar.date(byAdding: .day, value: 7, to: currentWeekStart),
+ calendar.isDate(weekStartDate, inSameDayAs: nextWeek) {
+ return "다음주"
+ }
+
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "ko_kr")
+ formatter.dateFormat = "M월 d일"
+ return "\(formatter.string(from: weekStartDate)) 주"
+ }
+
public func adjustedDate(for date: Date) -> Date {
var adjustedDate = date
@@ -79,4 +102,27 @@ public struct DatePolicy: Sendable {
return nextDate
}
+
+ public func startOfWeek(for date: Date) -> Date {
+ let calendar = Calendar.current
+
+ let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)
+ let weekStart = calendar.date(from: components) ?? date
+ return weekStart
+ }
+
+ public func adjustedWeekStart(for date: Date) -> Date {
+ let adjusted = adjustedDate(for: date)
+ return startOfWeek(for: adjusted)
+ }
+
+ public func previousWeekStart(from date: Date) -> Date {
+ let weekStart = startOfWeek(for: date)
+ return weekStart.adding(by: .day, value: -7)
+ }
+
+ public func nextWeekStart(from date: Date) -> Date {
+ let weekStart = startOfWeek(for: date)
+ return weekStart.adding(by: .day, value: 7)
+ }
}
diff --git a/Projects/Feature/MainFeature/Sources/MainCore.swift b/Projects/Feature/MainFeature/Sources/MainCore.swift
index 6820f5d0..94c7e769 100644
--- a/Projects/Feature/MainFeature/Sources/MainCore.swift
+++ b/Projects/Feature/MainFeature/Sources/MainCore.swift
@@ -17,42 +17,56 @@ public struct MainCore: Reducer {
public init() {}
public struct State: Equatable {
+ public enum DateSelectionMode: Equatable {
+ case daily
+ case weekly
+ }
+
public var school = ""
public var grade = ""
public var `class` = ""
@Shared public var displayDate: Date
public var currentTab = 0
public var isInitial: Bool = true
- public var isExistNewVersion: Bool = false
public var mealCore: MealCore.State?
+ public var weeklyMealCore: WeeklyMealCore.State?
public var timeTableCore: TimeTableCore.State?
public var weeklyTimeTableCore: WeeklyTimeTableCore.State?
@PresentationState public var settingsCore: SettingsCore.State?
@PresentationState public var noticeCore: NoticeCore.State?
public var isShowingReviewToast: Bool = false
+ public var dateSelectionMode: DateSelectionMode = .daily
public var displayTitle: String {
let calendar = Calendar.current
let today = Date()
- if calendar.isDate(displayDate, inSameDayAs: today) {
- return "오늘뭐임"
- }
+ switch dateSelectionMode {
+ case .daily:
+ if calendar.isDate(displayDate, inSameDayAs: today) {
+ return "오늘뭐임"
+ }
- if let yesterday = calendar.date(byAdding: .day, value: -1, to: today),
- calendar.isDate(displayDate, inSameDayAs: yesterday) {
- return "어제뭐임"
- }
+ if let yesterday = calendar.date(byAdding: .day, value: -1, to: today),
+ calendar.isDate(displayDate, inSameDayAs: yesterday) {
+ return "어제뭐임"
+ }
- if let tomorrow = calendar.date(byAdding: .day, value: 1, to: today),
- calendar.isDate(displayDate, inSameDayAs: tomorrow) {
- return "내일뭐임"
- }
+ if let tomorrow = calendar.date(byAdding: .day, value: 1, to: today),
+ calendar.isDate(displayDate, inSameDayAs: tomorrow) {
+ return "내일뭐임"
+ }
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "ko_kr")
- formatter.dateFormat = "EEEE"
- return "\(formatter.string(from: displayDate))뭐임"
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "ko_kr")
+ formatter.dateFormat = "EEEE"
+ return "\(formatter.string(from: displayDate))뭐임"
+
+ case .weekly:
+ let policy = DatePolicy(isSkipWeekend: false, isSkipAfterDinner: false)
+ let label = policy.weekDisplayText(for: policy.startOfWeek(for: displayDate), baseDate: today)
+ return "\(label)뭐임"
+ }
}
public init() {
@@ -67,10 +81,10 @@ public struct MainCore: Reducer {
case tabTapped(Int)
case tabSwiped(Int)
case mealCore(MealCore.Action)
+ case weeklyMealCore(WeeklyMealCore.Action)
case timeTableCore(TimeTableCore.Action)
case weeklyTimeTableCore(WeeklyTimeTableCore.Action)
case settingButtonDidTap
- case checkVersion(TaskResult)
case noticeButtonDidTap
case settingsCore(PresentationAction)
case noticeCore(PresentationAction)
@@ -78,6 +92,7 @@ public struct MainCore: Reducer {
case showReviewToast
case hideReviewToast
case requestReview
+ case weeklyModeUpdated(weeklyEnabled: Bool)
}
@Dependency(\.userDefaultsClient) var userDefaultsClient
@@ -91,6 +106,25 @@ public struct MainCore: Reducer {
let pageShowedEvengLog = PageShowedEventLog(pageName: "main_page")
TWLog.event(pageShowedEvengLog)
+ let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
+ let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
+
+ let datePolicy = DatePolicy(
+ isSkipWeekend: isSkipWeekend,
+ isSkipAfterDinner: isSkipAfterDinner
+ )
+
+ let today = Date()
+ let adjustedDate = datePolicy.adjustedDate(for: today)
+ state.$displayDate.withLock { date in
+ switch state.dateSelectionMode {
+ case .daily:
+ date = adjustedDate
+ case .weekly:
+ date = datePolicy.startOfWeek(for: adjustedDate)
+ }
+ }
+
if shouldRequestReview() {
return .send(.showReviewToast)
}
@@ -105,7 +139,15 @@ public struct MainCore: Reducer {
)
let today = Date()
- state.$displayDate.withLock { date in date = datePolicy.adjustedDate(for: today) }
+ let adjustedDate = datePolicy.adjustedDate(for: today)
+ state.$displayDate.withLock { date in
+ switch state.dateSelectionMode {
+ case .daily:
+ date = adjustedDate
+ case .weekly:
+ date = datePolicy.startOfWeek(for: adjustedDate)
+ }
+ }
state.school = userDefaultsClient.getValue(.school) as? String ?? ""
state.grade = "\(userDefaultsClient.getValue(.grade) as? Int ?? 1)"
@@ -113,22 +155,18 @@ public struct MainCore: Reducer {
if state.mealCore == nil {
state.mealCore = .init(displayDate: state.$displayDate)
}
+ if state.weeklyMealCore == nil {
+ state.weeklyMealCore = .init(displayDate: state.$displayDate)
+ }
if state.timeTableCore == nil {
state.timeTableCore = .init(displayDate: state.$displayDate)
}
if state.weeklyTimeTableCore == nil {
state.weeklyTimeTableCore = .init(displayDate: state.$displayDate)
}
- return Effect.run { send in
- let checkVersion = await Action.checkVersion(
- TaskResult {
- try await iTunesClient.fetchCurrentVersion(.ios)
- }
- )
- await send(checkVersion)
- }
+ return .none
- case .mealCore(.refresh), .timeTableCore(.refresh):
+ case .mealCore(.refresh), .weeklyMealCore(.refresh), .timeTableCore(.refresh), .weeklyTimeTableCore(.refresh):
let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
@@ -137,9 +175,6 @@ public struct MainCore: Reducer {
isSkipAfterDinner: isSkipAfterDinner
)
- let today = Date()
- state.$displayDate.withLock { date in date = datePolicy.adjustedDate(for: today) }
-
case let .tabTapped(tab):
state.currentTab = tab
logTabSelected(index: tab, selectionType: .tapped)
@@ -148,7 +183,7 @@ public struct MainCore: Reducer {
state.currentTab = tab
logTabSelected(index: tab, selectionType: .swiped)
- case .settingButtonDidTap, .mealCore(.settingsButtonDidTap):
+ case .settingButtonDidTap, .mealCore(.settingsButtonDidTap), .weeklyMealCore(.settingsButtonDidTap):
state.settingsCore = .init()
let log = SettingButtonClickedEventLog()
TWLog.event(log)
@@ -162,11 +197,6 @@ public struct MainCore: Reducer {
case .settingsCore(.presented(.schoolSettingCore(.presented(.schoolSettingFinished)))):
state.settingsCore = nil
- case let .checkVersion(.success(latestVersion)):
- guard !latestVersion.isEmpty else { break }
- let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
- state.isExistNewVersion = currentVersion != latestVersion
-
case .noticeButtonDidTap:
state.noticeCore = .init()
let log = BellButtonClickedEventLog()
@@ -195,6 +225,30 @@ public struct MainCore: Reducer {
}
return .none
+ case let .weeklyModeUpdated(weeklyEnabled):
+ let newMode: State.DateSelectionMode = weeklyEnabled ? .weekly : .daily
+ guard state.dateSelectionMode != newMode else { return .none }
+ state.dateSelectionMode = newMode
+
+ let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
+ let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
+ let datePolicy = DatePolicy(
+ isSkipWeekend: isSkipWeekend,
+ isSkipAfterDinner: isSkipAfterDinner
+ )
+ let today = Date()
+ let adjustedDate = datePolicy.adjustedDate(for: today)
+
+ state.$displayDate.withLock { date in
+ switch newMode {
+ case .daily:
+ date = adjustedDate
+ case .weekly:
+ date = datePolicy.startOfWeek(for: adjustedDate)
+ }
+ }
+ return .none
+
default:
return .none
}
@@ -231,6 +285,9 @@ extension Reducer where State == MainCore.State, Action == MainCore.Action {
.ifLet(\.mealCore, action: /Action.mealCore) {
MealCore()
}
+ .ifLet(\.weeklyMealCore, action: /Action.weeklyMealCore) {
+ WeeklyMealCore()
+ }
.ifLet(\.timeTableCore, action: /Action.timeTableCore) {
TimeTableCore()
}
diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift
index cd74259c..871dc956 100644
--- a/Projects/Feature/MainFeature/Sources/MainView.swift
+++ b/Projects/Feature/MainFeature/Sources/MainView.swift
@@ -15,7 +15,7 @@ public struct MainView: View {
@Environment(\.openURL) var openURL
@Environment(\.calendar) var calendar
@Dependency(\.userDefaultsClient) var userDefaultsClient
- @RemoteConfigProperty(key: "enable_weekly_time_table", fallback: false) private var enableWeeklyTimeTable
+ @RemoteConfigProperty(key: "enable_weekly", fallback: false) private var enableWeeklyView
public init(store: StoreOf) {
self.store = store
@@ -51,16 +51,27 @@ public struct MainView: View {
).animation(.default)
) {
VStack {
- IfLetStore(
- store.scope(state: \.mealCore, action: MainCore.Action.mealCore)
- ) { store in
- MealView(store: store)
+ if enableWeeklyView {
+ IfLetStore(
+ store.scope(
+ state: \.weeklyMealCore,
+ action: MainCore.Action.weeklyMealCore
+ )
+ ) { store in
+ WeeklyMealView(store: store)
+ }
+ } else {
+ IfLetStore(
+ store.scope(state: \.mealCore, action: MainCore.Action.mealCore)
+ ) { store in
+ MealView(store: store)
+ }
}
}
.tag(0)
VStack {
- if enableWeeklyTimeTable {
+ if enableWeeklyView {
IfLetStore(
store.scope(
state: \.weeklyTimeTableCore,
@@ -80,6 +91,12 @@ public struct MainView: View {
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
+ .background {
+ if enableWeeklyView {
+ Color.backgroundSecondary
+ .ignoresSafeArea()
+ }
+ }
if viewStore.isShowingReviewToast {
ReviewToast {
@@ -88,69 +105,110 @@ public struct MainView: View {
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
- .padding(.bottom, viewStore.isExistNewVersion ? 72 : 16)
+ .padding(.bottom, 24)
.animation(.default, value: viewStore.isShowingReviewToast)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
- DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
- viewStore.send(.hideReviewToast)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) {
+ viewStore.send(.hideReviewToast, animation: .default)
}
}
}
-
- if viewStore.isExistNewVersion {
- Button {
- let url = URL(
- string: "https://apps.apple.com/app/id1629567018"
- ) ?? URL(string: "https://google.com")!
- openURL(url)
- } label: {
- Circle()
- .frame(width: 56, height: 56)
- .foregroundColor(.extraBlack)
- .overlay {
- Image(systemName: "arrow.down.to.line")
- .foregroundColor(.extraWhite)
- .accessibilityHidden(true)
- }
- }
- .padding([.bottom, .trailing], 16)
- .accessibilityLabel("새 버전 업데이트")
- .accessibilityHint("앱스토어로 이동하여 새 버전을 설치할 수 있습니다")
- }
}
}
.background(Color.backgroundMain)
.toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
+ ToolbarItem(placement: .principal) {
+ Text("")
+ }
+
+ ToolbarItem(placement: .topBarLeading) {
Menu {
+ let isWeeklyModeEnabled = enableWeeklyView
let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
let datePolicy = DatePolicy(isSkipWeekend: isSkipWeekend, isSkipAfterDinner: isSkipAfterDinner)
let today = Date()
- let yesterday = datePolicy.previousDay(from: today)
- let tomorrow = datePolicy.nextDay(from: today)
-
- ForEach([yesterday, today, tomorrow], id: \.timeIntervalSince1970) { date in
- Button {
- let today = Date()
- let tense: SelectDateTenseEventLog.Tense
+ if isWeeklyModeEnabled {
+ let currentWeekStart = datePolicy.startOfWeek(for: today)
+ let previousWeek = datePolicy.previousWeekStart(from: currentWeekStart)
+ let nextWeek = datePolicy.nextWeekStart(from: currentWeekStart)
+ ForEach([previousWeek, currentWeekStart, nextWeek], id: \.timeIntervalSince1970) { weekStart in
+ let normalizedWeekStart = datePolicy.startOfWeek(for: weekStart)
+ Button {
+ let tense: SelectDateTenseEventLog.Tense
+ if calendar.isDate(normalizedWeekStart, inSameDayAs: currentWeekStart) {
+ tense = .present
+ } else if normalizedWeekStart > currentWeekStart {
+ tense = .future
+ } else {
+ tense = .past
+ }
- if calendar.isDate(date, inSameDayAs: today) {
- tense = .present
- } else if date > today {
- tense = .future
- } else {
- tense = .past
+ TWLog.event(SelectDateTenseEventLog(tense: tense))
+ _ = viewStore.send(.dateSelected(normalizedWeekStart))
+ } label: {
+ let labelText = datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)
+ let isSelected = calendar.isDate(
+ datePolicy.startOfWeek(for: viewStore.displayDate),
+ inSameDayAs: normalizedWeekStart
+ )
+ if isSelected {
+ Label {
+ Text(labelText)
+ .twFont(.body1)
+ .foregroundStyle(Color.extraWhite)
+ .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
+ } icon: {
+ Image(systemName: "checkmark")
+ }
+ } else {
+ Text(labelText)
+ .twFont(.body1)
+ .foregroundStyle(Color.extraBlack)
+ .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
+ }
}
+ .accessibilityLabel("\(datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)) 선택")
+ .accessibilityHint("주 변경")
+ }
+ } else {
+ let yesterday = datePolicy.previousDay(from: today)
+ let tomorrow = datePolicy.nextDay(from: today)
- TWLog.event(SelectDateTenseEventLog(tense: tense))
+ ForEach([yesterday, today, tomorrow], id: \.timeIntervalSince1970) { date in
+ Button {
+ let today = Date()
+ let tense: SelectDateTenseEventLog.Tense
+
+ if calendar.isDate(date, inSameDayAs: today) {
+ tense = .present
+ } else if date > today {
+ tense = .future
+ } else {
+ tense = .past
+ }
- _ = viewStore.send(.dateSelected(date))
- } label: {
- if calendar.isDate(viewStore.displayDate, inSameDayAs: date) {
- Label {
+ TWLog.event(SelectDateTenseEventLog(tense: tense))
+
+ _ = viewStore.send(.dateSelected(date))
+ } label: {
+ if calendar.isDate(viewStore.displayDate, inSameDayAs: date) {
+ Label {
+ Text(datePolicy.displayText(for: date, baseDate: today))
+ .twFont(.body1)
+ .foregroundStyle(
+ calendar.isDate(viewStore.displayDate, inSameDayAs: date)
+ ? Color.extraWhite
+ : Color.extraBlack
+ )
+ .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
+ } icon: {
+ Image(systemName: "checkmark")
+ }
+
+ } else {
Text(datePolicy.displayText(for: date, baseDate: today))
.twFont(.body1)
.foregroundStyle(
@@ -159,24 +217,11 @@ public struct MainView: View {
: Color.extraBlack
)
.animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
- } icon: {
- Image(systemName: "checkmark")
}
-
-
- } else {
- Text(datePolicy.displayText(for: date, baseDate: today))
- .twFont(.body1)
- .foregroundStyle(
- calendar.isDate(viewStore.displayDate, inSameDayAs: date)
- ? Color.extraWhite
- : Color.extraBlack
- )
- .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
}
+ .accessibilityLabel("\(datePolicy.displayText(for: date, baseDate: today)) 선택")
+ .accessibilityHint("날짜 변경")
}
- .accessibilityLabel("\(datePolicy.displayText(for: date, baseDate: today)) 선택")
- .accessibilityHint("날짜 변경")
}
} label: {
HStack(spacing: 0) {
@@ -194,7 +239,7 @@ public struct MainView: View {
.accessibilityHint("클릭하여 날짜를 선택할 수 있습니다")
}
- ToolbarItemGroup(placement: .navigationBarTrailing) {
+ ToolbarItemGroup(placement: .topBarTrailing) {
Button {
viewStore.send(.noticeButtonDidTap)
} label: {
@@ -218,16 +263,21 @@ public struct MainView: View {
}
.onAppear {
viewStore.send(.onAppear, animation: .default)
+ viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
}
- .onChange(of: enableWeeklyTimeTable, perform: { _ in
- TWLog.setUserProperty(property: .enableWeeklyTimeTable, value: enableWeeklyTimeTable.description)
+ .onChange(of: enableWeeklyView, perform: { _ in
+ TWLog.setUserProperty(property: .enableWeeklyView, value: enableWeeklyView.description)
+ viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
})
.onLoad {
viewStore.send(.onLoad)
+ viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
}
.background {
navigationLinks
}
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationTitle("급식 & 시간표")
}
.navigationViewStyle(.stack)
}
diff --git a/Projects/Feature/MealFeature/Sources/MealCore.swift b/Projects/Feature/MealFeature/Sources/Daily/MealCore.swift
similarity index 100%
rename from Projects/Feature/MealFeature/Sources/MealCore.swift
rename to Projects/Feature/MealFeature/Sources/Daily/MealCore.swift
diff --git a/Projects/Feature/MealFeature/Sources/MealView.swift b/Projects/Feature/MealFeature/Sources/Daily/MealView.swift
similarity index 81%
rename from Projects/Feature/MealFeature/Sources/MealView.swift
rename to Projects/Feature/MealFeature/Sources/Daily/MealView.swift
index 4b7e62b7..60a1bdde 100644
--- a/Projects/Feature/MealFeature/Sources/MealView.swift
+++ b/Projects/Feature/MealFeature/Sources/Daily/MealView.swift
@@ -4,6 +4,12 @@ import Entity
import EnumUtil
import SwiftUI
import UserDefaultsClient
+#if canImport(UIKit)
+import UIKit
+#endif
+#if canImport(AppKit)
+import AppKit
+#endif
public struct MealView: View {
let store: StoreOf
@@ -82,15 +88,19 @@ public struct MealView: View {
LazyVStack {
ForEach(subMeal.meals, id: \.hashValue) { meal in
+ let display = mealDisplay(meal: meal)
+ let containsAllergy = isMealContainsAllergy(meal: meal)
+
HStack {
- Text(mealDisplay(meal: meal))
- .twFont(.headline4, color: .textPrimary)
+ Text(display)
+ .twFont(.headline4, color: containsAllergy ? .point : .textPrimary)
Spacer()
- if isMealContainsAllergy(meal: meal) {
+ if containsAllergy {
Image.allergy
- .renderingMode(.original)
+ .renderingMode(.template)
+ .foregroundColor(.point)
.accessibilityHidden(true)
}
}
@@ -103,9 +113,13 @@ public struct MealView: View {
}
.padding(.horizontal, 16)
.accessibilityElement(children: .combine)
- .accessibilityLabel("\(mealDisplay(meal: meal))")
- .accessibilityHint(isMealContainsAllergy(meal: meal) ? "알레르기 유발 식품이 포함되어 있습니다" : "")
+ .accessibilityLabel("\(display)")
+ .accessibilityHint(containsAllergy ? "알레르기 유발 식품이 포함되어 있습니다" : "")
.accessibilitySortPriority(2)
+ .contentShape(Rectangle())
+ .onLongPressGesture {
+ copyMealToClipboard(display)
+ }
}
}
.padding(.bottom, 24)
@@ -128,3 +142,12 @@ private extension Meal {
self.dinner.meals.isEmpty
}
}
+
+private func copyMealToClipboard(_ text: String) {
+#if canImport(UIKit)
+ UIPasteboard.general.string = text
+#elseif canImport(AppKit)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+#endif
+}
diff --git a/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift
new file mode 100644
index 00000000..c08319d0
--- /dev/null
+++ b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift
@@ -0,0 +1,6 @@
+import TWLog
+
+struct ShareMealEventLog: EventLog {
+ let name: String = "share_meal"
+ let params: [String: String] = [:]
+}
diff --git a/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift
new file mode 100644
index 00000000..332e685b
--- /dev/null
+++ b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift
@@ -0,0 +1,6 @@
+import TWLog
+
+struct ShareMealImageEventLog: EventLog {
+ let name: String = "share_meal_image"
+ let params: [String: String] = [:]
+}
diff --git a/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift
new file mode 100644
index 00000000..f53f1a31
--- /dev/null
+++ b/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift
@@ -0,0 +1,6 @@
+import TWLog
+
+struct TapTodayMealEventLog: EventLog {
+ let name: String = "tap_today_meal"
+ let params: [String: String] = [:]
+}
diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift
new file mode 100644
index 00000000..933f93fa
--- /dev/null
+++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift
@@ -0,0 +1,156 @@
+import ComposableArchitecture
+import Entity
+import EnumUtil
+import Foundation
+import LocalDatabaseClient
+import MealClient
+import Sharing
+import UserDefaultsClient
+
+public struct WeeklyMealCore: Reducer {
+ private enum CancellableID: Hashable {
+ case fetch
+ }
+
+ public init() {}
+
+ public struct State: Equatable {
+ public struct DayMeal: Equatable {
+ public let date: Date
+ public let meal: Meal
+
+ public init(date: Date, meal: Meal) {
+ self.date = date
+ self.meal = meal
+ }
+
+ public var isEmpty: Bool {
+ meal.breakfast.meals.isEmpty &&
+ meal.lunch.meals.isEmpty &&
+ meal.dinner.meals.isEmpty
+ }
+ }
+
+ public var weeklyMeals: [DayMeal] = []
+ public var isLoading = false
+ public var allergyList: [AllergyType] = []
+ public var showWeekend = false
+ public var currentTimeMealType: MealType = .breakfast
+ public var today: Date = Date()
+ @Shared public var displayDate: Date
+
+ public init(displayDate: Shared) {
+ self._displayDate = displayDate
+ }
+ }
+
+ public enum Action: Equatable {
+ case onLoad
+ case onAppear
+ case refresh
+ case refreshData
+ case settingsButtonDidTap
+ case mealsResponse(TaskResult<[Date: Meal]>)
+ }
+
+ @Dependency(\.mealClient) var mealClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient
+ @Dependency(\.userDefaultsClient) var userDefaultsClient
+ @Dependency(\.date) var dateGenerator
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .onLoad:
+ do {
+ state.allergyList = try localDatabaseClient.readRecords(as: AllergyLocalEntity.self)
+ .compactMap { AllergyType(rawValue: $0.allergy) ?? nil }
+ } catch {}
+
+ state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false)
+ state.isLoading = true
+
+ let displayDate = state.displayDate
+ let showWeekend = state.showWeekend
+
+ return .merge(
+ fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend),
+ .publisher {
+ state.$displayDate.publisher
+ .map { _ in Action.refreshData }
+ }
+ )
+
+ case .onAppear:
+ do {
+ state.allergyList = try localDatabaseClient.readRecords(as: AllergyLocalEntity.self)
+ .compactMap { AllergyType(rawValue: $0.allergy) ?? nil }
+ } catch {}
+
+ state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false)
+ state.isLoading = true
+
+ let displayDate = state.displayDate
+ let showWeekend = state.showWeekend
+
+ return fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend)
+
+ case .refresh:
+ return .send(.refreshData)
+
+ case .refreshData:
+ state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false)
+ state.isLoading = true
+
+ let displayDate = state.displayDate
+ let showWeekend = state.showWeekend
+
+ return fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend)
+
+ case let .mealsResponse(.success(mealDictionary)):
+ state.isLoading = false
+
+ let calendar = Calendar.current
+ let sortedEntries = mealDictionary
+ .map { (calendar.startOfDay(for: $0.key), $0.value) }
+ .sorted { $0.0 < $1.0 }
+ state.weeklyMeals = sortedEntries.map { entry in
+ State.DayMeal(date: entry.0, meal: entry.1)
+ }
+
+ let now = dateGenerator.now
+ state.today = now
+ let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
+ state.currentTimeMealType = MealType(hour: now, isSkipWeekend: isSkipWeekend)
+
+ case .mealsResponse(.failure):
+ state.isLoading = false
+ state.weeklyMeals = []
+
+ case .settingsButtonDidTap:
+ break
+ }
+ return .none
+ }
+ }
+
+ private func fetchWeeklyMeals(displayDate: Date, showWeekend: Bool) -> Effect {
+ let calendar = Calendar.current
+ let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: displayDate)
+ let mondayDate = calendar.date(from: components) ?? displayDate
+ let dayCount = showWeekend ? 7 : 5
+
+ return .concatenate(
+ .cancel(id: CancellableID.fetch),
+ .run { [mondayDate, dayCount] send in
+ let response = await Action.mealsResponse(
+ TaskResult {
+ try await mealClient.fetchMeals(mondayDate, dayCount)
+ }
+ )
+ await send(response)
+ }
+ .cancellable(id: CancellableID.fetch, cancelInFlight: true)
+ )
+ }
+}
diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
new file mode 100644
index 00000000..0cbb5330
--- /dev/null
+++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
@@ -0,0 +1,399 @@
+import ComposableArchitecture
+import DesignSystem
+import Entity
+import EnumUtil
+import SwiftUI
+import TWLog
+import UIKit
+
+public struct WeeklyMealView: View {
+ let store: StoreOf
+ @ObservedObject var viewStore: ViewStoreOf
+ @Environment(\.calendar) private var calendar
+ @Environment(\.displayScale) var displayScale
+ @State private var shouldShowTodayButton = false
+ @State private var todayButtonDirection: TodayButtonDirection = .up
+
+ public init(store: StoreOf) {
+ self.store = store
+ self.viewStore = ViewStore(store, observe: { $0 })
+ }
+
+ public var body: some View {
+ GeometryReader { outerGeometry in
+ ScrollViewReader { proxy in
+ ZStack(alignment: .bottomTrailing) {
+ ScrollView {
+ ZStack(alignment: .top) {
+ if (viewStore.weeklyMeals.isEmpty || viewStore.weeklyMeals.allSatisfy { $0.isEmpty }) && !viewStore.isLoading {
+ emptyStateView
+ } else {
+ LazyVStack(spacing: 40) {
+ ForEach(viewStore.weeklyMeals, id: \.date) { dayMeal in
+ daySection(dayMeal: dayMeal)
+ .background(
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: DayFramePreferenceKey.self,
+ value: [
+ calendar.startOfDay(for: dayMeal.date): geometry.frame(in: .global)
+ ]
+ )
+ }
+ )
+ .id(dayMeal.date)
+ }
+
+ Spacer()
+ .frame(height: 64)
+ }
+ .padding(.top, 16)
+ }
+
+ if viewStore.isLoading {
+ ProgressView()
+ .progressViewStyle(.automatic)
+ .padding(.top, 16)
+ .accessibilityLabel("이번 주 급식 정보를 불러오는 중입니다")
+ .accessibilitySortPriority(1)
+ }
+ }
+ }
+ .coordinateSpace(name: "WeeklyMealScroll")
+ .onLoad {
+ viewStore.send(.onLoad)
+ }
+ .onAppear {
+ viewStore.send(.onAppear, animation: .default)
+ scrollToToday(proxy: proxy)
+ }
+ .onChange(of: viewStore.weeklyMeals) { _ in
+ scrollToToday(proxy: proxy)
+ }
+ .refreshable {
+ viewStore.send(.refresh, animation: .default)
+ }
+ .onPreferenceChange(DayFramePreferenceKey.self) { frames in
+ let containerFrame = outerGeometry.frame(in: .global)
+ updateTodayButtonVisibility(containerFrame: containerFrame, dayFrames: frames)
+ }
+
+ if shouldShowTodayButton {
+ todayButton(direction: todayButtonDirection) {
+ scrollToToday(proxy: proxy)
+ }
+ .padding(.trailing, 16)
+ .padding(.bottom, 16)
+ .transition(
+ .opacity.combined(with: .move(edge: .trailing))
+ )
+ .zIndex(1)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var emptyStateView: some View {
+ VStack(spacing: 14) {
+ Spacer()
+
+ Text("이번 주 급식을 찾을 수 없어요!")
+ .twFont(.body1, color: .textSecondary)
+ .padding(.top, 16)
+ .foregroundColor(.textSecondary)
+ .accessibilityLabel("급식을 찾을 수 없습니다")
+ .accessibilitySortPriority(1)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+
+ @ViewBuilder
+ private func daySection(dayMeal: WeeklyMealCore.State.DayMeal) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(dayMeal.date, format: .dateTime.month().day().weekday(.wide))
+ .twFont(.body3, color: .textSecondary)
+ .padding(.horizontal, 28)
+
+ if dayMeal.isEmpty {
+ Text("급식 없음")
+ .twFont(.body1, color: .textSecondary)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.backgroundMain)
+ }
+ .padding(.horizontal, 16)
+ } else {
+ ForEach([MealType.breakfast, .lunch, .dinner], id: \.hashValue) { type in
+ let subMeal = dayMeal.meal.mealByType(type: type)
+ if !subMeal.meals.isEmpty {
+ mealCard(dayMeal: dayMeal, type: type, subMeal: subMeal)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func mealCard(
+ dayMeal: WeeklyMealCore.State.DayMeal,
+ type: MealType,
+ subMeal: Meal.SubMeal
+ ) -> some View {
+ let isToday = calendar.isDate(dayMeal.date, inSameDayAs: viewStore.today)
+ let isHighlighted = isToday && viewStore.currentTimeMealType == type
+
+ let mealCardView = VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(relativeTitle(for: dayMeal.date, mealType: type))
+ .twFont(.headline4, color: .textPrimary)
+
+ Spacer()
+
+ Text("\(String(format: "%.1f", subMeal.cal)) Kcal")
+ .twFont(.body2, color: .unselectedPrimary)
+ }
+
+ Divider()
+ .foregroundStyle(Color.unselectedSecondary)
+
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(subMeal.meals, id: \.hashValue) { meal in
+ let mealText = mealDisplay(meal: meal)
+ let containsAllergy = isMealContainsAllergy(meal: meal)
+
+ HStack(alignment: .center, spacing: 8) {
+ Text(mealText)
+ .twFont(.headline4, color: containsAllergy ? .point : .textPrimary)
+
+ if containsAllergy {
+ Image.allergy
+ .renderingMode(.template)
+ .foregroundStyle(Color.point)
+ .accessibilityHidden(true)
+ }
+
+ Spacer()
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(mealText)
+ .accessibilityHint(
+ containsAllergy
+ ? "알레르기 유발 식품이 포함되어 있습니다"
+ : ""
+ )
+ .contentShape(Rectangle())
+ }
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background {
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.backgroundMain)
+ }
+
+ mealCardView
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(relativeTitle(for: dayMeal.date, mealType: type)) \(String(format: "%.1f", subMeal.cal)) 칼로리")
+ .contextMenu {
+ Button {
+ let mealToCopy = subMeal.meals
+ .map { mealDisplay(meal: $0) }
+ .joined(separator: "\n")
+ let dateText = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)"
+ UIPasteboard.general.string = "\(dateText)\n\(mealToCopy)"
+ TWLog.event(ShareMealEventLog())
+ } label: {
+ Label("복사하기", systemImage: "doc.on.doc")
+ }
+
+ if #available(iOS 16.0, *) {
+ Button {
+ let renderer = ImageRenderer(content: mealCardView)
+ renderer.scale = displayScale
+ if let image = renderer.uiImage {
+ UIPasteboard.general.image = image
+ TWLog.event(ShareMealImageEventLog())
+ }
+ } label: {
+ Label("이미지로 복사하기", systemImage: "photo")
+ }
+ }
+ }
+ .onDrag {
+ let mealToDrag = subMeal.meals
+ .map { mealDisplay(meal: $0) }
+ .joined(separator: "\n")
+ let dateText = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)"
+ return NSItemProvider(object: "\(dateText)\n\(mealToDrag)" as NSString)
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(isHighlighted ? Color.extraBlack : Color.clear, lineWidth: 2)
+ }
+ .padding(.horizontal, 16)
+ }
+
+ private func mealDisplay(meal: String) -> String {
+ meal.replacingOccurrences(of: "[0-9.() ]", with: "", options: [.regularExpression])
+ }
+
+ private func isMealContainsAllergy(meal: String) -> Bool {
+ viewStore.allergyList.first {
+ meal.contains("(\($0.number)") || meal.contains(".\($0.number)")
+ } != nil
+ }
+
+ private func relativeTitle(for date: Date, mealType: MealType) -> String {
+ let today = calendar.startOfDay(for: viewStore.today)
+ let target = calendar.startOfDay(for: date)
+
+ let components = calendar.dateComponents([.day], from: today, to: target)
+ let dayDifference = components.day ?? 0
+
+ let dayPrefix: String
+ switch dayDifference {
+ case 0:
+ dayPrefix = "오늘"
+ case 1:
+ dayPrefix = "내일"
+ case -1:
+ dayPrefix = "어제"
+ default:
+ dayPrefix = date.formatted(.dateTime.weekday(.wide))
+ }
+
+ return "\(dayPrefix) \(mealType.display)"
+ }
+
+ @ViewBuilder
+ private func todayButton(direction: TodayButtonDirection, action: @escaping () -> Void) -> some View {
+ let baseButton = Button(action: action) {
+ HStack(spacing: 6) {
+ Image(systemName: direction.systemName)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(Color.extraWhite)
+
+ Text("오늘")
+ .twFont(.headline3, color: .extraWhite)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+ .accessibilityLabel("오늘 급식으로 이동")
+ .accessibilityHint("스크롤해서 오늘 날짜 섹션을 보여줍니다")
+
+ if #available(iOS 26.0, *) {
+ baseButton
+ .glassEffect(.regular.tint(Color.extraBlack).interactive(), in: .capsule)
+ } else {
+ baseButton
+ .background {
+ Capsule()
+ .fill(Color.extraBlack)
+ }
+ }
+ }
+
+ private func updateTodayButtonVisibility(containerFrame: CGRect, dayFrames: [Date: CGRect]) {
+ guard containerFrame != .zero else {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ shouldShowTodayButton = false
+ }
+ return
+ }
+
+ let hasToday = viewStore.weeklyMeals.contains {
+ calendar.isDate($0.date, inSameDayAs: viewStore.today)
+ }
+ guard hasToday else {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ shouldShowTodayButton = false
+ }
+ return
+ }
+
+ let todayKey = calendar.startOfDay(for: viewStore.today)
+ if let todayFrame = dayFrames[todayKey], !todayFrame.isNull {
+ let isTodayVisible = todayFrame.intersects(containerFrame)
+
+ if isTodayVisible {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ shouldShowTodayButton = false
+ }
+ } else {
+ let direction: TodayButtonDirection = todayFrame.maxY < containerFrame.minY ? .up : .down
+ withAnimation(.easeInOut(duration: 0.2)) {
+ todayButtonDirection = direction
+ shouldShowTodayButton = true
+ }
+ }
+ return
+ }
+
+ let visibleDates = dayFrames.keys.sorted()
+ guard let firstVisible = visibleDates.first,
+ let lastVisible = visibleDates.last else {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ shouldShowTodayButton = false
+ }
+ return
+ }
+
+ if calendar.compare(lastVisible, to: todayKey, toGranularity: .day) == .orderedAscending {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ todayButtonDirection = .down
+ shouldShowTodayButton = true
+ }
+ } else if calendar.compare(firstVisible, to: todayKey, toGranularity: .day) == .orderedDescending {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ todayButtonDirection = .up
+ shouldShowTodayButton = true
+ }
+ } else {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ shouldShowTodayButton = false
+ }
+ }
+ }
+
+ private func scrollToToday(proxy: ScrollViewProxy) {
+ guard let todayMeal = viewStore.weeklyMeals.first(where: {
+ calendar.isDate($0.date, inSameDayAs: viewStore.today)
+ }) else { return }
+
+ withAnimation(.easeInOut(duration: 0.4)) {
+ proxy.scrollTo(todayMeal.date, anchor: .top)
+ }
+ }
+}
+
+private struct DayFramePreferenceKey: PreferenceKey {
+ static var defaultValue: [Date: CGRect] { [:] }
+
+ static func reduce(value: inout [Date: CGRect], nextValue: () -> [Date: CGRect]) {
+ value.merge(nextValue()) { _, new in new }
+ }
+}
+
+private enum TodayButtonDirection {
+ case up
+ case down
+
+ var systemName: String {
+ switch self {
+ case .up:
+ return "arrow.up"
+ case .down:
+ return "arrow.down"
+ }
+ }
+}
diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift
index e62896f2..5e247286 100644
--- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift
+++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift
@@ -87,23 +87,27 @@ public struct WeeklyTimeTableCore: Reducer {
Reduce { state, action in
switch action {
case .onLoad:
- return .publisher {
- state.$displayDate.publisher
- .map { _ in Action.refreshData }
- }
+ return .merge(
+ .send(.onAppear),
+ .publisher {
+ state.$displayDate.publisher
+ .map { _ in Action.refreshData }
+ }
+ )
case .onAppear, .refreshData:
state.isLoading = true
state.showWeekend =
!(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false)
- let baseDate = state.displayDate
let calendar = Calendar.current
- let weekday = calendar.component(.weekday, from: baseDate)
+ let baseDate = state.displayDate
+ let normalizedBaseDate = calendar.startOfDay(for: baseDate)
+ let weekday = calendar.component(.weekday, from: normalizedBaseDate)
let daysFromMonday = (weekday + 5) % 7
let mondayDate =
- calendar.date(byAdding: .day, value: -daysFromMonday, to: baseDate)
- ?? baseDate
+ calendar.date(byAdding: .day, value: -daysFromMonday, to: normalizedBaseDate)
+ ?? normalizedBaseDate
return .concatenate(
.cancel(id: CancellableID.fetch),
@@ -138,38 +142,46 @@ public struct WeeklyTimeTableCore: Reducer {
}
}
- private func fetchWeeklyTimeTable(mondayDate: Date, showWeekend: Bool)
- async throws -> WeeklyTimeTable {
+ private func fetchWeeklyTimeTable(mondayDate: Date, showWeekend: Bool) async throws -> WeeklyTimeTable {
let calendar = Calendar.current
let dayCount = showWeekend ? 7 : 5
var weeklyData: [Int: [TimeTable]] = [:]
-
- for i in 0.. Date in
+ let targetDate = table.date ?? mondayDate
+ return calendar.startOfDay(for: targetDate)
+ }
+ )
+
+ let modifiedRecords = try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self)
+
+ for i in 0.. String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.replacingOccurrences(
+ of: #"^\*+\s*"#,
+ with: "",
+ options: [.regularExpression]
+ )
+ }
}
diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
index 4157de28..9b57ed01 100644
--- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
+++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
@@ -60,9 +60,9 @@ public struct WeeklyTimeTableView: View {
}
public var body: some View {
- ScrollView(.vertical) {
- VStack {
- if viewStore.weeklyTimeTable == nil && !viewStore.isLoading {
+ ScrollView(.vertical, showsIndicators: false) {
+ if viewStore.weeklyTimeTable == nil && !viewStore.isLoading {
+ VStack {
Text("이번 주 시간표를 찾을 수 없어요!")
.padding(.top, 16)
.foregroundColor(.textSecondary)
@@ -77,29 +77,30 @@ public struct WeeklyTimeTableView: View {
.accessibilityLabel("학기 초에는 정규시간표가 등록되어 있지 않을 수 있습니다")
.accessibilitySortPriority(2)
}
- } else {
- ZStack(alignment: .top) {
- if viewStore.isLoading {
- ProgressView()
- .progressViewStyle(.automatic)
- .padding(.top, 16)
- .accessibilityLabel("시간표를 불러오는 중입니다")
- .accessibilitySortPriority(1)
- }
+ }
+ } else {
+ ZStack(alignment: .top) {
+ if viewStore.isLoading {
+ ProgressView()
+ .progressViewStyle(.automatic)
+ .padding(.top, 16)
+ .accessibilityLabel("시간표를 불러오는 중입니다")
+ .accessibilitySortPriority(1)
+ }
- if let weeklyTimeTable = viewStore.weeklyTimeTable {
- if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) {
+ if let weeklyTimeTable = viewStore.weeklyTimeTable {
+ if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) {
+ timeTableGrid(weeklyTimeTable: weeklyTimeTable)
+ .frame(alignment: .top)
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
timeTableGrid(weeklyTimeTable: weeklyTimeTable)
- .frame(alignment: .top)
- } else {
- ScrollView(.horizontal) {
- timeTableGrid(weeklyTimeTable: weeklyTimeTable)
- .frame(alignment: .top)
- }
}
+ .frame(alignment: .top)
}
}
}
+ .frame(minHeight: 560, alignment: .top)
}
}
.onLoad {
@@ -115,14 +116,78 @@ public struct WeeklyTimeTableView: View {
@ViewBuilder
private func timeTableGrid(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View {
+ let headerHeight: CGFloat = 40
+ let firstColumnWidth: CGFloat = 32
+ let columnCount = max(weeklyTimeTable.weekdays.count, 1)
+ let periodCount = CGFloat(weeklyTimeTable.periods.count)
+
+ if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) {
+ GeometryReader { geometry in
+ let availableWidth = max(geometry.size.width - firstColumnWidth, 0)
+ let fallbackWidth = columnWidth(weeklyTimeTable: weeklyTimeTable)
+ let rawCellSide = availableWidth / CGFloat(columnCount)
+ let cellSide = rawCellSide > 0 ? rawCellSide : fallbackWidth
+
+ timeTableContent(
+ weeklyTimeTable: weeklyTimeTable,
+ cellSide: cellSide,
+ headerHeight: headerHeight,
+ firstColumnWidth: firstColumnWidth
+ )
+ .frame(
+ width: firstColumnWidth + cellSide * CGFloat(columnCount),
+ alignment: .topLeading
+ )
+ .frame(
+ height: headerHeight + cellSide * periodCount,
+ alignment: .top
+ )
+ }
+ } else {
+ let cellSide = columnWidth(weeklyTimeTable: weeklyTimeTable)
+
+ timeTableContent(
+ weeklyTimeTable: weeklyTimeTable,
+ cellSide: cellSide,
+ headerHeight: headerHeight,
+ firstColumnWidth: firstColumnWidth
+ )
+ .frame(
+ width: firstColumnWidth + cellSide * CGFloat(columnCount),
+ alignment: .topLeading
+ )
+ .frame(
+ height: headerHeight + cellSide * periodCount,
+ alignment: .top
+ )
+ }
+ }
+
+ @ViewBuilder
+ private func timeTableContent(
+ weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable,
+ cellSide: CGFloat,
+ headerHeight: CGFloat,
+ firstColumnWidth: CGFloat
+ ) -> some View {
VStack(spacing: 0) {
- headerRow(weeklyTimeTable: weeklyTimeTable)
+ headerRow(
+ weeklyTimeTable: weeklyTimeTable,
+ cellWidth: cellSide,
+ headerHeight: headerHeight,
+ firstColumnWidth: firstColumnWidth
+ )
ForEach(weeklyTimeTable.periods, id: \.self) { period in
- timeTableRow(period: period, weeklyTimeTable: weeklyTimeTable)
+ timeTableRow(
+ period: period,
+ periodCount: weeklyTimeTable.periods.count,
+ weeklyTimeTable: weeklyTimeTable,
+ cellSide: cellSide,
+ firstColumnWidth: firstColumnWidth
+ )
}
}
- .frame(maxWidth: .infinity)
.background(Color.cardBackground)
.overlay {
if #available(iOS 16.0, *) {
@@ -130,59 +195,42 @@ public struct WeeklyTimeTableView: View {
borderWidth: 1,
cornerRadius: 16,
selectedColumnIndex: weeklyTimeTable.todayIndex,
- headerHeight: 44,
- firstColumnWidth: 40,
+ headerHeight: headerHeight,
+ firstColumnWidth: firstColumnWidth,
columnWidth: columnWidth(weeklyTimeTable: weeklyTimeTable),
useFlexibleWidth: shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable),
columnCount: weeklyTimeTable.weekdays.count,
- cellHeight: 56,
+ cellHeight: cellSide,
selectedColumnPeriodCount: {
- if let todayIndex = weeklyTimeTable
- .todayIndex { weeklyTimeTable.actualPeriodCount(for: todayIndex)
+ if let todayIndex = weeklyTimeTable.todayIndex {
+ weeklyTimeTable.actualPeriodCount(for: todayIndex)
} else {
0
}
}()
)
- .stroke(Color.extraBlack.opacity(0.8), lineWidth: 2)
+ .stroke(Color.extraBlack, lineWidth: 1)
}
}
}
@ViewBuilder
- private func headerRow(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View {
+ private func headerRow(
+ weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable,
+ cellWidth: CGFloat,
+ headerHeight: CGFloat,
+ firstColumnWidth: CGFloat
+ ) -> some View {
HStack(spacing: 0) {
Text("")
- .frame(width: 40, height: 44)
- .background(Color.unselectedSecondary.opacity(0.1))
+ .frame(width: firstColumnWidth, height: headerHeight)
ForEach(0.. CGFloat? in
- return shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable)
- ? nil
- : columnWidth(weeklyTimeTable: weeklyTimeTable)
- }()
- )
- .background(
- weeklyTimeTable.isToday(weekdayIndex: index)
- ? Color.unselectedSecondary.opacity(0.2)
- : Color.unselectedSecondary.opacity(0.1)
- )
- .overlay(
- Rectangle()
- .fill(Color.unselectedSecondary.opacity(0.3))
- .frame(height: 0.5),
- alignment: .bottom
- )
+ .frame(width: cellWidth, height: headerHeight)
}
}
}
@@ -190,13 +238,15 @@ public struct WeeklyTimeTableView: View {
@ViewBuilder
private func timeTableRow(
period: Int,
- weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable
+ periodCount: Int,
+ weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable,
+ cellSide: CGFloat,
+ firstColumnWidth: CGFloat
) -> some View {
HStack(spacing: 0) {
Text("\(period)")
- .twFont(horizontalSizeClass == .regular ? .body2 : .body3, color: .textPrimary)
- .frame(width: 40, height: 56)
- .background(Color.unselectedSecondary.opacity(0.05))
+ .twFont(.body2, color: .textSecondary)
+ .frame(width: firstColumnWidth, height: cellSide)
ForEach(0.. CGFloat? in
- shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable)
- ? nil
- : columnWidth(weeklyTimeTable: weeklyTimeTable)
- }()
- )
+ .minimumScaleFactor(0.3)
+ .padding(8)
+ .frame(width: cellSide, height: cellSide)
.background(Color.extraWhite)
- .overlay(
- Rectangle()
- .fill(Color.unselectedSecondary.opacity(0.3))
- .frame(height: 0.5),
- alignment: .bottom
- )
+ .overlay(alignment: .bottom) {
+ if period != periodCount {
+ Rectangle()
+ .fill(Color.unselectedSecondary)
+ .frame(height: 1.0)
+ }
+ }
}
}
}
@@ -241,14 +283,14 @@ public struct WeeklyTimeTableView: View {
}
private func columnWidth(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> CGFloat {
- return viewStore.showWeekend ? 55.0 : 65.5
+ return viewStore.showWeekend ? 64.0 : 65.5
}
private func fontForCell(isToday: Bool, horizontalSizeClass: UserInterfaceSizeClass?) -> Font.TWFontSystem {
if horizontalSizeClass == .regular {
return isToday ? .body1 : .body2
} else {
- return isToday ? .body3 : .body2
+ return isToday ? .caption1 : .caption1
}
}
}
diff --git a/Projects/Shared/Entity/Sources/TimeTable.swift b/Projects/Shared/Entity/Sources/TimeTable.swift
index fcddae5a..48f42b51 100644
--- a/Projects/Shared/Entity/Sources/TimeTable.swift
+++ b/Projects/Shared/Entity/Sources/TimeTable.swift
@@ -3,9 +3,11 @@ import Foundation
public struct TimeTable: Equatable, Hashable {
public let perio: Int
public let content: String
+ public let date: Date?
- public init(perio: Int, content: String) {
+ public init(perio: Int, content: String, date: Date? = nil) {
self.perio = perio
self.content = content
+ self.date = date
}
}
diff --git a/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift b/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift
index 942d9b9e..5b3e12ba 100644
--- a/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift
+++ b/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift
@@ -4,8 +4,10 @@ import FirebaseWrapper
public enum FeatureFlagKey: String, Sendable {
case reviewText = "review_text"
+ case enableWeeklyView = "enable_weekly_view"
}
+@available(*, deprecated, message: "deprecated")
public struct FeatureFlagClient: Sendable {
public var getString: @Sendable (FeatureFlagKey) -> String?
public var getBool: @Sendable (FeatureFlagKey) -> Bool
@@ -18,13 +20,10 @@ public struct FeatureFlagClient: Sendable {
getNumber: @Sendable @escaping (FeatureFlagKey) -> NSNumber,
getDictionary: @Sendable @escaping (FeatureFlagKey) -> [String: Any]?
) {
- do {
- try RemoteConfig.remoteConfig().setDefaults(from: [
- FeatureFlagKey.reviewText.rawValue: "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!"
- ])
- } catch {
- print(error)
- }
+ RemoteConfig.remoteConfig().setDefaults([
+ FeatureFlagKey.reviewText.rawValue: "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!" as NSString,
+ FeatureFlagKey.enableWeeklyView.rawValue: false as NSNumber
+ ] as [String: NSObject])
self.getString = getString
self.getBool = getBool
self.getNumber = getNumber
diff --git a/Projects/Shared/MealClient/Sources/MealClient.swift b/Projects/Shared/MealClient/Sources/MealClient.swift
index 27498063..30011e8f 100644
--- a/Projects/Shared/MealClient/Sources/MealClient.swift
+++ b/Projects/Shared/MealClient/Sources/MealClient.swift
@@ -8,6 +8,7 @@ import UserDefaultsClient
public struct MealClient: Sendable {
public var fetchMeal: @Sendable (_ date: Date) async throws -> Meal
+ public var fetchMeals: @Sendable (_ startDate: Date, _ dayCount: Int) async throws -> [Date: Meal]
}
extension MealClient: DependencyKey {
@@ -28,11 +29,7 @@ extension MealClient: DependencyKey {
let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
let code = userDefaultsClient.getValue(.schoolCode) as? String
else {
- return Meal(
- breakfast: .init(meals: [], cal: 0),
- lunch: .init(meals: [], cal: 0),
- dinner: .init(meals: [], cal: 0)
- )
+ return Meal.empty
}
@Dependency(\.neisClient) var neisClient
@@ -63,25 +60,109 @@ extension MealClient: DependencyKey {
response = []
}
- let breakfast = parseMeal(type: .breakfast)
- let lunch = parseMeal(type: .lunch)
- let dinner = parseMeal(type: .dinner)
+ let breakfast = parseMeal(response: response, type: .breakfast)
+ let lunch = parseMeal(response: response, type: .lunch)
+ let dinner = parseMeal(response: response, type: .dinner)
return Meal(breakfast: breakfast, lunch: lunch, dinner: dinner)
+ },
+ fetchMeals: { startDate, dayCount in
+ guard dayCount > 0 else { return [:] }
+ @Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ let calendar = Calendar.current
+ let normalizedStart = calendar.startOfDay(for: startDate)
- func parseMeal(type: MealType) -> Meal.SubMeal {
- return response.first { $0.type == type }
- .map { dto in
- Meal.SubMeal(
- meals: dto.info.replacingOccurrences(of: " ", with: "").components(separatedBy: "
"),
- cal: Double(dto.calInfo.components(separatedBy: " ").first ?? "0") ?? 0
- )
- } ?? .init(meals: [], cal: 0)
+ guard
+ let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
+ let code = userDefaultsClient.getValue(.schoolCode) as? String
+ else {
+ return populateEmptyMeals(calendar: calendar, start: normalizedStart, dayCount: dayCount)
}
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd"
+ formatter.locale = Locale(identifier: "ko_kr")
+
+ let endDate = calendar.date(byAdding: .day, value: dayCount - 1, to: normalizedStart) ?? normalizedStart
+
+ @Dependency(\.neisClient) var neisClient
+ let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
+ let response: [SingleMealResponseDTO]
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ "mealServiceDietInfo",
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "MLSV_FROM_YMD", value: formatter.string(from: normalizedStart)),
+ .init(name: "MLSV_TO_YMD", value: formatter.string(from: endDate))
+ ],
+ key: "mealServiceDietInfo",
+ type: [SingleMealResponseDTO].self
+ )
+ } catch {
+ response = []
+ }
+
+ let groupedByDate = Dictionary(grouping: response, by: \.serviceDate)
+ var result = populateEmptyMeals(calendar: calendar, start: normalizedStart, dayCount: dayCount)
+
+ for (dateString, entries) in groupedByDate {
+ guard let date = formatter.date(from: dateString) else { continue }
+ let normalizedDate = calendar.startOfDay(for: date)
+
+ let breakfast = parseMeal(response: entries, type: .breakfast)
+ let lunch = parseMeal(response: entries, type: .lunch)
+ let dinner = parseMeal(response: entries, type: .dinner)
+
+ result[normalizedDate] = Meal(
+ breakfast: breakfast,
+ lunch: lunch,
+ dinner: dinner
+ )
+ }
+
+ return result
}
)
}
+private func parseMeal(response: [SingleMealResponseDTO], type: MealType) -> Meal.SubMeal {
+ return response.first { $0.type == type }
+ .map { dto in
+ Meal.SubMeal(
+ meals: dto.info
+ .replacingOccurrences(of: " ", with: "")
+ .components(separatedBy: "
"),
+ cal: Double(dto.calInfo.components(separatedBy: " ").first ?? "0") ?? 0
+ )
+ } ?? .init(meals: [], cal: 0)
+}
+
+private func populateEmptyMeals(calendar: Calendar, start: Date, dayCount: Int) -> [Date: Meal] {
+ var result: [Date: Meal] = [:]
+ for offset in 0.. TimeTable {
+ let date = Self.dateFormatter.date(from: serviceDate)
+
return .init(
perio: Int(perio) ?? 1,
- content: content
+ content: content,
+ date: date
)
}
}
diff --git a/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift b/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift
index b662d0c6..c2edf64e 100644
--- a/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift
+++ b/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift
@@ -53,7 +53,7 @@ extension TimeTableClient: DependencyKey {
.init(name: "KEY", value: key),
.init(name: "Type", value: "json"),
.init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "30"),
+ .init(name: "pSize", value: "100"),
.init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
.init(name: "SD_SCHUL_CODE", value: code),
.init(name: "DDDEP_NM", value: major),
@@ -72,7 +72,7 @@ extension TimeTableClient: DependencyKey {
.init(name: "KEY", value: key),
.init(name: "Type", value: "json"),
.init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "30"),
+ .init(name: "pSize", value: "100"),
.init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
.init(name: "SD_SCHUL_CODE", value: code),
.init(name: "GRADE", value: "\(grade)"),
@@ -126,7 +126,7 @@ extension TimeTableClient: DependencyKey {
.init(name: "KEY", value: key),
.init(name: "Type", value: "json"),
.init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "30"),
+ .init(name: "pSize", value: "100"),
.init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
.init(name: "SD_SCHUL_CODE", value: code),
.init(name: "DDDEP_NM", value: major),
@@ -145,7 +145,7 @@ extension TimeTableClient: DependencyKey {
.init(name: "KEY", value: key),
.init(name: "Type", value: "json"),
.init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "30"),
+ .init(name: "pSize", value: "100"),
.init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
.init(name: "SD_SCHUL_CODE", value: code),
.init(name: "GRADE", value: "\(grade)"),
diff --git a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift
index 20a8a145..d48f6461 100644
--- a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift
+++ b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift
@@ -4,22 +4,27 @@ import SwiftUI
@available(watchOS, unavailable)
public extension View {
+ @ViewBuilder
func twBackButton(willDismiss: @escaping () -> Void = {}, dismiss: DismissAction) -> some View {
- self
- .toolbar {
- ToolbarItemGroup(placement: .navigationBarLeading) {
- Button {
- willDismiss()
- dismiss()
- } label: {
- Image(systemName: "chevron.left")
- .resizable()
- .frame(width: 9, height: 16)
- .foregroundColor(Color.extraBlack)
+ if #available(iOS 26.0, *) {
+ self
+ } else {
+ self
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarLeading) {
+ Button {
+ willDismiss()
+ dismiss()
+ } label: {
+ Image(systemName: "chevron.left")
+ .resizable()
+ .frame(width: 9, height: 16)
+ .foregroundColor(Color.extraBlack)
+ }
}
}
- }
- .navigationBarBackButtonHidden(true)
+ .navigationBarBackButtonHidden(true)
+ }
}
}
diff --git a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
index b50e3636..7b7a8f63 100644
--- a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
+++ b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
@@ -39,28 +39,28 @@ public extension Font.TWFontSystem {
var font: Font {
switch self {
case .headline1:
- return Font(DesignSystemFontFamily.Suit.bold.font(size: 28))
+ return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 28)
case .headline2:
- return Font(DesignSystemFontFamily.Suit.bold.font(size: 24))
+ return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 24)
case .headline3:
- return Font(DesignSystemFontFamily.Suit.bold.font(size: 18))
+ return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 18)
case .headline4:
- return Font(DesignSystemFontFamily.Suit.bold.font(size: 16))
+ return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 16)
case .body1:
- return Font(DesignSystemFontFamily.Suit.medium.font(size: 16))
+ return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 16)
case .body2:
- return Font(DesignSystemFontFamily.Suit.medium.font(size: 14))
+ return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 14)
case .body3:
- return Font(DesignSystemFontFamily.Suit.bold.font(size: 14))
+ return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 14)
case .caption1:
- return Font(DesignSystemFontFamily.Suit.medium.font(size: 12))
+ return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 12)
}
}
}
diff --git a/Workspace.swift b/Workspace.swift
index e33c294b..9e367d21 100644
--- a/Workspace.swift
+++ b/Workspace.swift
@@ -6,4 +6,4 @@ let workspace = Workspace(
projects: [
"Projects/App"
]
-)
\ No newline at end of file
+)