feat(timeline): TimelineRow + DateSection + grouping tests + Diary sheet

- TimelineRow: 时间线条目单行视图
- DateSection + TimelineGrouping: 今日/昨日/本周/更早分组
- DiaryQuickSheet: 文字日记快速记录入口
- TimelineGroupingTests: 分组逻辑烟测
- SymptomEndSheet / RootView: 配套微调
This commit is contained in:
link2026
2026-05-25 23:23:21 +08:00
parent b1b8d0a8c7
commit b63b26bce5
6 changed files with 337 additions and 14 deletions

View File

@@ -0,0 +1,102 @@
import SwiftUI
import SwiftData
struct DiaryQuickSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@State private var content: String = ""
@State private var createdAt: Date = .now
private var canSubmit: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
HStack {
Text("写日记")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本机保存")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("内容")
TextField("今天怎么样?", text: $content, axis: .vertical)
.lineLimit(4...10)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("时间")
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
}
.padding(.horizontal, 20)
Spacer(minLength: 12)
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private func submit() {
guard canSubmit else { return }
let entry = DiaryEntry(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
createdAt: createdAt
)
ctx.insert(entry)
try? ctx.save()
dismiss()
}
}

View File

@@ -104,16 +104,3 @@ struct SymptomEndSheet: View {
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let totalMinutes = Int(interval / 60)
let days = totalMinutes / (60 * 24)
let hours = (totalMinutes % (60 * 24)) / 60
let minutes = totalMinutes % 60
if days > 0 && hours > 0 { return "\(days)\(hours) 小时" }
if days > 0 { return "\(days)" }
if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes)" }
if hours > 0 { return "\(hours) 小时" }
if minutes > 0 { return "\(minutes) 分钟" }
return "刚刚"
}

View File

@@ -0,0 +1,74 @@
import Foundation
enum DateSection: Hashable {
case today
case yesterday
case thisWeek
case thisMonth
case year(Int)
var label: String {
switch self {
case .today: return "今天"
case .yesterday: return "昨天"
case .thisWeek: return "本周"
case .thisMonth: return "本月"
case .year(let y): return "\(y)"
}
}
var sortIndex: Int {
switch self {
case .today: return 0
case .yesterday: return 1
case .thisWeek: return 2
case .thisMonth: return 3
case .year(let y): return 10_000 - y
}
}
}
enum TimelineGrouping {
static func section(for date: Date,
now: Date = .now,
calendar: Calendar = .current) -> DateSection {
if calendar.isDateInToday(date) { return .today }
if calendar.isDateInYesterday(date) { return .yesterday }
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
return .thisWeek
}
if calendar.isDate(date, equalTo: now, toGranularity: .month) {
return .thisMonth
}
let year = calendar.component(.year, from: date)
return .year(year)
}
static func group(_ entries: [TimelineEntry],
now: Date = .now,
calendar: Calendar = .current)
-> [(section: DateSection, items: [TimelineEntry])] {
var buckets: [DateSection: [TimelineEntry]] = [:]
for entry in entries {
let key = section(for: entry.date, now: now, calendar: calendar)
buckets[key, default: []].append(entry)
}
return buckets
.map { ($0.key, $0.value.sorted { $0.date > $1.date }) }
.sorted { $0.0.sortIndex < $1.0.sortIndex }
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let totalMinutes = Int(max(0, interval) / 60)
let days = totalMinutes / (60 * 24)
let hours = (totalMinutes % (60 * 24)) / 60
let minutes = totalMinutes % 60
if days > 0 && hours > 0 { return "\(days)\(hours) 小时" }
if days > 0 { return "\(days)" }
if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes)" }
if hours > 0 { return "\(hours) 小时" }
if minutes > 0 { return "\(minutes) 分钟" }
return "刚刚"
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
struct TimelineRow: View {
let entry: TimelineEntry
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(entry.kind.accent.opacity(0.12))
Image(systemName: entry.kind.icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(entry.kind.accent)
}
.frame(width: 36, height: 36)
.overlay(alignment: .topTrailing) {
if entry.isOngoing {
Circle()
.fill(Tj.Palette.brick)
.frame(width: 7, height: 7)
.overlay(Circle().strokeBorder(Tj.Palette.sand, lineWidth: 1.5))
.offset(x: 3, y: -3)
}
}
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
.font(.system(size: 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(entry.title)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
if let trailing = entry.trailing {
Text(trailing)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
.lineLimit(1)
.fixedSize()
}
}
.padding(12)
.tjCard(bordered: true)
}
}
extension Date {
var timelineLabel: String {
let cal = Calendar.current
if cal.isDateInToday(self) {
return self.formatted(date: .omitted, time: .shortened)
}
if cal.isDateInYesterday(self) {
return "昨天 " + self.formatted(date: .omitted, time: .shortened)
}
let now = Date.now
if cal.isDate(self, equalTo: now, toGranularity: .year) {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: self)
}
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年 M 月 d 日"
return f.string(from: self)
}
}

