remove TallyFlow Project
This commit is contained in:
parent
b209199c2d
commit
1fe3386d55
@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@ -1,37 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
//
|
||||
// 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())
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
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<Void, Never>?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,248 +0,0 @@
|
||||
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<UUID>.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<UUID>.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())
|
||||
}
|
||||
@ -1,217 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,260 +0,0 @@
|
||||
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<UUID> = []
|
||||
|
||||
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())
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -1,870 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,333 +0,0 @@
|
||||
// !$*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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
72C598542FED9ACF00D61864 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72C598532FED9ACF00D61864 /* TallyFlow.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>TallyFlow.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Loading…
x
Reference in New Issue
Block a user