diff --git a/myApp/TallyFlow/CLAUDE.md b/myApp/TallyFlow/CLAUDE.md new file mode 100644 index 0000000..60e08aa --- /dev/null +++ b/myApp/TallyFlow/CLAUDE.md @@ -0,0 +1,63 @@ +# TallyFlow + +## Product summary +TallyFlow is an iOS productivity tracker for measuring tasks by either time or count. +It supports hierarchical tags (super tag / sub tag), flexible goals, historical statistics, widgets, Live Activities, and a paired watchOS experience. + +## Source root +- iOS source root folder: `IOS/` +- Do not assume the default inner app folder name is `TallyFlow`; use `IOS/` as the app source root. + +## Non-negotiable constraints +- Use SwiftUI. +- Use SwiftData as the local persistence layer. +- Keep the project buildable after every milestone. +- Do not scan or refactor the entire project. +- Read only the files explicitly listed in each prompt. +- Edit only the files explicitly listed in each prompt. +- Do not mention competitor app names in code comments, docs, or commit messages. +- Korean is the default app language; English must be supported. +- Theme must support Light and Dark modes with custom green/yellow palettes, not generic system default vibes. +- All date aggregation must respect configurable week start day and configurable custom day start time. +- Time tracking must support multiple concurrent running timer tasks. +- A task can have multiple tags. +- Selecting a sub tag implies its parent super tag is also associated logically. + +## Architecture rules +- Separate app shell, domain models, services, and feature views. +- Create a single source of truth for: + 1. task definitions, + 2. tag hierarchy, + 3. goals, + 4. tracking sessions / count logs, + 5. calendar boundary calculations. +- Avoid business logic inside SwiftUI view bodies. +- Put theme colors/tokens in dedicated files. +- Keep milestone 1 free of widget/watch/live activity target work; only prepare the architecture so those can be added later. + +## UX rules +- Tab order must be: + 1. Main + 2. Tasks + 3. Tags + 4. Goals + 5. Stats + 6. Settings +- Add a splash/loading screen that shows the app name. +- Main tab should be scaffolded for: + - today / week / month summary cards, + - task action buttons, + - future layout-edit mode. +- Settings must be scaffolded for: + - theme mode, + - language, + - week start day, + - custom day start time, + - premium section entry. + +## Coding style +- Prefer small SwiftUI views. +- Prefer explicit model names over vague names. +- Add TODO markers only where the next milestone will extend logic. +- Do not add placeholder lorem ipsum text. +- Do not introduce third-party dependencies in milestone 1. diff --git a/myApp/TallyFlow/IOS/App/RootTabView.swift b/myApp/TallyFlow/IOS/App/RootTabView.swift new file mode 100644 index 0000000..57e1234 --- /dev/null +++ b/myApp/TallyFlow/IOS/App/RootTabView.swift @@ -0,0 +1,37 @@ +import SwiftData +import SwiftUI + +struct RootTabView: View { + var body: some View { + TabView { + Tab(String(localized: "tab.main"), systemImage: "house.fill") { + MainTabView() + } + Tab(String(localized: "tab.tasks"), systemImage: "checkmark.circle.fill") { + TasksTabView() + } + Tab(String(localized: "tab.tags"), systemImage: "tag.fill") { + TagsTabView() + } + Tab(String(localized: "tab.goals"), systemImage: "target") { + GoalsTabView() + } + Tab(String(localized: "tab.stats"), systemImage: "chart.bar.fill") { + StatsTabView() + } + Tab(String(localized: "tab.settings"), systemImage: "gearshape.fill") { + SettingsTabView() + } + } + .tint(Color.tfPrimary) + } +} + +#Preview { + RootTabView() + .modelContainer( + for: [TaskItem.self, TagEntity.self, GoalEntity.self, TrackingRecord.self], + inMemory: true + ) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/App/SplashView.swift b/myApp/TallyFlow/IOS/App/SplashView.swift new file mode 100644 index 0000000..3374d66 --- /dev/null +++ b/myApp/TallyFlow/IOS/App/SplashView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct SplashView: View { + var body: some View { + ZStack { + Color.tfBackground.ignoresSafeArea() + VStack(spacing: 14) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 60, weight: .semibold)) + .foregroundStyle(Color.tfPrimary) + Text("TallyFlow") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundStyle(Color.tfOnBackground) + Text(String(localized: "splash.tagline")) + .font(.subheadline) + .foregroundStyle(Color.tfMuted) + } + } + } +} + +#Preview { + SplashView() + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/TallyFlow/IOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/TallyFlow/IOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/TallyFlow/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/TallyFlow/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/TallyFlow/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/TallyFlow/IOS/Assets.xcassets/Contents.json b/myApp/TallyFlow/IOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/TallyFlow/IOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/TallyFlow/IOS/ContentView.swift b/myApp/TallyFlow/IOS/ContentView.swift new file mode 100644 index 0000000..a57acd7 --- /dev/null +++ b/myApp/TallyFlow/IOS/ContentView.swift @@ -0,0 +1,42 @@ +// +// ContentView.swift +// TallyFlow +// +// Created by 송예찬 on 6/26/26. +// +import SwiftData +import SwiftUI + +struct ContentView: View { + @Environment(AppPreferences.self) private var preferences + @State private var isShowingSplash = true + + var body: some View { + Group { + if isShowingSplash { + SplashView() + .transition(.opacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { + withAnimation(.easeOut(duration: 0.4)) { + isShowingSplash = false + } + } + } + } else { + RootTabView() + .transition(.opacity) + } + } + .preferredColorScheme(preferences.themeMode.colorScheme) + } +} + +#Preview { + ContentView() + .modelContainer( + for: [TaskItem.self, TagEntity.self, GoalEntity.self, TrackingRecord.self], + inMemory: true + ) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Domain/Models/GoalEntity.swift b/myApp/TallyFlow/IOS/Domain/Models/GoalEntity.swift new file mode 100644 index 0000000..6b17fd1 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Models/GoalEntity.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +enum GoalUnit: String, Codable, CaseIterable { + case seconds + case count +} + +enum GoalPeriod: String, Codable, CaseIterable { + case daily + case weekly + case monthly +} + +// Cannot be stored directly in SwiftData; use targetType computed property on GoalEntity. +enum TargetType { + case duration(min: Double, max: Double?) + case count(min: Int, max: Int?) +} + +@Model +final class GoalEntity { + var id: UUID + var title: String + var targetValue: Double + var targetMax: Double? + var unit: GoalUnit + var period: GoalPeriod + var createdAt: Date + var isActive: Bool + + var task: TaskItem? + var tag: TagEntity? + + var targetType: TargetType { + switch unit { + case .seconds: + return .duration(min: targetValue, max: targetMax) + case .count: + return .count(min: Int(targetValue), max: targetMax.map(Int.init)) + } + } + + init( + id: UUID = UUID(), + title: String, + targetValue: Double, + targetMax: Double? = nil, + unit: GoalUnit = .seconds, + period: GoalPeriod = .daily, + createdAt: Date = .now, + isActive: Bool = true + ) { + self.id = id + self.title = title + self.targetValue = targetValue + self.targetMax = targetMax + self.unit = unit + self.period = period + self.createdAt = createdAt + self.isActive = isActive + } +} + +extension GoalPeriod { + var localizedName: String { + switch self { + case .daily: return String(localized: "goal.period.daily") + case .weekly: return String(localized: "goal.period.weekly") + case .monthly: return String(localized: "goal.period.monthly") + } + } +} diff --git a/myApp/TallyFlow/IOS/Domain/Models/TagEntity.swift b/myApp/TallyFlow/IOS/Domain/Models/TagEntity.swift new file mode 100644 index 0000000..5530435 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Models/TagEntity.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftData + +@Model +final class TagEntity { + var id: UUID + var name: String + var colorHex: String + var createdAt: Date + var sortOrder: Int + + var parent: TagEntity? + + @Relationship(deleteRule: .cascade, inverse: \TagEntity.parent) + var subTags: [TagEntity] = [] + + // Inverse is declared on TaskItem.tags + var tasks: [TaskItem] = [] + + var isSubTag: Bool { parent != nil } + + init( + id: UUID = UUID(), + name: String, + colorHex: String = "#52A878", + createdAt: Date = .now, + sortOrder: Int = 0 + ) { + self.id = id + self.name = name + self.colorHex = colorHex + self.createdAt = createdAt + self.sortOrder = sortOrder + } +} diff --git a/myApp/TallyFlow/IOS/Domain/Models/TaskItem.swift b/myApp/TallyFlow/IOS/Domain/Models/TaskItem.swift new file mode 100644 index 0000000..a881a91 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Models/TaskItem.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +enum TaskType: String, Codable, CaseIterable { + case timer + case counter +} + +@Model +final class TaskItem { + var id: UUID + var name: String + var icon: String + var taskType: TaskType + var createdAt: Date + var isArchived: Bool + var sortOrder: Int + + @Relationship(deleteRule: .nullify, inverse: \TagEntity.tasks) + var tags: [TagEntity] = [] + + @Relationship(deleteRule: .cascade, inverse: \TrackingRecord.task) + var trackingRecords: [TrackingRecord] = [] + + init( + id: UUID = UUID(), + name: String, + icon: String = "star", + taskType: TaskType = .timer, + createdAt: Date = .now, + isArchived: Bool = false, + sortOrder: Int = 0 + ) { + self.id = id + self.name = name + self.icon = icon + self.taskType = taskType + self.createdAt = createdAt + self.isArchived = isArchived + self.sortOrder = sortOrder + } +} diff --git a/myApp/TallyFlow/IOS/Domain/Models/TrackingRecord.swift b/myApp/TallyFlow/IOS/Domain/Models/TrackingRecord.swift new file mode 100644 index 0000000..a70d026 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Models/TrackingRecord.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftData + +enum TrackingRecordType: String, Codable { + case timerSession + case countLog +} + +@Model +final class TrackingRecord { + var id: UUID + var recordType: TrackingRecordType + var startTime: Date? + var endTime: Date? + var timestamp: Date? + var duration: TimeInterval + var createdAt: Date + + // Inverse is declared on TaskItem.trackingRecords + var task: TaskItem? + + var isRunning: Bool { recordType == .timerSession && startTime != nil && endTime == nil } + + init( + id: UUID = UUID(), + task: TaskItem? = nil, + recordType: TrackingRecordType, + startTime: Date? = nil, + endTime: Date? = nil, + timestamp: Date? = nil, + duration: TimeInterval = 0, + createdAt: Date = .now + ) { + self.id = id + self.task = task + self.recordType = recordType + self.startTime = startTime + self.endTime = endTime + self.timestamp = timestamp + self.duration = duration + self.createdAt = createdAt + } +} diff --git a/myApp/TallyFlow/IOS/Domain/Services/CalendarBoundaryService.swift b/myApp/TallyFlow/IOS/Domain/Services/CalendarBoundaryService.swift new file mode 100644 index 0000000..21a19a0 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Services/CalendarBoundaryService.swift @@ -0,0 +1,46 @@ +import Foundation + +final class CalendarBoundaryService { + private let preferences: AppPreferences + + init(preferences: AppPreferences) { + self.preferences = preferences + } + + private var calendar: Calendar { + var cal = Calendar.current + cal.firstWeekday = preferences.weekStartDay + return cal + } + + func customDayStart(for date: Date) -> Date { + // TODO: apply dayStartHour / dayStartMinute boundary shift in milestone 2 + var comps = calendar.dateComponents([.year, .month, .day], from: date) + comps.hour = preferences.dayStartHour + comps.minute = preferences.dayStartMinute + comps.second = 0 + return calendar.date(from: comps) ?? date + } + + func dayRange(for date: Date) -> DateInterval { + let start = customDayStart(for: date) + let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start + return DateInterval(start: start, end: end) + } + + func weekRange(for date: Date) -> DateInterval { + // TODO: align to custom day-start boundary in milestone 2 + guard let interval = calendar.dateInterval(of: .weekOfYear, for: date) else { + return DateInterval(start: date, duration: 7 * 86_400) + } + return interval + } + + func monthRange(for date: Date) -> DateInterval { + // TODO: align to custom day-start boundary in milestone 2 + guard let interval = calendar.dateInterval(of: .month, for: date) else { + return DateInterval(start: date, duration: 30 * 86_400) + } + return interval + } +} diff --git a/myApp/TallyFlow/IOS/Domain/Services/TrackingEngine.swift b/myApp/TallyFlow/IOS/Domain/Services/TrackingEngine.swift new file mode 100644 index 0000000..ec09f92 --- /dev/null +++ b/myApp/TallyFlow/IOS/Domain/Services/TrackingEngine.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftData + +@MainActor +@Observable +final class TrackingEngine { + private(set) var activeSessions: [UUID: Date] = [:] + private(set) var elapsedTimes: [UUID: TimeInterval] = [:] + + private var tickTask: Task? + + init() { + tickTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + self.tick() + } + } + } + + private func tick() { + let now = Date.now + for (id, start) in activeSessions { + elapsedTimes[id] = now.timeIntervalSince(start) + } + } + + func isRunning(_ task: TaskItem) -> Bool { + activeSessions[task.id] != nil + } + + func elapsed(for task: TaskItem) -> TimeInterval { + elapsedTimes[task.id] ?? 0 + } + + func startTimer(for task: TaskItem, context: ModelContext) { + guard !isRunning(task) else { return } + let now = Date.now + let record = TrackingRecord(task: task, recordType: .timerSession, startTime: now) + context.insert(record) + task.trackingRecords.append(record) + activeSessions[task.id] = now + elapsedTimes[task.id] = 0 + } + + func stopTimer(for task: TaskItem, context: ModelContext) { + guard let startTime = activeSessions[task.id] else { return } + let now = Date.now + if let record = task.trackingRecords.first(where: { $0.isRunning }) { + record.endTime = now + record.duration = now.timeIntervalSince(startTime) + } + activeSessions.removeValue(forKey: task.id) + elapsedTimes.removeValue(forKey: task.id) + } + + func incrementCounter(for task: TaskItem, context: ModelContext) { + let record = TrackingRecord(task: task, recordType: .countLog, timestamp: .now) + context.insert(record) + task.trackingRecords.append(record) + } +} diff --git a/myApp/TallyFlow/IOS/Features/Goals/GoalsTabView.swift b/myApp/TallyFlow/IOS/Features/Goals/GoalsTabView.swift new file mode 100644 index 0000000..6628e0b --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Goals/GoalsTabView.swift @@ -0,0 +1,248 @@ +import SwiftUI +import SwiftData + +struct GoalsTabView: View { + @Query(sort: \GoalEntity.createdAt) private var goals: [GoalEntity] + @State private var showAddSheet = false + + var body: some View { + NavigationStack { + Group { + if goals.isEmpty { + EmptyStateView( + icon: "target", + message: String(localized: "goals.empty") + ) + } else { + List(goals) { goal in + GoalRowView(goal: goal) + } + .scrollContentBackground(.hidden) + } + } + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.goals")) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + } + .tint(Color.tfPrimary) + } + } + .sheet(isPresented: $showAddSheet) { + AddGoalSheet() + } + } + } +} + +// MARK: – Goal row + +private struct GoalRowView: View { + let goal: GoalEntity + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(goal.title) + .foregroundStyle(Color.tfOnBackground) + HStack(spacing: 4) { + Text(goal.period.localizedName) + Text("·") + Text(targetDescription) + } + .font(.caption) + .foregroundStyle(Color.tfMuted) + } + Spacer() + Text(goal.isActive + ? String(localized: "goal.active") + : String(localized: "goal.inactive")) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + goal.isActive + ? Color.tfPrimary.opacity(0.15) + : Color.tfMuted.opacity(0.15) + ) + .foregroundStyle(goal.isActive ? Color.tfPrimary : Color.tfMuted) + .clipShape(Capsule()) + } + .padding(.vertical, 4) + } + + private var targetDescription: String { + switch goal.targetType { + case .duration(let min, let max): + if let max { + return "\(min.hhmmss) – \(max.hhmmss)" + } + return min.hhmmss + case .count(let min, let max): + if let max { return "\(min) – \(max)" } + return "\(min)" + } + } +} + +// MARK: – Add goal sheet + +private struct AddGoalSheet: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + + @Query(sort: \TaskItem.sortOrder) private var allTasks: [TaskItem] + @Query(sort: \TagEntity.sortOrder) private var allTags: [TagEntity] + + @State private var title = "" + @State private var selectedTaskID: UUID? = nil + @State private var selectedTagID: UUID? = nil + @State private var period: GoalPeriod = .daily + @State private var unit: GoalUnit = .seconds + @State private var targetMin: Double = 1800 + @State private var hasMax = false + @State private var targetMaxValue: Double = 3600 + + private var activeTasks: [TaskItem] { allTasks.filter { !$0.isArchived } } + private var canSave: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + NavigationStack { + Form { + Section(String(localized: "goal.form.section.title")) { + TextField(String(localized: "goal.form.title.placeholder"), text: $title) + } + + Section(String(localized: "goal.form.section.scope")) { + Picker(String(localized: "goal.form.task"), selection: $selectedTaskID) { + Text(String(localized: "goal.form.none")).tag(Optional.none) + ForEach(activeTasks) { task in + Label(task.name, systemImage: task.icon).tag(Optional(task.id)) + } + } + Picker(String(localized: "goal.form.tag"), selection: $selectedTagID) { + Text(String(localized: "goal.form.none")).tag(Optional.none) + ForEach(allTags) { tag in + Text(tag.name).tag(Optional(tag.id)) + } + } + } + + Section(String(localized: "goal.form.section.period")) { + Picker("", selection: $period) { + ForEach(GoalPeriod.allCases, id: \.self) { p in + Text(p.localizedName).tag(p) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + Section(String(localized: "goal.form.section.target")) { + Picker("", selection: $unit) { + Text(String(localized: "goal.unit.duration")).tag(GoalUnit.seconds) + Text(String(localized: "goal.unit.count")).tag(GoalUnit.count) + } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: unit) { _, newUnit in + targetMin = newUnit == .seconds ? 1800 : 1 + targetMaxValue = newUnit == .seconds ? 3600 : 5 + } + + minValueStepper + + Toggle(String(localized: "goal.form.has_max"), isOn: $hasMax) + + if hasMax { + maxValueStepper + } + } + + Section { + Toggle(String(localized: "goal.active"), isOn: .constant(true)) + } + } + .navigationTitle(String(localized: "goal.add.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "action.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "action.save")) { save() } + .disabled(!canSave) + .tint(Color.tfPrimary) + } + } + } + } + + @ViewBuilder + private var minValueStepper: some View { + if unit == .seconds { + Stepper( + value: $targetMin, + in: 60...86400, + step: 300 + ) { + Text("\(String(localized: "goal.form.min")): \(targetMin.hhmmss)") + .font(.subheadline.monospacedDigit()) + } + } else { + Stepper( + value: $targetMin, + in: 1...9999, + step: 1 + ) { + Text("\(String(localized: "goal.form.min")): \(Int(targetMin))") + .font(.subheadline) + } + } + } + + @ViewBuilder + private var maxValueStepper: some View { + if unit == .seconds { + Stepper( + value: $targetMaxValue, + in: targetMin...86400 * 7, + step: 300 + ) { + Text("\(String(localized: "goal.form.max")): \(targetMaxValue.hhmmss)") + .font(.subheadline.monospacedDigit()) + } + } else { + Stepper( + value: $targetMaxValue, + in: targetMin...9999, + step: 1 + ) { + Text("\(String(localized: "goal.form.max")): \(Int(targetMaxValue))") + .font(.subheadline) + } + } + } + + private func save() { + let goal = GoalEntity( + title: title.trimmingCharacters(in: .whitespaces), + targetValue: targetMin, + targetMax: hasMax ? targetMaxValue : nil, + unit: unit, + period: period + ) + goal.task = activeTasks.first { $0.id == selectedTaskID } + goal.tag = allTags.first { $0.id == selectedTagID } + context.insert(goal) + dismiss() + } +} + +#Preview { + GoalsTabView() + .modelContainer(for: [GoalEntity.self, TaskItem.self, TagEntity.self], inMemory: true) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Features/Main/MainTabView.swift b/myApp/TallyFlow/IOS/Features/Main/MainTabView.swift new file mode 100644 index 0000000..3759252 --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Main/MainTabView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import SwiftData + +// Module-level so GoalsTabView (same module) can reuse this formatter. +extension TimeInterval { + var hhmmss: String { + let s = Int(self) + return String(format: "%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60) + } +} + +struct MainTabView: View { + @Query(sort: \TaskItem.sortOrder) private var tasks: [TaskItem] + @State private var engine = TrackingEngine() + + private var activeTasks: [TaskItem] { tasks.filter { !$0.isArchived } } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + SummaryCardsSection() + if !activeTasks.isEmpty { + TaskListSection(tasks: activeTasks) + } + LayoutEditEntryBanner() + } + .padding() + } + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.main")) + } + .environment(engine) + } +} + +// MARK: – Summary cards + +private struct SummaryCardsSection: View { + var body: some View { + VStack(spacing: 12) { + SummaryCard(period: String(localized: "main.period.today"), icon: "sun.max.fill") + SummaryCard(period: String(localized: "main.period.week"), icon: "calendar.badge.clock") + SummaryCard(period: String(localized: "main.period.month"), icon: "calendar") + } + } +} + +private struct SummaryCard: View { + let period: String + let icon: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(Color.tfPrimary) + .frame(width: 28) + Text(period) + .font(.headline) + .foregroundStyle(Color.tfOnBackground) + Spacer() + Text("—") + .font(.subheadline) + .foregroundStyle(Color.tfMuted) + } + .padding() + .background(Color.tfSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.tfSeparator, radius: 2, x: 0, y: 1) + } +} + +// MARK: – Task list + +private struct TaskListSection: View { + let tasks: [TaskItem] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("작업") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.tfMuted) + .textCase(.uppercase) + ForEach(tasks) { task in + TaskActionCard(task: task) + } + } + } +} + +private struct TaskActionCard: View { + @Environment(\.modelContext) private var context + @Environment(TrackingEngine.self) private var engine + let task: TaskItem + + @State private var counterPulse = false + + private var primaryColor: Color { + task.tags.first.map { Color(hex: $0.colorHex) } ?? Color.tfPrimary + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: task.icon) + .font(.title3) + .foregroundStyle(primaryColor) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(task.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.tfOnBackground) + Text(statusLabel) + .font(.caption.monospacedDigit()) + .foregroundStyle(engine.isRunning(task) ? primaryColor : Color.tfMuted) + .contentTransition(.numericText()) + .animation(.linear(duration: 0.2), value: statusLabel) + } + + Spacer() + + actionButton + } + .padding() + .background(Color.tfSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.tfSeparator, radius: 2, x: 0, y: 1) + } + + private var statusLabel: String { + switch task.taskType { + case .timer: + return engine.isRunning(task) + ? engine.elapsed(for: task).hhmmss + : String(localized: "task.type.timer") + case .counter: + return String(localized: "task.type.counter") + } + } + + @ViewBuilder + private var actionButton: some View { + switch task.taskType { + case .timer: + let running = engine.isRunning(task) + Button { toggleTimer() } label: { + Image(systemName: running ? "stop.fill" : "play.fill") + .font(.title3) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(running ? Color.red : primaryColor) + .clipShape(Circle()) + } + .buttonStyle(.plain) + case .counter: + Button { logCount() } label: { + Image(systemName: "plus") + .font(.title3) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(primaryColor) + .clipShape(Circle()) + .scaleEffect(counterPulse ? 1.3 : 1.0) + } + .buttonStyle(.plain) + } + } + + private func toggleTimer() { + if engine.isRunning(task) { + engine.stopTimer(for: task, context: context) + } else { + engine.startTimer(for: task, context: context) + } + } + + private func logCount() { + engine.incrementCounter(for: task, context: context) + withAnimation(.spring(duration: 0.15, bounce: 0.6)) { counterPulse = true } + Task { + try? await Task.sleep(for: .milliseconds(350)) + withAnimation(.spring(duration: 0.2)) { counterPulse = false } + } + } +} + +// MARK: – Layout-edit mode entry + +private struct LayoutEditEntryBanner: View { + var body: some View { + HStack(spacing: 10) { + Image(systemName: "square.grid.2x2") + .foregroundStyle(Color.tfAccent) + Text(String(localized: "main.layout_edit.placeholder")) + .font(.footnote) + .foregroundStyle(Color.tfMuted) + Spacer() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.tfMuted) + } + .padding() + .background(Color.tfSurface) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.tfSeparator, lineWidth: 1) + ) + } +} + +#Preview { + MainTabView() + .modelContainer(for: [TaskItem.self, TagEntity.self, TrackingRecord.self], inMemory: true) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Features/Settings/SettingsTabView.swift b/myApp/TallyFlow/IOS/Features/Settings/SettingsTabView.swift new file mode 100644 index 0000000..982e865 --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Settings/SettingsTabView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct SettingsTabView: View { + @Environment(AppPreferences.self) private var preferences + + var body: some View { + @Bindable var prefs = preferences + NavigationStack { + Form { + Section(String(localized: "settings.section.appearance")) { + Picker(String(localized: "settings.theme"), selection: $prefs.themeMode) { + ForEach(ThemeMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } + } + } + + Section(String(localized: "settings.section.language")) { + Picker(String(localized: "settings.language"), selection: $prefs.languageCode) { + Text("한국어").tag("ko") + Text("English").tag("en") + } + } + + Section(String(localized: "settings.section.calendar")) { + Picker(String(localized: "settings.week_start"), selection: $prefs.weekStartDay) { + Text(String(localized: "weekday.sunday")).tag(1) + Text(String(localized: "weekday.monday")).tag(2) + Text(String(localized: "weekday.saturday")).tag(7) + } + Stepper(value: $prefs.dayStartHour, in: 0...23) { + HStack { + Text(String(localized: "settings.day_start")) + Spacer() + Text(String(format: "%02d:00", prefs.dayStartHour)) + .foregroundStyle(Color.tfMuted) + } + } + } + + Section { + NavigationLink { + // TODO: premium paywall in milestone 3+ + Text(String(localized: "premium.coming_soon")) + .foregroundStyle(Color.tfMuted) + } label: { + Label(String(localized: "settings.premium"), systemImage: "star.fill") + .foregroundStyle(Color.tfAccent) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.settings")) + } + } +} + +#Preview { + SettingsTabView() + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Features/Stats/StatsTabView.swift b/myApp/TallyFlow/IOS/Features/Stats/StatsTabView.swift new file mode 100644 index 0000000..7e15450 --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Stats/StatsTabView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct StatsTabView: View { + var body: some View { + NavigationStack { + EmptyStateView( + icon: "chart.bar", + message: String(localized: "stats.empty") + ) + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.stats")) + } + } +} + +#Preview { + StatsTabView() + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Features/Tags/TagsTabView.swift b/myApp/TallyFlow/IOS/Features/Tags/TagsTabView.swift new file mode 100644 index 0000000..8b86719 --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Tags/TagsTabView.swift @@ -0,0 +1,211 @@ +import SwiftUI +import SwiftData + +struct TagsTabView: View { + @Environment(\.modelContext) private var context + @Query(sort: \TagEntity.sortOrder) private var allTags: [TagEntity] + @State private var showAddSheet = false + @State private var editTarget: TagEntity? + + private var superTags: [TagEntity] { allTags.filter { $0.parent == nil } } + + var body: some View { + NavigationStack { + Group { + if superTags.isEmpty { + EmptyStateView( + icon: "tag", + message: String(localized: "tags.empty") + ) + } else { + List { + ForEach(superTags) { tag in + TagRowView(tag: tag, onEdit: { editTarget = $0 }) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + context.delete(tag) + } label: { + Label("삭제", systemImage: "trash") + } + Button { + editTarget = tag + } label: { + Label("편집", systemImage: "pencil") + } + .tint(.blue) + } + } + } + .scrollContentBackground(.hidden) + } + } + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.tags")) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + } + .tint(Color.tfPrimary) + } + } + .sheet(isPresented: $showAddSheet) { + TagFormView(superTags: superTags) + } + .sheet(item: $editTarget) { tag in + TagFormView(existing: tag, superTags: superTags) + } + } + } +} + +// MARK: – Row + +private struct TagRowView: View { + @Environment(\.modelContext) private var context + let tag: TagEntity + let onEdit: (TagEntity) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: tag.colorHex)) + .frame(width: 10, height: 10) + Text(tag.name) + .foregroundStyle(Color.tfOnBackground) + Spacer() + if !tag.subTags.isEmpty { + Text(String(format: String(localized: "tags.subtag_count"), tag.subTags.count)) + .font(.caption) + .foregroundStyle(Color.tfMuted) + } + } + ForEach(tag.subTags) { sub in + HStack(spacing: 8) { + Spacer().frame(width: 14) + Circle() + .fill(Color(hex: sub.colorHex)) + .frame(width: 8, height: 8) + Text(sub.name) + .font(.subheadline) + .foregroundStyle(Color.tfMuted) + Spacer() + } + .contentShape(Rectangle()) + .contextMenu { + Button("편집") { onEdit(sub) } + Button("삭제", role: .destructive) { context.delete(sub) } + } + } + } + .padding(.vertical, 4) + } +} + +// MARK: – Form + +struct TagFormView: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + + var existing: TagEntity? = nil + var superTags: [TagEntity] + + @State private var name = "" + @State private var selectedColor = "#52A878" + @State private var selectedParent: TagEntity? = nil + + private let colorPresets = [ + "#52A878", "#1E5C40", "#4CBF80", + "#C99728", "#E8B84B", "#D64545", + "#4A90D9", "#9B59B6", "#E67E22", + "#1ABC9C" + ] + + private var isEditing: Bool { existing != nil } + private var isSaveDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty } + private var availableParents: [TagEntity] { + superTags.filter { $0.id != existing?.id } + } + + var body: some View { + NavigationStack { + Form { + Section("이름") { + TextField("태그 이름", text: $name) + } + + Section("색상") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 12) { + ForEach(colorPresets, id: \.self) { hex in + Circle() + .fill(Color(hex: hex)) + .frame(width: 36, height: 36) + .overlay { + if hex == selectedColor { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + } + } + .onTapGesture { selectedColor = hex } + } + } + .padding(.vertical, 4) + } + + Section("상위 태그") { + Picker("상위 태그", selection: $selectedParent) { + Text("없음 (슈퍼 태그)").tag(nil as TagEntity?) + ForEach(availableParents) { tag in + Text(tag.name).tag(tag as TagEntity?) + } + } + .pickerStyle(.menu) + } + } + .navigationTitle(isEditing ? "태그 편집" : "태그 추가") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { save() } + .disabled(isSaveDisabled) + } + } + .onAppear { loadExisting() } + } + } + + private func loadExisting() { + guard let tag = existing else { return } + name = tag.name + selectedColor = tag.colorHex + selectedParent = tag.parent + } + + private func save() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + if let tag = existing { + tag.name = trimmed + tag.colorHex = selectedColor + tag.parent = selectedParent + } else { + let newTag = TagEntity(name: trimmed, colorHex: selectedColor) + newTag.parent = selectedParent + context.insert(newTag) + } + dismiss() + } +} + +#Preview { + TagsTabView() + .modelContainer(for: [TagEntity.self, TaskItem.self], inMemory: true) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Features/Tasks/TasksTabView.swift b/myApp/TallyFlow/IOS/Features/Tasks/TasksTabView.swift new file mode 100644 index 0000000..7418cd0 --- /dev/null +++ b/myApp/TallyFlow/IOS/Features/Tasks/TasksTabView.swift @@ -0,0 +1,260 @@ +import SwiftUI +import SwiftData + +struct TasksTabView: View { + @Environment(\.modelContext) private var context + @Query(sort: \TaskItem.sortOrder) private var tasks: [TaskItem] + @State private var showAddSheet = false + @State private var editTarget: TaskItem? + + private var activeTasks: [TaskItem] { tasks.filter { !$0.isArchived } } + + var body: some View { + NavigationStack { + Group { + if activeTasks.isEmpty { + EmptyStateView( + icon: "checkmark.circle", + message: String(localized: "tasks.empty") + ) + } else { + List { + ForEach(activeTasks) { task in + TaskRowView(task: task) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + context.delete(task) + } label: { + Label("삭제", systemImage: "trash") + } + Button { + editTarget = task + } label: { + Label("편집", systemImage: "pencil") + } + .tint(.blue) + } + } + } + .scrollContentBackground(.hidden) + } + } + .background(Color.tfBackground) + .navigationTitle(String(localized: "tab.tasks")) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + } + .tint(Color.tfPrimary) + } + } + .sheet(isPresented: $showAddSheet) { + TaskFormView() + } + .sheet(item: $editTarget) { task in + TaskFormView(existing: task) + } + } + } +} + +// MARK: – Row + +private struct TaskRowView: View { + let task: TaskItem + + private var primaryColor: Color { + task.tags.first.map { Color(hex: $0.colorHex) } ?? Color.tfPrimary + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: task.icon) + .foregroundStyle(primaryColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(task.name) + .foregroundStyle(Color.tfOnBackground) + HStack(spacing: 4) { + Text(task.taskType == .timer ? "타이머" : "카운터") + .font(.caption) + .foregroundStyle(Color.tfMuted) + if let firstTag = task.tags.first { + Text("·") + .font(.caption) + .foregroundStyle(Color.tfMuted) + Text(firstTag.name) + .font(.caption) + .foregroundStyle(Color(hex: firstTag.colorHex)) + } + } + } + Spacer() + Image(systemName: task.taskType == .timer ? "timer" : "number") + .foregroundStyle(primaryColor) + } + .padding(.vertical, 4) + } +} + +// MARK: – Form + +struct TaskFormView: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + @Query(sort: \TagEntity.sortOrder) private var allTags: [TagEntity] + + var existing: TaskItem? = nil + + @State private var name = "" + @State private var icon = "star" + @State private var taskType: TaskType = .timer + @State private var selectedTagIDs: Set = [] + + private let iconPresets = [ + "star", "heart", "bolt", "flame", + "book.fill", "pencil", "laptopcomputer", "dumbbell.fill", + "music.note", "camera.fill", "gamecontroller.fill", "fork.knife", + "figure.walk", "bicycle", "car.fill", "airplane", + "moon.stars.fill", "sun.max.fill", "drop.fill", "leaf.fill" + ] + + private var isEditing: Bool { existing != nil } + private var isSaveDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + NavigationStack { + Form { + Section("이름") { + TextField("작업 이름", text: $name) + } + + Section("아이콘") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 12) { + ForEach(iconPresets, id: \.self) { sym in + Image(systemName: sym) + .font(.title2) + .foregroundStyle(sym == icon ? Color.tfPrimary : Color.tfMuted) + .frame(width: 44, height: 44) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(sym == icon ? Color.tfPrimary.opacity(0.15) : Color.clear) + ) + .onTapGesture { icon = sym } + } + } + .padding(.vertical, 4) + } + + Section("유형") { + Picker("유형", selection: $taskType) { + Text("타이머").tag(TaskType.timer) + Text("카운터").tag(TaskType.counter) + } + .pickerStyle(.segmented) + } + + if !allTags.isEmpty { + Section("태그") { + ForEach(allTags) { tag in + TagSelectionRow( + tag: tag, + isSelected: selectedTagIDs.contains(tag.id), + onToggle: { toggleTag(tag) } + ) + } + } + } + } + .navigationTitle(isEditing ? "작업 편집" : "작업 추가") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { save() } + .disabled(isSaveDisabled) + } + } + .onAppear { loadExisting() } + } + } + + private func loadExisting() { + guard let task = existing else { return } + name = task.name + icon = task.icon + taskType = task.taskType + selectedTagIDs = Set(task.tags.map { $0.id }) + } + + private func toggleTag(_ tag: TagEntity) { + if selectedTagIDs.contains(tag.id) { + selectedTagIDs.remove(tag.id) + } else { + selectedTagIDs.insert(tag.id) + // Selecting a sub tag implicitly includes its parent super tag + if let parent = tag.parent { + selectedTagIDs.insert(parent.id) + } + } + } + + private func save() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + let selectedTags = allTags.filter { selectedTagIDs.contains($0.id) } + + if let task = existing { + task.name = trimmed + task.icon = icon + task.taskType = taskType + task.tags = selectedTags + } else { + let newTask = TaskItem(name: trimmed, icon: icon, taskType: taskType) + newTask.tags = selectedTags + context.insert(newTask) + } + dismiss() + } +} + +private struct TagSelectionRow: View { + let tag: TagEntity + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 8) { + if tag.isSubTag { + Spacer().frame(width: 14) + } + Circle() + .fill(Color(hex: tag.colorHex)) + .frame(width: 10, height: 10) + Text(tag.name) + .foregroundStyle(Color.tfOnBackground) + if tag.isSubTag, let parent = tag.parent { + Text("(\(parent.name))") + .font(.caption) + .foregroundStyle(Color.tfMuted) + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(Color.tfPrimary) + } + } + .contentShape(Rectangle()) + .onTapGesture { onToggle() } + } +} + +#Preview { + TasksTabView() + .modelContainer(for: [TaskItem.self, TagEntity.self, TrackingRecord.self], inMemory: true) + .environment(AppPreferences()) +} diff --git a/myApp/TallyFlow/IOS/Item.swift b/myApp/TallyFlow/IOS/Item.swift new file mode 100644 index 0000000..c468542 --- /dev/null +++ b/myApp/TallyFlow/IOS/Item.swift @@ -0,0 +1,18 @@ +// +// Item.swift +// TallyFlow +// +// Created by 송예찬 on 6/26/26. +// + +import Foundation +import SwiftData + +@Model +final class Item { + var timestamp: Date + + init(timestamp: Date) { + self.timestamp = timestamp + } +} diff --git a/myApp/TallyFlow/IOS/Localizable.xcstrings b/myApp/TallyFlow/IOS/Localizable.xcstrings new file mode 100644 index 0000000..42f1b95 --- /dev/null +++ b/myApp/TallyFlow/IOS/Localizable.xcstrings @@ -0,0 +1,870 @@ +{ + "sourceLanguage" : "ko", + "strings" : { + "" : { + + }, + "—" : { + "comment" : "A separator between the summary card and the task action section.", + "isCommentAutoGenerated" : true + }, + "·" : { + "comment" : "A period used to separate two items.", + "isCommentAutoGenerated" : true + }, + "(%@)" : { + "comment" : "A tag that shows the name of the parent tag in parentheses.", + "isCommentAutoGenerated" : true + }, + "%@: %@" : { + "comment" : "A stepper that lets the user select a minimum value for a goal. The value is displayed in a formatted time string. The argument is the string “goal.form.min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@" + } + } + } + }, + "%@: %lld" : { + "comment" : "A label displaying the minimum value for a goal. The argument is the string “goal.form.min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$lld" + } + } + } + }, + "action.cancel" : { + "comment" : "The label of a button that cancels an action.", + "isCommentAutoGenerated" : true + }, + "action.save" : { + "comment" : "The label of a button that saves the goal.", + "isCommentAutoGenerated" : true + }, + "English" : { + "comment" : "A language option in the settings.", + "isCommentAutoGenerated" : true + }, + "goal.active" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성" + } + } + } + }, + "goal.add.title" : { + "comment" : "The title of the add goal sheet.", + "isCommentAutoGenerated" : true + }, + "goal.form.has_max" : { + "comment" : "A toggle that allows users to specify a maximum value for a goal.", + "isCommentAutoGenerated" : true + }, + "goal.form.max" : { + "comment" : "A stepper that lets the user select a maximum value for a goal. The value is displayed in a formatted time string.", + "isCommentAutoGenerated" : true + }, + "goal.form.min" : { + "comment" : "A label displaying the minimum value of a goal. The value is formatted as a time.", + "isCommentAutoGenerated" : true + }, + "goal.form.none" : { + "comment" : "A placeholder for a picker that allows the user to select a task.", + "isCommentAutoGenerated" : true + }, + "goal.form.section.period" : { + "comment" : "A section in the goal form where the user can select the period for the goal.", + "isCommentAutoGenerated" : true + }, + "goal.form.section.scope" : { + "comment" : "A section in the goal form that allows the user to select a task and a tag.", + "isCommentAutoGenerated" : true + }, + "goal.form.section.target" : { + "comment" : "A section in the goal form that allows the user to set the target value.", + "isCommentAutoGenerated" : true + }, + "goal.form.section.title" : { + "comment" : "A section in the goal form.", + "isCommentAutoGenerated" : true + }, + "goal.form.tag" : { + "comment" : "A label for the tag picker in the goal form.", + "isCommentAutoGenerated" : true + }, + "goal.form.task" : { + "comment" : "A label for the task picker in the goal form.", + "isCommentAutoGenerated" : true + }, + "goal.form.title.placeholder" : { + "comment" : "A placeholder text for the goal's title.", + "isCommentAutoGenerated" : true + }, + "goal.inactive" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inactive" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성" + } + } + } + }, + "goal.period.daily" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매일" + } + } + } + }, + "goal.period.monthly" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monthly" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매월" + } + } + } + }, + "goal.period.weekly" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매주" + } + } + } + }, + "goal.unit.count" : { + "comment" : "A unit of measurement for a goal.", + "isCommentAutoGenerated" : true + }, + "goal.unit.duration" : { + "comment" : "A unit of time for a goal.", + "isCommentAutoGenerated" : true + }, + "goals.empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No goals yet" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표가 없습니다" + } + } + } + }, + "main.actions.log_count" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log Count" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "횟수 기록" + } + } + } + }, + "main.actions.start_timer" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Timer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 시작" + } + } + } + }, + "main.actions.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick Actions" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빠른 실행" + } + } + } + }, + "main.layout_edit.placeholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Layout (coming soon)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레이아웃 편집 (준비 중)" + } + } + } + }, + "main.period.month" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Month" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번 달" + } + } + } + }, + "main.period.today" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘" + } + } + } + }, + "main.period.week" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Week" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번 주" + } + } + } + }, + "premium.coming_soon" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coming Soon" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "준비 중입니다" + } + } + } + }, + "settings.day_start" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day Start Time" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하루 시작 시간" + } + } + } + }, + "settings.language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "언어" + } + } + } + }, + "settings.premium" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Premium" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리미엄" + } + } + } + }, + "settings.section.appearance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외관" + } + } + } + }, + "settings.section.calendar" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더" + } + } + } + }, + "settings.section.language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "언어" + } + } + } + }, + "settings.theme" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theme" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테마" + } + } + } + }, + "settings.week_start" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Week Start Day" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주 시작일" + } + } + } + }, + "splash.tagline" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track. Grow." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록하고, 성장하세요" + } + } + } + }, + "stats.empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No statistics yet" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 통계가 없습니다" + } + } + } + }, + "tab.goals" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goals" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표" + } + } + } + }, + "tab.main" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메인" + } + } + } + }, + "tab.settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + } + } + }, + "tab.stats" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stats" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "통계" + } + } + } + }, + "tab.tags" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tags" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그" + } + } + } + }, + "tab.tasks" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasks" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "할일" + } + } + } + }, + "tags.empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tags yet" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그가 없습니다" + } + } + } + }, + "tags.subtag_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d subtags" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하위 태그 %d개" + } + } + } + }, + "TallyFlow" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true + }, + "task.type.count" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Count" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "횟수" + } + } + } + }, + "task.type.counter" : { + "comment" : "A label for a task with a counter.", + "isCommentAutoGenerated" : true + }, + "task.type.time" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간" + } + } + } + }, + "task.type.timer" : { + "comment" : "A label for a timer task.", + "isCommentAutoGenerated" : true + }, + "tasks.empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tasks yet" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업이 없습니다" + } + } + } + }, + "theme.dark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다크" + } + } + } + }, + "theme.light" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이트" + } + } + } + }, + "theme.system" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템" + } + } + } + }, + "weekday.monday" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monday" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "월요일" + } + } + } + }, + "weekday.saturday" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saturday" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "토요일" + } + } + } + }, + "weekday.sunday" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunday" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일요일" + } + } + } + }, + "삭제" : { + "comment" : "A button that deletes a tag.", + "isCommentAutoGenerated" : true + }, + "상위 태그" : { + "comment" : "A section for selecting a parent tag.", + "isCommentAutoGenerated" : true + }, + "색상" : { + "comment" : "A section for selecting a color for a tag.", + "isCommentAutoGenerated" : true + }, + "아이콘" : { + "comment" : "A section for selecting an icon for the task.", + "isCommentAutoGenerated" : true + }, + "없음 (슈퍼 태그)" : { + "comment" : "A placeholder for a tag that has no parent.", + "isCommentAutoGenerated" : true + }, + "유형" : { + "comment" : "A section for selecting the type of task.", + "isCommentAutoGenerated" : true + }, + "이름" : { + "comment" : "A label for the tag's name.", + "isCommentAutoGenerated" : true + }, + "작업" : { + "comment" : "A heading for a list of tasks.", + "isCommentAutoGenerated" : true + }, + "작업 이름" : { + "comment" : "A label for the task name field.", + "isCommentAutoGenerated" : true + }, + "작업 추가" : { + "comment" : "The title of the task creation screen.", + "isCommentAutoGenerated" : true + }, + "작업 편집" : { + "comment" : "The title of the screen for editing a task.", + "isCommentAutoGenerated" : true + }, + "저장" : { + "comment" : "A button that saves the current tag.", + "isCommentAutoGenerated" : true + }, + "취소" : { + "comment" : "A button that cancels the current action.", + "isCommentAutoGenerated" : true + }, + "카운터" : { + "comment" : "A label describing a task type", + "isCommentAutoGenerated" : true + }, + "타이머" : { + "comment" : "A label for a task type that is a timer.", + "isCommentAutoGenerated" : true + }, + "태그" : { + "comment" : "A section for selecting tags for a task.", + "isCommentAutoGenerated" : true + }, + "태그 이름" : { + "comment" : "A label for the name of a tag.", + "isCommentAutoGenerated" : true + }, + "태그 추가" : { + "comment" : "A title for a view that lets the user add a tag.", + "isCommentAutoGenerated" : true + }, + "태그 편집" : { + "comment" : "The title of a screen for editing a tag.", + "isCommentAutoGenerated" : true + }, + "편집" : { + "comment" : "A button that opens a sheet for editing a tag.", + "isCommentAutoGenerated" : true + }, + "한국어" : { + "comment" : "Korean for \"한국어\".", + "isCommentAutoGenerated" : true + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/myApp/TallyFlow/IOS/Settings/AppPreferences.swift b/myApp/TallyFlow/IOS/Settings/AppPreferences.swift new file mode 100644 index 0000000..291ded0 --- /dev/null +++ b/myApp/TallyFlow/IOS/Settings/AppPreferences.swift @@ -0,0 +1,26 @@ +import Foundation +import Observation + +@Observable +final class AppPreferences { + // Calendar.weekday constants: 1 = Sunday, 2 = Monday … 7 = Saturday + var themeMode: ThemeMode = ThemeMode(rawValue: UserDefaults.standard.string(forKey: "pref.themeMode") ?? "system") ?? .system { + didSet { UserDefaults.standard.set(themeMode.rawValue, forKey: "pref.themeMode") } + } + + var languageCode: String = UserDefaults.standard.string(forKey: "pref.languageCode") ?? "ko" { + didSet { UserDefaults.standard.set(languageCode, forKey: "pref.languageCode") } + } + + var weekStartDay: Int = (UserDefaults.standard.object(forKey: "pref.weekStartDay") as? Int) ?? 2 { + didSet { UserDefaults.standard.set(weekStartDay, forKey: "pref.weekStartDay") } + } + + var dayStartHour: Int = (UserDefaults.standard.object(forKey: "pref.dayStartHour") as? Int) ?? 0 { + didSet { UserDefaults.standard.set(dayStartHour, forKey: "pref.dayStartHour") } + } + + var dayStartMinute: Int = (UserDefaults.standard.object(forKey: "pref.dayStartMinute") as? Int) ?? 0 { + didSet { UserDefaults.standard.set(dayStartMinute, forKey: "pref.dayStartMinute") } + } +} diff --git a/myApp/TallyFlow/IOS/TallyFlowApp.swift b/myApp/TallyFlow/IOS/TallyFlowApp.swift new file mode 100644 index 0000000..023144e --- /dev/null +++ b/myApp/TallyFlow/IOS/TallyFlowApp.swift @@ -0,0 +1,37 @@ +// +// TallyFlowApp.swift +// TallyFlow +// +// Created by 송예찬 on 6/26/26. +// + +import SwiftUI +import SwiftData + +@main +struct TallyFlowApp: App { + private let preferences = AppPreferences() + + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + TaskItem.self, + TagEntity.self, + GoalEntity.self, + TrackingRecord.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(preferences) + } + .modelContainer(sharedModelContainer) + } +} diff --git a/myApp/TallyFlow/IOS/Theme/AppColors.swift b/myApp/TallyFlow/IOS/Theme/AppColors.swift new file mode 100644 index 0000000..e5708d6 --- /dev/null +++ b/myApp/TallyFlow/IOS/Theme/AppColors.swift @@ -0,0 +1,43 @@ +import SwiftUI +import UIKit + +// MARK: – Semantic adaptive color tokens + +extension Color { + // Brand Greens + static let tfPrimary = Color(light: Color(hex: "#1E5C40"), dark: Color(hex: "#4CBF80")) + static let tfSecondary = Color(light: Color(hex: "#52A878"), dark: Color(hex: "#2D7050")) + + // Brand Accent – Amber Gold + static let tfAccent = Color(light: Color(hex: "#C99728"), dark: Color(hex: "#E8B84B")) + + // Surfaces + static let tfBackground = Color(light: Color(hex: "#F5FAF7"), dark: Color(hex: "#0C100D")) + static let tfSurface = Color(light: Color(hex: "#FFFFFF"), dark: Color(hex: "#141D16")) + + // Text / Content + static let tfOnBackground = Color(light: Color(hex: "#112318"), dark: Color(hex: "#E8F0EB")) + static let tfMuted = Color(light: Color(hex: "#5E7A69"), dark: Color(hex: "#5A7B66")) + static let tfSeparator = Color(light: Color(hex: "#DCE8E1"), dark: Color(hex: "#1E2B22")) +} + +// MARK: – Color helpers + +extension Color { + init(light: Color, dark: Color) { + self.init(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + } + + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double( value & 0xFF) / 255 + ) + } +} diff --git a/myApp/TallyFlow/IOS/Theme/AppTheme.swift b/myApp/TallyFlow/IOS/Theme/AppTheme.swift new file mode 100644 index 0000000..9555eca --- /dev/null +++ b/myApp/TallyFlow/IOS/Theme/AppTheme.swift @@ -0,0 +1,39 @@ +import SwiftUI + +// MARK: – ThemeMode → ColorScheme + +extension ThemeMode { + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +// MARK: – Shared reusable views + +struct EmptyStateView: View { + let icon: String + let message: String + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(Color.tfMuted) + Text(message) + .font(.subheadline) + .foregroundStyle(Color.tfMuted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +#Preview("EmptyStateView") { + EmptyStateView(icon: "tag", message: "태그가 없습니다") + .background(Color.tfBackground) +} diff --git a/myApp/TallyFlow/IOS/Theme/ThemeMode.swift b/myApp/TallyFlow/IOS/Theme/ThemeMode.swift new file mode 100644 index 0000000..e43f709 --- /dev/null +++ b/myApp/TallyFlow/IOS/Theme/ThemeMode.swift @@ -0,0 +1,17 @@ +import Foundation + +enum ThemeMode: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .system: return String(localized: "theme.system") + case .light: return String(localized: "theme.light") + case .dark: return String(localized: "theme.dark") + } + } +} diff --git a/myApp/TallyFlow/TallyFlow.xcodeproj/project.pbxproj b/myApp/TallyFlow/TallyFlow.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e7cc968 --- /dev/null +++ b/myApp/TallyFlow/TallyFlow.xcodeproj/project.pbxproj @@ -0,0 +1,333 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 72C598532FED9ACF00D61864 /* TallyFlow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TallyFlow.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 72C598552FED9ACF00D61864 /* IOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = IOS; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 72C598502FED9ACF00D61864 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 72C5984A2FED9ACF00D61864 = { + isa = PBXGroup; + children = ( + 72C598552FED9ACF00D61864 /* IOS */, + 72C598542FED9ACF00D61864 /* Products */, + ); + sourceTree = ""; + }; + 72C598542FED9ACF00D61864 /* Products */ = { + isa = PBXGroup; + children = ( + 72C598532FED9ACF00D61864 /* TallyFlow.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 72C598522FED9ACF00D61864 /* TallyFlow */ = { + isa = PBXNativeTarget; + buildConfigurationList = 72C598602FED9AD100D61864 /* Build configuration list for PBXNativeTarget "TallyFlow" */; + buildPhases = ( + 72C5984F2FED9ACF00D61864 /* Sources */, + 72C598502FED9ACF00D61864 /* Frameworks */, + 72C598512FED9ACF00D61864 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 72C598552FED9ACF00D61864 /* IOS */, + ); + name = TallyFlow; + packageProductDependencies = ( + ); + productName = TallyFlow; + productReference = 72C598532FED9ACF00D61864 /* TallyFlow.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 72C5984B2FED9ACF00D61864 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 72C598522FED9ACF00D61864 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = 72C5984E2FED9ACF00D61864 /* Build configuration list for PBXProject "TallyFlow" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 72C5984A2FED9ACF00D61864; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 72C598542FED9ACF00D61864 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 72C598522FED9ACF00D61864 /* TallyFlow */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 72C598512FED9ACF00D61864 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 72C5984F2FED9ACF00D61864 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 72C5985E2FED9AD100D61864 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 72C5985F2FED9AD100D61864 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 72C598612FED9AD100D61864 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.TallyFlow; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 72C598622FED9AD100D61864 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.TallyFlow; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 72C5984E2FED9ACF00D61864 /* Build configuration list for PBXProject "TallyFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 72C5985E2FED9AD100D61864 /* Debug */, + 72C5985F2FED9AD100D61864 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 72C598602FED9AD100D61864 /* Build configuration list for PBXNativeTarget "TallyFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 72C598612FED9AD100D61864 /* Debug */, + 72C598622FED9AD100D61864 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 72C5984B2FED9ACF00D61864 /* Project object */; +} diff --git a/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..1117e77 Binary files /dev/null and b/myApp/TallyFlow/TallyFlow.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/TallyFlow/TallyFlow.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/TallyFlow/TallyFlow.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..4d7608d --- /dev/null +++ b/myApp/TallyFlow/TallyFlow.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + TallyFlow.xcscheme_^#shared#^_ + + orderHint + 0 + + + +