237 lines
11 KiB
Swift
237 lines
11 KiB
Swift
|
|
// MARK: - 冰箱贴 ViewModel
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
final class FridgeViewModel: ObservableObject {
|
|||
|
|
@Published var notes: [FridgeNote] = []
|
|||
|
|
private let session = URLSession(configuration: .default)
|
|||
|
|
|
|||
|
|
struct FridgeNote: Identifiable {
|
|||
|
|
let id: String; let text: String; let role: String; let createdAt: String
|
|||
|
|
var isMine: Bool { role == "眠眠" || role == "user" }
|
|||
|
|
var replies: [FridgeReply] = []
|
|||
|
|
}
|
|||
|
|
struct FridgeReply: Identifiable {
|
|||
|
|
let id: String; let text: String; let role: String; let createdAt: String
|
|||
|
|
var isMine: Bool { role == "眠眠" || role == "user" }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func fetch() async {
|
|||
|
|
let req = ServerConfig.makeRequest(path: "fridge/list")
|
|||
|
|
do {
|
|||
|
|
let (data, _) = try await session.data(for: req)
|
|||
|
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|||
|
|
let items = obj["notes"] as? [[String: Any]] {
|
|||
|
|
notes = items.map { item in
|
|||
|
|
let replies: [FridgeReply] = (item["replies"] as? [[String: Any]] ?? []).map { r in
|
|||
|
|
FridgeReply(id: r["id"] as? String ?? "", text: r["text"] as? String ?? "", role: r["role"] as? String ?? "user", createdAt: r["created_at"] as? String ?? "")
|
|||
|
|
}
|
|||
|
|
return FridgeNote(id: item["id"] as? String ?? "", text: item["text"] as? String ?? "", role: item["role"] as? String ?? "user", createdAt: item["created_at"] as? String ?? "", replies: replies)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func add(text: String) async {
|
|||
|
|
var req = ServerConfig.makeRequest(path: "fridge/add")
|
|||
|
|
req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|||
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text, "role": "眠眠"])
|
|||
|
|
_ = try? await session.data(for: req)
|
|||
|
|
await fetch()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func reply(noteId: String, text: String) async {
|
|||
|
|
var req = ServerConfig.makeRequest(path: "fridge/reply")
|
|||
|
|
req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|||
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": noteId, "text": text, "role": "眠眠"])
|
|||
|
|
_ = try? await session.data(for: req)
|
|||
|
|
await fetch()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func delete(id: String) async {
|
|||
|
|
var req = ServerConfig.makeRequest(path: "fridge/delete")
|
|||
|
|
req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|||
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": id])
|
|||
|
|
_ = try? await session.data(for: req)
|
|||
|
|
await fetch()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - 冰箱贴页
|
|||
|
|
|
|||
|
|
struct FridgePage: View {
|
|||
|
|
@StateObject private var vm = FridgeViewModel()
|
|||
|
|
@State private var showAdd = false
|
|||
|
|
@State private var newText = ""
|
|||
|
|
@State private var selectedNote: FridgeViewModel.FridgeNote?
|
|||
|
|
let theme: Theme
|
|||
|
|
|
|||
|
|
private let size: CGFloat = 42
|
|||
|
|
private let gap: CGFloat = 3
|
|||
|
|
private let cols: [CGFloat] = [32, 77, 122, 167, 212, 257, 302]
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
VStack(spacing: 0) {
|
|||
|
|
Spacer()
|
|||
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|||
|
|
HStack(alignment: .bottom, spacing: 3) {
|
|||
|
|
ForEach((0..<7).reversed(), id: \.self) { dayIndex in
|
|||
|
|
let dayNotes = notesForDay(dayIndex)
|
|||
|
|
VStack(spacing: 3) {
|
|||
|
|
if dayIndex == 6 {
|
|||
|
|
Button { showAdd = true } label: {
|
|||
|
|
Rectangle()
|
|||
|
|
.fill(theme.fridgeAddBtn)
|
|||
|
|
.frame(width: size, height: size)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
ForEach(dayNotes) { note in
|
|||
|
|
Button {
|
|||
|
|
selectedNote = note
|
|||
|
|
} label: {
|
|||
|
|
Rectangle()
|
|||
|
|
.fill(note.isMine ? theme.fridgeMine : theme.fridgeOther)
|
|||
|
|
.frame(width: size, height: size)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
.contextMenu { Button("删除", role: .destructive) { Task { await vm.delete(id: note.id) } } }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 8)
|
|||
|
|
}
|
|||
|
|
.environment(\.layoutDirection, .rightToLeft)
|
|||
|
|
.padding(.bottom, 20)
|
|||
|
|
}
|
|||
|
|
.onAppear { Task { await vm.fetch() } }
|
|||
|
|
.alert("新冰箱贴(最多50字)", isPresented: $showAdd) {
|
|||
|
|
TextField("内容", text: $newText)
|
|||
|
|
Button("取消", role: .cancel) { newText = "" }
|
|||
|
|
Button("添加") { Task { await vm.add(text: String(newText.prefix(50))); newText = "" } }
|
|||
|
|
}
|
|||
|
|
.sheet(item: $selectedNote) { note in
|
|||
|
|
FridgeDetailView(noteId: note.id, vm: vm, theme: theme)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func notesForDay(_ dayIndex: Int) -> [FridgeViewModel.FridgeNote] {
|
|||
|
|
let cal = Calendar.current
|
|||
|
|
let today = cal.startOfDay(for: Date())
|
|||
|
|
guard let targetDay = cal.date(byAdding: .day, value: -(6 - dayIndex), to: today) else { return [] }
|
|||
|
|
let nextDay = cal.date(byAdding: .day, value: 1, to: targetDay)!
|
|||
|
|
return vm.notes.filter { note in
|
|||
|
|
let iso = String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: " ")
|
|||
|
|
let fm = DateFormatter(); fm.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|||
|
|
guard let d = fm.date(from: iso) else { return false }
|
|||
|
|
return d >= targetDay && d < nextDay
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct FridgeNoteCard: View {
|
|||
|
|
let note: FridgeViewModel.FridgeNote
|
|||
|
|
let theme: Theme
|
|||
|
|
var body: some View {
|
|||
|
|
VStack(alignment: .leading, spacing: 4) {
|
|||
|
|
Text(formattedDate).font(.custom("HYPixel-11px-J", size: 18).weight(.bold)).foregroundStyle(theme.textPrimary)
|
|||
|
|
Text(note.text).font(.custom("HYPixel-11px-J", size: 13).weight(.bold)).foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther).lineSpacing(4).lineLimit(2)
|
|||
|
|
if !note.replies.isEmpty {
|
|||
|
|
Text("\(note.replies.count) 条回复").font(.custom("HYPixel-11px-J", size: 11)).foregroundStyle(theme.textTyping)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 20).padding(.vertical, 10)
|
|||
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|||
|
|
.background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private var formattedDate: String {
|
|||
|
|
String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - 冰箱贴详情
|
|||
|
|
|
|||
|
|
struct FridgeDetailView: View {
|
|||
|
|
let noteId: String
|
|||
|
|
@ObservedObject var vm: FridgeViewModel
|
|||
|
|
@Environment(\.dismiss) private var dismiss
|
|||
|
|
@State private var replyText = ""
|
|||
|
|
let theme: Theme
|
|||
|
|
|
|||
|
|
private var note: FridgeViewModel.FridgeNote? {
|
|||
|
|
vm.notes.first(where: { $0.id == noteId })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
VStack(spacing: 0) {
|
|||
|
|
HStack {
|
|||
|
|
Text(formattedDate).font(.custom("HYPixel-11px-J", size: 16).weight(.bold)).foregroundStyle(theme.textPrimary)
|
|||
|
|
Spacer()
|
|||
|
|
Button("关闭") { dismiss() }.foregroundStyle(theme.textTyping)
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 20).padding(.top, 20)
|
|||
|
|
|
|||
|
|
if let note {
|
|||
|
|
Text(note.text)
|
|||
|
|
.font(.custom("HYPixel-11px-J", size: 16))
|
|||
|
|
.foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther)
|
|||
|
|
.lineSpacing(6)
|
|||
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|||
|
|
.padding(20)
|
|||
|
|
.background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther))
|
|||
|
|
.padding(.horizontal, 20).padding(.top, 12)
|
|||
|
|
|
|||
|
|
ScrollView {
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
ForEach(note.replies) { reply in
|
|||
|
|
HStack {
|
|||
|
|
if reply.isMine { Spacer(minLength: 40) }
|
|||
|
|
VStack(alignment: reply.isMine ? .trailing : .leading, spacing: 2) {
|
|||
|
|
Text(reply.text).font(.custom("HYPixel-11px-J", size: 16))
|
|||
|
|
.foregroundStyle(reply.isMine ? theme.bubbleTextRight : theme.bubbleTextLeft)
|
|||
|
|
.lineSpacing(5)
|
|||
|
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
|||
|
|
.background(Rectangle().fill(reply.isMine ? theme.bubbleRight : theme.bubbleLeft))
|
|||
|
|
Text(replyTime(reply.createdAt)).font(.custom("HYPixel-11px-J", size: 10)).foregroundStyle(theme.textTyping)
|
|||
|
|
}
|
|||
|
|
if !reply.isMine { Spacer(minLength: 40) }
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 20)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(.vertical, 12)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HStack(spacing: 0) {
|
|||
|
|
TextField("回复(最多50字)...", text: $replyText)
|
|||
|
|
.font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPlaceholder)
|
|||
|
|
Spacer()
|
|||
|
|
Button {
|
|||
|
|
guard !replyText.isEmpty else { return }
|
|||
|
|
let t = String(replyText.prefix(50)); replyText = ""
|
|||
|
|
Task { await vm.reply(noteId: noteId, text: t) }
|
|||
|
|
} label: {
|
|||
|
|
Image(systemName: "arrowshape.turn.up.left.fill").font(.system(size: 14)).foregroundStyle(.white)
|
|||
|
|
.frame(width: 32, height: 32).background(Circle().fill(theme.sendBtn))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 16).frame(height: 44)
|
|||
|
|
.background(Rectangle().fill(theme.inputBg))
|
|||
|
|
.overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 1))
|
|||
|
|
.padding(.horizontal, 14).padding(.bottom, 8)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.background(theme.bg.ignoresSafeArea())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private var formattedDate: String {
|
|||
|
|
guard let note else { return "" }
|
|||
|
|
return String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-")
|
|||
|
|
}
|
|||
|
|
private func replyTime(_ iso: String) -> String {
|
|||
|
|
String(iso.prefix(19)).replacingOccurrences(of: "T", with: " ")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|