335 lines
13 KiB
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)
|
|
}
|
|
}
|
|
}
|