From 3c4d91e3ce420b5411285555307a5c0963eefae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 09:34:34 +0000 Subject: [PATCH 1/4] feat: scaffold TodoFlow macOS app (SwiftUI + SPM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete project structure for a native macOS academic planner: - 4 views: Calendar (monthly grid), Courses (card grid), Course Detail (assignments / local files / eLearning), Activities & Projects (sticky notes) - Claude-inspired warm off-white theme with amber accent - AppStore: JSON persistence in ~/Library/Application Support/TodoFlow/ - FileService: reads ~/Desktop/ folder, auto-categorises files (courseware / reading / lecture / submission) - ELearningService: Moodle Mobile App REST API login, enrolled course fetch, resource sync with "new item" tracking; credentials stored in macOS Keychain - All add/edit sheets: Course, Assignment, Activity, StickyNote - Sidebar with upcoming-deadline preview and eLearning new-badge Open TodoFlow/Package.swift in Xcode → run on My Mac. https://claude.ai/code/session_01Wx3P3ip3mkWME3rsmzy7pX --- TodoFlow/Package.swift | 13 + TodoFlow/Sources/TodoFlow/AppStore.swift | 193 ++++++++ TodoFlow/Sources/TodoFlow/Models.swift | 145 ++++++ .../TodoFlow/Services/ELearningService.swift | 289 +++++++++++ .../TodoFlow/Services/FileService.swift | 140 ++++++ TodoFlow/Sources/TodoFlow/Theme.swift | 114 +++++ .../TodoFlow/Views/ActivitiesView.swift | 318 ++++++++++++ .../Sources/TodoFlow/Views/CalendarView.swift | 272 +++++++++++ .../Sources/TodoFlow/Views/ContentView.swift | 46 ++ .../TodoFlow/Views/CourseDetailView.swift | 451 ++++++++++++++++++ .../Sources/TodoFlow/Views/CoursesView.swift | 236 +++++++++ .../Sources/TodoFlow/Views/SettingsView.swift | 154 ++++++ TodoFlow/Sources/TodoFlow/Views/Sheets.swift | 381 +++++++++++++++ .../Sources/TodoFlow/Views/SidebarView.swift | 168 +++++++ TodoFlow/Sources/TodoFlow/main.swift | 44 ++ 15 files changed, 2964 insertions(+) create mode 100644 TodoFlow/Package.swift create mode 100644 TodoFlow/Sources/TodoFlow/AppStore.swift create mode 100644 TodoFlow/Sources/TodoFlow/Models.swift create mode 100644 TodoFlow/Sources/TodoFlow/Services/ELearningService.swift create mode 100644 TodoFlow/Sources/TodoFlow/Services/FileService.swift create mode 100644 TodoFlow/Sources/TodoFlow/Theme.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/CalendarView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/ContentView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/CoursesView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/SettingsView.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/Sheets.swift create mode 100644 TodoFlow/Sources/TodoFlow/Views/SidebarView.swift create mode 100644 TodoFlow/Sources/TodoFlow/main.swift diff --git a/TodoFlow/Package.swift b/TodoFlow/Package.swift new file mode 100644 index 0000000..a469370 --- /dev/null +++ b/TodoFlow/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "TodoFlow", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "TodoFlow", + path: "Sources/TodoFlow" + ) + ] +) diff --git a/TodoFlow/Sources/TodoFlow/AppStore.swift b/TodoFlow/Sources/TodoFlow/AppStore.swift new file mode 100644 index 0000000..134e020 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/AppStore.swift @@ -0,0 +1,193 @@ +import Foundation +import SwiftUI + +// MARK: - AppStore + +@MainActor +final class AppStore: ObservableObject { + + @Published var courses: [Course] = [] + @Published var activities: [Activity] = [] + + // MARK: Init + + init() { + load() + } + + // MARK: - Persistence + + private var saveURL: URL { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("TodoFlow", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("data.json") + } + + private struct Snapshot: Codable { + var courses: [Course] + var activities: [Activity] + } + + func save() { + let snap = Snapshot(courses: courses, activities: activities) + if let data = try? JSONEncoder().encode(snap) { + try? data.write(to: saveURL, options: .atomic) + } + } + + func load() { + guard + let data = try? Data(contentsOf: saveURL), + let snap = try? JSONDecoder().decode(Snapshot.self, from: data) + else { + insertSampleData() + return + } + courses = snap.courses + activities = snap.activities + } + + // MARK: - Course CRUD + + func addCourse(_ c: Course) { courses.append(c); save() } + func updateCourse(_ c: Course) { if let i = courses.firstIndex(where: { $0.id == c.id }) { courses[i] = c; save() } } + func deleteCourse(_ c: Course) { courses.removeAll { $0.id == c.id }; save() } + + // MARK: - Assignment CRUD + + func addAssignment(_ a: Assignment) { + guard let i = courses.firstIndex(where: { $0.id == a.courseId }) else { return } + courses[i].assignments.append(a) + save() + } + + func toggleAssignment(_ a: Assignment) { + for i in courses.indices { + if let j = courses[i].assignments.firstIndex(where: { $0.id == a.id }) { + courses[i].assignments[j].isCompleted.toggle() + save() + return + } + } + } + + func updateAssignment(_ a: Assignment) { + for i in courses.indices { + if let j = courses[i].assignments.firstIndex(where: { $0.id == a.id }) { + courses[i].assignments[j] = a + save() + return + } + } + } + + func deleteAssignment(_ a: Assignment) { + for i in courses.indices { courses[i].assignments.removeAll { $0.id == a.id } } + save() + } + + // MARK: - Activity CRUD + + func addActivity(_ a: Activity) { activities.append(a); save() } + func updateActivity(_ a: Activity) { if let i = activities.firstIndex(where: { $0.id == a.id }) { activities[i] = a; save() } } + func deleteActivity(_ a: Activity) { activities.removeAll { $0.id == a.id }; save() } + + // MARK: - Sticky Note CRUD + + func addStickyNote(_ note: StickyNote, to activityId: UUID) { + guard let i = activities.firstIndex(where: { $0.id == activityId }) else { return } + activities[i].stickyNotes.append(note) + save() + } + + func updateStickyNote(_ note: StickyNote, in activityId: UUID) { + guard let i = activities.firstIndex(where: { $0.id == activityId }), + let j = activities[i].stickyNotes.firstIndex(where: { $0.id == note.id }) else { return } + activities[i].stickyNotes[j] = note + save() + } + + func deleteStickyNote(_ noteId: UUID, from activityId: UUID) { + guard let i = activities.firstIndex(where: { $0.id == activityId }) else { return } + activities[i].stickyNotes.removeAll { $0.id == noteId } + save() + } + + // MARK: - Calendar feed + + var calendarEvents: [CalendarEvent] { + var events: [CalendarEvent] = [] + for course in courses { + for a in course.assignments where !a.isCompleted { + events.append(CalendarEvent( + title: a.title, + date: a.dueDate, + courseId: course.id, + colorHex: course.colorHex + )) + } + } + for act in activities { + events.append(CalendarEvent( + title: act.title, + date: act.startDate, + courseId: nil, + colorHex: act.category.colorHex + )) + } + return events.sorted { $0.date < $1.date } + } + + var upcomingCount: Int { + let horizon = Calendar.current.date(byAdding: .day, value: 7, to: Date())! + return courses.reduce(0) { sum, c in + sum + c.assignments.filter { !$0.isCompleted && $0.dueDate >= Date() && $0.dueDate <= horizon }.count + } + } + + // MARK: - Sample data + + private func insertSampleData() { + let cal = Calendar.current + let now = Date() + func days(_ n: Int) -> Date { cal.date(byAdding: .day, value: n, to: now)! } + + var cs = Course(name: "数据结构", code: "CS101", professor: "张教授", colorHex: "3B82F6") + cs.assignments = [ + Assignment(title: "作业一:链表实现", dueDate: days(3), courseId: cs.id), + Assignment(title: "期中项目提交", dueDate: days(14), courseId: cs.id), + ] + + var eng = Course(name: "学术英语写作", code: "ENG201", professor: "Smith 教授", colorHex: "10B981") + eng.assignments = [ + Assignment(title: "Essay Draft 1", dueDate: days(5), courseId: eng.id), + Assignment(title: "Final Essay", dueDate: days(21), courseId: eng.id), + ] + + var econ = Course(name: "微观经济学", code: "ECON101", professor: "李教授", colorHex: "F59E0B") + econ.assignments = [ + Assignment(title: "第三章习题集", dueDate: days(7), courseId: econ.id), + ] + + courses = [cs, eng, econ] + + activities = [ + Activity( + title: "学生会策划会议", + startDate: days(2), + description: "讨论五四晚会安排", + category: .meeting + ), + Activity( + title: "NLP 科研训练项目", + startDate: days(-7), + endDate: days(60), + description: "基于大模型的情感分析研究", + category: .research + ), + ] + save() + } +} diff --git a/TodoFlow/Sources/TodoFlow/Models.swift b/TodoFlow/Sources/TodoFlow/Models.swift new file mode 100644 index 0000000..d6c8e20 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Models.swift @@ -0,0 +1,145 @@ +import Foundation + +// MARK: - Course + +struct Course: Identifiable, Codable, Hashable { + var id: UUID = UUID() + var name: String + var code: String + var professor: String + var colorHex: String + var folderPath: String? // override; default = ~/Desktop/ + var eLearningCourseId: Int? // Moodle course ID + var assignments: [Assignment] = [] + var createdAt: Date = Date() +} + +// MARK: - Assignment + +struct Assignment: Identifiable, Codable, Hashable { + var id: UUID = UUID() + var title: String + var dueDate: Date + var courseId: UUID + var isCompleted: Bool = false + var notes: String = "" + var source: Source = .manual + + enum Source: String, Codable { + case manual + case elearning + } +} + +// MARK: - Activity + +struct Activity: Identifiable, Codable { + var id: UUID = UUID() + var title: String + var startDate: Date + var endDate: Date? + var description: String = "" + var category: Category = .project + var stickyNotes: [StickyNote] = [] + var createdAt: Date = Date() + + enum Category: String, Codable, CaseIterable { + case project = "项目" + case meeting = "会议" + case event = "活动" + case research = "研究" + case other = "其他" + + var icon: String { + switch self { + case .project: return "target" + case .meeting: return "person.2" + case .event: return "calendar.badge.plus" + case .research: return "magnifyingglass" + case .other: return "star" + } + } + + var colorHex: String { + switch self { + case .project: return "3B82F6" + case .meeting: return "8B5CF6" + case .event: return "F59E0B" + case .research: return "10B981" + case .other: return "6B7280" + } + } + } +} + +// MARK: - StickyNote + +struct StickyNote: Identifiable, Codable { + var id: UUID = UUID() + var content: String + var colorName: StickyColor = .yellow + var createdAt: Date = Date() + var activityId: UUID + + enum StickyColor: String, Codable, CaseIterable { + case yellow = "yellow" + case pink = "pink" + case blue = "blue" + case green = "green" + case purple = "purple" + } +} + +// MARK: - Local File (not Codable – rebuilt at runtime) + +struct LocalFile: Identifiable { + var id: String { path } + var name: String + var path: String + var category: FileCategory + var dateModified: Date + var fileExtension: String + + enum FileCategory: String, CaseIterable { + case courseware = "课件" + case reading = "阅读材料" + case lecture = "讲解视频" + case submission = "作业提交" + case other = "其他" + } +} + +// MARK: - eLearning Resource + +struct ELearningResource: Identifiable, Codable { + var id: String + var courseId: Int + var courseName: String + var name: String + var modType: String // assign / resource / url / folder / quiz … + var url: String + var addedTimestamp: Date + var isNew: Bool = false + + var typeIcon: String { + switch modType { + case "resource": return "doc.fill" + case "url": return "link" + case "assign": return "pencil.and.list.clipboard" + case "forum": return "bubble.left.and.bubble.right" + case "quiz": return "questionmark.circle.fill" + case "folder": return "folder.fill" + default: return "doc" + } + } +} + +// MARK: - Calendar Event (computed, not persisted) + +struct CalendarEvent: Identifiable { + var id: String { "\(title)-\(date.timeIntervalSince1970)-\(courseId?.uuidString ?? "act")" } + var title: String + var date: Date + var courseId: UUID? + var colorHex: String +} diff --git a/TodoFlow/Sources/TodoFlow/Services/ELearningService.swift b/TodoFlow/Sources/TodoFlow/Services/ELearningService.swift new file mode 100644 index 0000000..e2c3117 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Services/ELearningService.swift @@ -0,0 +1,289 @@ +import Foundation +import Security + +// MARK: - ELearningService +// Connects to Fudan elearning (Moodle) via the standard Moodle Mobile App web service. +// Credentials are stored only in the macOS Keychain – never transmitted elsewhere. + +@MainActor +final class ELearningService: ObservableObject { + + @Published var isLoggedIn = false + @Published var isLoading = false + @Published var errorMessage: String? + @Published var enrolledCourses: [MoodleCourse] = [] + @Published var resources: [ELearningResource] = [] + @Published var lastSyncDate: Date? + + private var wsToken: String? + private var currentUserId: Int? + private let base = "https://elearning.fudan.edu.cn" + private var syncTask: Task? + + // MARK: - Moodle types + + struct MoodleCourse: Codable, Identifiable { + let id: Int + let fullname: String + let shortname: String + } + + private struct TokenResponse: Codable { + let token: String? + let error: String? + let errorcode: String? + } + + private struct SiteInfo: Codable { + let userid: Int + let username: String + let fullname: String? + } + + private struct CourseModule: Codable { + let id: Int + let name: String + let modname: String + let url: String? + let timemodified: Int? + } + + private struct Section: Codable { + let name: String + let modules: [CourseModule] + } + + // MARK: - Session restore + + func restoreSession() { + guard let token = Keychain.load("tf_elearning_token") else { return } + wsToken = token + if let uid = UserDefaults.standard.object(forKey: "tf_elearning_userid") as? Int { + currentUserId = uid + } + isLoggedIn = true + loadPersistedResources() + } + + // MARK: - Login + + func login(username: String, password: String) async -> Bool { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + guard var components = URLComponents(string: "\(base)/login/token.php") else { return false } + components.queryItems = [ + URLQueryItem(name: "username", value: username), + URLQueryItem(name: "password", value: password), + URLQueryItem(name: "service", value: "moodle_mobile_app"), + ] + + var req = URLRequest(url: components.url!) + req.httpMethod = "POST" + req.httpBody = components.percentEncodedQuery?.data(using: .utf8) + req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + do { + let (data, _) = try await URLSession.shared.data(for: req) + let resp = try JSONDecoder().decode(TokenResponse.self, from: data) + + if let token = resp.token { + wsToken = token + Keychain.save("tf_elearning_token", value: token) + Keychain.save("tf_elearning_user", value: username) + await fetchSiteInfo() + isLoggedIn = true + await fetchEnrolledCourses() + return true + } else { + errorMessage = resp.error ?? "登录失败,请检查账号密码" + return false + } + } catch { + errorMessage = "网络错误:\(error.localizedDescription)" + return false + } + } + + func logout() { + wsToken = nil + currentUserId = nil + isLoggedIn = false + resources = [] + enrolledCourses = [] + syncTask?.cancel() + Keychain.delete("tf_elearning_token") + Keychain.delete("tf_elearning_user") + UserDefaults.standard.removeObject(forKey: "tf_elearning_userid") + saveResources([]) + } + + // MARK: - Fetch + + private func fetchSiteInfo() async { + guard let token = wsToken, + let data = await call("core_webservice_get_site_info", params: [:], token: token), + let info = try? JSONDecoder().decode(SiteInfo.self, from: data) else { return } + currentUserId = info.userid + UserDefaults.standard.set(info.userid, forKey: "tf_elearning_userid") + } + + func fetchEnrolledCourses() async { + guard let token = wsToken, let uid = currentUserId else { return } + guard let data = await call( + "core_enrol_get_users_courses", + params: ["userid": "\(uid)"], + token: token + ), let courses = try? JSONDecoder().decode([MoodleCourse].self, from: data) else { return } + enrolledCourses = courses + } + + func syncResources(for courseId: Int) async -> [ELearningResource] { + guard let token = wsToken else { return [] } + guard let data = await call( + "core_course_get_contents", + params: ["courseid": "\(courseId)"], + token: token + ), let sections = try? JSONDecoder().decode([Section].self, from: data) else { return [] } + + let courseName = enrolledCourses.first { $0.id == courseId }?.fullname ?? "" + var result: [ELearningResource] = [] + for section in sections { + for mod in section.modules { + result.append(ELearningResource( + id: "\(courseId)_\(mod.id)", + courseId: courseId, + courseName: courseName, + name: mod.name, + modType: mod.modname, + url: mod.url ?? "", + addedTimestamp: mod.timemodified.map { Date(timeIntervalSince1970: TimeInterval($0)) } ?? Date() + )) + } + } + return result + } + + func syncAll() async { + guard isLoggedIn else { return } + isLoading = true + defer { isLoading = false } + + await fetchEnrolledCourses() + + let oldIds = Set(resources.map(\.id)) + var fresh: [ELearningResource] = [] + for course in enrolledCourses { + var batch = await syncResources(for: course.id) + for i in batch.indices { + batch[i].isNew = !oldIds.contains(batch[i].id) + } + fresh.append(contentsOf: batch) + } + resources = fresh + lastSyncDate = Date() + saveResources(fresh) + } + + // Mark a resource as "seen" (removes the "new" badge) + func markSeen(_ resourceId: String) { + if let i = resources.firstIndex(where: { $0.id == resourceId }) { + resources[i].isNew = false + saveResources(resources) + } + } + + // MARK: - Auto-sync + + func startAutoSync(intervalMinutes: Int) { + syncTask?.cancel() + guard intervalMinutes > 0 else { return } + syncTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(intervalMinutes) * 60 * 1_000_000_000) + guard !Task.isCancelled else { break } + await self?.syncAll() + } + } + } + + // MARK: - Persistence (eLearning resources survive restarts) + + private var resourcesURL: URL { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("TodoFlow", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("elearning.json") + } + + private func saveResources(_ r: [ELearningResource]) { + if let data = try? JSONEncoder().encode(r) { + try? data.write(to: resourcesURL, options: .atomic) + } + } + + private func loadPersistedResources() { + guard let data = try? Data(contentsOf: resourcesURL), + let r = try? JSONDecoder().decode([ELearningResource].self, from: data) else { return } + // Clear "new" flags on restart – only mark new after an actual sync + resources = r.map { var x = $0; x.isNew = false; return x } + } + + // MARK: - Moodle REST helper + + private func call(_ function: String, params: [String: String], token: String) async -> Data? { + var components = URLComponents(string: "\(base)/webservice/rest/server.php")! + var items: [URLQueryItem] = [ + URLQueryItem(name: "wstoken", value: token), + URLQueryItem(name: "wsfunction", value: function), + URLQueryItem(name: "moodlewsrestformat", value: "json"), + ] + for (k, v) in params { items.append(URLQueryItem(name: k, value: v)) } + components.queryItems = items + guard let url = components.url else { return nil } + return try? await URLSession.shared.data(from: url).0 + } +} + +// MARK: - Keychain helper + +enum Keychain { + private static let service = "TodoFlow" + + static func save(_ key: String, value: String) { + let data = Data(value.utf8) + let q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + ] + SecItemDelete(q as CFDictionary) + SecItemAdd(q as CFDictionary, nil) + } + + static func load(_ key: String) -> String? { + let q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + SecItemCopyMatching(q as CFDictionary, &result) + guard let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(_ key: String) { + let q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(q as CFDictionary) + } +} diff --git a/TodoFlow/Sources/TodoFlow/Services/FileService.swift b/TodoFlow/Sources/TodoFlow/Services/FileService.swift new file mode 100644 index 0000000..83ed0cc --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Services/FileService.swift @@ -0,0 +1,140 @@ +import Foundation +import AppKit + +// MARK: - FileService +// Scans a local folder and auto-categorises files by name / extension. + +enum FileService { + + // MARK: - Public API + + static func defaultFolderPath(for courseName: String) -> String { + let desktop = FileManager.default + .urls(for: .desktopDirectory, in: .userDomainMask)[0] + return desktop.appendingPathComponent(courseName).path + } + + static func folderExists(at path: String) -> Bool { + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + return exists && isDir.boolValue + } + + static func files(at path: String) -> [LocalFile] { + let url = URL(fileURLWithPath: path) + guard let contents = try? FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + return contents.compactMap { fileURL -> LocalFile? in + guard + let res = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]), + res.isRegularFile == true + else { return nil } + + let name = fileURL.lastPathComponent + let ext = fileURL.pathExtension.lowercased() + let mDate = res.contentModificationDate ?? Date() + + return LocalFile( + name: name, + path: fileURL.path, + category: categorise(name: name, ext: ext), + dateModified: mDate, + fileExtension: ext + ) + } + .sorted { $0.dateModified > $1.dateModified } + } + + static func open(_ file: LocalFile) { + NSWorkspace.shared.open(URL(fileURLWithPath: file.path)) + } + + static func openFolder(at path: String) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + + // MARK: - Icons + + static func fileIcon(_ ext: String) -> String { + switch ext { + case "pdf": return "doc.richtext" + case "ppt", "pptx": return "rectangle.on.rectangle" + case "doc", "docx": return "doc.text" + case "xls", "xlsx": return "tablecells" + case "mp4", "mov", "avi", "mkv": return "play.rectangle.fill" + case "zip", "rar", "7z": return "archivebox" + case "png", "jpg", "jpeg", "heic": return "photo" + case "key": return "rectangle.on.rectangle.angled" + case "pages": return "doc.text.fill" + default: return "doc" + } + } + + static func categoryIcon(_ cat: LocalFile.FileCategory) -> String { + switch cat { + case .courseware: return "rectangle.on.rectangle" + case .reading: return "book" + case .lecture: return "play.rectangle" + case .submission: return "paperclip" + case .other: return "doc" + } + } + + static func categoryColor(_ cat: LocalFile.FileCategory) -> String { + switch cat { + case .courseware: return "3B82F6" // blue + case .reading: return "8B5CF6" // violet + case .lecture: return "EC4899" // pink + case .submission: return "F59E0B" // amber + case .other: return "6B7280" // gray + } + } + + // MARK: - Categorisation logic + + private static func categorise(name: String, ext: String) -> LocalFile.FileCategory { + let lower = name.lowercased() + + // Video → lecture recording + if ["mp4", "mov", "avi", "mkv", "m4v", "wmv"].contains(ext) { + return .lecture + } + + // Submission keywords + if lower.contains("submit") || lower.contains("作业") || + lower.contains("hw") || lower.contains("homework") || + lower.contains("assignment") || lower.contains("submission") { + return .submission + } + + // Slides / Courseware keywords + if lower.contains("slide") || lower.contains("lec") || + lower.contains("week") || lower.contains("chapter") || + lower.contains("课件") || lower.contains("讲义") || + ext == "pptx" || ext == "ppt" || ext == "key" { + return .courseware + } + + // Reading material keywords + if lower.contains("reading") || lower.contains("paper") || + lower.contains("article") || lower.contains("阅读") || + lower.contains("文献") || lower.contains("论文") { + return .reading + } + + // PDF: courseware if slide-like name, else reading + if ext == "pdf" { + if lower.contains("lec") || lower.contains("slide") || + lower.contains("课件") || lower.contains("week") { + return .courseware + } + return .reading + } + + return .other + } +} diff --git a/TodoFlow/Sources/TodoFlow/Theme.swift b/TodoFlow/Sources/TodoFlow/Theme.swift new file mode 100644 index 0000000..34e2f27 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Theme.swift @@ -0,0 +1,114 @@ +import SwiftUI + +// MARK: - Color from hex + +extension Color { + init(hex: String) { + let h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: h).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch h.count { + case 3: (a,r,g,b) = (255,(int>>8)*17,(int>>4 & 0xF)*17,(int & 0xF)*17) + case 6: (a,r,g,b) = (255,int>>16,int>>8 & 0xFF,int & 0xFF) + case 8: (a,r,g,b) = (int>>24,int>>16 & 0xFF,int>>8 & 0xFF,int & 0xFF) + default: (a,r,g,b) = (255,0,0,0) + } + self.init(.sRGB, + red: Double(r)/255, + green: Double(g)/255, + blue: Double(b)/255, + opacity: Double(a)/255) + } +} + +// MARK: - Theme + +enum Theme { + + // Backgrounds – warm off-white like Claude's UI + static let background = Color(hex: "F7F6F3") + static let sidebarBg = Color(hex: "EFEDE8") + static let cardBg = Color.white + static let inputBg = Color(hex: "F0EDE8") + static let divider = Color(hex: "E5E2DC") + + // Text + static let textPrimary = Color(hex: "1A1A1A") + static let textSecondary = Color(hex: "6B6B6B") + static let textTertiary = Color(hex: "9B9B9B") + + // Accent – Claude amber + static let accent = Color(hex: "D97706") + static let accentLight = Color(hex: "FEF3C7") + + // Status + static let success = Color(hex: "059669") + static let warning = Color(hex: "D97706") + static let danger = Color(hex: "DC2626") + + // Sticky notes + static let stickyYellow = Color(hex: "FEF08A") + static let stickyPink = Color(hex: "FBCFE8") + static let stickyBlue = Color(hex: "BAE6FD") + static let stickyGreen = Color(hex: "BBF7D0") + static let stickyPurple = Color(hex: "E9D5FF") + + // Typography + static let titleFont = Font.system(size: 24, weight: .semibold) + static let headlineFont = Font.system(size: 16, weight: .semibold) + static let bodyFont = Font.system(size: 14, weight: .regular) + static let captionFont = Font.system(size: 12, weight: .regular) + static let smallFont = Font.system(size: 11, weight: .medium) + + // Layout + static let cornerRadius: CGFloat = 12 + static let cardPadding: CGFloat = 16 + static let sectionSpacing: CGFloat = 24 + + // Sticky color helper + static func stickyColor(_ name: StickyNote.StickyColor) -> Color { + switch name { + case .yellow: return stickyYellow + case .pink: return stickyPink + case .blue: return stickyBlue + case .green: return stickyGreen + case .purple: return stickyPurple + } + } + + // Urgency color for due dates + static func urgencyColor(daysUntil: Int) -> Color { + if daysUntil < 0 { return danger } + if daysUntil <= 1 { return danger } + if daysUntil <= 3 { return warning } + return success + } +} + +// MARK: - View Modifiers + +struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .background(Theme.cardBg) + .cornerRadius(Theme.cornerRadius) + .shadow(color: .black.opacity(0.055), radius: 8, x: 0, y: 2) + } +} + +struct SectionHeaderStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(Theme.captionFont) + .fontWeight(.semibold) + .foregroundColor(Theme.textTertiary) + .textCase(.uppercase) + .tracking(0.5) + } +} + +extension View { + func cardStyle() -> some View { modifier(CardStyle()) } + func sectionHeader() -> some View { modifier(SectionHeaderStyle()) } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift b/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift new file mode 100644 index 0000000..311f868 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift @@ -0,0 +1,318 @@ +import SwiftUI + +// MARK: - ActivitiesView + +struct ActivitiesView: View { + @EnvironmentObject var store: AppStore + + @State private var showAddActivity = false + @State private var selectedActivity: Activity? + + private let columns = [GridItem(.flexible(), spacing: 16), GridItem(.flexible(), spacing: 16)] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Theme.sectionSpacing) { + + // ── Header ────────────────────────────────────── + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("活动 & 项目") + .font(Theme.titleFont).foregroundColor(Theme.textPrimary) + Text("\(store.activities.count) 项进行中") + .font(Theme.bodyFont).foregroundColor(Theme.textSecondary) + } + Spacer() + Button { showAddActivity = true } label: { + Label("添加", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + .padding(.horizontal, 24).padding(.top, 24) + + if store.activities.isEmpty { + EmptyPlaceholder(icon: "target", title: "还没有活动或项目", subtitle: "点击「添加」创建你的第一个项目") + } else { + // Group by category + ForEach(Activity.Category.allCases, id: \.self) { cat in + let items = store.activities.filter { $0.category == cat } + if !items.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: cat.icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: cat.colorHex)) + Text(cat.rawValue) + .sectionHeader() + } + .padding(.horizontal, 24) + + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { activity in + ActivityCard(activity: activity) + .onTapGesture { selectedActivity = activity } + } + } + .padding(.horizontal, 24) + } + } + } + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + .sheet(isPresented: $showAddActivity) { + AddActivitySheet().environmentObject(store) + } + .sheet(item: $selectedActivity) { act in + ActivityDetailView(activity: act).environmentObject(store) + } + } +} + +// MARK: - ActivityCard + +struct ActivityCard: View { + let activity: Activity + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // Category badge + HStack { + Label(activity.category.rawValue, systemImage: activity.category.icon) + .font(Theme.captionFont).fontWeight(.semibold) + .foregroundColor(Color(hex: activity.category.colorHex)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color(hex: activity.category.colorHex).opacity(0.1)) + .cornerRadius(5) + Spacer() + if !activity.stickyNotes.isEmpty { + HStack(spacing: 3) { + Image(systemName: "note.text") + .font(.system(size: 10)) + Text("\(activity.stickyNotes.count)") + .font(.system(size: 10)) + } + .foregroundColor(Theme.textTertiary) + } + } + + Text(activity.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Theme.textPrimary) + .lineLimit(2) + + if !activity.description.isEmpty { + Text(activity.description) + .font(Theme.captionFont).foregroundColor(Theme.textSecondary) + .lineLimit(2) + } + + HStack(spacing: 6) { + Image(systemName: "calendar").font(.system(size: 10)).foregroundColor(Theme.textTertiary) + Text(activity.startDate, style: .date).font(Theme.captionFont).foregroundColor(Theme.textTertiary) + if let end = activity.endDate { + Text("→").font(Theme.captionFont).foregroundColor(Theme.textTertiary) + Text(end, style: .date).font(Theme.captionFont).foregroundColor(Theme.textTertiary) + } + Spacer() + } + + // Sticky note preview (first note) + if let note = activity.stickyNotes.first { + Text(note.content) + .font(Theme.captionFont).foregroundColor(Theme.textPrimary) + .lineLimit(2) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.stickyColor(note.colorName)) + .cornerRadius(6) + } + } + .padding(Theme.cardPadding) + .cardStyle() + } +} + +// MARK: - ActivityDetailView + +struct ActivityDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + let activity: Activity + + @State private var showAddNote = false + + private var live: Activity { store.activities.first { $0.id == activity.id } ?? activity } + + private let noteColumns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + ] + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Label(live.category.rawValue, systemImage: live.category.icon) + .font(Theme.captionFont).fontWeight(.semibold) + .foregroundColor(Color(hex: live.category.colorHex)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)).foregroundColor(Theme.textTertiary) + } + .buttonStyle(.plain) + } + Text(live.title) + .font(Theme.titleFont).foregroundColor(Theme.textPrimary) + HStack(spacing: 6) { + Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Theme.textTertiary) + Text(live.startDate, style: .date).font(Theme.captionFont).foregroundColor(Theme.textSecondary) + if let end = live.endDate { + Text("→").foregroundColor(Theme.textTertiary) + Text(end, style: .date).font(Theme.captionFont).foregroundColor(Theme.textSecondary) + } + } + if !live.description.isEmpty { + Text(live.description) + .font(Theme.bodyFont).foregroundColor(Theme.textSecondary) + .padding(.top, 2) + } + } + .padding(20) + } + .background(Color(hex: live.category.colorHex).opacity(0.05)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Sticky notes header + HStack { + Text("便利贴 (\(live.stickyNotes.count))") + .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Spacer() + Button { showAddNote = true } label: { + Label("添加便利贴", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + .padding(.horizontal, 24).padding(.top, 20) + + if live.stickyNotes.isEmpty { + EmptyPlaceholder(icon: "note.text", title: "还没有便利贴", subtitle: "添加想法、会议记录或备忘") + } else { + LazyVGrid(columns: noteColumns, spacing: 12) { + ForEach(live.stickyNotes) { note in + StickyNoteCard(note: note, activityId: live.id) + } + } + .padding(.horizontal, 24) + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + } + .frame(minWidth: 640, minHeight: 480) + .sheet(isPresented: $showAddNote) { + AddStickyNoteSheet(activityId: live.id).environmentObject(store) + } + } +} + +// MARK: - StickyNoteCard + +struct StickyNoteCard: View { + let note: StickyNote + let activityId: UUID + @EnvironmentObject var store: AppStore + + @State private var isEditing = false + @State private var editedContent = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + // Color dot picker + HStack(spacing: 4) { + ForEach(StickyNote.StickyColor.allCases, id: \.self) { c in + Circle() + .fill(Theme.stickyColor(c)) + .frame(width: 10, height: 10) + .overlay( + Circle().stroke(c == note.colorName ? Color.gray.opacity(0.5) : Color.clear, lineWidth: 1.5) + ) + .onTapGesture { + var updated = note; updated.colorName = c + store.updateStickyNote(updated, in: activityId) + } + } + } + Spacer() + Button { + store.deleteStickyNote(note.id, from: activityId) + } label: { + Image(systemName: "xmark") + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.5)) + } + .buttonStyle(.plain) + } + + if isEditing { + TextEditor(text: $editedContent) + .font(.system(size: 13)) + .frame(minHeight: 70) + .scrollContentBackground(.hidden) + .background(Color.clear) + .onSubmit { + var updated = note; updated.content = editedContent + store.updateStickyNote(updated, in: activityId) + isEditing = false + } + HStack { + Spacer() + Button("保存") { + var updated = note; updated.content = editedContent + store.updateStickyNote(updated, in: activityId) + isEditing = false + } + .font(.system(size: 11, weight: .semibold)) + .buttonStyle(.plain) + .foregroundColor(Theme.accent) + } + } else { + Text(note.content) + .font(.system(size: 13)) + .foregroundColor(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + editedContent = note.content + isEditing = true + } + } + + Spacer() + + Text(note.createdAt, style: .date) + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.5)) + } + .padding(12) + .frame(minHeight: 120) + .background(Theme.stickyColor(note.colorName)) + .cornerRadius(8) + .shadow(color: .black.opacity(0.07), radius: 4, x: 0, y: 2) + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/CalendarView.swift b/TodoFlow/Sources/TodoFlow/Views/CalendarView.swift new file mode 100644 index 0000000..c30f81e --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/CalendarView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +// MARK: - CalendarView + +struct CalendarView: View { + @EnvironmentObject var store: AppStore + + @State private var displayMonth = Date() + @State private var selectedDate: Date? + + private let cal = Calendar.current + private let weekdays = ["日", "一", "二", "三", "四", "五", "六"] + private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) + + var body: some View { + ScrollView { + VStack(spacing: Theme.sectionSpacing) { + + // ── Header ───────────────────────────────────── + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(displayMonth, formatter: monthYearFmt) + .font(Theme.titleFont) + .foregroundColor(Theme.textPrimary) + Text(subtitleText) + .font(Theme.bodyFont) + .foregroundColor(Theme.textSecondary) + } + Spacer() + HStack(spacing: 6) { + navButton("chevron.left") { shiftMonth(-1) } + Button("今天") { displayMonth = Date(); selectedDate = Date() } + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Theme.accent) + .buttonStyle(.plain) + navButton("chevron.right") { shiftMonth(1) } + } + } + .padding(.horizontal, 24) + .padding(.top, 24) + + // ── Calendar grid ────────────────────────────── + VStack(spacing: 0) { + // Weekday labels + HStack(spacing: 0) { + ForEach(weekdays, id: \.self) { d in + Text(d) + .font(Theme.captionFont) + .fontWeight(.semibold) + .foregroundColor(Theme.textTertiary) + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 8) + + Divider().padding(.horizontal, 8) + + // Day cells + LazyVGrid(columns: columns, spacing: 0) { + ForEach(gridDays.indices, id: \.self) { idx in + if let date = gridDays[idx] { + DayCell( + date: date, + events: eventsOn(date), + isToday: cal.isDateInToday(date), + isSelected: selectedDate.map { cal.isDate($0, inSameDayAs: date) } ?? false, + isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month) + ) + .onTapGesture { selectedDate = date } + } else { + Color.clear.frame(height: 68) + } + } + } + .padding(.horizontal, 4) + .padding(.bottom, 4) + } + .cardStyle() + .padding(.horizontal, 24) + + // ── Events for selected date ─────────────────── + if let sel = selectedDate { + let dayEvents = eventsOn(sel) + if !dayEvents.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text(sel, formatter: dayFmt) + .font(Theme.headlineFont) + .foregroundColor(Theme.textPrimary) + .padding(.horizontal, 24) + + ForEach(dayEvents) { ev in + EventRow(event: ev).padding(.horizontal, 24) + } + } + } + } + + // ── Upcoming list ────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + Text("全部待办 (\(upcomingAll.count))") + .font(Theme.headlineFont) + .foregroundColor(Theme.textPrimary) + .padding(.horizontal, 24) + + if upcomingAll.isEmpty { + Text("暂无待办事项") + .font(Theme.bodyFont) + .foregroundColor(Theme.textTertiary) + .padding(.horizontal, 24) + } else { + ForEach(upcomingAll.prefix(20)) { ev in + EventRow(event: ev).padding(.horizontal, 24) + } + } + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + } + + // MARK: - Computed + + private var subtitleText: String { + let n = store.upcomingCount + return n == 0 ? "本周无截止" : "本周 \(n) 项截止" + } + + private var upcomingAll: [CalendarEvent] { + store.calendarEvents.filter { $0.date >= Date() } + } + + private var gridDays: [Date?] { + guard let interval = cal.dateInterval(of: .month, for: displayMonth), + let firstWeekday = cal.dateComponents([.weekday], from: interval.start).weekday + else { return [] } + + var days: [Date?] = Array(repeating: nil, count: firstWeekday - 1) + var cursor = interval.start + while cursor < interval.end { + days.append(cursor) + cursor = cal.date(byAdding: .day, value: 1, to: cursor)! + } + while days.count % 7 != 0 { days.append(nil) } + return days + } + + private func eventsOn(_ date: Date) -> [CalendarEvent] { + store.calendarEvents.filter { cal.isDate($0.date, inSameDayAs: date) } + } + + private func shiftMonth(_ delta: Int) { + displayMonth = cal.date(byAdding: .month, value: delta, to: displayMonth) ?? displayMonth + } + + private func navButton(_ icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Theme.textSecondary) + .frame(width: 28, height: 28) + .background(Theme.inputBg) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + // MARK: - Formatters + + private var monthYearFmt: DateFormatter { + let f = DateFormatter(); f.dateFormat = "yyyy年 M月"; return f + } + private var dayFmt: DateFormatter { + let f = DateFormatter() + f.dateFormat = "M月d日 EEEE" + f.locale = Locale(identifier: "zh_CN") + return f + } +} + +// MARK: - DayCell + +struct DayCell: View { + let date: Date + let events: [CalendarEvent] + let isToday: Bool + let isSelected: Bool + let isCurrentMonth: Bool + + private let cal = Calendar.current + + var body: some View { + VStack(spacing: 3) { + ZStack { + if isSelected { + Circle().fill(Theme.accent).frame(width: 26, height: 26) + } else if isToday { + Circle().fill(Theme.accent.opacity(0.15)).frame(width: 26, height: 26) + } + Text("\(cal.component(.day, from: date))") + .font(.system(size: 12, weight: isToday || isSelected ? .semibold : .regular)) + .foregroundColor( + isSelected ? .white : + isToday ? Theme.accent : + isCurrentMonth ? Theme.textPrimary : Theme.textTertiary + ) + } + + // Up to 3 event dots + HStack(spacing: 2) { + ForEach(events.prefix(3)) { ev in + Circle() + .fill(Color(hex: ev.colorHex)) + .frame(width: 4, height: 4) + } + } + .frame(height: 5) + } + .frame(maxWidth: .infinity) + .frame(height: 58) + .contentShape(Rectangle()) + } +} + +// MARK: - EventRow + +struct EventRow: View { + let event: CalendarEvent + + private var daysUntil: Int { + Calendar.current.dateComponents([.day], from: .now, to: event.date).day ?? 0 + } + + private var daysLabel: String { + if daysUntil < 0 { return "已过期" } + if daysUntil == 0 { return "今天" } + return "\(daysUntil) 天后" + } + + var body: some View { + HStack(spacing: 12) { + Rectangle() + .fill(Color(hex: event.colorHex)) + .frame(width: 3) + .cornerRadius(2) + + VStack(alignment: .leading, spacing: 2) { + Text(event.title) + .font(Theme.bodyFont) + .foregroundColor(Theme.textPrimary) + .lineLimit(1) + Text(event.date, style: .date) + .font(Theme.captionFont) + .foregroundColor(Theme.textSecondary) + } + + Spacer() + + Text(daysLabel) + .font(Theme.captionFont) + .foregroundColor(Theme.urgencyColor(daysUntil: daysUntil)) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Theme.urgencyColor(daysUntil: daysUntil).opacity(0.1)) + .cornerRadius(6) + } + .padding(12) + .cardStyle() + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/ContentView.swift b/TodoFlow/Sources/TodoFlow/Views/ContentView.swift new file mode 100644 index 0000000..667d03d --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/ContentView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +// MARK: - Navigation tabs + +enum AppTab: String, CaseIterable, Hashable { + case calendar = "日历" + case courses = "课程" + case activities = "活动 & 项目" + + var icon: String { + switch self { + case .calendar: return "calendar" + case .courses: return "books.vertical" + case .activities: return "target" + } + } +} + +// MARK: - ContentView + +struct ContentView: View { + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + @State private var selectedTab: AppTab = .courses + @State private var showSettings = false + + var body: some View { + NavigationSplitView(columnVisibility: .constant(.all)) { + SidebarView(selectedTab: $selectedTab, showSettings: $showSettings) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 260) + } detail: { + switch selectedTab { + case .calendar: CalendarView() + case .courses: CoursesView() + case .activities: ActivitiesView() + } + } + .navigationSplitViewStyle(.prominentDetail) + .sheet(isPresented: $showSettings) { + SettingsView() + .environmentObject(store) + .environmentObject(eLearning) + } + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift b/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift new file mode 100644 index 0000000..f14d3e8 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift @@ -0,0 +1,451 @@ +import SwiftUI + +// MARK: - CourseDetailView + +struct CourseDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + let course: Course + + @State private var section: Section = .assignments + @State private var showAddAssignment = false + @State private var showEditCourse = false + + enum Section: String, CaseIterable { + case assignments = "作业 & DDL" + case files = "本地文件" + case elearning = "eLearning" + } + + private var live: Course { store.courses.first { $0.id == course.id } ?? course } + + var body: some View { + VStack(spacing: 0) { + + // ── Header ───────────────────────────────────────── + HStack(spacing: 0) { + Rectangle() + .fill(Color(hex: live.colorHex)) + .frame(width: 5) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(live.code) + .font(Theme.captionFont).fontWeight(.semibold) + .foregroundColor(Color(hex: live.colorHex)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color(hex: live.colorHex).opacity(0.1)) + .cornerRadius(5) + Spacer() + Button { showEditCourse = true } label: { + Image(systemName: "pencil") + .foregroundColor(Theme.textTertiary) + } + .buttonStyle(.plain) + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(Theme.textTertiary) + } + .buttonStyle(.plain) + } + Text(live.name) + .font(Theme.titleFont) + .foregroundColor(Theme.textPrimary) + Text(live.professor) + .font(Theme.bodyFont) + .foregroundColor(Theme.textSecondary) + } + .padding(20) + } + .background(Color(hex: live.colorHex).opacity(0.05)) + + // ── Tab bar ──────────────────────────────────────── + HStack(spacing: 0) { + ForEach(Section.allCases, id: \.self) { s in + Button { section = s } label: { + VStack(spacing: 0) { + Text(s.rawValue) + .font(.system(size: 13, weight: section == s ? .semibold : .regular)) + .foregroundColor(section == s ? Theme.accent : Theme.textSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + Rectangle() + .fill(section == s ? Theme.accent : Color.clear) + .frame(height: 2) + } + } + .buttonStyle(.plain) + } + Spacer() + } + .padding(.horizontal, 8) + Divider() + + // ── Content ──────────────────────────────────────── + ScrollView { + switch section { + case .assignments: AssignmentsSection(course: live, showAddAssignment: $showAddAssignment) + case .files: FilesSection(course: live) + case .elearning: ELearningSection(course: live) + } + } + } + .frame(minWidth: 720, minHeight: 520) + .background(Theme.background) + .sheet(isPresented: $showAddAssignment) { + AddAssignmentSheet(courseId: live.id).environmentObject(store) + } + .sheet(isPresented: $showEditCourse) { + EditCourseSheet(course: live).environmentObject(store) + } + } +} + +// MARK: - Assignments Section + +struct AssignmentsSection: View { + let course: Course + @Binding var showAddAssignment: Bool + @EnvironmentObject var store: AppStore + + private var pending: [Assignment] { course.assignments.filter { !$0.isCompleted }.sorted { $0.dueDate < $1.dueDate } } + private var completed: [Assignment] { course.assignments.filter { $0.isCompleted }.sorted { $0.dueDate > $1.dueDate } } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("待完成 (\(pending.count))") + .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Spacer() + Button { showAddAssignment = true } label: { + Label("添加", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + .padding(.horizontal, 24).padding(.top, 20) + + if pending.isEmpty { + EmptyPlaceholder(icon: "checkmark.circle", title: "没有待完成的作业", subtitle: "添加新作业或从 eLearning 同步") + } else { + VStack(spacing: 8) { + ForEach(pending) { a in + AssignmentRow(assignment: a).padding(.horizontal, 24) + } + } + } + + if !completed.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("已完成 (\(completed.count))") + .sectionHeader() + .padding(.horizontal, 24).padding(.top, 8) + ForEach(completed) { a in + AssignmentRow(assignment: a) + .padding(.horizontal, 24) + .opacity(0.5) + } + } + } + + Spacer(minLength: 40) + } + } +} + +// MARK: - AssignmentRow + +struct AssignmentRow: View { + let assignment: Assignment + @EnvironmentObject var store: AppStore + + private var days: Int { Calendar.current.dateComponents([.day], from: .now, to: assignment.dueDate).day ?? 0 } + + private var daysLabel: String { + if days < 0 { return "已过期" } + if days == 0 { return "今天截止" } + return "\(days) 天后" + } + + var body: some View { + HStack(spacing: 12) { + Button { store.toggleAssignment(assignment) } label: { + Image(systemName: assignment.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundColor(assignment.isCompleted ? Theme.success : Theme.textTertiary) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 2) { + Text(assignment.title) + .font(Theme.bodyFont).foregroundColor(Theme.textPrimary) + .strikethrough(assignment.isCompleted) + .lineLimit(1) + HStack(spacing: 8) { + Text(assignment.dueDate, style: .date) + .font(Theme.captionFont) + .foregroundColor(Theme.urgencyColor(daysUntil: days)) + if assignment.source == .elearning { + Label("eLearning", systemImage: "link") + .font(.system(size: 10)) + .foregroundColor(Theme.textTertiary) + } + } + } + + Spacer() + + Text(daysLabel) + .font(Theme.captionFont) + .foregroundColor(Theme.urgencyColor(daysUntil: days)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Theme.urgencyColor(daysUntil: days).opacity(0.1)) + .cornerRadius(6) + } + .padding(12) + .cardStyle() + .contextMenu { + Button(role: .destructive) { store.deleteAssignment(assignment) } label: { + Label("删除", systemImage: "trash") + } + } + } +} + +// MARK: - Files Section + +struct FilesSection: View { + let course: Course + + @State private var files: [LocalFile] = [] + @State private var selectedCategory: LocalFile.FileCategory? + + private var folderPath: String { + course.folderPath ?? FileService.defaultFolderPath(for: course.name) + } + + private var filtered: [LocalFile] { + guard let cat = selectedCategory else { return files } + return files.filter { $0.category == cat } + } + + private var groups: [LocalFile.FileCategory: [LocalFile]] { + Dictionary(grouping: files, by: \.category) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("本地文件") + .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Text(folderPath) + .font(Theme.captionFont).foregroundColor(Theme.textTertiary) + .lineLimit(1).truncationMode(.middle) + } + Spacer() + Button("刷新") { files = FileService.files(at: folderPath) } + .buttonStyle(.bordered) + Button("打开文件夹") { FileService.openFolder(at: folderPath) } + .buttonStyle(.bordered) + } + .padding(.horizontal, 24).padding(.top, 20) + + if !FileService.folderExists(at: folderPath) { + EmptyPlaceholder( + icon: "folder.badge.questionmark", + title: "未找到文件夹", + subtitle: "请在桌面创建名为「\(course.name)」的文件夹" + ) + } else if files.isEmpty { + EmptyPlaceholder( + icon: "folder", + title: "文件夹为空", + subtitle: "将课程文件放入桌面「\(course.name)」文件夹" + ) + } else { + // Category filter chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip(label: "全部 (\(files.count))", isSelected: selectedCategory == nil) + .onTapGesture { selectedCategory = nil } + ForEach(LocalFile.FileCategory.allCases, id: \.self) { cat in + if let count = groups[cat]?.count, count > 0 { + FilterChip( + label: "\(cat.rawValue) (\(count))", + isSelected: selectedCategory == cat, + colorHex: FileService.categoryColor(cat) + ) + .onTapGesture { selectedCategory = selectedCategory == cat ? nil : cat } + } + } + } + .padding(.horizontal, 24) + } + + LazyVStack(spacing: 8) { + ForEach(filtered) { file in + FileRow(file: file).padding(.horizontal, 24) + } + } + } + + Spacer(minLength: 40) + } + .onAppear { files = FileService.files(at: folderPath) } + } +} + +struct FileRow: View { + let file: LocalFile + + private var colorHex: String { FileService.categoryColor(file.category) } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: FileService.fileIcon(file.fileExtension)) + .font(.system(size: 18)) + .foregroundColor(Color(hex: colorHex)) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .font(Theme.bodyFont).foregroundColor(Theme.textPrimary).lineLimit(1) + Text(file.dateModified, style: .relative) + .font(Theme.captionFont).foregroundColor(Theme.textTertiary) + } + Spacer() + Text(file.category.rawValue) + .font(Theme.captionFont).foregroundColor(Color(hex: colorHex)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color(hex: colorHex).opacity(0.1)) + .cornerRadius(5) + } + .padding(12) + .cardStyle() + .contentShape(Rectangle()) + .onTapGesture { FileService.open(file) } + } +} + +struct FilterChip: View { + let label: String + let isSelected: Bool + var colorHex: String = "D97706" + + var body: some View { + Text(label) + .font(Theme.captionFont) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? Color(hex: colorHex) : Theme.textSecondary) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(isSelected ? Color(hex: colorHex).opacity(0.1) : Theme.inputBg) + .cornerRadius(20) + } +} + +// MARK: - eLearning Section + +struct ELearningSection: View { + let course: Course + @EnvironmentObject var eLearning: ELearningService + + private var courseResources: [ELearningResource] { + guard let cid = course.eLearningCourseId else { return [] } + return eLearning.resources.filter { $0.courseId == cid } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("eLearning 内容") + .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Spacer() + if eLearning.isLoading { + ProgressView().scaleEffect(0.7) + } else if course.eLearningCourseId != nil { + Button("同步") { Task { await eLearning.syncAll() } } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 24).padding(.top, 20) + + if !eLearning.isLoggedIn { + EmptyPlaceholder(icon: "lock", title: "未登录 eLearning", subtitle: "请在「设置」中登录 Fudan eLearning") + } else if course.eLearningCourseId == nil { + EmptyPlaceholder( + icon: "link.badge.plus", + title: "未关联 eLearning 课程", + subtitle: "编辑课程,填写对应的 Moodle 课程 ID" + ) + } else if courseResources.isEmpty { + EmptyPlaceholder(icon: "arrow.clockwise", title: "暂无内容", subtitle: "点击「同步」拉取最新内容") + } else { + let newItems = courseResources.filter(\.isNew) + if !newItems.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("新增 (\(newItems.count))") + .sectionHeader().padding(.horizontal, 24) + ForEach(newItems) { r in + ELearningRow(resource: r).padding(.horizontal, 24) + } + } + .padding(.bottom, 4) + } + VStack(alignment: .leading, spacing: 6) { + Text("全部 (\(courseResources.count))") + .sectionHeader().padding(.horizontal, 24) + ForEach(courseResources) { r in + ELearningRow(resource: r).padding(.horizontal, 24) + } + } + } + + Spacer(minLength: 40) + } + } +} + +struct ELearningRow: View { + let resource: ELearningResource + @EnvironmentObject var eLearning: ELearningService + + var body: some View { + HStack(spacing: 12) { + Image(systemName: resource.typeIcon) + .font(.system(size: 15)) + .foregroundColor(Theme.accent) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(resource.name) + .font(Theme.bodyFont).foregroundColor(Theme.textPrimary).lineLimit(1) + if resource.isNew { + Text("新") + .font(.system(size: 9, weight: .bold)).foregroundColor(.white) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Theme.danger).cornerRadius(3) + } + } + Text(resource.addedTimestamp, style: .date) + .font(Theme.captionFont).foregroundColor(Theme.textTertiary) + } + Spacer() + Text(resource.modType) + .font(Theme.captionFont).foregroundColor(Theme.textTertiary) + } + .padding(12) + .cardStyle() + .contentShape(Rectangle()) + .onTapGesture { + if resource.isNew { eLearning.markSeen(resource.id) } + if let url = URL(string: resource.url), !resource.url.isEmpty { + NSWorkspace.shared.open(url) + } + } + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift b/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift new file mode 100644 index 0000000..35af6fb --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +// MARK: - CoursesView + +struct CoursesView: View { + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + @State private var showAddCourse = false + @State private var selectedCourse: Course? + + private let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Theme.sectionSpacing) { + + // ── Header ───────────────────────────────────── + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("课程") + .font(Theme.titleFont) + .foregroundColor(Theme.textPrimary) + Text("\(store.courses.count) 门课程 · \(totalPending) 项待完成") + .font(Theme.bodyFont) + .foregroundColor(Theme.textSecondary) + } + Spacer() + Button { showAddCourse = true } label: { + Label("添加课程", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + } + .padding(.horizontal, 24) + .padding(.top, 24) + + // ── Course grid ──────────────────────────────── + if store.courses.isEmpty { + EmptyPlaceholder( + icon: "books.vertical", + title: "还没有课程", + subtitle: "点击右上角「添加课程」开始" + ) + } else { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(store.courses) { course in + CourseCard(course: course) + .onTapGesture { selectedCourse = course } + } + } + .padding(.horizontal, 24) + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + .sheet(isPresented: $showAddCourse) { + AddCourseSheet().environmentObject(store) + } + .sheet(item: $selectedCourse) { course in + CourseDetailView(course: course) + .environmentObject(store) + .environmentObject(eLearning) + } + } + + private var totalPending: Int { + store.courses.reduce(0) { $0 + $1.assignments.filter { !$0.isCompleted }.count } + } +} + +// MARK: - CourseCard + +struct CourseCard: View { + let course: Course + @EnvironmentObject var store: AppStore + + private var pending: Int { course.assignments.filter { !$0.isCompleted }.count } + + private var nextDue: Assignment? { + course.assignments + .filter { !$0.isCompleted && $0.dueDate >= Date() } + .sorted { $0.dueDate < $1.dueDate } + .first + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + // Colour accent bar + Rectangle() + .fill(Color(hex: course.colorHex)) + .frame(height: 4) + .cornerRadius(Theme.cornerRadius, corners: [.topLeft, .topRight]) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(course.code) + .font(Theme.captionFont) + .fontWeight(.semibold) + .foregroundColor(Color(hex: course.colorHex)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(hex: course.colorHex).opacity(0.12)) + .cornerRadius(5) + Spacer() + if pending > 0 { + Text("\(pending)") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .frame(width: 19, height: 19) + .background(Theme.danger) + .clipShape(Circle()) + } + } + + Text(course.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Theme.textPrimary) + .lineLimit(2) + + Text(course.professor) + .font(Theme.captionFont) + .foregroundColor(Theme.textSecondary) + } + .padding(Theme.cardPadding) + + Divider() + + // Next deadline footer + Group { + if let next = nextDue { + HStack(spacing: 6) { + Image(systemName: "clock") + .font(.system(size: 10)) + .foregroundColor(Theme.textTertiary) + Text(next.title) + .font(Theme.captionFont) + .foregroundColor(Theme.textSecondary) + .lineLimit(1) + Spacer() + Text(next.dueDate, style: .date) + .font(.system(size: 10)) + .foregroundColor(Theme.textTertiary) + } + } else { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + .font(.system(size: 10)) + .foregroundColor(Theme.success) + Text("暂无待完成作业") + .font(Theme.captionFont) + .foregroundColor(Theme.textTertiary) + } + } + } + .padding(.horizontal, Theme.cardPadding) + .padding(.vertical, 10) + } + .background(Theme.cardBg) + .cornerRadius(Theme.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2) + } +} + +// MARK: - Rounded corners helper + +extension View { + func cornerRadius(_ radius: CGFloat, corners: RectCorner) -> some View { + clipShape(RoundedCorners(radius: radius, corners: corners)) + } +} + +struct RectCorner: OptionSet { + let rawValue: Int + static let topLeft = RectCorner(rawValue: 1 << 0) + static let topRight = RectCorner(rawValue: 1 << 1) + static let bottomLeft = RectCorner(rawValue: 1 << 2) + static let bottomRight = RectCorner(rawValue: 1 << 3) + static let all: RectCorner = [.topLeft, .topRight, .bottomLeft, .bottomRight] +} + +struct RoundedCorners: Shape { + var radius: CGFloat + var corners: RectCorner + func path(in rect: CGRect) -> Path { + var path = Path() + let tl = corners.contains(.topLeft) + let tr = corners.contains(.topRight) + let bl = corners.contains(.bottomLeft) + let br = corners.contains(.bottomRight) + path.move(to: CGPoint(x: rect.minX + (tl ? radius : 0), y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - (tr ? radius : 0), y: rect.minY)) + if tr { path.addArc(center: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), radius: radius, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false) } + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - (br ? radius : 0))) + if br { path.addArc(center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), radius: radius, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) } + path.addLine(to: CGPoint(x: rect.minX + (bl ? radius : 0), y: rect.maxY)) + if bl { path.addArc(center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), radius: radius, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) } + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + (tl ? radius : 0))) + if tl { path.addArc(center: CGPoint(x: rect.minX + radius, y: rect.minY + radius), radius: radius, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) } + path.closeSubpath() + return path + } +} + +// MARK: - Shared empty state + +struct EmptyPlaceholder: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(Theme.textTertiary) + Text(title) + .font(Theme.headlineFont) + .foregroundColor(Theme.textSecondary) + Text(subtitle) + .font(Theme.captionFont) + .foregroundColor(Theme.textTertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(60) + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift b/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift new file mode 100644 index 0000000..3e75cda --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + @State private var username = "" + @State private var password = "" + @State private var isLoggingIn = false + @State private var syncInterval = 30 + + var body: some View { + VStack(spacing: 0) { + // Title bar + HStack { + Text("设置") + .font(Theme.titleFont).foregroundColor(Theme.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)).foregroundColor(Theme.textTertiary) + } + .buttonStyle(.plain) + } + .padding(20) + + Divider() + + Form { + // ── eLearning ────────────────────────────────── + Section { + if eLearning.isLoggedIn { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Theme.success) + VStack(alignment: .leading, spacing: 2) { + Text("已连接 Fudan eLearning") + .font(Theme.bodyFont).foregroundColor(Theme.textPrimary) + if let d = eLearning.lastSyncDate { + Text("上次同步:\(d, style: .relative)前") + .font(Theme.captionFont).foregroundColor(Theme.textSecondary) + } + } + Spacer() + Button("立即同步") { Task { await eLearning.syncAll() } } + .buttonStyle(.bordered) + Button("退出登录", role: .destructive) { eLearning.logout() } + .buttonStyle(.bordered) + } + + Picker("自动同步频率", selection: $syncInterval) { + Text("每 15 分钟").tag(15) + Text("每 30 分钟").tag(30) + Text("每 1 小时").tag(60) + Text("不自动同步").tag(0) + } + .onChange(of: syncInterval) { _, new in + eLearning.startAutoSync(intervalMinutes: new) + } + + let newCount = eLearning.resources.filter(\.isNew).count + if newCount > 0 { + Label("\(newCount) 个未查看的新内容", systemImage: "bell.badge") + .font(Theme.captionFont) + .foregroundColor(Theme.warning) + } + + } else { + // Login form + TextField("学号 / 用户名", text: $username) + SecureField("密码", text: $password) + + HStack { + Button { + isLoggingIn = true + Task { + _ = await eLearning.login(username: username, password: password) + isLoggingIn = false + if eLearning.isLoggedIn { password = "" } + } + } label: { + HStack { + if isLoggingIn { ProgressView().scaleEffect(0.7) } + Text("登录 eLearning") + } + } + .disabled(username.isEmpty || password.isEmpty || isLoggingIn) + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + + Spacer() + } + + if let err = eLearning.errorMessage { + Text(err) + .font(Theme.captionFont) + .foregroundColor(Theme.danger) + } + + Text("账号密码仅存储于本地 macOS Keychain,不会上传至任何服务器。") + .font(Theme.captionFont) + .foregroundColor(Theme.textTertiary) + } + } header: { + Text("eLearning 集成") + } + + // ── Data ────────────────────────────────────── + Section { + HStack { + Text("课程数据") + Spacer() + Text("~/Library/Application Support/TodoFlow/") + .font(Theme.captionFont).foregroundColor(Theme.textTertiary) + } + Button("重置为示例数据", role: .destructive) { + // Clear existing and reload sample data + store.courses = [] + store.activities = [] + store.save() + store.load() + } + } header: { + Text("数据") + } + + // ── About ───────────────────────────────────── + Section { + HStack { + Text("版本") + Spacer() + Text("1.0.0").foregroundColor(Theme.textSecondary) + } + HStack { + Text("作者") + Spacer() + Text("TodoFlow").foregroundColor(Theme.textSecondary) + } + Text("专为复旦学子设计的学期整理工具。支持课程管理、本地文件展示、eLearning 更新追踪与活动便利贴。") + .font(Theme.captionFont).foregroundColor(Theme.textSecondary) + } header: { + Text("关于") + } + } + .formStyle(.grouped) + } + .frame(width: 520, height: 500) + .background(Theme.background) + .onAppear { + eLearning.restoreSession() + } + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/Sheets.swift b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift new file mode 100644 index 0000000..ad490c5 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift @@ -0,0 +1,381 @@ +import SwiftUI + +// ───────────────────────────────────────────── +// MARK: - AddCourseSheet +// ───────────────────────────────────────────── + +struct AddCourseSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + @State private var name = "" + @State private var code = "" + @State private var professor = "" + @State private var colorHex = "3B82F6" + @State private var folderPath = "" + @State private var eLearningId = "" + + private let presetColors = [ + "3B82F6", "10B981", "F59E0B", "EF4444", + "8B5CF6", "EC4899", "06B6D4", "84CC16", + ] + + var body: some View { + SheetContainer(title: "添加课程", onCancel: dismiss.callAsFunction) { + Button("添加") { + var c = Course(name: name, code: code, professor: professor, colorHex: colorHex) + if !folderPath.isEmpty { c.folderPath = folderPath } + if let id = Int(eLearningId) { c.eLearningCourseId = id } + store.addCourse(c) + dismiss() + } + .disabled(name.isEmpty || code.isEmpty) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } content: { + VStack(spacing: 14) { + SheetField(label: "课程名称", placeholder: "例:数据结构", text: $name) + SheetField(label: "课程代码", placeholder: "例:CS101", text: $code) + SheetField(label: "任课教师", placeholder: "例:张教授", text: $professor) + + // Color picker + VStack(alignment: .leading, spacing: 6) { + Text("颜色").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + HStack(spacing: 10) { + ForEach(presetColors, id: \.self) { hex in + Circle() + .fill(Color(hex: hex)) + .frame(width: 24, height: 24) + .overlay( + Circle().stroke(colorHex == hex ? Theme.textPrimary : Color.clear, lineWidth: 2) + ) + .onTapGesture { colorHex = hex } + } + } + } + + SheetField(label: "本地文件夹路径(留空则默认 ~/Desktop/<课程名>)", placeholder: "/Users/you/Desktop/数据结构", text: $folderPath) + SheetField(label: "eLearning Moodle 课程 ID(可选)", placeholder: "例:12345", text: $eLearningId) + } + } + } +} + +// ───────────────────────────────────────────── +// MARK: - EditCourseSheet +// ───────────────────────────────────────────── + +struct EditCourseSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + let course: Course + + @State private var name = "" + @State private var code = "" + @State private var professor = "" + @State private var colorHex = "" + @State private var folderPath = "" + @State private var eLearningId = "" + + private let presetColors = [ + "3B82F6", "10B981", "F59E0B", "EF4444", + "8B5CF6", "EC4899", "06B6D4", "84CC16", + ] + + var body: some View { + SheetContainer(title: "编辑课程", onCancel: dismiss.callAsFunction) { + HStack(spacing: 8) { + Button("删除课程", role: .destructive) { + store.deleteCourse(course) + dismiss() + } + .buttonStyle(.bordered) + Spacer() + Button("保存") { + var c = course + c.name = name; c.code = code; c.professor = professor; c.colorHex = colorHex + c.folderPath = folderPath.isEmpty ? nil : folderPath + c.eLearningCourseId = Int(eLearningId) + store.updateCourse(c) + dismiss() + } + .disabled(name.isEmpty || code.isEmpty) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + } content: { + VStack(spacing: 14) { + SheetField(label: "课程名称", placeholder: "例:数据结构", text: $name) + SheetField(label: "课程代码", placeholder: "例:CS101", text: $code) + SheetField(label: "任课教师", placeholder: "例:张教授", text: $professor) + + VStack(alignment: .leading, spacing: 6) { + Text("颜色").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + HStack(spacing: 10) { + ForEach(presetColors, id: \.self) { hex in + Circle() + .fill(Color(hex: hex)) + .frame(width: 24, height: 24) + .overlay(Circle().stroke(colorHex == hex ? Theme.textPrimary : Color.clear, lineWidth: 2)) + .onTapGesture { colorHex = hex } + } + } + } + + SheetField(label: "本地文件夹路径", placeholder: "/Users/you/Desktop/数据结构", text: $folderPath) + SheetField(label: "eLearning Moodle 课程 ID(可选)", placeholder: "例:12345", text: $eLearningId) + } + } + .onAppear { + name = course.name + code = course.code + professor = course.professor + colorHex = course.colorHex + folderPath = course.folderPath ?? "" + eLearningId = course.eLearningCourseId.map(String.init) ?? "" + } + } +} + +// ───────────────────────────────────────────── +// MARK: - AddAssignmentSheet +// ───────────────────────────────────────────── + +struct AddAssignmentSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + let courseId: UUID + + @State private var title = "" + @State private var dueDate = Date() + @State private var notes = "" + + var body: some View { + SheetContainer(title: "添加作业 / DDL", onCancel: dismiss.callAsFunction) { + Button("添加") { + let a = Assignment(title: title, dueDate: dueDate, courseId: courseId, notes: notes) + store.addAssignment(a) + dismiss() + } + .disabled(title.isEmpty) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } content: { + VStack(spacing: 14) { + SheetField(label: "作业标题", placeholder: "例:第三章习题集", text: $title) + + VStack(alignment: .leading, spacing: 6) { + Text("截止日期").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + DatePicker("", selection: $dueDate, displayedComponents: [.date, .hourAndMinute]) + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 6) { + Text("备注(可选)").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + TextEditor(text: $notes) + .font(Theme.bodyFont) + .frame(height: 60) + .padding(8) + .background(Theme.inputBg) + .cornerRadius(8) + .scrollContentBackground(.hidden) + } + } + } + } +} + +// ───────────────────────────────────────────── +// MARK: - AddActivitySheet +// ───────────────────────────────────────────── + +struct AddActivitySheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + @State private var title = "" + @State private var description = "" + @State private var category = Activity.Category.project + @State private var startDate = Date() + @State private var hasEndDate = false + @State private var endDate = Date() + + var body: some View { + SheetContainer(title: "添加活动 / 项目", onCancel: dismiss.callAsFunction) { + Button("添加") { + let a = Activity( + title: title, + startDate: startDate, + endDate: hasEndDate ? endDate : nil, + description: description, + category: category + ) + store.addActivity(a) + dismiss() + } + .disabled(title.isEmpty) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } content: { + VStack(spacing: 14) { + SheetField(label: "标题", placeholder: "例:学生会策划会议", text: $title) + + VStack(alignment: .leading, spacing: 6) { + Text("类型").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + Picker("", selection: $category) { + ForEach(Activity.Category.allCases, id: \.self) { c in + Text(c.rawValue).tag(c) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + + VStack(alignment: .leading, spacing: 6) { + Text("开始日期").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + DatePicker("", selection: $startDate, displayedComponents: [.date]) + .labelsHidden() + } + + Toggle("设置截止 / 结束日期", isOn: $hasEndDate) + if hasEndDate { + VStack(alignment: .leading, spacing: 6) { + Text("结束日期").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + DatePicker("", selection: $endDate, in: startDate..., displayedComponents: [.date]) + .labelsHidden() + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("描述(可选)").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + TextEditor(text: $description) + .font(Theme.bodyFont) + .frame(height: 60) + .padding(8) + .background(Theme.inputBg) + .cornerRadius(8) + .scrollContentBackground(.hidden) + } + } + } + } +} + +// ───────────────────────────────────────────── +// MARK: - AddStickyNoteSheet +// ───────────────────────────────────────────── + +struct AddStickyNoteSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var store: AppStore + + let activityId: UUID + + @State private var content = "" + @State private var noteColor = StickyNote.StickyColor.yellow + + var body: some View { + SheetContainer(title: "添加便利贴", onCancel: dismiss.callAsFunction) { + Button("添加") { + let n = StickyNote(content: content, colorName: noteColor, activityId: activityId) + store.addStickyNote(n, to: activityId) + dismiss() + } + .disabled(content.isEmpty) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } content: { + VStack(spacing: 14) { + // Color selector + VStack(alignment: .leading, spacing: 8) { + Text("颜色").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + HStack(spacing: 10) { + ForEach(StickyNote.StickyColor.allCases, id: \.self) { c in + ZStack { + Circle().fill(Theme.stickyColor(c)).frame(width: 30, height: 30) + if c == noteColor { + Circle().stroke(Theme.textPrimary.opacity(0.4), lineWidth: 2).frame(width: 30, height: 30) + Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)).foregroundColor(.gray) + } + } + .onTapGesture { noteColor = c } + } + } + } + + // Content + VStack(alignment: .leading, spacing: 6) { + Text("内容").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10) + .fill(Theme.stickyColor(noteColor)) + if content.isEmpty { + Text("写下你的想法、会议记录、提醒…") + .font(Theme.bodyFont).foregroundColor(.gray.opacity(0.5)) + .padding(10) + } + TextEditor(text: $content) + .font(Theme.bodyFont) + .frame(minHeight: 100) + .padding(6) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + .frame(minHeight: 110) + } + } + } + } +} + +// ───────────────────────────────────────────── +// MARK: - Shared sheet scaffolding +// ───────────────────────────────────────────── + +struct SheetContainer: View { + let title: String + let onCancel: () -> Void + @ViewBuilder var trailing: () -> Trailing + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(spacing: 0) { + // Navigation-style header + HStack { + Button("取消", action: onCancel) + .buttonStyle(.plain) + .foregroundColor(Theme.textSecondary) + Spacer() + Text(title) + .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Spacer() + trailing() + } + .padding(20) + + Divider() + + ScrollView { + content() + .padding(20) + } + } + .frame(minWidth: 460, maxWidth: 520) + .background(Theme.background) + } +} + +struct SheetField: View { + let label: String + let placeholder: String + @Binding var text: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(label).font(Theme.captionFont).foregroundColor(Theme.textSecondary) + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .font(Theme.bodyFont) + .padding(10) + .background(Theme.inputBg) + .cornerRadius(8) + } + } +} diff --git a/TodoFlow/Sources/TodoFlow/Views/SidebarView.swift b/TodoFlow/Sources/TodoFlow/Views/SidebarView.swift new file mode 100644 index 0000000..ebb7052 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/SidebarView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +struct SidebarView: View { + @Binding var selectedTab: AppTab + @Binding var showSettings: Bool + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + var body: some View { + VStack(spacing: 0) { + + // ── App logo ────────────────────────────────────── + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Theme.accent) + .frame(width: 30, height: 30) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.white) + .font(.system(size: 16, weight: .semibold)) + } + Text("TodoFlow") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(Theme.textPrimary) + Spacer() + } + .padding(.horizontal, 14) + .padding(.top, 18) + .padding(.bottom, 14) + + Divider().padding(.horizontal, 10) + + // ── Navigation ──────────────────────────────────── + VStack(spacing: 2) { + ForEach(AppTab.allCases, id: \.self) { tab in + SidebarItem( + tab: tab, + isSelected: selectedTab == tab, + badge: tab == .courses ? store.upcomingCount : 0 + ) + .onTapGesture { selectedTab = tab } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + + Divider().padding(.horizontal, 10) + + // ── Upcoming deadlines preview ──────────────────── + let upcoming = upcomingItems + if !upcoming.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("即将到期") + .sectionHeader() + .padding(.horizontal, 10) + .padding(.top, 10) + + ForEach(upcoming.prefix(5)) { event in + HStack(spacing: 8) { + Circle() + .fill(Color(hex: event.colorHex)) + .frame(width: 6, height: 6) + Text(event.title) + .font(Theme.captionFont) + .foregroundColor(Theme.textSecondary) + .lineLimit(1) + Spacer() + Text(event.date, style: .relative) + .font(.system(size: 10)) + .foregroundColor(Theme.textTertiary) + } + .padding(.horizontal, 14) + } + } + .padding(.bottom, 8) + } + + // ── eLearning new-items badge ───────────────────── + let newCount = eLearning.resources.filter(\.isNew).count + if newCount > 0 { + HStack(spacing: 8) { + Image(systemName: "link.badge.plus") + .foregroundColor(Theme.accent) + .font(.system(size: 12)) + Text("\(newCount) 个 eLearning 新内容") + .font(Theme.captionFont) + .foregroundColor(Theme.textSecondary) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Theme.accentLight) + .cornerRadius(8) + .padding(.horizontal, 8) + .padding(.bottom, 4) + } + + Spacer() + + Divider().padding(.horizontal, 10) + + // ── Settings button ─────────────────────────────── + Button { showSettings = true } label: { + HStack(spacing: 10) { + Image(systemName: "gearshape") + .font(.system(size: 13)) + .foregroundColor(Theme.textSecondary) + Text("设置") + .font(Theme.bodyFont) + .foregroundColor(Theme.textSecondary) + Spacer() + if eLearning.isLoggedIn { + Circle().fill(Theme.success).frame(width: 7, height: 7) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + .buttonStyle(.plain) + } + .background(Theme.sidebarBg) + } + + private var upcomingItems: [CalendarEvent] { + let horizon = Calendar.current.date(byAdding: .day, value: 7, to: Date())! + return store.calendarEvents.filter { $0.date >= Date() && $0.date <= horizon } + } +} + +// MARK: - Sidebar Item + +struct SidebarItem: View { + let tab: AppTab + let isSelected: Bool + let badge: Int + + var body: some View { + HStack(spacing: 10) { + Image(systemName: tab.icon) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? Theme.accent : Theme.textSecondary) + .frame(width: 18) + + Text(tab.rawValue) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? Theme.textPrimary : Theme.textSecondary) + + Spacer() + + if badge > 0 { + Text("\(min(badge, 99))") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Theme.danger) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(isSelected ? Theme.accent.opacity(0.12) : Color.clear) + ) + .contentShape(Rectangle()) + } +} diff --git a/TodoFlow/Sources/TodoFlow/main.swift b/TodoFlow/Sources/TodoFlow/main.swift new file mode 100644 index 0000000..6c33aba --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/main.swift @@ -0,0 +1,44 @@ +import SwiftUI + +// MARK: - App definition +// Note: in an SPM executableTarget, @main is not used. +// We define the App struct here and call .main() explicitly. + +struct TodoFlowApp: App { + + @StateObject private var store = AppStore() + @StateObject private var eLearning = ELearningService() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(store) + .environmentObject(eLearning) + .frame(minWidth: 900, minHeight: 600) + .onAppear { + eLearning.restoreSession() + } + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified(showsTitle: false)) + .commands { + CommandGroup(replacing: .newItem) {} // hide default "New Window" + CommandMenu("TodoFlow") { + Button("日历") {} + .keyboardShortcut("1", modifiers: .command) + Button("课程") {} + .keyboardShortcut("2", modifiers: .command) + Button("活动 & 项目") {} + .keyboardShortcut("3", modifiers: .command) + } + } + + Settings { + SettingsView() + .environmentObject(store) + .environmentObject(eLearning) + } + } +} + +TodoFlowApp.main() From caa193afdacafe44d0ac74b995f6a97dc9fd3a7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 02:11:18 +0000 Subject: [PATCH 2/4] fix: use macOS 13-compatible onChange API in SettingsView --- TodoFlow/Sources/TodoFlow/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift b/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift index 3e75cda..a746c51 100644 --- a/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift +++ b/TodoFlow/Sources/TodoFlow/Views/SettingsView.swift @@ -55,7 +55,7 @@ struct SettingsView: View { Text("每 1 小时").tag(60) Text("不自动同步").tag(0) } - .onChange(of: syncInterval) { _, new in + .onChange(of: syncInterval) { new in eLearning.startAutoSync(intervalMinutes: new) } From 0742a4f8fe65751d904cad14f5d22b7d820a0077 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 03:11:56 +0000 Subject: [PATCH 3/4] feat: full-page course detail, Songti SC/Times New Roman fonts, remove pending badge, fix TextField focus in sheets --- TodoFlow/Sources/TodoFlow/Theme.swift | 16 +- .../Sources/TodoFlow/Views/ContentView.swift | 10 +- .../TodoFlow/Views/CourseDetailView.swift | 156 +++++++++--------- .../Sources/TodoFlow/Views/CoursesView.swift | 26 +-- TodoFlow/Sources/TodoFlow/Views/Sheets.swift | 15 ++ 5 files changed, 114 insertions(+), 109 deletions(-) diff --git a/TodoFlow/Sources/TodoFlow/Theme.swift b/TodoFlow/Sources/TodoFlow/Theme.swift index 34e2f27..52478d8 100644 --- a/TodoFlow/Sources/TodoFlow/Theme.swift +++ b/TodoFlow/Sources/TodoFlow/Theme.swift @@ -54,12 +54,16 @@ enum Theme { static let stickyGreen = Color(hex: "BBF7D0") static let stickyPurple = Color(hex: "E9D5FF") - // Typography - static let titleFont = Font.system(size: 24, weight: .semibold) - static let headlineFont = Font.system(size: 16, weight: .semibold) - static let bodyFont = Font.system(size: 14, weight: .regular) - static let captionFont = Font.system(size: 12, weight: .regular) - static let smallFont = Font.system(size: 11, weight: .medium) + // Typography — Chinese: Songti SC (宋体简), English: Times New Roman + static let titleFont = Font.custom("Songti SC", size: 24).weight(.semibold) + static let headlineFont = Font.custom("Songti SC", size: 16).weight(.semibold) + static let bodyFont = Font.custom("Songti SC", size: 14) + static let captionFont = Font.custom("Songti SC", size: 12) + static let smallFont = Font.custom("Songti SC", size: 11).weight(.medium) + + // English-specific typography (course codes, professor names, etc.) + static let englishBodyFont = Font.custom("Times New Roman", size: 14) + static let englishHeadFont = Font.custom("Times New Roman", size: 16) // Layout static let cornerRadius: CGFloat = 12 diff --git a/TodoFlow/Sources/TodoFlow/Views/ContentView.swift b/TodoFlow/Sources/TodoFlow/Views/ContentView.swift index 667d03d..5231df7 100644 --- a/TodoFlow/Sources/TodoFlow/Views/ContentView.swift +++ b/TodoFlow/Sources/TodoFlow/Views/ContentView.swift @@ -30,10 +30,12 @@ struct ContentView: View { SidebarView(selectedTab: $selectedTab, showSettings: $showSettings) .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 260) } detail: { - switch selectedTab { - case .calendar: CalendarView() - case .courses: CoursesView() - case .activities: ActivitiesView() + NavigationStack { + switch selectedTab { + case .calendar: CalendarView() + case .courses: CoursesView() + case .activities: ActivitiesView() + } } } .navigationSplitViewStyle(.prominentDetail) diff --git a/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift b/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift index f14d3e8..f252685 100644 --- a/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift +++ b/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift @@ -1,100 +1,73 @@ import SwiftUI -// MARK: - CourseDetailView +// MARK: - CourseDetailView (full-page, no tabs) struct CourseDetailView: View { - @Environment(\.dismiss) private var dismiss @EnvironmentObject var store: AppStore @EnvironmentObject var eLearning: ELearningService let course: Course - @State private var section: Section = .assignments @State private var showAddAssignment = false - @State private var showEditCourse = false - - enum Section: String, CaseIterable { - case assignments = "作业 & DDL" - case files = "本地文件" - case elearning = "eLearning" - } + @State private var showEditCourse = false private var live: Course { store.courses.first { $0.id == course.id } ?? course } var body: some View { - VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { - // ── Header ───────────────────────────────────────── - HStack(spacing: 0) { - Rectangle() - .fill(Color(hex: live.colorHex)) - .frame(width: 5) + // ── Course header ────────────────────────────── + HStack(spacing: 0) { + Rectangle() + .fill(Color(hex: live.colorHex)) + .frame(width: 5) - VStack(alignment: .leading, spacing: 4) { - HStack { + VStack(alignment: .leading, spacing: 6) { Text(live.code) - .font(Theme.captionFont).fontWeight(.semibold) + .font(Theme.englishBodyFont) + .fontWeight(.semibold) .foregroundColor(Color(hex: live.colorHex)) .padding(.horizontal, 8).padding(.vertical, 3) .background(Color(hex: live.colorHex).opacity(0.1)) .cornerRadius(5) - Spacer() - Button { showEditCourse = true } label: { - Image(systemName: "pencil") - .foregroundColor(Theme.textTertiary) - } - .buttonStyle(.plain) - Button { dismiss() } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 20)) - .foregroundColor(Theme.textTertiary) - } - .buttonStyle(.plain) - } - Text(live.name) - .font(Theme.titleFont) - .foregroundColor(Theme.textPrimary) - Text(live.professor) - .font(Theme.bodyFont) - .foregroundColor(Theme.textSecondary) - } - .padding(20) - } - .background(Color(hex: live.colorHex).opacity(0.05)) - - // ── Tab bar ──────────────────────────────────────── - HStack(spacing: 0) { - ForEach(Section.allCases, id: \.self) { s in - Button { section = s } label: { - VStack(spacing: 0) { - Text(s.rawValue) - .font(.system(size: 13, weight: section == s ? .semibold : .regular)) - .foregroundColor(section == s ? Theme.accent : Theme.textSecondary) - .padding(.horizontal, 16) - .padding(.vertical, 10) - Rectangle() - .fill(section == s ? Theme.accent : Color.clear) - .frame(height: 2) - } + Text(live.name) + .font(Theme.titleFont) + .foregroundColor(Theme.textPrimary) + Text(live.professor) + .font(Theme.englishBodyFont) + .foregroundColor(Theme.textSecondary) } - .buttonStyle(.plain) + .padding(20) + + Spacer() } - Spacer() + .background(Color(hex: live.colorHex).opacity(0.05)) + + // ── 作业 & DDL ───────────────────────────────── + PageSectionHeader(title: "作业 & DDL", icon: "checklist") + AssignmentsSection(course: live, showAddAssignment: $showAddAssignment) + + // ── 本地文件 ─────────────────────────────────── + PageSectionHeader(title: "本地文件", icon: "folder") + FilesSection(course: live) + + // ── eLearning ────────────────────────────────── + PageSectionHeader(title: "eLearning", icon: "link") + ELearningSection(course: live) + + Spacer(minLength: 60) } - .padding(.horizontal, 8) - Divider() - - // ── Content ──────────────────────────────────────── - ScrollView { - switch section { - case .assignments: AssignmentsSection(course: live, showAddAssignment: $showAddAssignment) - case .files: FilesSection(course: live) - case .elearning: ELearningSection(course: live) + } + .background(Theme.background) + .navigationTitle(live.name) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showEditCourse = true } label: { + Label("编辑课程", systemImage: "pencil") } } } - .frame(minWidth: 720, minHeight: 520) - .background(Theme.background) .sheet(isPresented: $showAddAssignment) { AddAssignmentSheet(courseId: live.id).environmentObject(store) } @@ -104,6 +77,34 @@ struct CourseDetailView: View { } } +// MARK: - Page Section Header + +struct PageSectionHeader: View { + let title: String + let icon: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Theme.accent) + Text(title) + .font(Font.custom("Songti SC", size: 20).weight(.semibold)) + .foregroundColor(Theme.textPrimary) + Spacer() + } + .padding(.horizontal, 24) + .padding(.top, 32) + .padding(.bottom, 2) + + Rectangle() + .fill(Theme.divider) + .frame(height: 1.5) + .padding(.horizontal, 24) + .padding(.bottom, 8) + } +} + // MARK: - Assignments Section struct AssignmentsSection: View { @@ -126,7 +127,7 @@ struct AssignmentsSection: View { } .buttonStyle(.borderedProminent).tint(Theme.accent) } - .padding(.horizontal, 24).padding(.top, 20) + .padding(.horizontal, 24).padding(.top, 12) if pending.isEmpty { EmptyPlaceholder(icon: "checkmark.circle", title: "没有待完成的作业", subtitle: "添加新作业或从 eLearning 同步") @@ -150,8 +151,6 @@ struct AssignmentsSection: View { } } } - - Spacer(minLength: 40) } } } @@ -240,8 +239,6 @@ struct FilesSection: View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 2) { - Text("本地文件") - .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) Text(folderPath) .font(Theme.captionFont).foregroundColor(Theme.textTertiary) .lineLimit(1).truncationMode(.middle) @@ -252,7 +249,7 @@ struct FilesSection: View { Button("打开文件夹") { FileService.openFolder(at: folderPath) } .buttonStyle(.bordered) } - .padding(.horizontal, 24).padding(.top, 20) + .padding(.horizontal, 24).padding(.top, 12) if !FileService.folderExists(at: folderPath) { EmptyPlaceholder( @@ -267,7 +264,6 @@ struct FilesSection: View { subtitle: "将课程文件放入桌面「\(course.name)」文件夹" ) } else { - // Category filter chips ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { FilterChip(label: "全部 (\(files.count))", isSelected: selectedCategory == nil) @@ -292,8 +288,6 @@ struct FilesSection: View { } } } - - Spacer(minLength: 40) } .onAppear { files = FileService.files(at: folderPath) } } @@ -361,8 +355,6 @@ struct ELearningSection: View { var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { - Text("eLearning 内容") - .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) Spacer() if eLearning.isLoading { ProgressView().scaleEffect(0.7) @@ -371,7 +363,7 @@ struct ELearningSection: View { .buttonStyle(.bordered) } } - .padding(.horizontal, 24).padding(.top, 20) + .padding(.horizontal, 24).padding(.top, 12) if !eLearning.isLoggedIn { EmptyPlaceholder(icon: "lock", title: "未登录 eLearning", subtitle: "请在「设置」中登录 Fudan eLearning") diff --git a/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift b/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift index 35af6fb..7b0f9c7 100644 --- a/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift +++ b/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift @@ -7,7 +7,6 @@ struct CoursesView: View { @EnvironmentObject var eLearning: ELearningService @State private var showAddCourse = false - @State private var selectedCourse: Course? private let columns = [ GridItem(.flexible(), spacing: 16), @@ -50,8 +49,14 @@ struct CoursesView: View { } else { LazyVGrid(columns: columns, spacing: 16) { ForEach(store.courses) { course in - CourseCard(course: course) - .onTapGesture { selectedCourse = course } + NavigationLink { + CourseDetailView(course: course) + .environmentObject(store) + .environmentObject(eLearning) + } label: { + CourseCard(course: course) + } + .buttonStyle(.plain) } } .padding(.horizontal, 24) @@ -64,11 +69,6 @@ struct CoursesView: View { .sheet(isPresented: $showAddCourse) { AddCourseSheet().environmentObject(store) } - .sheet(item: $selectedCourse) { course in - CourseDetailView(course: course) - .environmentObject(store) - .environmentObject(eLearning) - } } private var totalPending: Int { @@ -103,7 +103,7 @@ struct CourseCard: View { VStack(alignment: .leading, spacing: 8) { HStack { Text(course.code) - .font(Theme.captionFont) + .font(Theme.englishBodyFont) .fontWeight(.semibold) .foregroundColor(Color(hex: course.colorHex)) .padding(.horizontal, 8) @@ -111,14 +111,6 @@ struct CourseCard: View { .background(Color(hex: course.colorHex).opacity(0.12)) .cornerRadius(5) Spacer() - if pending > 0 { - Text("\(pending)") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.white) - .frame(width: 19, height: 19) - .background(Theme.danger) - .clipShape(Circle()) - } } Text(course.name) diff --git a/TodoFlow/Sources/TodoFlow/Views/Sheets.swift b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift index ad490c5..50d20b1 100644 --- a/TodoFlow/Sources/TodoFlow/Views/Sheets.swift +++ b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift @@ -1,4 +1,18 @@ import SwiftUI +import AppKit + +// Forces the sheet's NSWindow to become key so TextFields accept input. +// This is needed because NavigationSplitView retains key-window status on macOS. +private struct SheetWindowFocuser: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let v = NSView() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + v.window?.makeKey() + } + return v + } + func updateNSView(_ nsView: NSView, context: Context) {} +} // ───────────────────────────────────────────── // MARK: - AddCourseSheet @@ -359,6 +373,7 @@ struct SheetContainer: View { } .frame(minWidth: 460, maxWidth: 520) .background(Theme.background) + .background(SheetWindowFocuser()) } } From 51f5df061e815cf10506770b4b4fa93ef96db26b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 03:41:32 +0000 Subject: [PATCH 4/4] feat: activities full-page, free-text category with chips, fix sheet TextField focus --- TodoFlow/Sources/TodoFlow/AppStore.swift | 6 +- TodoFlow/Sources/TodoFlow/Models.swift | 45 +++---- .../TodoFlow/Views/ActivitiesView.swift | 114 ++++++++---------- TodoFlow/Sources/TodoFlow/Views/Sheets.swift | 46 +++---- 4 files changed, 99 insertions(+), 112 deletions(-) diff --git a/TodoFlow/Sources/TodoFlow/AppStore.swift b/TodoFlow/Sources/TodoFlow/AppStore.swift index 134e020..521c0a8 100644 --- a/TodoFlow/Sources/TodoFlow/AppStore.swift +++ b/TodoFlow/Sources/TodoFlow/AppStore.swift @@ -134,7 +134,7 @@ final class AppStore: ObservableObject { title: act.title, date: act.startDate, courseId: nil, - colorHex: act.category.colorHex + colorHex: Activity.colorHex(for: act.category) )) } return events.sorted { $0.date < $1.date } @@ -178,14 +178,14 @@ final class AppStore: ObservableObject { title: "学生会策划会议", startDate: days(2), description: "讨论五四晚会安排", - category: .meeting + category: "会议" ), Activity( title: "NLP 科研训练项目", startDate: days(-7), endDate: days(60), description: "基于大模型的情感分析研究", - category: .research + category: "研究" ), ] save() diff --git a/TodoFlow/Sources/TodoFlow/Models.swift b/TodoFlow/Sources/TodoFlow/Models.swift index d6c8e20..ec351db 100644 --- a/TodoFlow/Sources/TodoFlow/Models.swift +++ b/TodoFlow/Sources/TodoFlow/Models.swift @@ -39,36 +39,31 @@ struct Activity: Identifiable, Codable { var startDate: Date var endDate: Date? var description: String = "" - var category: Category = .project + var category: String = "项目" // free-form; was enum, kept as String for Codable compat var stickyNotes: [StickyNote] = [] var createdAt: Date = Date() - enum Category: String, Codable, CaseIterable { - case project = "项目" - case meeting = "会议" - case event = "活动" - case research = "研究" - case other = "其他" - - var icon: String { - switch self { - case .project: return "target" - case .meeting: return "person.2" - case .event: return "calendar.badge.plus" - case .research: return "magnifyingglass" - case .other: return "star" - } + // Default suggestions shown in the add sheet + static let suggestedCategories = ["项目", "会议", "活动", "研究", "比赛", "实习", "社团", "其他"] + + static func icon(for category: String) -> String { + switch category { + case "项目": return "target" + case "会议": return "person.2" + case "活动": return "calendar.badge.plus" + case "研究": return "magnifyingglass" + case "比赛": return "trophy" + case "实习": return "briefcase" + case "社团": return "person.3" + default: return "star" } + } - var colorHex: String { - switch self { - case .project: return "3B82F6" - case .meeting: return "8B5CF6" - case .event: return "F59E0B" - case .research: return "10B981" - case .other: return "6B7280" - } - } + // Deterministic color per category name (same name → same color) + static func colorHex(for category: String) -> String { + let palette = ["3B82F6", "8B5CF6", "F59E0B", "10B981", "EF4444", "EC4899", "06B6D4", "84CC16"] + let hash = category.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } + return palette[abs(hash) % palette.count] } } diff --git a/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift b/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift index 311f868..fa33b96 100644 --- a/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift +++ b/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift @@ -6,10 +6,15 @@ struct ActivitiesView: View { @EnvironmentObject var store: AppStore @State private var showAddActivity = false - @State private var selectedActivity: Activity? private let columns = [GridItem(.flexible(), spacing: 16), GridItem(.flexible(), spacing: 16)] + // Unique categories present in data, preserving insertion order + private var categories: [String] { + var seen = Set() + return store.activities.compactMap { seen.insert($0.category).inserted ? $0.category : nil } + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.sectionSpacing) { @@ -34,24 +39,28 @@ struct ActivitiesView: View { if store.activities.isEmpty { EmptyPlaceholder(icon: "target", title: "还没有活动或项目", subtitle: "点击「添加」创建你的第一个项目") } else { - // Group by category - ForEach(Activity.Category.allCases, id: \.self) { cat in + ForEach(categories, id: \.self) { cat in let items = store.activities.filter { $0.category == cat } if !items.isEmpty { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { - Image(systemName: cat.icon) + Image(systemName: Activity.icon(for: cat)) .font(.system(size: 11, weight: .semibold)) - .foregroundColor(Color(hex: cat.colorHex)) - Text(cat.rawValue) + .foregroundColor(Color(hex: Activity.colorHex(for: cat))) + Text(cat) .sectionHeader() } .padding(.horizontal, 24) LazyVGrid(columns: columns, spacing: 16) { ForEach(items) { activity in - ActivityCard(activity: activity) - .onTapGesture { selectedActivity = activity } + NavigationLink { + ActivityDetailView(activity: activity) + .environmentObject(store) + } label: { + ActivityCard(activity: activity) + } + .buttonStyle(.plain) } } .padding(.horizontal, 24) @@ -67,9 +76,6 @@ struct ActivitiesView: View { .sheet(isPresented: $showAddActivity) { AddActivitySheet().environmentObject(store) } - .sheet(item: $selectedActivity) { act in - ActivityDetailView(activity: act).environmentObject(store) - } } } @@ -80,28 +86,25 @@ struct ActivityCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - // Category badge HStack { - Label(activity.category.rawValue, systemImage: activity.category.icon) + Label(activity.category, systemImage: Activity.icon(for: activity.category)) .font(Theme.captionFont).fontWeight(.semibold) - .foregroundColor(Color(hex: activity.category.colorHex)) + .foregroundColor(Color(hex: Activity.colorHex(for: activity.category))) .padding(.horizontal, 8).padding(.vertical, 3) - .background(Color(hex: activity.category.colorHex).opacity(0.1)) + .background(Color(hex: Activity.colorHex(for: activity.category)).opacity(0.1)) .cornerRadius(5) Spacer() if !activity.stickyNotes.isEmpty { HStack(spacing: 3) { - Image(systemName: "note.text") - .font(.system(size: 10)) - Text("\(activity.stickyNotes.count)") - .font(.system(size: 10)) + Image(systemName: "note.text").font(.system(size: 10)) + Text("\(activity.stickyNotes.count)").font(.system(size: 10)) } .foregroundColor(Theme.textTertiary) } } Text(activity.title) - .font(.system(size: 15, weight: .semibold)) + .font(Font.custom("Songti SC", size: 15).weight(.semibold)) .foregroundColor(Theme.textPrimary) .lineLimit(2) @@ -121,7 +124,6 @@ struct ActivityCard: View { Spacer() } - // Sticky note preview (first note) if let note = activity.stickyNotes.first { Text(note.content) .font(Theme.captionFont).foregroundColor(Theme.textPrimary) @@ -137,10 +139,9 @@ struct ActivityCard: View { } } -// MARK: - ActivityDetailView +// MARK: - ActivityDetailView (full page) struct ActivityDetailView: View { - @Environment(\.dismiss) private var dismiss @EnvironmentObject var store: AppStore let activity: Activity @@ -156,23 +157,21 @@ struct ActivityDetailView: View { ] var body: some View { - VStack(spacing: 0) { - // Header - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Label(live.category.rawValue, systemImage: live.category.icon) - .font(Theme.captionFont).fontWeight(.semibold) - .foregroundColor(Color(hex: live.category.colorHex)) - Spacer() - Button { dismiss() } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 20)).foregroundColor(Theme.textTertiary) - } - .buttonStyle(.plain) - } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + // ── Header ───────────────────────────────────── + VStack(alignment: .leading, spacing: 6) { + Label(live.category, systemImage: Activity.icon(for: live.category)) + .font(Theme.captionFont).fontWeight(.semibold) + .foregroundColor(Color(hex: Activity.colorHex(for: live.category))) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color(hex: Activity.colorHex(for: live.category)).opacity(0.1)) + .cornerRadius(5) + Text(live.title) .font(Theme.titleFont).foregroundColor(Theme.textPrimary) + HStack(spacing: 6) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Theme.textTertiary) Text(live.startDate, style: .date).font(Theme.captionFont).foregroundColor(Theme.textSecondary) @@ -181,24 +180,24 @@ struct ActivityDetailView: View { Text(end, style: .date).font(Theme.captionFont).foregroundColor(Theme.textSecondary) } } + if !live.description.isEmpty { Text(live.description) .font(Theme.bodyFont).foregroundColor(Theme.textSecondary) .padding(.top, 2) } } - .padding(20) - } - .background(Color(hex: live.category.colorHex).opacity(0.05)) + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(hex: Activity.colorHex(for: live.category)).opacity(0.05)) - Divider() + // ── 便利贴 ───────────────────────────────────── + PageSectionHeader(title: "便利贴", icon: "note.text") - ScrollView { VStack(alignment: .leading, spacing: 16) { - // Sticky notes header HStack { - Text("便利贴 (\(live.stickyNotes.count))") - .font(Theme.headlineFont).foregroundColor(Theme.textPrimary) + Text("共 \(live.stickyNotes.count) 张") + .font(Theme.captionFont).foregroundColor(Theme.textSecondary) Spacer() Button { showAddNote = true } label: { Label("添加便利贴", systemImage: "plus") @@ -206,7 +205,7 @@ struct ActivityDetailView: View { } .buttonStyle(.borderedProminent).tint(Theme.accent) } - .padding(.horizontal, 24).padding(.top, 20) + .padding(.horizontal, 24).padding(.top, 12) if live.stickyNotes.isEmpty { EmptyPlaceholder(icon: "note.text", title: "还没有便利贴", subtitle: "添加想法、会议记录或备忘") @@ -219,12 +218,12 @@ struct ActivityDetailView: View { .padding(.horizontal, 24) } - Spacer(minLength: 40) + Spacer(minLength: 60) } } - .background(Theme.background) } - .frame(minWidth: 640, minHeight: 480) + .background(Theme.background) + .navigationTitle(live.title) .sheet(isPresented: $showAddNote) { AddStickyNoteSheet(activityId: live.id).environmentObject(store) } @@ -244,7 +243,6 @@ struct StickyNoteCard: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - // Color dot picker HStack(spacing: 4) { ForEach(StickyNote.StickyColor.allCases, id: \.self) { c in Circle() @@ -260,9 +258,7 @@ struct StickyNoteCard: View { } } Spacer() - Button { - store.deleteStickyNote(note.id, from: activityId) - } label: { + Button { store.deleteStickyNote(note.id, from: activityId) } label: { Image(systemName: "xmark") .font(.system(size: 10)) .foregroundColor(.gray.opacity(0.5)) @@ -276,11 +272,6 @@ struct StickyNoteCard: View { .frame(minHeight: 70) .scrollContentBackground(.hidden) .background(Color.clear) - .onSubmit { - var updated = note; updated.content = editedContent - store.updateStickyNote(updated, in: activityId) - isEditing = false - } HStack { Spacer() Button("保存") { @@ -297,10 +288,7 @@ struct StickyNoteCard: View { .font(.system(size: 13)) .foregroundColor(Theme.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) - .onTapGesture { - editedContent = note.content - isEditing = true - } + .onTapGesture { editedContent = note.content; isEditing = true } } Spacer() diff --git a/TodoFlow/Sources/TodoFlow/Views/Sheets.swift b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift index 50d20b1..d7ca2cf 100644 --- a/TodoFlow/Sources/TodoFlow/Views/Sheets.swift +++ b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift @@ -1,19 +1,6 @@ import SwiftUI import AppKit -// Forces the sheet's NSWindow to become key so TextFields accept input. -// This is needed because NavigationSplitView retains key-window status on macOS. -private struct SheetWindowFocuser: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - let v = NSView() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - v.window?.makeKey() - } - return v - } - func updateNSView(_ nsView: NSView, context: Context) {} -} - // ───────────────────────────────────────────── // MARK: - AddCourseSheet // ───────────────────────────────────────────── @@ -208,7 +195,7 @@ struct AddActivitySheet: View { @State private var title = "" @State private var description = "" - @State private var category = Activity.Category.project + @State private var category = "项目" @State private var startDate = Date() @State private var hasEndDate = false @State private var endDate = Date() @@ -232,15 +219,23 @@ struct AddActivitySheet: View { VStack(spacing: 14) { SheetField(label: "标题", placeholder: "例:学生会策划会议", text: $title) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text("类型").font(Theme.captionFont).foregroundColor(Theme.textSecondary) - Picker("", selection: $category) { - ForEach(Activity.Category.allCases, id: \.self) { c in - Text(c.rawValue).tag(c) + SheetField(label: "", placeholder: "自定义类型,例:比赛、实习…", text: $category) + // Quick-pick suggestion chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Activity.suggestedCategories, id: \.self) { s in + Text(s) + .font(Theme.captionFont) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(category == s ? Theme.accent.opacity(0.15) : Theme.inputBg) + .foregroundColor(category == s ? Theme.accent : Theme.textSecondary) + .cornerRadius(16) + .onTapGesture { category = s } + } } } - .labelsHidden() - .pickerStyle(.segmented) } VStack(alignment: .leading, spacing: 6) { @@ -373,7 +368,16 @@ struct SheetContainer: View { } .frame(minWidth: 460, maxWidth: 520) .background(Theme.background) - .background(SheetWindowFocuser()) + .onAppear { + // Make the sheet window key so TextFields accept keyboard input on macOS + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NSApp.windows + .filter { $0.isVisible && $0.canBecomeKey } + .max(by: { $0.windowNumber < $1.windowNumber })? + .makeKeyAndOrderFront(nil) + } + } } }