View File

@@ -30,6 +30,7 @@ struct RootView: View {
@State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false
@State private var showDiary = false
var body: some View {
VStack(spacing: 0) {
@@ -56,7 +57,7 @@ struct RootView: View {
case .quick: activeFlow = .quick
case .archive: activeFlow = .archive
case .symptom: showSymptomStart = true
case .diary: break
case .diary: showDiary = true
}
}
}
@@ -64,6 +65,9 @@ struct RootView: View {
.sheet(isPresented: $showSymptomStart) {
SymptomStartSheet()
}
.sheet(isPresented: $showDiary) {
DiaryQuickSheet()
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {

View File

@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import
struct TimelineGroupingTests {
private let now: Date = {
var c = DateComponents()
c.year = 2026; c.month = 5; c.day = 25; c.hour = 12
return Calendar(identifier: .gregorian).date(from: c)!
}()
@Test func todaySection() {
#expect(TimelineGrouping.section(for: now, now: now) == .today)
}
@Test func yesterdaySection() {
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
#expect(TimelineGrouping.section(for: yesterday, now: now) == .yesterday)
}
@Test func thisWeekSection() {
// 5/25 , ( 5/24 zh_CN ; 3 , within-week)
let cal = Calendar(identifier: .gregorian)
let twoDaysAgo = cal.date(byAdding: .day, value: -2, to: now)!
let section = TimelineGrouping.section(for: twoDaysAgo, now: now, calendar: cal)
// 2 .thisWeek .yesterday/.thisMonth .today
#expect(section != .today)
}
@Test func thisMonthSection() {
let earlierThisMonth = Calendar.current.date(byAdding: .day, value: -15, to: now)!
let section = TimelineGrouping.section(for: earlierThisMonth, now: now)
#expect(section == .thisMonth)
}
@Test func yearSection() {
var c = DateComponents()
c.year = 2024; c.month = 3; c.day = 1
let oldDate = Calendar(identifier: .gregorian).date(from: c)!
let section = TimelineGrouping.section(for: oldDate, now: now)
#expect(section == .year(2024))
}
@Test func sectionOrderingTodayFirst() {
let cal = Calendar(identifier: .gregorian)
let yesterday = cal.date(byAdding: .day, value: -1, to: now)!
let lastYear = cal.date(byAdding: .year, value: -1, to: now)!
let entries = [
mockEntry(id: "old", date: lastYear),
mockEntry(id: "yest", date: yesterday),
mockEntry(id: "today", date: now),
]
let grouped = TimelineGrouping.group(entries, now: now, calendar: cal)
let labels = grouped.map { $0.section.label }
#expect(labels.first == "今天")
#expect(labels.last?.contains("") == true)
}
@Test func formatDurationBoundaries() {
#expect(formatDuration(0) == "刚刚")
#expect(formatDuration(30) == "刚刚") // < 1 min
#expect(formatDuration(120) == "2 分钟") // 2 min
#expect(formatDuration(3600) == "1 小时") // 1h
#expect(formatDuration(3600 + 1800) == "1 小时 30 分")
#expect(formatDuration(86400) == "1 天")
#expect(formatDuration(86400 + 3600) == "1 天 1 小时")
}
private func mockEntry(id: String, date: Date) -> TimelineEntry {
TimelineEntry(
id: id,
kind: .diary,
date: date,
title: id,
subtitle: "test",
trailing: nil,
trailingIsAlert: false,
isOngoing: false
)
}
}