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..521c0a8 --- /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: Activity.colorHex(for: act.category) + )) + } + 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: "会议" + ), + Activity( + title: "NLP 科研训练项目", + startDate: days(-7), + endDate: days(60), + description: "基于大模型的情感分析研究", + category: "研究" + ), + ] + save() + } +} diff --git a/TodoFlow/Sources/TodoFlow/Models.swift b/TodoFlow/Sources/TodoFlow/Models.swift new file mode 100644 index 0000000..ec351db --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Models.swift @@ -0,0 +1,140 @@ +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: String = "项目" // free-form; was enum, kept as String for Codable compat + var stickyNotes: [StickyNote] = [] + var createdAt: Date = Date() + + // 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" + } + } + + // 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] + } +} + +// 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..52478d8 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Theme.swift @@ -0,0 +1,118 @@ +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 — 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 + 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..fa33b96 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/ActivitiesView.swift @@ -0,0 +1,306 @@ +import SwiftUI + +// MARK: - ActivitiesView + +struct ActivitiesView: View { + @EnvironmentObject var store: AppStore + + @State private var showAddActivity = false + + 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) { + + // ── 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 { + 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: Activity.icon(for: cat)) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: Activity.colorHex(for: cat))) + Text(cat) + .sectionHeader() + } + .padding(.horizontal, 24) + + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { activity in + NavigationLink { + ActivityDetailView(activity: activity) + .environmentObject(store) + } label: { + ActivityCard(activity: activity) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + } + } + } + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + .sheet(isPresented: $showAddActivity) { + AddActivitySheet().environmentObject(store) + } + } +} + +// MARK: - ActivityCard + +struct ActivityCard: View { + let activity: Activity + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label(activity.category, systemImage: Activity.icon(for: activity.category)) + .font(Theme.captionFont).fontWeight(.semibold) + .foregroundColor(Color(hex: Activity.colorHex(for: activity.category))) + .padding(.horizontal, 8).padding(.vertical, 3) + .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)) + } + .foregroundColor(Theme.textTertiary) + } + } + + Text(activity.title) + .font(Font.custom("Songti SC", 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() + } + + 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 (full page) + +struct ActivityDetailView: View { + @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 { + 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) + 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(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(hex: Activity.colorHex(for: live.category)).opacity(0.05)) + + // ── 便利贴 ───────────────────────────────────── + PageSectionHeader(title: "便利贴", icon: "note.text") + + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("共 \(live.stickyNotes.count) 张") + .font(Theme.captionFont).foregroundColor(Theme.textSecondary) + Spacer() + Button { showAddNote = true } label: { + Label("添加便利贴", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + .padding(.horizontal, 24).padding(.top, 12) + + 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: 60) + } + } + } + .background(Theme.background) + .navigationTitle(live.title) + .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 { + 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) + 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..5231df7 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/ContentView.swift @@ -0,0 +1,48 @@ +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: { + NavigationStack { + 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..f252685 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/CourseDetailView.swift @@ -0,0 +1,443 @@ +import SwiftUI + +// MARK: - CourseDetailView (full-page, no tabs) + +struct CourseDetailView: View { + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + let course: Course + + @State private var showAddAssignment = false + @State private var showEditCourse = false + + private var live: Course { store.courses.first { $0.id == course.id } ?? course } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + // ── Course header ────────────────────────────── + HStack(spacing: 0) { + Rectangle() + .fill(Color(hex: live.colorHex)) + .frame(width: 5) + + VStack(alignment: .leading, spacing: 6) { + Text(live.code) + .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) + Text(live.name) + .font(Theme.titleFont) + .foregroundColor(Theme.textPrimary) + Text(live.professor) + .font(Theme.englishBodyFont) + .foregroundColor(Theme.textSecondary) + } + .padding(20) + + 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) + } + } + .background(Theme.background) + .navigationTitle(live.name) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showEditCourse = true } label: { + Label("编辑课程", systemImage: "pencil") + } + } + } + .sheet(isPresented: $showAddAssignment) { + AddAssignmentSheet(courseId: live.id).environmentObject(store) + } + .sheet(isPresented: $showEditCourse) { + EditCourseSheet(course: live).environmentObject(store) + } + } +} + +// 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 { + 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, 12) + + 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) + } + } + } + } + } +} + +// 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(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, 12) + + 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 { + 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) + } + } + } + } + .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 { + 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, 12) + + 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..7b0f9c7 --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/CoursesView.swift @@ -0,0 +1,228 @@ +import SwiftUI + +// MARK: - CoursesView + +struct CoursesView: View { + @EnvironmentObject var store: AppStore + @EnvironmentObject var eLearning: ELearningService + + @State private var showAddCourse = false + + 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 + NavigationLink { + CourseDetailView(course: course) + .environmentObject(store) + .environmentObject(eLearning) + } label: { + CourseCard(course: course) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + } + + Spacer(minLength: 40) + } + } + .background(Theme.background) + .sheet(isPresented: $showAddCourse) { + AddCourseSheet().environmentObject(store) + } + } + + 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.englishBodyFont) + .fontWeight(.semibold) + .foregroundColor(Color(hex: course.colorHex)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(hex: course.colorHex).opacity(0.12)) + .cornerRadius(5) + Spacer() + } + + 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..a746c51 --- /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..d7ca2cf --- /dev/null +++ b/TodoFlow/Sources/TodoFlow/Views/Sheets.swift @@ -0,0 +1,400 @@ +import SwiftUI +import AppKit + +// ───────────────────────────────────────────── +// 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 = "项目" + @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: 8) { + Text("类型").font(Theme.captionFont).foregroundColor(Theme.textSecondary) + 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 } + } + } + } + } + + 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) + .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) + } + } + } +} + +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()