feat(ipad): adaptive layout — NavigationSplitView sidebar + timer detail side panel on iPad
This commit is contained in:
parent
29cd7ffc62
commit
be0e8748b2
@ -1,17 +1,21 @@
|
||||
// ── 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 {
|
||||
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"
|
||||
@ -23,6 +27,19 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -48,6 +65,249 @@ struct ContentView: View {
|
||||
}
|
||||
.tag(Tab.settings)
|
||||
}
|
||||
.tint(CMColors.accent)
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout (NavigationSplitView)
|
||||
|
||||
private var iPadLayout: some View {
|
||||
NavigationSplitView {
|
||||
// Sidebar
|
||||
List(Tab.allCases, selection: $selectedTab) { tab in
|
||||
Label(tab.rawValue, systemImage: tab.icon)
|
||||
.tag(tab)
|
||||
}
|
||||
.navigationTitle("ChronoMind")
|
||||
.listStyle(.sidebar)
|
||||
.scrollContentBackground(.hidden)
|
||||
.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(
|
||||
timer: timer,
|
||||
now: timerStore.now,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user