From b63b26bce5b11372aacf3f441e1f59f8c16c6b6c Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 23:23:21 +0800 Subject: [PATCH] feat(timeline): TimelineRow + DateSection + grouping tests + Diary sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimelineRow: 时间线条目单行视图 - DateSection + TimelineGrouping: 今日/昨日/本周/更早分组 - DiaryQuickSheet: 文字日记快速记录入口 - TimelineGroupingTests: 分组逻辑烟测 - SymptomEndSheet / RootView: 配套微调 --- 康康/Features/Diary/DiaryQuickSheet.swift | 102 ++++++++++++++++++++ 康康/Features/Symptom/SymptomEndSheet.swift | 13 --- 康康/Features/Timeline/DateSection.swift | 74 ++++++++++++++ 康康/Features/Timeline/TimelineRow.swift | 73 ++++++++++++++ 康康/RootView.swift | 6 +- 康康Tests/TimelineGroupingTests.swift | 83 ++++++++++++++++ 6 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 康康/Features/Diary/DiaryQuickSheet.swift create mode 100644 康康/Features/Timeline/DateSection.swift create mode 100644 康康/Features/Timeline/TimelineRow.swift create mode 100644 康康Tests/TimelineGroupingTests.swift diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift new file mode 100644 index 0000000..bb07eec --- /dev/null +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -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() + } +} diff --git a/康康/Features/Symptom/SymptomEndSheet.swift b/康康/Features/Symptom/SymptomEndSheet.swift index 5521fcd..8646a9b 100644 --- a/康康/Features/Symptom/SymptomEndSheet.swift +++ b/康康/Features/Symptom/SymptomEndSheet.swift @@ -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 "刚刚" -} diff --git a/康康/Features/Timeline/DateSection.swift b/康康/Features/Timeline/DateSection.swift new file mode 100644 index 0000000..d5a65c6 --- /dev/null +++ b/康康/Features/Timeline/DateSection.swift @@ -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 "刚刚" +} diff --git a/康康/Features/Timeline/TimelineRow.swift b/康康/Features/Timeline/TimelineRow.swift new file mode 100644 index 0000000..0533ac9 --- /dev/null +++ b/康康/Features/Timeline/TimelineRow.swift @@ -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) + } +} diff --git a/康康/RootView.swift b/康康/RootView.swift index 110b67f..0d5f5e4 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -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 { diff --git a/康康Tests/TimelineGroupingTests.swift b/康康Tests/TimelineGroupingTests.swift new file mode 100644 index 0000000..1f5edce --- /dev/null +++ b/康康Tests/TimelineGroupingTests.swift @@ -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 + ) + } +}