- Initialize iOS project with 6-tab navigation structure - Configure custom Light/Dark themes and AppColors - Define SwiftData models for Tasks, Tags, Goals, and TrackingRecords - Setup relationships (Super/Sub tags) and cascade delete rules - Implement Observable TrackingEngine for real-time timer updates
218 lines
6.7 KiB
Swift
218 lines
6.7 KiB
Swift
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())
|
||
}
|