215 lines
9.3 KiB
Swift
215 lines
9.3 KiB
Swift
import AppKit
|
|
|
|
// MARK: - Work Log View Builder
|
|
|
|
enum WorkLogViewBuilder {
|
|
|
|
static func build(in window: NSWindow, isPrimary: Bool, delegate: AppDelegate) {
|
|
let size = window.frame.size
|
|
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
|
view.wantsLayer = true
|
|
let bgColor = Settings.shared.gradientColor.withAlphaComponent(0.92)
|
|
view.layer?.backgroundColor = bgColor.cgColor
|
|
|
|
let entries = WorkLogStore.shared.todayEntries()
|
|
let summary = WorkLogStore.shared.todaySummary()
|
|
|
|
// Title
|
|
let title = NSTextField(labelWithString: "Today's Work Log")
|
|
title.font = NSFont.systemFont(ofSize: 48, weight: .bold)
|
|
title.textColor = .white
|
|
title.alignment = .center
|
|
title.sizeToFit()
|
|
title.frame.origin = CGPoint(
|
|
x: (size.width - title.frame.width) / 2,
|
|
y: size.height - 120
|
|
)
|
|
view.addSubview(title)
|
|
|
|
// Summary stats
|
|
let hours = summary.totalSeconds / 3600
|
|
let mins = (summary.totalSeconds % 3600) / 60
|
|
let timeStr = hours > 0
|
|
? String(format: "%dh %dm", hours, mins)
|
|
: String(format: "%dm", mins)
|
|
let completedCount = entries.filter { $0.completed }.count
|
|
let summaryText = "\(summary.intervals) intervals | \(completedCount) completed | \(timeStr) focused"
|
|
let summaryLabel = NSTextField(labelWithString: summaryText)
|
|
summaryLabel.font = NSFont.systemFont(ofSize: 22, weight: .medium)
|
|
summaryLabel.textColor = NSColor.white.withAlphaComponent(0.85)
|
|
summaryLabel.alignment = .center
|
|
summaryLabel.sizeToFit()
|
|
summaryLabel.frame.origin = CGPoint(
|
|
x: (size.width - summaryLabel.frame.width) / 2,
|
|
y: size.height - 165
|
|
)
|
|
view.addSubview(summaryLabel)
|
|
|
|
if isPrimary {
|
|
// Close button
|
|
let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36))
|
|
closeBtn.title = "Close"
|
|
closeBtn.bezelStyle = .rounded
|
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
|
closeBtn.target = delegate
|
|
closeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume)
|
|
view.addSubview(closeBtn)
|
|
|
|
// Scrollable entry list
|
|
let scrollW: CGFloat = min(700, size.width - 100)
|
|
let scrollH: CGFloat = size.height - 300
|
|
let scrollX = (size.width - scrollW) / 2
|
|
let scrollY: CGFloat = 120
|
|
|
|
let scrollView = NSScrollView(frame: NSRect(x: scrollX, y: scrollY, width: scrollW, height: scrollH))
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.drawsBackground = false
|
|
scrollView.autohidesScrollers = true
|
|
|
|
let contentView = buildEntryList(entries: entries, width: scrollW)
|
|
scrollView.documentView = contentView
|
|
view.addSubview(scrollView)
|
|
|
|
// Bottom buttons: Back to Work | Quit
|
|
let btnW: CGFloat = 180
|
|
let btnH: CGFloat = 44
|
|
let gap: CGFloat = 24
|
|
let totalW = btnW * 3 + gap * 2
|
|
let startX = (size.width - totalW) / 2
|
|
let btnY: CGFloat = 50
|
|
|
|
let copyBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH))
|
|
copyBtn.title = "Copy"
|
|
copyBtn.bezelStyle = .rounded
|
|
copyBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
|
copyBtn.target = delegate
|
|
copyBtn.action = #selector(AppDelegate.copyWorkLog)
|
|
view.addSubview(copyBtn)
|
|
|
|
let resumeBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH))
|
|
|
|
resumeBtn.title = "Back to Work"
|
|
resumeBtn.bezelStyle = .rounded
|
|
resumeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
|
resumeBtn.target = delegate
|
|
resumeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume)
|
|
view.addSubview(resumeBtn)
|
|
|
|
let quitBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH))
|
|
quitBtn.title = "Quit Pommedoro"
|
|
quitBtn.bezelStyle = .rounded
|
|
quitBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
|
quitBtn.target = delegate
|
|
quitBtn.action = #selector(AppDelegate.closePommedoro)
|
|
view.addSubview(quitBtn)
|
|
}
|
|
|
|
window.contentView = view
|
|
}
|
|
|
|
// MARK: - Entry List
|
|
|
|
private static func buildEntryList(entries: [WorkLogEntry], width: CGFloat) -> NSView {
|
|
let rowHeight: CGFloat = 86
|
|
let padding: CGFloat = 12
|
|
let totalHeight = max(CGFloat(entries.count) * (rowHeight + padding) + padding, 100)
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: totalHeight))
|
|
|
|
if entries.isEmpty {
|
|
let empty = NSTextField(labelWithString: "No intervals logged yet today.\nStart a work cycle and your log will appear here.")
|
|
empty.font = NSFont.systemFont(ofSize: 18, weight: .medium)
|
|
empty.textColor = NSColor.white.withAlphaComponent(0.7)
|
|
empty.alignment = .center
|
|
empty.maximumNumberOfLines = 0
|
|
empty.preferredMaxLayoutWidth = width - 40
|
|
empty.sizeToFit()
|
|
empty.frame.origin = CGPoint(
|
|
x: (width - empty.frame.width) / 2,
|
|
y: (totalHeight - empty.frame.height) / 2
|
|
)
|
|
container.addSubview(empty)
|
|
return container
|
|
}
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "h:mm a"
|
|
|
|
for (index, entry) in entries.enumerated() {
|
|
let y = totalHeight - CGFloat(index + 1) * (rowHeight + padding)
|
|
let row = NSView(frame: NSRect(x: 0, y: y, width: width, height: rowHeight))
|
|
row.wantsLayer = true
|
|
row.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
|
row.layer?.cornerRadius = 8
|
|
|
|
// Time and duration
|
|
let timeStr = formatter.string(from: entry.date)
|
|
let durMins = entry.durationSeconds / 60
|
|
let status = entry.completed ? "completed" : "partial"
|
|
let header = "\(timeStr) — \(durMins)m (\(status))"
|
|
let headerLabel = NSTextField(labelWithString: header)
|
|
headerLabel.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold)
|
|
headerLabel.textColor = NSColor.white.withAlphaComponent(0.9)
|
|
headerLabel.frame = NSRect(x: 16, y: rowHeight - 24, width: width - 32, height: 18)
|
|
row.addSubview(headerLabel)
|
|
|
|
// Intent (always show line; use "—" when nil or empty)
|
|
let rawIntent = entry.intent?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let intentText = (rawIntent?.isEmpty != false) ? "—" : (rawIntent ?? "—")
|
|
let intentLabel = NSTextField(labelWithString: "Intent: \(intentText)")
|
|
intentLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
|
intentLabel.textColor = NSColor.white.withAlphaComponent(0.75)
|
|
intentLabel.frame = NSRect(x: 16, y: rowHeight - 44, width: width - 32, height: 16)
|
|
intentLabel.lineBreakMode = .byTruncatingTail
|
|
if let intent = rawIntent, !intent.isEmpty { intentLabel.toolTip = intent }
|
|
row.addSubview(intentLabel)
|
|
|
|
// Reflection
|
|
let reflText = entry.reflection ?? "—"
|
|
let reflLabel = NSTextField(labelWithString: "Reflection: \(reflText)")
|
|
reflLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
|
reflLabel.textColor = NSColor.white.withAlphaComponent(0.75)
|
|
reflLabel.frame = NSRect(x: 16, y: rowHeight - 62, width: width - 32, height: 16)
|
|
reflLabel.lineBreakMode = .byTruncatingTail
|
|
row.addSubview(reflLabel)
|
|
|
|
// Suggestion feedback
|
|
let suggText = formatSuggestionFeedback(entry)
|
|
let suggLabel = NSTextField(labelWithString: suggText)
|
|
suggLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
|
suggLabel.textColor = NSColor.white.withAlphaComponent(0.65)
|
|
suggLabel.frame = NSRect(x: 16, y: rowHeight - 80, width: width - 32, height: 16)
|
|
suggLabel.lineBreakMode = .byTruncatingTail
|
|
row.addSubview(suggLabel)
|
|
|
|
container.addSubview(row)
|
|
}
|
|
|
|
return container
|
|
}
|
|
|
|
private static func formatSuggestionFeedback(_ entry: WorkLogEntry) -> String {
|
|
guard let suggestion = entry.suggestion, !suggestion.isEmpty else {
|
|
return "Wellness: —"
|
|
}
|
|
let fbStr: String
|
|
switch entry.suggestionFeedback {
|
|
case .didIt:
|
|
let wellStr: String
|
|
switch entry.wellnessReflection {
|
|
case .great: wellStr = " — felt great"
|
|
case .same: wellStr = " — about the same"
|
|
case .notReally: wellStr = " — not really"
|
|
case .none: wellStr = ""
|
|
}
|
|
fbStr = "did it\(wellStr)"
|
|
case .skipped:
|
|
fbStr = "skipped"
|
|
case .dismissed:
|
|
fbStr = "removed from pool"
|
|
case .none:
|
|
fbStr = "—"
|
|
}
|
|
return "Wellness: \(suggestion) (\(fbStr))"
|
|
}
|
|
}
|