diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..00a18fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# LemonLimeTracker Project Guidelines + +## Core Rules & Constraints +- **NO Paid Entitlements**: NEVER add CloudKit, iCloud sync, App Groups, or Remote Notifications to Xcode Signing & Capabilities. Avoid build errors. +- **Persistence**: Use strictly local SwiftData. Do NOT use `NSPersistentCloudKitContainer`. + +## Architecture & Future Scope +- **Pattern**: Clean Architecture + MVVM. +- **Repository Pattern**: Strict separation of Data processing (SwiftData) and UI Views to allow easy future integration of CloudKit, Apple Watch, and Widgets. +- **Folder Structure**: + - `IOS/Models`: SwiftData Models (Task, Category, Goal, Log). + - `IOS/Repositories`: Data access layer (Protocols + Implementations). + - `IOS/ViewModels`: Business logic (`@Observable`). + - `IOS/Views`: SwiftUI Views. + - `IOS/Core`: App entry point, Constants, ActivityKit. + - `IOS/Resources`: Assets, Info.plist, Localizable.strings. + +## UI / Design System +- **Light Mode**: Green, Yellow, White. +- **Dark Mode**: Green, Yellow, Black. +- **Localization**: Default is Korean. Support English. diff --git a/LemonLimeTracker/Assets.xcassets/AccentColor.colorset/Contents.json b/IOS/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from LemonLimeTracker/Assets.xcassets/AccentColor.colorset/Contents.json rename to IOS/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/LemonLimeTracker/Assets.xcassets/AppIcon.appiconset/Contents.json b/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from LemonLimeTracker/Assets.xcassets/AppIcon.appiconset/Contents.json rename to IOS/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/LemonLimeTracker/Assets.xcassets/Contents.json b/IOS/Assets.xcassets/Contents.json similarity index 100% rename from LemonLimeTracker/Assets.xcassets/Contents.json rename to IOS/Assets.xcassets/Contents.json diff --git a/LemonLimeTracker/ContentView.swift b/IOS/ContentView.swift similarity index 100% rename from LemonLimeTracker/ContentView.swift rename to IOS/ContentView.swift diff --git a/IOS/Core/Theme.swift b/IOS/Core/Theme.swift new file mode 100644 index 0000000..a62070a --- /dev/null +++ b/IOS/Core/Theme.swift @@ -0,0 +1,21 @@ +import SwiftUI +import UIKit + +enum Theme { + static let green = Color(red: 0.18, green: 0.72, blue: 0.32) + static let yellow = Color(red: 0.96, green: 0.86, blue: 0.12) + + static let background = Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? .black : .white + }) + + static let surface = Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1) + : UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1) + }) + + static let primaryText = Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + }) +} diff --git a/IOS/Core/TimerActivityAttributes.swift b/IOS/Core/TimerActivityAttributes.swift new file mode 100644 index 0000000..4b3f522 --- /dev/null +++ b/IOS/Core/TimerActivityAttributes.swift @@ -0,0 +1,14 @@ +#if os(iOS) +import ActivityKit +import Foundation + +struct TimerActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var startDate: Date + var elapsedSeconds: Int + } + + var taskName: String + var taskIcon: String +} +#endif diff --git a/LemonLimeTracker/LemonLimeTrackerApp.swift b/IOS/LemonLimeTrackerApp.swift similarity index 69% rename from LemonLimeTracker/LemonLimeTrackerApp.swift rename to IOS/LemonLimeTrackerApp.swift index ce90505..434cfec 100644 --- a/LemonLimeTracker/LemonLimeTrackerApp.swift +++ b/IOS/LemonLimeTrackerApp.swift @@ -6,12 +6,14 @@ // import SwiftUI +import SwiftData @main struct LemonLimeTrackerApp: App { var body: some Scene { WindowGroup { - ContentView() + RootView() } + .modelContainer(SwiftDataService.shared.container) } } diff --git a/IOS/Models/Category.swift b/IOS/Models/Category.swift new file mode 100644 index 0000000..79339cb --- /dev/null +++ b/IOS/Models/Category.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +@Model +final class Category { + @Attribute(.unique) var id: UUID + var name: String + var colorHex: String + + @Relationship(deleteRule: .cascade, inverse: \TaskItem.category) + var tasks: [TaskItem] = [] + + init(id: UUID = UUID(), name: String, colorHex: String) { + self.id = id + self.name = name + self.colorHex = colorHex + } +} diff --git a/IOS/Models/Goal.swift b/IOS/Models/Goal.swift new file mode 100644 index 0000000..959e939 --- /dev/null +++ b/IOS/Models/Goal.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftData + +enum GoalFrequency: String, Codable { + case daily + case weekly + case monthly +} + +@Model +final class Goal { + @Attribute(.unique) var id: UUID + var targetType: String // "count" or "duration" + var conditions: Double // threshold value to meet the goal + var frequency: GoalFrequency + + var task: TaskItem? + var category: Category? + + init( + id: UUID = UUID(), + targetType: String, + conditions: Double, + frequency: GoalFrequency, + task: TaskItem? = nil, + category: Category? = nil + ) { + self.id = id + self.targetType = targetType + self.conditions = conditions + self.frequency = frequency + self.task = task + self.category = category + } +} diff --git a/IOS/Models/TaskItem.swift b/IOS/Models/TaskItem.swift new file mode 100644 index 0000000..bbe039e --- /dev/null +++ b/IOS/Models/TaskItem.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftData + +enum TaskType: String, Codable { + case count + case time +} + +@Model +final class TaskItem { + @Attribute(.unique) var id: UUID + var name: String + var icon: String + var type: TaskType + + var category: Category? + + @Relationship(deleteRule: .cascade, inverse: \TaskLog.task) + var logs: [TaskLog] = [] + + init(id: UUID = UUID(), name: String, icon: String, type: TaskType, category: Category? = nil) { + self.id = id + self.name = name + self.icon = icon + self.type = type + self.category = category + } +} diff --git a/IOS/Models/TaskLog.swift b/IOS/Models/TaskLog.swift new file mode 100644 index 0000000..ccd5273 --- /dev/null +++ b/IOS/Models/TaskLog.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +@Model +final class TaskLog { + @Attribute(.unique) var id: UUID + var date: Date + var duration: TimeInterval + var count: Int + + var task: TaskItem? + + init(id: UUID = UUID(), date: Date = .now, duration: TimeInterval = 0, count: Int = 0) { + self.id = id + self.date = date + self.duration = duration + self.count = count + } +} diff --git a/IOS/Repositories/SwiftDataService.swift b/IOS/Repositories/SwiftDataService.swift new file mode 100644 index 0000000..b056f1e --- /dev/null +++ b/IOS/Repositories/SwiftDataService.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftData + +@MainActor +final class SwiftDataService { + static let shared = SwiftDataService() + + let container: ModelContainer + + private init() { + let schema = Schema([ + Category.self, + TaskItem.self, + TaskLog.self, + Goal.self + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + container = try ModelContainer(for: schema, configurations: config) + } catch { + fatalError("SwiftData container failed to initialize: \(error)") + } + } +} diff --git a/IOS/Resources/en.lproj/Localizable.strings b/IOS/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..01f34ff --- /dev/null +++ b/IOS/Resources/en.lproj/Localizable.strings @@ -0,0 +1,50 @@ +/* Tab Bar */ +"tab.dashboard" = "Dashboard"; +"tab.category" = "Category"; +"tab.tasks" = "Tasks"; +"tab.goals" = "Goals"; +"tab.settings" = "Settings"; + +/* Task Tracker */ +"task.empty" = "No tasks added yet"; +"task.add" = "Add Task"; +"task.name" = "Name"; +"task.icon" = "Icon"; +"task.type" = "Type"; +"task.type.count" = "Count"; +"task.type.time" = "Time"; +"task.add.count" = "Add Count"; +"task.delete" = "Delete"; +"task.category" = "Category"; +"task.category.none" = "None"; + +/* Buttons */ +"button.cancel" = "Cancel"; +"button.add" = "Add"; + +/* Category */ +"category.empty" = "No categories yet"; +"category.add" = "Add Category"; +"category.name" = "Name"; +"category.color" = "Color"; +"category.delete" = "Delete"; +"category.task.count" = "tasks"; + +/* Goals */ +"goal.empty" = "No goals yet"; +"goal.add" = "Add Goal"; +"goal.chart.title" = "Goal Progress"; +"goal.progress" = "Progress"; +"goal.target" = "Target"; +"goal.target.task" = "Task"; +"goal.target.category" = "Category"; +"goal.no.target" = "None"; +"goal.condition" = "Condition"; +"goal.condition.count" = "Count"; +"goal.condition.time" = "Time"; +"goal.frequency" = "Frequency"; +"goal.frequency.daily" = "Daily"; +"goal.frequency.weekly" = "Weekly"; +"goal.frequency.monthly" = "Monthly"; +"goal.threshold" = "Target Value"; +"goal.delete" = "Delete"; diff --git a/IOS/Resources/ko.lproj/Localizable.strings b/IOS/Resources/ko.lproj/Localizable.strings new file mode 100644 index 0000000..2595057 --- /dev/null +++ b/IOS/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,50 @@ +/* Tab Bar */ +"tab.dashboard" = "대시보드"; +"tab.category" = "카테고리"; +"tab.tasks" = "작업 추적"; +"tab.goals" = "목표"; +"tab.settings" = "설정"; + +/* Task Tracker */ +"task.empty" = "등록된 작업이 없습니다"; +"task.add" = "작업 추가"; +"task.name" = "이름"; +"task.icon" = "아이콘"; +"task.type" = "유형"; +"task.type.count" = "횟수"; +"task.type.time" = "시간"; +"task.add.count" = "횟수 추가"; +"task.delete" = "삭제"; +"task.category" = "카테고리"; +"task.category.none" = "없음"; + +/* Buttons */ +"button.cancel" = "취소"; +"button.add" = "추가"; + +/* Category */ +"category.empty" = "카테고리가 없습니다"; +"category.add" = "카테고리 추가"; +"category.name" = "이름"; +"category.color" = "색상"; +"category.delete" = "삭제"; +"category.task.count" = "작업"; + +/* Goals */ +"goal.empty" = "등록된 목표가 없습니다"; +"goal.add" = "목표 추가"; +"goal.chart.title" = "목표 진행률"; +"goal.progress" = "진행률"; +"goal.target" = "대상"; +"goal.target.task" = "작업"; +"goal.target.category" = "카테고리"; +"goal.no.target" = "없음"; +"goal.condition" = "조건"; +"goal.condition.count" = "횟수"; +"goal.condition.time" = "시간"; +"goal.frequency" = "주기"; +"goal.frequency.daily" = "매일"; +"goal.frequency.weekly" = "매주"; +"goal.frequency.monthly" = "매월"; +"goal.threshold" = "목표값"; +"goal.delete" = "삭제"; diff --git a/IOS/ViewModels/CategoryViewModel.swift b/IOS/ViewModels/CategoryViewModel.swift new file mode 100644 index 0000000..a7a858b --- /dev/null +++ b/IOS/ViewModels/CategoryViewModel.swift @@ -0,0 +1,45 @@ +import Foundation +import Observation +import SwiftData + +@MainActor +@Observable +final class CategoryViewModel { + private(set) var categories: [Category] = [] + private var context: ModelContext? + + static let presetColors: [String] = [ + "#2EB852", "#F5DC1F", "#4A90D9", "#F5A623", + "#9B59B6", "#E74C3C", "#1ABC9C", "#F39C12" + ] + + func setup(context: ModelContext) { + self.context = context + fetchCategories() + } + + func fetchCategories() { + guard let context else { return } + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) + categories = (try? context.fetch(descriptor)) ?? [] + } + + func addCategory(name: String, colorHex: String) { + guard let context else { return } + let category = Category(name: name, colorHex: colorHex) + context.insert(category) + try? context.save() + fetchCategories() + } + + func deleteCategory(_ category: Category) { + guard let context else { return } + context.delete(category) + try? context.save() + fetchCategories() + } + + func taskCount(for category: Category) -> Int { + category.tasks.count + } +} diff --git a/IOS/ViewModels/GoalViewModel.swift b/IOS/ViewModels/GoalViewModel.swift new file mode 100644 index 0000000..196b392 --- /dev/null +++ b/IOS/ViewModels/GoalViewModel.swift @@ -0,0 +1,93 @@ +import Foundation +import Observation +import SwiftData + +enum GoalTargetType: String, CaseIterable { + case task + case category +} + +struct GoalProgress: Identifiable { + var id: UUID { goal.id } + let goal: Goal + let current: Double + let target: Double + + var ratio: Double { target > 0 ? min(current / target, 1.0) : 0.0 } + var isCompleted: Bool { current >= target } + + var displayName: String { + if let task = goal.task { return "\(task.icon) \(task.name)" } + if let category = goal.category { return category.name } + return "—" + } + + var percentText: String { "\(Int(ratio * 100))%" } +} + +@MainActor +@Observable +final class GoalViewModel { + private(set) var goals: [Goal] = [] + private(set) var goalProgress: [GoalProgress] = [] + private var context: ModelContext? + + func setup(context: ModelContext) { + self.context = context + fetchGoals() + } + + func fetchGoals() { + guard let context else { return } + let descriptor = FetchDescriptor() + goals = (try? context.fetch(descriptor)) ?? [] + computeProgress() + } + + func addGoal(targetType: String, conditions: Double, frequency: GoalFrequency, task: TaskItem?, category: Category?) { + guard let context else { return } + let goal = Goal(targetType: targetType, conditions: conditions, frequency: frequency, task: task, category: category) + context.insert(goal) + try? context.save() + fetchGoals() + } + + func deleteGoal(_ goal: Goal) { + guard let context else { return } + context.delete(goal) + try? context.save() + fetchGoals() + } + + private func computeProgress() { + let now = Date.now + let cal = Calendar.current + goalProgress = goals.map { goal in + let logs = relevantLogs(for: goal, reference: now, calendar: cal) + let current: Double = goal.targetType == "count" + ? Double(logs.reduce(0) { $0 + $1.count }) + : logs.reduce(0.0) { $0 + $1.duration } + return GoalProgress(goal: goal, current: current, target: goal.conditions) + } + } + + private func relevantLogs(for goal: Goal, reference: Date, calendar: Calendar) -> [TaskLog] { + let allLogs: [TaskLog] + if let task = goal.task { + allLogs = task.logs + } else if let category = goal.category { + allLogs = category.tasks.flatMap { $0.logs } + } else { + return [] + } + return allLogs.filter { inPeriod($0.date, frequency: goal.frequency, reference: reference, calendar: calendar) } + } + + private func inPeriod(_ date: Date, frequency: GoalFrequency, reference: Date, calendar: Calendar) -> Bool { + switch frequency { + case .daily: return calendar.isDate(date, inSameDayAs: reference) + case .weekly: return calendar.isDate(date, equalTo: reference, toGranularity: .weekOfYear) + case .monthly: return calendar.isDate(date, equalTo: reference, toGranularity: .month) + } + } +} diff --git a/IOS/ViewModels/TaskViewModel.swift b/IOS/ViewModels/TaskViewModel.swift new file mode 100644 index 0000000..0923425 --- /dev/null +++ b/IOS/ViewModels/TaskViewModel.swift @@ -0,0 +1,157 @@ +import Foundation +import Observation +import SwiftData +#if os(iOS) +import ActivityKit +#endif + +@MainActor +@Observable +final class TaskViewModel { + private(set) var tasks: [TaskItem] = [] + private(set) var activeTimerStartDates: [UUID: Date] = [:] + private(set) var elapsedSeconds: [UUID: Int] = [:] + + private var context: ModelContext? + private var timerTasks: [UUID: Task] = [:] +#if os(iOS) + private var liveActivities: [UUID: Activity] = [:] +#endif + + func setup(context: ModelContext) { + self.context = context + fetchTasks() + } + + func fetchTasks() { + guard let context else { return } + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) + tasks = (try? context.fetch(descriptor)) ?? [] + } + + func addTask(name: String, icon: String, type: TaskType, category: Category? = nil) { + guard let context else { return } + let item = TaskItem(name: name, icon: icon, type: type, category: category) + context.insert(item) + try? context.save() + fetchTasks() + } + + func deleteTask(_ task: TaskItem) { + stopTimer(for: task) + guard let context else { return } + context.delete(task) + try? context.save() + fetchTasks() + } + + func addCount(to task: TaskItem) { + guard let context else { return } + let log = TaskLog(date: .now, duration: 0, count: 1) + log.task = task + context.insert(log) + try? context.save() + } + + func toggleTimer(for task: TaskItem) { + if activeTimerStartDates[task.id] != nil { + stopTimer(for: task) + } else { + startTimer(for: task) + } + } + + func isTimerRunning(for taskID: UUID) -> Bool { + activeTimerStartDates[taskID] != nil + } + + func formattedElapsed(for taskID: UUID) -> String { + let s = elapsedSeconds[taskID] ?? 0 + let h = s / 3600 + let m = (s % 3600) / 60 + let sec = s % 60 + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, sec) + : String(format: "%02d:%02d", m, sec) + } + + func todayCount(for task: TaskItem) -> Int { + task.logs + .filter { Calendar.current.isDateInToday($0.date) } + .reduce(0) { $0 + $1.count } + } + + // MARK: - Timer + + private func startTimer(for task: TaskItem) { + let startDate = Date.now + activeTimerStartDates[task.id] = startDate + elapsedSeconds[task.id] = 0 + startLiveActivity(for: task, startDate: startDate) + + let taskID = task.id + timerTasks[taskID] = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self else { break } + let elapsed = Int(Date.now.timeIntervalSince(startDate)) + self.elapsedSeconds[taskID] = elapsed + await self.updateLiveActivity(taskID: taskID, elapsed: elapsed, startDate: startDate) + } + } + } + + private func stopTimer(for task: TaskItem) { + guard let startDate = activeTimerStartDates[task.id] else { return } + + timerTasks[task.id]?.cancel() + timerTasks.removeValue(forKey: task.id) + + let duration = Date.now.timeIntervalSince(startDate) + activeTimerStartDates.removeValue(forKey: task.id) + elapsedSeconds.removeValue(forKey: task.id) + + endLiveActivity(for: task) + + guard let context else { return } + let log = TaskLog(date: .now, duration: duration, count: 0) + log.task = task + context.insert(log) + try? context.save() + } + + // MARK: - ActivityKit + + private func startLiveActivity(for task: TaskItem, startDate: Date) { +#if os(iOS) + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let attrs = TimerActivityAttributes(taskName: task.name, taskIcon: task.icon) + let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: 0) + let content = ActivityContent(state: state, staleDate: nil) + do { + let activity = try Activity.request(attributes: attrs, content: content) + liveActivities[task.id] = activity + } catch { + // Live Activity unavailable (simulator, denied, or OS < 16.2) + } +#endif + } + + private func updateLiveActivity(taskID: UUID, elapsed: Int, startDate: Date) async { +#if os(iOS) + guard let activity = liveActivities[taskID] else { return } + let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: elapsed) + await activity.update(ActivityContent(state: state, staleDate: nil)) +#endif + } + + private func endLiveActivity(for task: TaskItem) { +#if os(iOS) + guard let activity = liveActivities[task.id] else { return } + liveActivities.removeValue(forKey: task.id) + Task { + await activity.end(nil, dismissalPolicy: .immediate) + } +#endif + } +} diff --git a/IOS/Views/CategoryView.swift b/IOS/Views/CategoryView.swift new file mode 100644 index 0000000..2b6369f --- /dev/null +++ b/IOS/Views/CategoryView.swift @@ -0,0 +1,177 @@ +import SwiftUI +import SwiftData + +struct CategoryView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = CategoryViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.categories.isEmpty { + emptyState + } else { + categoryList + } + } + } + .navigationTitle(String(localized: "tab.category")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddCategorySheet { name, colorHex in + viewModel.addCategory(name: name, colorHex: colorHex) + } + } + .onAppear { viewModel.setup(context: modelContext) } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "category.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var categoryList: some View { + List { + ForEach(viewModel.categories) { category in + CategoryRow(category: category, taskCount: viewModel.taskCount(for: category)) + .listRowBackground(Theme.surface) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.deleteCategory(category) + } label: { + Label(String(localized: "category.delete"), systemImage: "trash") + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } +} + +// MARK: - Category Row + +private struct CategoryRow: View { + let category: Category + let taskCount: Int + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: category.colorHex)) + .frame(width: 14, height: 14) + + Text(category.name) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.primaryText) + + Spacer() + + Text("\(taskCount) \(String(localized: "category.task.count"))") + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.green.opacity(0.12), in: Capsule()) + } + .padding(.vertical, 6) + } +} + +// MARK: - Add Category Sheet + +private struct AddCategorySheet: View { + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var selectedColor = CategoryViewModel.presetColors[0] + + let onAdd: (String, String) -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "category.name"), text: $name) + } + + Section(String(localized: "category.color")) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 16) { + ForEach(CategoryViewModel.presetColors, id: \.self) { colorHex in + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 42, height: 42) + .overlay(alignment: .center) { + if selectedColor == colorHex { + Image(systemName: "checkmark") + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.white) + } + } + .overlay( + Circle() + .strokeBorder( + selectedColor == colorHex ? Theme.primaryText.opacity(0.4) : Color.clear, + lineWidth: 2 + ) + ) + .onTapGesture { selectedColor = colorHex } + } + } + .padding(.vertical, 8) + } + } + .navigationTitle(String(localized: "category.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + onAdd(trimmed, selectedColor) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} + +// MARK: - Color Hex Extension + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let r = Double((int >> 16) & 0xFF) / 255 + let g = Double((int >> 8) & 0xFF) / 255 + let b = Double(int & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} + +#Preview { + NavigationStack { + CategoryView() + } +} diff --git a/IOS/Views/DashboardView.swift b/IOS/Views/DashboardView.swift new file mode 100644 index 0000000..be4aa44 --- /dev/null +++ b/IOS/Views/DashboardView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct DashboardView: View { + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Text(String(localized: "tab.dashboard")) + .font(.title) + .foregroundStyle(Theme.primaryText) + } + .navigationTitle(String(localized: "tab.dashboard")) + } +} + +#Preview { + DashboardView() +} diff --git a/IOS/Views/GoalView.swift b/IOS/Views/GoalView.swift new file mode 100644 index 0000000..8d60d24 --- /dev/null +++ b/IOS/Views/GoalView.swift @@ -0,0 +1,306 @@ +import SwiftUI +import SwiftData +import Charts + +struct GoalView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = GoalViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.goals.isEmpty { + emptyState + } else { + goalContent + } + } + } + .navigationTitle(String(localized: "tab.goals")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddGoalSheet { targetType, conditions, frequency, task, category in + viewModel.addGoal(targetType: targetType, conditions: conditions, frequency: frequency, task: task, category: category) + } + } + .onAppear { viewModel.setup(context: modelContext) } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "flag.badge.ellipsis") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "goal.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var goalContent: some View { + ScrollView { + VStack(spacing: 16) { + progressChart + .padding(.horizontal) + goalCards + .padding(.horizontal) + } + .padding(.vertical) + } + } + + // MARK: - Chart + + private var progressChart: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "goal.chart.title")) + .font(.headline) + .foregroundStyle(Theme.primaryText) + + Chart(viewModel.goalProgress) { progress in + BarMark( + x: .value(String(localized: "goal.progress"), progress.ratio * 100), + y: .value("", progress.displayName) + ) + .foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow) + .cornerRadius(4) + } + .chartXScale(domain: 0.0...100.0) + .chartXAxis { + AxisMarks(values: [0.0, 25.0, 50.0, 75.0, 100.0]) { value in + AxisGridLine() + AxisValueLabel { + if let v = value.as(Double.self) { + Text("\(Int(v))%") + .font(.caption2) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + } + } + } + } + .chartYAxis { + AxisMarks { _ in + AxisValueLabel() + } + } + .frame(height: CGFloat(viewModel.goalProgress.count) * 48 + 20) + } + .padding() + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Goal Cards + + private var goalCards: some View { + VStack(spacing: 12) { + ForEach(viewModel.goalProgress) { progress in + GoalCard(progress: progress) { + viewModel.deleteGoal(progress.goal) + } + } + } + } +} + +// MARK: - Goal Card + +private struct GoalCard: View { + let progress: GoalProgress + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(progress.displayName) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(Theme.primaryText) + Text(subtitleText) + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + } + Spacer() + Button(role: .destructive, action: onDelete) { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + } + + ProgressView(value: progress.ratio) + .tint(progress.isCompleted ? Theme.green : Theme.yellow) + + HStack { + Text(currentText) + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + Spacer() + Text(progress.percentText) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow) + } + } + .padding() + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 12)) + } + + private var subtitleText: String { + let cond = progress.goal.targetType == "count" + ? String(localized: "goal.condition.count") + : String(localized: "goal.condition.time") + let freq: String + switch progress.goal.frequency { + case .daily: freq = String(localized: "goal.frequency.daily") + case .weekly: freq = String(localized: "goal.frequency.weekly") + case .monthly: freq = String(localized: "goal.frequency.monthly") + } + return "\(cond) · \(freq)" + } + + private var currentText: String { + if progress.goal.targetType == "count" { + return "\(Int(progress.current)) / \(Int(progress.target)) \(String(localized: "goal.condition.count"))" + } else { + let currentMins = Int(progress.current) / 60 + let targetMins = Int(progress.target) / 60 + return "\(currentMins)m / \(targetMins)m" + } + } +} + +// MARK: - Add Goal Sheet + +private struct AddGoalSheet: View { + @Environment(\.dismiss) private var dismiss + @Query(sort: \TaskItem.name) private var tasks: [TaskItem] + @Query(sort: \Category.name) private var categories: [Category] + + @State private var targetType: GoalTargetType = .task + @State private var selectedTask: TaskItem? + @State private var selectedCategory: Category? + @State private var conditionType: String = "count" + @State private var threshold: Double = 5 + @State private var frequency: GoalFrequency = .daily + + let onAdd: (String, Double, GoalFrequency, TaskItem?, Category?) -> Void + + var body: some View { + NavigationStack { + Form { + Section(header: Text(String(localized: "goal.target"))) { + Picker(String(localized: "goal.target"), selection: $targetType) { + Text(String(localized: "goal.target.task")).tag(GoalTargetType.task) + Text(String(localized: "goal.target.category")).tag(GoalTargetType.category) + } + .pickerStyle(.segmented) + .onChange(of: targetType) { _, _ in + selectedTask = nil + selectedCategory = nil + } + + if targetType == .task { + Picker(String(localized: "goal.target.task"), selection: $selectedTask) { + Text(String(localized: "goal.no.target")).tag(Optional.none) + ForEach(tasks) { task in + Text("\(task.icon) \(task.name)").tag(Optional(task)) + } + } + } else { + Picker(String(localized: "goal.target.category"), selection: $selectedCategory) { + Text(String(localized: "goal.no.target")).tag(Optional.none) + ForEach(categories) { cat in + Text(cat.name).tag(Optional(cat)) + } + } + } + } + + Section(header: Text(String(localized: "goal.condition"))) { + Picker(String(localized: "goal.condition"), selection: $conditionType) { + Text(String(localized: "goal.condition.count")).tag("count") + Text(String(localized: "goal.condition.time")).tag("duration") + } + .pickerStyle(.segmented) + .onChange(of: conditionType) { _, _ in threshold = 5 } + } + + Section(header: Text(String(localized: "goal.threshold"))) { + HStack { + Slider( + value: $threshold, + in: conditionType == "count" ? 1.0...100.0 : 5.0...240.0, + step: conditionType == "count" ? 1.0 : 5.0 + ) + .tint(Theme.green) + Text(thresholdText) + .font(.body) + .monospacedDigit() + .foregroundStyle(Theme.green) + .frame(width: 52, alignment: .trailing) + } + } + + Section(header: Text(String(localized: "goal.frequency"))) { + Picker(String(localized: "goal.frequency"), selection: $frequency) { + Text(String(localized: "goal.frequency.daily")).tag(GoalFrequency.daily) + Text(String(localized: "goal.frequency.weekly")).tag(GoalFrequency.weekly) + Text(String(localized: "goal.frequency.monthly")).tag(GoalFrequency.monthly) + } + .pickerStyle(.segmented) + } + } + .navigationTitle(String(localized: "goal.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let storedConditions = conditionType == "duration" ? threshold * 60.0 : threshold + onAdd( + conditionType, + storedConditions, + frequency, + targetType == .task ? selectedTask : nil, + targetType == .category ? selectedCategory : nil + ) + dismiss() + } + .disabled(!canAdd) + } + } + } + } + + private var thresholdText: String { + if conditionType == "count" { + return "\(Int(threshold))" + } else { + let h = Int(threshold) / 60 + let m = Int(threshold) % 60 + return h > 0 ? "\(h)h\(m)m" : "\(Int(threshold))m" + } + } + + private var canAdd: Bool { + targetType == .task ? selectedTask != nil : selectedCategory != nil + } +} + +#Preview { + NavigationStack { + GoalView() + } +} diff --git a/IOS/Views/LaunchScreenView.swift b/IOS/Views/LaunchScreenView.swift new file mode 100644 index 0000000..7e198e0 --- /dev/null +++ b/IOS/Views/LaunchScreenView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct LaunchScreenView: View { + var body: some View { + ZStack { + Theme.background + .ignoresSafeArea() + VStack(spacing: 12) { + Text("🍋🍈") + .font(.system(size: 60)) + Text("LemonLimeTracker") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(Theme.green) + } + } + } +} + +#Preview { + LaunchScreenView() +} diff --git a/IOS/Views/MainTabView.swift b/IOS/Views/MainTabView.swift new file mode 100644 index 0000000..323770f --- /dev/null +++ b/IOS/Views/MainTabView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + NavigationStack { + DashboardView() + } + .tabItem { + Label(String(localized: "tab.dashboard"), systemImage: "chart.bar.fill") + } + + NavigationStack { + CategoryView() + } + .tabItem { + Label(String(localized: "tab.category"), systemImage: "folder.fill") + } + + NavigationStack { + TaskTrackerView() + } + .tabItem { + Label(String(localized: "tab.tasks"), systemImage: "checkmark.circle.fill") + } + + NavigationStack { + GoalView() + } + .tabItem { + Label(String(localized: "tab.goals"), systemImage: "flag.fill") + } + + NavigationStack { + SettingsView() + } + .tabItem { + Label(String(localized: "tab.settings"), systemImage: "gearshape.fill") + } + } + .tint(Theme.green) + } +} + +#Preview { + MainTabView() +} diff --git a/IOS/Views/RootView.swift b/IOS/Views/RootView.swift new file mode 100644 index 0000000..c535012 --- /dev/null +++ b/IOS/Views/RootView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct RootView: View { + @State private var isLaunching = true + + var body: some View { + if isLaunching { + LaunchScreenView() + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.4)) { + isLaunching = false + } + } + } + } else { + MainTabView() + } + } +} + +#Preview { + RootView() +} diff --git a/IOS/Views/SettingsView.swift b/IOS/Views/SettingsView.swift new file mode 100644 index 0000000..aad1414 --- /dev/null +++ b/IOS/Views/SettingsView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct SettingsView: View { + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Text(String(localized: "tab.settings")) + .font(.title) + .foregroundStyle(Theme.primaryText) + } + .navigationTitle(String(localized: "tab.settings")) + } +} + +#Preview { + SettingsView() +} diff --git a/IOS/Views/TaskTrackerView.swift b/IOS/Views/TaskTrackerView.swift new file mode 100644 index 0000000..2869bd5 --- /dev/null +++ b/IOS/Views/TaskTrackerView.swift @@ -0,0 +1,212 @@ +import SwiftUI +import SwiftData + +struct TaskTrackerView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = TaskViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.tasks.isEmpty { + emptyState + } else { + taskList + } + } + } + .navigationTitle(String(localized: "tab.tasks")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddTaskSheet { name, icon, type, category in + viewModel.addTask(name: name, icon: icon, type: type, category: category) + } + } + .onAppear { + viewModel.setup(context: modelContext) + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.badge.plus") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "task.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var taskList: some View { + List { + ForEach(viewModel.tasks) { task in + TaskRow(task: task, viewModel: viewModel) + .listRowBackground(Theme.surface) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.deleteTask(task) + } label: { + Label(String(localized: "task.delete"), systemImage: "trash") + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if task.type == .count { + Button { + viewModel.addCount(to: task) + } label: { + Label(String(localized: "task.add.count"), systemImage: "plus") + } + .tint(Theme.green) + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } +} + +// MARK: - Task Row + +private struct TaskRow: View { + let task: TaskItem + let viewModel: TaskViewModel + + private var isRunning: Bool { viewModel.isTimerRunning(for: task.id) } + + var body: some View { + HStack(spacing: 12) { + Text(task.icon) + .font(.title2) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(task.name) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.primaryText) + if let category = task.category { + Text(category.name) + .font(.caption2) + .foregroundStyle(Theme.green) + } + } + + Spacer() + + if task.type == .time { + timeControls + } else { + countBadge + } + } + .padding(.vertical, 4) + } + + private var timeControls: some View { + HStack(spacing: 8) { + if isRunning { + Text(viewModel.formattedElapsed(for: task.id)) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(Theme.green) + .contentTransition(.numericText()) + .animation(.linear(duration: 0.3), value: viewModel.elapsedSeconds[task.id]) + } + Button { + viewModel.toggleTimer(for: task) + } label: { + Image(systemName: isRunning ? "stop.fill" : "play.fill") + .font(.title3) + .foregroundStyle(isRunning ? Theme.yellow : Theme.green) + .frame(width: 36, height: 36) + .background( + Circle() + .fill(isRunning ? Theme.yellow.opacity(0.15) : Theme.green.opacity(0.15)) + ) + } + .buttonStyle(.plain) + } + } + + private var countBadge: some View { + let count = viewModel.todayCount(for: task) + return Text("×\(count)") + .font(.headline) + .monospacedDigit() + .foregroundStyle(count > 0 ? Theme.green : Theme.primaryText.opacity(0.3)) + } +} + +// MARK: - Add Task Sheet + +private struct AddTaskSheet: View { + @Environment(\.dismiss) private var dismiss + @Query private var categories: [Category] + + @State private var name = "" + @State private var icon = "⭐" + @State private var type: TaskType = .count + @State private var selectedCategory: Category? + + let onAdd: (String, String, TaskType, Category?) -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "task.name"), text: $name) + TextField(String(localized: "task.icon"), text: $icon) + } + Section { + Picker(String(localized: "task.type"), selection: $type) { + Text(String(localized: "task.type.count")).tag(TaskType.count) + Text(String(localized: "task.type.time")).tag(TaskType.time) + } + .pickerStyle(.segmented) + } + if !categories.isEmpty { + Section { + Picker(String(localized: "task.category"), selection: $selectedCategory) { + Text(String(localized: "task.category.none")).tag(Optional.none) + ForEach(categories) { cat in + Text(cat.name).tag(Optional(cat)) + } + } + } + } + } + .navigationTitle(String(localized: "task.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + let resolvedIcon = icon.trimmingCharacters(in: .whitespaces).isEmpty ? "⭐" : icon + onAdd(trimmed, resolvedIcon, type, selectedCategory) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} + +#Preview { + TaskTrackerView() +} diff --git a/LemonLimeTracker-Info.plist b/LemonLimeTracker-Info.plist new file mode 100644 index 0000000..c41d6c0 --- /dev/null +++ b/LemonLimeTracker-Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleLocalizations + + ko + en + + NSSupportsLiveActivities + + + diff --git a/LemonLimeTracker.xcodeproj/project.pbxproj b/LemonLimeTracker.xcodeproj/project.pbxproj index 4abb03b..01f3c85 100644 --- a/LemonLimeTracker.xcodeproj/project.pbxproj +++ b/LemonLimeTracker.xcodeproj/project.pbxproj @@ -6,14 +6,76 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 7280EB402FE41F11006B83D9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */; }; + 7280EB422FE41F11006B83D9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7280EB412FE41F11006B83D9 /* SwiftUI.framework */; }; + 7280EB4F2FE41F12006B83D9 /* LemonLimeWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7280EB4D2FE41F12006B83D9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7280EAF02FE40D38006B83D9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7280EB3C2FE41F10006B83D9; + remoteInfo = LemonLimeWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 7280EB542FE41F12006B83D9 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 7280EB4F2FE41F12006B83D9 /* LemonLimeWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LemonLimeTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7280EB1E2FE41A14006B83D9 /* LemonLimeTracker-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "LemonLimeTracker-Info.plist"; sourceTree = ""; }; + 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LemonLimeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 7280EB412FE41F11006B83D9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7280EB502FE41F12006B83D9 /* Exceptions for "LemonLimeWidget" folder in "LemonLimeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + }; + 7280EB5A2FE42219006B83D9 /* Exceptions for "IOS" folder in "LemonLimeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Core/TimerActivityAttributes.swift, + ); + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ - 7280EAFA2FE40D38006B83D9 /* LemonLimeTracker */ = { + 7280EAFA2FE40D38006B83D9 /* IOS */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = LemonLimeTracker; + exceptions = ( + 7280EB5A2FE42219006B83D9 /* Exceptions for "IOS" folder in "LemonLimeWidgetExtension" target */, + ); + path = IOS; + sourceTree = ""; + }; + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7280EB502FE41F12006B83D9 /* Exceptions for "LemonLimeWidget" folder in "LemonLimeWidgetExtension" target */, + ); + path = LemonLimeWidget; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -26,13 +88,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7280EB3A2FE41F10006B83D9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7280EB422FE41F11006B83D9 /* SwiftUI.framework in Frameworks */, + 7280EB402FE41F11006B83D9 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 7280EAEF2FE40D38006B83D9 = { isa = PBXGroup; children = ( - 7280EAFA2FE40D38006B83D9 /* LemonLimeTracker */, + 7280EB1E2FE41A14006B83D9 /* LemonLimeTracker-Info.plist */, + 7280EAFA2FE40D38006B83D9 /* IOS */, + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */, + 7280EB3E2FE41F10006B83D9 /* Frameworks */, 7280EAF92FE40D38006B83D9 /* Products */, ); sourceTree = ""; @@ -41,10 +115,20 @@ isa = PBXGroup; children = ( 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */, + 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */, ); name = Products; sourceTree = ""; }; + 7280EB3E2FE41F10006B83D9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */, + 7280EB412FE41F11006B83D9 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -55,13 +139,15 @@ 7280EAF42FE40D38006B83D9 /* Sources */, 7280EAF52FE40D38006B83D9 /* Frameworks */, 7280EAF62FE40D38006B83D9 /* Resources */, + 7280EB542FE41F12006B83D9 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 7280EB4E2FE41F12006B83D9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 7280EAFA2FE40D38006B83D9 /* LemonLimeTracker */, + 7280EAFA2FE40D38006B83D9 /* IOS */, ); name = LemonLimeTracker; packageProductDependencies = ( @@ -70,6 +156,28 @@ productReference = 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */; productType = "com.apple.product-type.application"; }; + 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7280EB512FE41F12006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeWidgetExtension" */; + buildPhases = ( + 7280EB392FE41F10006B83D9 /* Sources */, + 7280EB3A2FE41F10006B83D9 /* Frameworks */, + 7280EB3B2FE41F10006B83D9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */, + ); + name = LemonLimeWidgetExtension; + packageProductDependencies = ( + ); + productName = LemonLimeWidgetExtension; + productReference = 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +191,9 @@ 7280EAF72FE40D38006B83D9 = { CreatedOnToolsVersion = 26.5; }; + 7280EB3C2FE41F10006B83D9 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = 7280EAF32FE40D38006B83D9 /* Build configuration list for PBXProject "LemonLimeTracker" */; @@ -91,6 +202,7 @@ knownRegions = ( en, Base, + ko, ); mainGroup = 7280EAEF2FE40D38006B83D9; minimizedProjectReferenceProxies = 1; @@ -100,6 +212,7 @@ projectRoot = ""; targets = ( 7280EAF72FE40D38006B83D9 /* LemonLimeTracker */, + 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */, ); }; /* End PBXProject section */ @@ -112,6 +225,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7280EB3B2FE41F10006B83D9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,14 +242,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7280EB392FE41F10006B83D9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 7280EB4E2FE41F12006B83D9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + targetProxy = 7280EB4D2FE41F12006B83D9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 7280EB012FE40D39006B83D9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -193,6 +329,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -253,6 +390,9 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LemonLimeTracker-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -284,6 +424,9 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LemonLimeTracker-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -306,6 +449,64 @@ }; name = Release; }; + 7280EB522FE41F12006B83D9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LemonLimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LemonLimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker.LemonLimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7280EB532FE41F12006B83D9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LemonLimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LemonLimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker.LemonLimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + 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 */ @@ -327,6 +528,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7280EB512FE41F12006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7280EB522FE41F12006B83D9 /* Debug */, + 7280EB532FE41F12006B83D9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 7280EAF02FE40D38006B83D9 /* Project object */; diff --git a/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..cf0295c --- /dev/null +++ b/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + LemonLimeTracker.xcscheme_^#shared#^_ + + orderHint + 0 + + LemonLimeWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LemonLimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/LemonLimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/LemonLimeWidget/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/LemonLimeWidget/Assets.xcassets/Contents.json b/LemonLimeWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LemonLimeWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LemonLimeWidget/Info.plist b/LemonLimeWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/LemonLimeWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LemonLimeWidget/LemonLimeWidget.swift b/LemonLimeWidget/LemonLimeWidget.swift new file mode 100644 index 0000000..f5d0984 --- /dev/null +++ b/LemonLimeWidget/LemonLimeWidget.swift @@ -0,0 +1,84 @@ +// +// LemonLimeWidget.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import WidgetKit +import SwiftUI + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "😀") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "😀") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let emoji: String +} + +struct LemonLimeWidgetEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Emoji:") + Text(entry.emoji) + } + } +} + +struct LemonLimeWidget: Widget { + let kind: String = "LemonLimeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + if #available(iOS 17.0, *) { + LemonLimeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + LemonLimeWidgetEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +#Preview(as: .systemSmall) { + LemonLimeWidget() +} timeline: { + SimpleEntry(date: .now, emoji: "😀") + SimpleEntry(date: .now, emoji: "🤩") +} diff --git a/LemonLimeWidget/LemonLimeWidgetBundle.swift b/LemonLimeWidget/LemonLimeWidgetBundle.swift new file mode 100644 index 0000000..8b40784 --- /dev/null +++ b/LemonLimeWidget/LemonLimeWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// LemonLimeWidgetBundle.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import WidgetKit +import SwiftUI + +@main +struct LemonLimeWidgetBundle: WidgetBundle { + var body: some Widget { + LemonLimeWidget() + LemonLimeWidgetLiveActivity() + } +} diff --git a/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift b/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift new file mode 100644 index 0000000..594a7e8 --- /dev/null +++ b/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift @@ -0,0 +1,125 @@ +// +// LemonLimeWidgetLiveActivity.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +private let timerEndDate = Date.distantFuture + +struct LemonLimeWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TimerActivityAttributes.self) { context in + // MARK: Lock Screen / Banner + HStack(spacing: 14) { + Image(systemName: "timer") + .font(.title2.weight(.semibold)) + .foregroundStyle(.yellow) + + VStack(alignment: .leading, spacing: 3) { + Text(context.attributes.taskName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .font(.caption.monospacedDigit()) + .foregroundStyle(.yellow) + } + + Spacer() + + Text(context.attributes.taskIcon) + .font(.largeTitle) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .activityBackgroundTint(Color(red: 0.08, green: 0.30, blue: 0.08)) + .activitySystemActionForegroundColor(.yellow) + + } dynamicIsland: { context in + DynamicIsland { + // MARK: Expanded – top row + DynamicIslandExpandedRegion(.leading) { + Label { + Text(context.attributes.taskName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } icon: { + Text(context.attributes.taskIcon) + .font(.caption) + } + .foregroundStyle(.green) + } + + DynamicIslandExpandedRegion(.trailing) { + Image(systemName: "circle.fill") + .font(.caption) + .foregroundStyle(.green) + .padding(.trailing, 4) + } + + // MARK: Expanded – center (large timer) + DynamicIslandExpandedRegion(.bottom) { + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .font(.system(size: 44, weight: .bold, design: .monospaced)) + .foregroundStyle(.yellow) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + } + + } compactLeading: { + Image(systemName: "timer") + .foregroundStyle(.green) + .font(.caption.weight(.semibold)) + + } compactTrailing: { + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .monospacedDigit() + .font(.caption2.weight(.medium)) + .foregroundStyle(.yellow) + .frame(maxWidth: 52) + + } minimal: { + ZStack { + Circle() + .fill(LinearGradient( + colors: [.green, Color(red: 0.8, green: 0.9, blue: 0.0)], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + Text(context.attributes.taskIcon) + .font(.system(size: 11)) + } + } + .keylineTint(.green) + } + } +} + +// MARK: - Previews + +extension TimerActivityAttributes { + fileprivate static var preview: TimerActivityAttributes { + TimerActivityAttributes(taskName: "운동하기", taskIcon: "🏃") + } +} + +extension TimerActivityAttributes.ContentState { + fileprivate static var running: TimerActivityAttributes.ContentState { + TimerActivityAttributes.ContentState( + startDate: Date().addingTimeInterval(-125), + elapsedSeconds: 125 + ) + } +} + +#Preview("Notification", as: .content, using: TimerActivityAttributes.preview) { + LemonLimeWidgetLiveActivity() +} contentStates: { + TimerActivityAttributes.ContentState.running +}