Переглянути джерело

Add Teaching class creation with level input and activation fallback.

Made-with: Cursor
huzaifahayat12 3 годин тому
батько
коміт
792b59950d

+ 3 - 1
classroom_app/Auth/GoogleOAuthService.swift

@@ -48,9 +48,11 @@ final class GoogleOAuthService: NSObject {
48 48
         "profile",
49 49
         // Classroom To-do (assignments/quizzes)
50 50
         "https://www.googleapis.com/auth/classroom.courses.readonly",
51
+        "https://www.googleapis.com/auth/classroom.courses",
51 52
         "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
52 53
         "https://www.googleapis.com/auth/classroom.announcements.readonly",
53
-        "https://www.googleapis.com/auth/classroom.rosters.readonly"
54
+        "https://www.googleapis.com/auth/classroom.rosters.readonly",
55
+        "https://www.googleapis.com/auth/classroom.rosters"
54 56
     ]
55 57
 
56 58
     private let tokenStore = KeychainTokenStore()

+ 248 - 0
classroom_app/Google/GoogleClassroomClient.swift

@@ -8,6 +8,14 @@ enum GoogleClassroomClientError: Error {
8 8
 
9 9
 /// Minimal Google Classroom REST wrapper for a student's to-do list.
10 10
 final class GoogleClassroomClient {
11
+    struct CreateCourseRequest: Sendable {
12
+        var name: String
13
+        var section: String?
14
+        var room: String?
15
+        var descriptionHeading: String?
16
+        var description: String?
17
+    }
18
+
11 19
     struct Options: Sendable {
12 20
         var maxCourses: Int
13 21
         var maxCourseWorkPerCourse: Int
@@ -205,6 +213,94 @@ final class GoogleClassroomClient {
205 213
         )
206 214
     }
207 215
 
216
+    func createCourse(accessToken: String, request: CreateCourseRequest) async throws -> ClassroomCourse {
217
+        let trimmedName = request.name.trimmingCharacters(in: .whitespacesAndNewlines)
218
+        guard trimmedName.isEmpty == false else {
219
+            throw GoogleClassroomClientError.decodeFailed("Course name is required.")
220
+        }
221
+
222
+        var payload = CreateCoursePayload(
223
+            name: trimmedName,
224
+            ownerId: "me",
225
+            courseState: "PROVISIONED",
226
+            section: request.section?.nonEmptyTrimmed,
227
+            room: request.room?.nonEmptyTrimmed,
228
+            descriptionHeading: request.descriptionHeading?.nonEmptyTrimmed,
229
+            description: request.description?.nonEmptyTrimmed
230
+        )
231
+        if payload.descriptionHeading == nil, let section = payload.section {
232
+            payload.descriptionHeading = section
233
+        }
234
+
235
+        var requestURL = URLRequest(url: URL(string: "https://classroom.googleapis.com/v1/courses")!)
236
+        requestURL.httpMethod = "POST"
237
+        requestURL.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
238
+        requestURL.setValue("application/json", forHTTPHeaderField: "Content-Type")
239
+        requestURL.httpBody = try JSONEncoder().encode(payload)
240
+
241
+        let (data, response) = try await session.data(for: requestURL)
242
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
243
+        guard (200..<300).contains(http.statusCode) else {
244
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
245
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
246
+        }
247
+
248
+        let created: Course
249
+        do {
250
+            created = try JSONDecoder().decode(Course.self, from: data)
251
+        } catch {
252
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
253
+            throw GoogleClassroomClientError.decodeFailed(raw)
254
+        }
255
+
256
+        let acceptedOrCreated: Course
257
+        do {
258
+            acceptedOrCreated = try await acceptOwnTeachingInvitationIfPresent(accessToken: accessToken, courseId: created.id)
259
+        } catch let err as GoogleClassroomClientError where err.isInsufficientScope {
260
+            throw err
261
+        } catch {
262
+            acceptedOrCreated = created
263
+        }
264
+
265
+        let finalizedCourse: Course
266
+        if (acceptedOrCreated.courseState ?? "PROVISIONED").uppercased() == "ACTIVE" {
267
+            finalizedCourse = acceptedOrCreated
268
+        } else {
269
+            do {
270
+                finalizedCourse = try await updateCourseState(
271
+                    accessToken: accessToken,
272
+                    courseId: acceptedOrCreated.id,
273
+                    courseState: "ACTIVE"
274
+                )
275
+            } catch let err as GoogleClassroomClientError where err.isInsufficientScope {
276
+                throw err
277
+            } catch {
278
+                finalizedCourse = acceptedOrCreated
279
+            }
280
+        }
281
+
282
+        let settledCourse = (try? await waitForCourseToSettle(accessToken: accessToken, courseId: finalizedCourse.id)) ?? finalizedCourse
283
+
284
+        let teachers = (try? await listCourseTeachers(accessToken: accessToken, courseId: settledCourse.id)) ?? []
285
+        let teacherNames = teachers.compactMap { teacher -> String? in
286
+            let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines)
287
+            if let profileName, profileName.isEmpty == false { return profileName }
288
+            let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
289
+            if let email, email.isEmpty == false { return email }
290
+            return nil
291
+        }
292
+
293
+        return ClassroomCourse(
294
+            id: settledCourse.id,
295
+            name: (settledCourse.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (settledCourse.name ?? "Course") : "Course",
296
+            section: settledCourse.section,
297
+            room: settledCourse.room,
298
+            teacherNames: teacherNames,
299
+            enrollmentCode: settledCourse.enrollmentCode,
300
+            courseState: settledCourse.courseState ?? "PROVISIONED"
301
+        )
302
+    }
303
+
208 304
     // MARK: - Courses
209 305
 
210 306
     private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
@@ -292,6 +388,120 @@ final class GoogleClassroomClient {
292 388
         return decoded.teachers ?? []
293 389
     }
294 390
 
391
+    private func updateCourseState(accessToken: String, courseId: String, courseState: String) async throws -> Course {
392
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)")!
393
+        components.queryItems = [
394
+            URLQueryItem(name: "updateMask", value: "courseState")
395
+        ]
396
+
397
+        var request = URLRequest(url: components.url!)
398
+        request.httpMethod = "PATCH"
399
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
400
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
401
+        request.httpBody = try JSONEncoder().encode(UpdateCourseStatePayload(courseState: courseState))
402
+
403
+        let (data, response) = try await session.data(for: request)
404
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
405
+        guard (200..<300).contains(http.statusCode) else {
406
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
407
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
408
+        }
409
+
410
+        do {
411
+            return try JSONDecoder().decode(Course.self, from: data)
412
+        } catch {
413
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
414
+            throw GoogleClassroomClientError.decodeFailed(raw)
415
+        }
416
+    }
417
+
418
+    private func acceptOwnTeachingInvitationIfPresent(accessToken: String, courseId: String) async throws -> Course {
419
+        for attempt in 0..<3 {
420
+            if let invitation = try await listOwnTeacherInvitations(accessToken: accessToken, courseId: courseId).first {
421
+                try await acceptInvitation(accessToken: accessToken, invitationId: invitation.id)
422
+                return try await fetchCourse(accessToken: accessToken, courseId: courseId)
423
+            }
424
+            if attempt < 2 {
425
+                try await Task.sleep(nanoseconds: 400_000_000)
426
+            }
427
+        }
428
+        return try await fetchCourse(accessToken: accessToken, courseId: courseId)
429
+    }
430
+
431
+    private func listOwnTeacherInvitations(accessToken: String, courseId: String) async throws -> [Invitation] {
432
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/invitations")!
433
+        components.queryItems = [
434
+            URLQueryItem(name: "userId", value: "me"),
435
+            URLQueryItem(name: "courseId", value: courseId)
436
+        ]
437
+
438
+        var request = URLRequest(url: components.url!)
439
+        request.httpMethod = "GET"
440
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
441
+
442
+        let (data, response) = try await session.data(for: request)
443
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
444
+        guard (200..<300).contains(http.statusCode) else {
445
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
446
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
447
+        }
448
+
449
+        let decoded: InvitationsList
450
+        do {
451
+            decoded = try JSONDecoder().decode(InvitationsList.self, from: data)
452
+        } catch {
453
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
454
+            throw GoogleClassroomClientError.decodeFailed(raw)
455
+        }
456
+
457
+        return (decoded.invitations ?? []).filter { ($0.role ?? "").uppercased() == "TEACHER" }
458
+    }
459
+
460
+    private func acceptInvitation(accessToken: String, invitationId: String) async throws {
461
+        var request = URLRequest(url: URL(string: "https://classroom.googleapis.com/v1/invitations/\(invitationId):accept")!)
462
+        request.httpMethod = "POST"
463
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
464
+
465
+        let (data, response) = try await session.data(for: request)
466
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
467
+        guard (200..<300).contains(http.statusCode) else {
468
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
469
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
470
+        }
471
+    }
472
+
473
+    private func fetchCourse(accessToken: String, courseId: String) async throws -> Course {
474
+        var request = URLRequest(url: URL(string: "https://classroom.googleapis.com/v1/courses/\(courseId)")!)
475
+        request.httpMethod = "GET"
476
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
477
+
478
+        let (data, response) = try await session.data(for: request)
479
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
480
+        guard (200..<300).contains(http.statusCode) else {
481
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
482
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
483
+        }
484
+
485
+        do {
486
+            return try JSONDecoder().decode(Course.self, from: data)
487
+        } catch {
488
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
489
+            throw GoogleClassroomClientError.decodeFailed(raw)
490
+        }
491
+    }
492
+
493
+    private func waitForCourseToSettle(accessToken: String, courseId: String) async throws -> Course {
494
+        var latest = try await fetchCourse(accessToken: accessToken, courseId: courseId)
495
+        if (latest.courseState ?? "").uppercased() == "ACTIVE" { return latest }
496
+        for attempt in 0..<4 {
497
+            let delayNs: UInt64 = (attempt < 2) ? 350_000_000 : 700_000_000
498
+            try await Task.sleep(nanoseconds: delayNs)
499
+            latest = try await fetchCourse(accessToken: accessToken, courseId: courseId)
500
+            if (latest.courseState ?? "").uppercased() == "ACTIVE" { return latest }
501
+        }
502
+        return latest
503
+    }
504
+
295 505
     private func listCourseAnnouncements(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseAnnouncement] {
296 506
         var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/announcements")!
297 507
         components.queryItems = [
@@ -419,6 +629,13 @@ private extension GoogleClassroomClientError {
419 629
         let lowercasedBody = body.lowercased()
420 630
         return lowercasedBody.contains("permission_denied") || lowercasedBody.contains("does not have permission")
421 631
     }
632
+
633
+    var isInsufficientScope: Bool {
634
+        guard case let .httpStatus(status, body) = self, status == 403 else { return false }
635
+        let lowercasedBody = body.lowercased()
636
+        return lowercasedBody.contains("access_token_scope_insufficient")
637
+            || lowercasedBody.contains("insufficient authentication scopes")
638
+    }
422 639
 }
423 640
 
424 641
 extension GoogleClassroomClientError: LocalizedError {
@@ -450,6 +667,32 @@ private struct Course: Decodable {
450 667
     let courseState: String?
451 668
 }
452 669
 
670
+private struct CreateCoursePayload: Encodable {
671
+    var name: String
672
+    var ownerId: String
673
+    var courseState: String
674
+    var section: String?
675
+    var room: String?
676
+    var descriptionHeading: String?
677
+    var description: String?
678
+}
679
+
680
+private struct UpdateCourseStatePayload: Encodable {
681
+    let courseState: String
682
+}
683
+
684
+private struct InvitationsList: Decodable {
685
+    let invitations: [Invitation]?
686
+    let nextPageToken: String?
687
+}
688
+
689
+private struct Invitation: Decodable {
690
+    let id: String
691
+    let courseId: String?
692
+    let role: String?
693
+    let userId: String?
694
+}
695
+
453 696
 private struct CourseWorkList: Decodable {
454 697
     let courseWork: [CourseWork]?
455 698
     let nextPageToken: String?
@@ -572,6 +815,11 @@ private extension String {
572 815
         let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
573 816
         return trimmed.isEmpty ? fallback : trimmed
574 817
     }
818
+
819
+    var nonEmptyTrimmed: String? {
820
+        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
821
+        return trimmed.isEmpty ? nil : trimmed
822
+    }
575 823
 }
576 824
 
577 825
 private extension Date {

+ 231 - 0
classroom_app/ViewController.swift

@@ -2362,9 +2362,13 @@ private extension ViewController {
2362 2362
         let refreshButton = makeScheduleRefreshButton()
2363 2363
         refreshButton.target = self
2364 2364
         refreshButton.action = #selector(teachingPageRefreshPressed(_:))
2365
+        let createClassButton = makeTeachingCreateClassButton()
2366
+        createClassButton.target = self
2367
+        createClassButton.action = #selector(teachingCreateClassPressed(_:))
2365 2368
 
2366 2369
         titleRow.addArrangedSubview(titleLabel)
2367 2370
         titleRow.addArrangedSubview(spacer)
2371
+        titleRow.addArrangedSubview(createClassButton)
2368 2372
         titleRow.addArrangedSubview(refreshButton)
2369 2373
 
2370 2374
         let heading = textLabel(teachingPageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
@@ -3805,6 +3809,13 @@ private extension ViewController {
3805 3809
         return button
3806 3810
     }
3807 3811
 
3812
+    private func makeTeachingCreateClassButton() -> NSButton {
3813
+        let button = makeSchedulePillButton(title: "+ Create class")
3814
+        button.contentTintColor = palette.textPrimary
3815
+        button.toolTip = "Create a new Google Classroom class"
3816
+        return button
3817
+    }
3818
+
3808 3819
     func scheduleCardsRow(todos: [ClassroomTodoItem]) -> NSView {
3809 3820
         let cardWidth: CGFloat = 240
3810 3821
         let cardsPerViewport: CGFloat = 3
@@ -4073,6 +4084,20 @@ private extension PremiumPlan {
4073 4084
     }
4074 4085
 }
4075 4086
 
4087
+private extension Int {
4088
+    var ordinalSuffix: String {
4089
+        let tens = (self / 10) % 10
4090
+        let ones = self % 10
4091
+        if tens == 1 { return "th" }
4092
+        switch ones {
4093
+        case 1: return "st"
4094
+        case 2: return "nd"
4095
+        case 3: return "rd"
4096
+        default: return "th"
4097
+        }
4098
+    }
4099
+}
4100
+
4076 4101
 extension ViewController: NSTextFieldDelegate {
4077 4102
     func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
4078 4103
         if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
@@ -5590,6 +5615,10 @@ private extension ViewController {
5590 5615
         scheduleReloadClicked()
5591 5616
     }
5592 5617
 
5618
+    @objc func teachingCreateClassPressed(_ sender: NSButton) {
5619
+        teachingCreateClassClicked()
5620
+    }
5621
+
5593 5622
     @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
5594 5623
         scrollScheduleCards(direction: -1)
5595 5624
     }
@@ -5644,6 +5673,125 @@ private extension ViewController {
5644 5673
         }
5645 5674
     }
5646 5675
 
5676
+    private struct TeachingClassFormValues {
5677
+        let name: String
5678
+        let section: String
5679
+        let levels: String
5680
+        let room: String
5681
+        let descriptionText: String
5682
+    }
5683
+
5684
+    private func teachingCreateClassClicked() {
5685
+        guard storeKitCoordinator.hasPremiumAccess else {
5686
+            showPaywall()
5687
+            return
5688
+        }
5689
+        guard let values = promptForTeachingClassValues() else { return }
5690
+        Task { [weak self] in
5691
+            await self?.createTeachingClass(values)
5692
+        }
5693
+    }
5694
+
5695
+    private func promptForTeachingClassValues() -> TeachingClassFormValues? {
5696
+        let alert = NSAlert()
5697
+        alert.messageText = "Create class"
5698
+        alert.informativeText = "Create a class in Google Classroom."
5699
+        alert.addButton(withTitle: "Create")
5700
+        alert.addButton(withTitle: "Cancel")
5701
+
5702
+        let content = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 222))
5703
+        let nameField = NSTextField(string: "")
5704
+        nameField.placeholderString = "Class name (required)"
5705
+        let sectionField = NSTextField(string: "")
5706
+        sectionField.placeholderString = "Section (optional)"
5707
+        let levelField = NSComboBox(frame: .zero)
5708
+        levelField.placeholderString = "Level(s) (optional)"
5709
+        levelField.usesDataSource = false
5710
+        levelField.isEditable = true
5711
+        levelField.completes = true
5712
+        levelField.addItems(withObjectValues: teachingClassLevelOptions())
5713
+        let roomField = NSTextField(string: "")
5714
+        roomField.placeholderString = "Room (optional)"
5715
+        let descriptionField = NSTextField(string: "")
5716
+        descriptionField.placeholderString = "Description (optional)"
5717
+        let textFields: [NSTextField] = [nameField, sectionField, roomField, descriptionField]
5718
+        for field in textFields {
5719
+            field.translatesAutoresizingMaskIntoConstraints = false
5720
+            field.focusRingType = .default
5721
+            content.addSubview(field)
5722
+        }
5723
+        levelField.translatesAutoresizingMaskIntoConstraints = false
5724
+        content.addSubview(levelField)
5725
+        alert.accessoryView = content
5726
+
5727
+        NSLayoutConstraint.activate([
5728
+            nameField.leadingAnchor.constraint(equalTo: content.leadingAnchor),
5729
+            nameField.trailingAnchor.constraint(equalTo: content.trailingAnchor),
5730
+            nameField.topAnchor.constraint(equalTo: content.topAnchor),
5731
+
5732
+            sectionField.leadingAnchor.constraint(equalTo: content.leadingAnchor),
5733
+            sectionField.trailingAnchor.constraint(equalTo: content.trailingAnchor),
5734
+            sectionField.topAnchor.constraint(equalTo: nameField.bottomAnchor, constant: 10),
5735
+
5736
+            levelField.leadingAnchor.constraint(equalTo: content.leadingAnchor),
5737
+            levelField.trailingAnchor.constraint(equalTo: content.trailingAnchor),
5738
+            levelField.topAnchor.constraint(equalTo: sectionField.bottomAnchor, constant: 10),
5739
+            levelField.heightAnchor.constraint(equalToConstant: 24),
5740
+
5741
+            roomField.leadingAnchor.constraint(equalTo: content.leadingAnchor),
5742
+            roomField.trailingAnchor.constraint(equalTo: content.trailingAnchor),
5743
+            roomField.topAnchor.constraint(equalTo: levelField.bottomAnchor, constant: 10),
5744
+
5745
+            descriptionField.leadingAnchor.constraint(equalTo: content.leadingAnchor),
5746
+            descriptionField.trailingAnchor.constraint(equalTo: content.trailingAnchor),
5747
+            descriptionField.topAnchor.constraint(equalTo: roomField.bottomAnchor, constant: 10),
5748
+            descriptionField.bottomAnchor.constraint(equalTo: content.bottomAnchor)
5749
+        ])
5750
+
5751
+        let response = alert.runModal()
5752
+        guard response == .alertFirstButtonReturn else { return nil }
5753
+        return TeachingClassFormValues(
5754
+            name: nameField.stringValue,
5755
+            section: sectionField.stringValue,
5756
+            levels: levelField.stringValue,
5757
+            room: roomField.stringValue,
5758
+            descriptionText: descriptionField.stringValue
5759
+        )
5760
+    }
5761
+
5762
+    private func teachingClassLevelOptions() -> [String] {
5763
+        let earlyYears = [
5764
+            "Nursery",
5765
+            "Reception",
5766
+            "Year 1",
5767
+            "Year 2",
5768
+            "Year 3",
5769
+            "Year 4",
5770
+            "Year 5",
5771
+            "Year 6",
5772
+            "Year 7",
5773
+            "Year 8",
5774
+            "Year 9",
5775
+            "Year 10",
5776
+            "Year 11",
5777
+            "Year 12",
5778
+            "Year 13",
5779
+            "Preschool",
5780
+            "Kindergarten"
5781
+        ]
5782
+        let grades = (1...12).map { "\($0)\($0.ordinalSuffix) Grade" }
5783
+        return earlyYears + grades
5784
+    }
5785
+
5786
+    private func hasRequiredScopesForClassCreation() -> Bool {
5787
+        guard let scopeText = googleOAuth.loadTokens()?.scope?.lowercased(), !scopeText.isEmpty else { return true }
5788
+        let required = [
5789
+            "https://www.googleapis.com/auth/classroom.courses",
5790
+            "https://www.googleapis.com/auth/classroom.rosters"
5791
+        ]
5792
+        return required.allSatisfy { scopeText.contains($0.lowercased()) }
5793
+    }
5794
+
5647 5795
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
5648 5796
         guard let selectedItem = sender.selectedItem,
5649 5797
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
@@ -6528,6 +6676,89 @@ private extension ViewController {
6528 6676
         }
6529 6677
     }
6530 6678
 
6679
+    private func createTeachingClass(_ values: TeachingClassFormValues) async {
6680
+        do {
6681
+            if googleOAuth.loadTokens() == nil {
6682
+                await MainActor.run {
6683
+                    showSimpleAlert(title: "Connect Google", message: "Connect your Google account to create classes.")
6684
+                }
6685
+                return
6686
+            }
6687
+
6688
+            guard hasRequiredScopesForClassCreation() else {
6689
+                await MainActor.run {
6690
+                    _ = try? googleOAuth.signOut()
6691
+                    applyGoogleProfile(nil)
6692
+                    updateGoogleAuthButtonTitle()
6693
+                    showSimpleAlert(
6694
+                        title: "Reconnect Google",
6695
+                        message: "Please reconnect Google once so we can get class create and auto-accept permissions."
6696
+                    )
6697
+                }
6698
+                return
6699
+            }
6700
+
6701
+            let trimmedName = values.name.trimmingCharacters(in: .whitespacesAndNewlines)
6702
+            guard trimmedName.isEmpty == false else {
6703
+                await MainActor.run {
6704
+                    showSimpleAlert(title: "Class name required", message: "Enter a class name before creating the class.")
6705
+                }
6706
+                return
6707
+            }
6708
+
6709
+            await MainActor.run {
6710
+                showSimpleAlert(title: "Class created", message: "\"\(trimmedName)\" was created.\nPlease wait untill setup your class.")
6711
+            }
6712
+
6713
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
6714
+            let sectionTrimmed = values.section.trimmingCharacters(in: .whitespacesAndNewlines)
6715
+            let levelTrimmed = values.levels.trimmingCharacters(in: .whitespacesAndNewlines)
6716
+            let effectiveSection = sectionTrimmed.isEmpty ? levelTrimmed : sectionTrimmed
6717
+            var effectiveDescription = values.descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
6718
+            if !levelTrimmed.isEmpty, !sectionTrimmed.isEmpty {
6719
+                effectiveDescription = effectiveDescription.isEmpty
6720
+                    ? "Level(s): \(levelTrimmed)"
6721
+                    : "Level(s): \(levelTrimmed)\n\(effectiveDescription)"
6722
+            }
6723
+            let createdCourse = try await classroomClient.createCourse(
6724
+                accessToken: token,
6725
+                request: GoogleClassroomClient.CreateCourseRequest(
6726
+                    name: trimmedName,
6727
+                    section: effectiveSection,
6728
+                    room: values.room,
6729
+                    descriptionHeading: effectiveSection,
6730
+                    description: effectiveDescription
6731
+                )
6732
+            )
6733
+            await loadTeachingClasses()
6734
+            await MainActor.run {
6735
+                if createdCourse.courseState.uppercased() != "ACTIVE" {
6736
+                    showSimpleAlert(
6737
+                        title: "Class created",
6738
+                        message: "\"\(createdCourse.name)\" was created. Please accept the class in Google Classroom."
6739
+                    )
6740
+                    if let classroomHome = URL(string: "https://classroom.google.com") {
6741
+                        openURL(classroomHome)
6742
+                    }
6743
+                }
6744
+            }
6745
+        } catch {
6746
+            await MainActor.run {
6747
+                if errorRequiresReconsentForClassroomScopes(error) {
6748
+                    _ = try? googleOAuth.signOut()
6749
+                    applyGoogleProfile(nil)
6750
+                    updateGoogleAuthButtonTitle()
6751
+                    showSimpleAlert(
6752
+                        title: "Reconnect Google",
6753
+                        message: "We added Google Classroom create permissions. Please connect your Google account again and grant access."
6754
+                    )
6755
+                    return
6756
+                }
6757
+                showSimpleError("Couldn’t create class.", error: error)
6758
+            }
6759
+        }
6760
+    }
6761
+
6531 6762
     func showScheduleHelp() {
6532 6763
         let alert = NSAlert()
6533 6764
         alert.messageText = "Google Classroom to-do"