learning_ai_clock/ios/ChronoMind/App/ContentView.swift

335 lines
13 KiB
Swift

// Main Content View with Tab Navigation
// iPhone: TabView, iPad: NavigationSplitView with sidebar
import SwiftUI
struct ContentView: View {
@EnvironmentObject var timerStore: TimerStore
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedTab: Tab = .timeline
enum Tab: String, CaseIterable, Identifiable {
case timeline = "Timeline"
case focus = "Focus"
case history = "History"
case settings = "Settings"
var id: String { rawValue }
var icon: String {
switch self {
case .timeline: return "clock.fill"
case .focus: return "target"
case .history: return "chart.bar.fill"
case .settings: return "gearshape.fill"
}
}
}
var body: some View {
Group {
if sizeClass == .regular {
iPadLayout
} else {
iPhoneLayout
}
}
.tint(CMColors.accent)
}
// MARK: - iPhone Layout (TabView)
private var iPhoneLayout: some View {
TabView(selection: $selectedTab) {
TimelineView()
.tabItem {
Label(Tab.timeline.rawValue, systemImage: Tab.timeline.icon)
}
.tag(Tab.timeline)
if FeatureFlagService.shared.isEnabled("focus_mode_enabled") {
PomodoroView()
.tabItem {
Label(Tab.focus.rawValue, systemImage: Tab.focus.icon)
}
.tag(Tab.focus)
}
HistoryView()
.tabItem {
Label(Tab.history.rawValue, systemImage: Tab.history.icon)
}
.tag(Tab.history)
SettingsView()
.tabItem {
Label(Tab.settings.rawValue, systemImage: Tab.settings.icon)
}
.tag(Tab.settings)
}
}
// MARK: - iPad Layout (NavigationSplitView)
private var iPadLayout: some View {
NavigationSplitView {
// Sidebar
VStack(spacing: 0) {
ForEach(Tab.allCases.filter { tab in
if tab == .focus { return FeatureFlagService.shared.isEnabled("focus_mode_enabled") }
return true
}) { tab in
Button {
selectedTab = tab
} label: {
Label(tab.rawValue, systemImage: tab.icon)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(selectedTab == tab ? CMColors.accent.opacity(0.15) : Color.clear)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 8)
.navigationTitle("ChronoMind")
.background(CMColors.bg)
} detail: {
// Detail pane
switch selectedTab {
case .timeline:
iPadTimelineDetail
case .focus:
PomodoroView()
case .history:
HistoryView()
case .settings:
SettingsView()
}
}
.navigationSplitViewStyle(.balanced)
}
// MARK: - iPad Timeline with Side Detail
@State private var selectedTimerId: String?
private var iPadTimelineDetail: some View {
HStack(spacing: 0) {
// Left: Timeline
TimelineView()
.frame(maxWidth: .infinity)
// Right: Selected timer detail (if any)
if let timerId = selectedTimerId,
let timer = timerStore.getTimer(timerId) {
Divider()
.background(CMColors.border)
iPadTimerDetail(timer: timer)
.frame(width: 340)
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: 0.25), value: selectedTimerId)
}
private func iPadTimerDetail(timer: CMTimer) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: CMSpacing.lg) {
// Header
HStack {
Text("Timer Detail")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
Spacer()
Button {
selectedTimerId = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(CMColors.textMuted)
}
}
// Timer info
VStack(alignment: .leading, spacing: CMSpacing.sm) {
HStack {
Text(timer.label)
.font(CMFonts.display(size: 22))
.foregroundStyle(CMColors.text)
Spacer()
UrgencyBadge(urgency: timer.urgency)
}
Text(timer.type.label)
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textSecondary)
}
// Countdown
CountdownRing(
progress: {
guard let duration = timer.duration, duration > 0 else { return 0 }
let remaining = max(0, timer.targetTime.timeIntervalSince(timerStore.now))
return 1.0 - (remaining / duration)
}(),
urgency: timer.urgency,
remainingSeconds: max(0, timer.targetTime.timeIntervalSince(timerStore.now)),
totalSeconds: timer.duration ?? 0,
size: 180
)
.frame(maxWidth: .infinity)
// Cascade progress
if !timer.warnings.isEmpty {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("CASCADE WARNINGS")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
CascadeProgressBar(
fired: timer.warnings.filter(\.fired).count,
total: timer.warnings.count,
urgency: timer.urgency
)
ForEach(timer.warnings) { warning in
HStack {
Image(systemName: warning.fired ? "checkmark.circle.fill" : "circle")
.font(.caption)
.foregroundStyle(warning.fired ? CMColors.success : CMColors.textMuted)
Text("\(warning.minutesBefore)m before")
.font(CMFonts.body(size: 13))
.foregroundStyle(warning.fired ? CMColors.text : CMColors.textSecondary)
Spacer()
Text(formatTime(warning.scheduledTime))
.font(CMFonts.mono(size: 12))
.foregroundStyle(CMColors.textMuted)
}
}
}
}
// Pomodoro info
if let pomState = timer.pomodoroState, let pomConfig = timer.pomodoroConfig {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("POMODORO")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
HStack {
Text("Round \(pomState.currentRound)/\(pomConfig.rounds)")
.font(CMFonts.body(size: 14, weight: .medium))
.foregroundStyle(CMColors.text)
Spacer()
Text(pomState.isBreak ? "Break" : "Work")
.font(CMFonts.body(size: 13, weight: .semibold))
.foregroundStyle(pomState.isBreak ? CMColors.success : CMColors.accent)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.xxs)
.background((pomState.isBreak ? CMColors.success : CMColors.accent).opacity(0.15))
.clipShape(Capsule())
}
}
}
// Actions
HStack(spacing: CMSpacing.md) {
if timer.state == .active || timer.state == .warning {
Button {
HapticEngine.tap()
timerStore.pause(timer.id)
} label: {
Label("Pause", systemImage: "pause.fill")
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.important)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
if timer.state == .paused {
Button {
HapticEngine.tap()
timerStore.resume(timer.id)
} label: {
Label("Resume", systemImage: "play.fill")
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.accent)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
if timer.state == .firing {
Button {
HapticEngine.tap()
timerStore.dismiss(timer.id)
selectedTimerId = nil
} label: {
Label("Dismiss", systemImage: "xmark")
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.error)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
Button {
HapticEngine.tap()
timerStore.removeTimer(timer.id)
selectedTimerId = nil
} label: {
Image(systemName: "trash")
.font(.body.weight(.semibold))
.foregroundStyle(CMColors.error)
.padding(CMSpacing.sm)
.background(CMColors.error.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
// Metadata
VStack(alignment: .leading, spacing: CMSpacing.xs) {
Text("DETAILS")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
metadataRow("Created", value: formatDateTime(timer.createdAt))
metadataRow("Target", value: formatDateTime(timer.targetTime))
metadataRow("State", value: timer.state.rawValue)
metadataRow("Snoozes", value: "\(timer.snoozeCount)")
if let category = timer.category {
metadataRow("Category", value: category)
}
}
}
.padding(CMSpacing.lg)
}
.background(CMColors.bg)
}
private func metadataRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
Spacer()
Text(value)
.font(CMFonts.mono(size: 12))
.foregroundStyle(CMColors.textSecondary)
}
}
}