Bladeren bron

Port Classroom UI from meetings app with green accent

- Add full AppKit UI (ViewController), Google OAuth, Calendar client, models
- Rebrand copy and URLs for Google Classroom; green palette and sidebar hover
- Add Info.plist, sandbox entitlements, StoreKit config, shared scheme
- Sync AppDelegate appearance with system dark mode
- Keep only asset images referenced in code; remove unused logo imagesets

Made-with: Cursor
huzaifahayat12 1 week geleden
bovenliggende
commit
996ad817a8
30 gewijzigde bestanden met toevoegingen van 8348 en 20 verwijderingen
  1. 59 0
      Info.plist
  2. 10 8
      classroom_app.xcodeproj/project.pbxproj
  3. 82 0
      classroom_app.xcodeproj/xcshareddata/xcschemes/classroom_app.xcscheme
  4. 8 0
      classroom_app.xcodeproj/xcuserdata/devmac1.xcuserdatad/xcschemes/xcschememanagement.plist
  5. 9 7
      classroom_app/AppDelegate.swift
  6. 9 0
      classroom_app/Assets.xcassets/AccentColor.colorset/Contents.json
  7. 27 0
      classroom_app/Assets.xcassets/GoogleGLogo.imageset/Contents.json
  8. BIN
      classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g.png
  9. BIN
      classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g@2x.png
  10. BIN
      classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g@3x.png
  11. 23 0
      classroom_app/Assets.xcassets/HeaderLogo.imageset/Contents.json
  12. BIN
      classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@1x.png
  13. BIN
      classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@2x.png
  14. BIN
      classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@3x.png
  15. 26 0
      classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/Contents.json
  16. BIN
      classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo.png
  17. BIN
      classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo@2x.png
  18. BIN
      classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo@3x.png
  19. 26 0
      classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/Contents.json
  20. BIN
      classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings.png
  21. BIN
      classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings@2x.png
  22. BIN
      classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings@3x.png
  23. 444 0
      classroom_app/Auth/GoogleOAuthService.swift
  24. 30 0
      classroom_app/Auth/KeychainTokenStore.swift
  25. 323 0
      classroom_app/Google/GoogleCalendarClient.swift
  26. 50 0
      classroom_app/Google/GoogleMeetClient.swift
  27. 12 0
      classroom_app/Models/ScheduledMeeting.swift
  28. 82 0
      classroom_app/StoreKit.storekit
  29. 7110 5
      classroom_app/ViewController.swift
  30. 18 0
      classroom_app/classroom_app.entitlements

+ 59 - 0
Info.plist

@@ -0,0 +1,59 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>CFBundleDevelopmentRegion</key>
6
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
7
+	<key>CFBundleDisplayName</key>
8
+	<string>$(PRODUCT_NAME)</string>
9
+	<key>CFBundleExecutable</key>
10
+	<string>$(EXECUTABLE_NAME)</string>
11
+	<key>CFBundleIdentifier</key>
12
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+	<key>CFBundleInfoDictionaryVersion</key>
14
+	<string>6.0</string>
15
+	<key>CFBundleName</key>
16
+	<string>$(PRODUCT_NAME)</string>
17
+	<key>CFBundlePackageType</key>
18
+	<string>APPL</string>
19
+	<key>CFBundleShortVersionString</key>
20
+	<string>$(MARKETING_VERSION)</string>
21
+	<key>CFBundleVersion</key>
22
+	<string>$(CURRENT_PROJECT_VERSION)</string>
23
+	<key>LSMinimumSystemVersion</key>
24
+	<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
25
+	<key>NSMainStoryboardFile</key>
26
+	<string>Main</string>
27
+	<key>NSPrincipalClass</key>
28
+	<string>NSApplication</string>
29
+	<key>NSCameraUsageDescription</key>
30
+	<string>Camera may be used when you open Google Classroom or other sites inside this app.</string>
31
+	<key>NSMicrophoneUsageDescription</key>
32
+	<string>Microphone may be used when you open Google Classroom or other sites inside this app.</string>
33
+	<key>AppLaunchPlaceholderURL</key>
34
+	<string>https://classroom.google.com/</string>
35
+	<key>AppShareURL</key>
36
+	<string>https://classroom.google.com/</string>
37
+	<key>SupportURL</key>
38
+	<string>https://support.google.com/classroom</string>
39
+	<key>MoreAppsURL</key>
40
+	<string>https://apps.apple.com/mac/search?term=Google%20Classroom</string>
41
+	<key>PrivacyPolicyURL</key>
42
+	<string>https://policies.google.com/privacy</string>
43
+	<key>TermsOfServiceURL</key>
44
+	<string>https://policies.google.com/terms</string>
45
+	<key>RateUsURL</key>
46
+	<string>https://apps.apple.com/mac/search?term=Google%20Classroom</string>
47
+	<key>CFBundleURLTypes</key>
48
+	<array>
49
+		<dict>
50
+			<key>CFBundleURLName</key>
51
+			<string>GoogleOAuthRedirect</string>
52
+			<key>CFBundleURLSchemes</key>
53
+			<array>
54
+				<string>classroomapp.oauth</string>
55
+			</array>
56
+		</dict>
57
+	</array>
58
+</dict>
59
+</plist>

+ 10 - 8
classroom_app.xcodeproj/project.pbxproj

@@ -178,7 +178,7 @@
178 178
 				GCC_WARN_UNUSED_FUNCTION = YES;
179 179
 				GCC_WARN_UNUSED_VARIABLE = YES;
180 180
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
181
-				MACOSX_DEPLOYMENT_TARGET = 26.4;
181
+				MACOSX_DEPLOYMENT_TARGET = 12.0;
182 182
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
183 183
 				MTL_FAST_MATH = YES;
184 184
 				ONLY_ACTIVE_ARCH = YES;
@@ -235,7 +235,7 @@
235 235
 				GCC_WARN_UNUSED_FUNCTION = YES;
236 236
 				GCC_WARN_UNUSED_VARIABLE = YES;
237 237
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
238
-				MACOSX_DEPLOYMENT_TARGET = 26.4;
238
+				MACOSX_DEPLOYMENT_TARGET = 12.0;
239 239
 				MTL_ENABLE_DEBUG_INFO = NO;
240 240
 				MTL_FAST_MATH = YES;
241 241
 				SDKROOT = macosx;
@@ -248,15 +248,16 @@
248 248
 			buildSettings = {
249 249
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
250 250
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
251
+				CODE_SIGN_ENTITLEMENTS = classroom_app/classroom_app.entitlements;
251 252
 				CODE_SIGN_STYLE = Automatic;
252 253
 				COMBINE_HIDPI_IMAGES = YES;
253 254
 				CURRENT_PROJECT_VERSION = 1;
255
+				DEVELOPMENT_TEAM = 5AT3P29RBZ;
254 256
 				ENABLE_APP_SANDBOX = YES;
255 257
 				ENABLE_USER_SELECTED_FILES = readonly;
256
-				GENERATE_INFOPLIST_FILE = YES;
258
+				GENERATE_INFOPLIST_FILE = NO;
259
+				INFOPLIST_FILE = Info.plist;
257 260
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
258
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
259
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
260 261
 				LD_RUNPATH_SEARCH_PATHS = (
261 262
 					"$(inherited)",
262 263
 					"@executable_path/../Frameworks",
@@ -279,15 +280,16 @@
279 280
 			buildSettings = {
280 281
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
281 282
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
283
+				CODE_SIGN_ENTITLEMENTS = classroom_app/classroom_app.entitlements;
282 284
 				CODE_SIGN_STYLE = Automatic;
283 285
 				COMBINE_HIDPI_IMAGES = YES;
284 286
 				CURRENT_PROJECT_VERSION = 1;
287
+				DEVELOPMENT_TEAM = 5AT3P29RBZ;
285 288
 				ENABLE_APP_SANDBOX = YES;
286 289
 				ENABLE_USER_SELECTED_FILES = readonly;
287
-				GENERATE_INFOPLIST_FILE = YES;
290
+				GENERATE_INFOPLIST_FILE = NO;
291
+				INFOPLIST_FILE = Info.plist;
288 292
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
289
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
290
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
291 293
 				LD_RUNPATH_SEARCH_PATHS = (
292 294
 					"$(inherited)",
293 295
 					"@executable_path/../Frameworks",

+ 82 - 0
classroom_app.xcodeproj/xcshareddata/xcschemes/classroom_app.xcscheme

@@ -0,0 +1,82 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<Scheme
3
+   LastUpgradeVersion = "2640"
4
+   version = "1.7">
5
+   <BuildAction
6
+      parallelizeBuildables = "YES"
7
+      buildImplicitDependencies = "YES"
8
+      buildArchitectures = "Automatic">
9
+      <BuildActionEntries>
10
+         <BuildActionEntry
11
+            buildForTesting = "YES"
12
+            buildForRunning = "YES"
13
+            buildForProfiling = "YES"
14
+            buildForArchiving = "YES"
15
+            buildForAnalyzing = "YES">
16
+            <BuildableReference
17
+               BuildableIdentifier = "primary"
18
+               BlueprintIdentifier = "1391CA292F8CB08600B3B198"
19
+               BuildableName = "classroom_app.app"
20
+               BlueprintName = "classroom_app"
21
+               ReferencedContainer = "container:classroom_app.xcodeproj">
22
+            </BuildableReference>
23
+         </BuildActionEntry>
24
+      </BuildActionEntries>
25
+   </BuildAction>
26
+   <TestAction
27
+      buildConfiguration = "Debug"
28
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
+      shouldUseLaunchSchemeArgsEnv = "YES"
31
+      shouldAutocreateTestPlan = "YES">
32
+   </TestAction>
33
+   <LaunchAction
34
+      buildConfiguration = "Debug"
35
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
36
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
37
+      launchStyle = "0"
38
+      useCustomWorkingDirectory = "NO"
39
+      ignoresPersistentStateOnLaunch = "NO"
40
+      debugDocumentVersioning = "YES"
41
+      debugServiceExtension = "internal"
42
+      allowLocationSimulation = "YES"
43
+      queueDebuggingEnableBacktraceRecording = "Yes">
44
+      <BuildableProductRunnable
45
+         runnableDebuggingMode = "0">
46
+         <BuildableReference
47
+            BuildableIdentifier = "primary"
48
+            BlueprintIdentifier = "1391CA292F8CB08600B3B198"
49
+            BuildableName = "classroom_app.app"
50
+            BlueprintName = "classroom_app"
51
+            ReferencedContainer = "container:classroom_app.xcodeproj">
52
+         </BuildableReference>
53
+      </BuildableProductRunnable>
54
+      <StoreKitConfigurationFileReference
55
+         identifier = "../../classroom_app/StoreKit.storekit">
56
+      </StoreKitConfigurationFileReference>
57
+   </LaunchAction>
58
+   <ProfileAction
59
+      buildConfiguration = "Release"
60
+      shouldUseLaunchSchemeArgsEnv = "YES"
61
+      savedToolIdentifier = ""
62
+      useCustomWorkingDirectory = "NO"
63
+      debugDocumentVersioning = "YES">
64
+      <BuildableProductRunnable
65
+         runnableDebuggingMode = "0">
66
+         <BuildableReference
67
+            BuildableIdentifier = "primary"
68
+            BlueprintIdentifier = "1391CA292F8CB08600B3B198"
69
+            BuildableName = "classroom_app.app"
70
+            BlueprintName = "classroom_app"
71
+            ReferencedContainer = "container:classroom_app.xcodeproj">
72
+         </BuildableReference>
73
+      </BuildableProductRunnable>
74
+   </ProfileAction>
75
+   <AnalyzeAction
76
+      buildConfiguration = "Debug">
77
+   </AnalyzeAction>
78
+   <ArchiveAction
79
+      buildConfiguration = "Release"
80
+      revealArchiveInOrganizer = "YES">
81
+   </ArchiveAction>
82
+</Scheme>

+ 8 - 0
classroom_app.xcodeproj/xcuserdata/devmac1.xcuserdatad/xcschemes/xcschememanagement.plist

@@ -10,5 +10,13 @@
10 10
 			<integer>0</integer>
11 11
 		</dict>
12 12
 	</dict>
13
+	<key>SuppressBuildableAutocreation</key>
14
+	<dict>
15
+		<key>1391CA292F8CB08600B3B198</key>
16
+		<dict>
17
+			<key>primary</key>
18
+			<true/>
19
+		</dict>
20
+	</dict>
13 21
 </dict>
14 22
 </plist>

+ 9 - 7
classroom_app/AppDelegate.swift

@@ -9,22 +9,24 @@ import Cocoa
9 9
 
10 10
 @main
11 11
 class AppDelegate: NSObject, NSApplicationDelegate {
12
-
13
-    
14
-
12
+    private let darkModeDefaultsKey = "settings.darkModeEnabled"
15 13
 
16 14
     func applicationDidFinishLaunching(_ aNotification: Notification) {
17
-        // Insert code here to initialize your application
15
+        let darkEnabled = systemPrefersDarkMode()
16
+        UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
17
+        NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
18 18
     }
19 19
 
20 20
     func applicationWillTerminate(_ aNotification: Notification) {
21
-        // Insert code here to tear down your application
22 21
     }
23 22
 
24 23
     func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
25 24
         return true
26 25
     }
27 26
 
28
-
27
+    private func systemPrefersDarkMode() -> Bool {
28
+        let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
29
+        let style = global?["AppleInterfaceStyle"] as? String
30
+        return style?.lowercased() == "dark"
31
+    }
29 32
 }
30
-

+ 9 - 0
classroom_app/Assets.xcassets/AccentColor.colorset/Contents.json

@@ -1,6 +1,15 @@
1 1
 {
2 2
   "colors" : [
3 3
     {
4
+      "color" : {
5
+        "color-space" : "srgb",
6
+        "components" : {
7
+          "alpha" : "1.000",
8
+          "blue" : "0.345",
9
+          "green" : "0.616",
10
+          "red" : "0.059"
11
+        }
12
+      },
4 13
       "idiom" : "universal"
5 14
     }
6 15
   ],

+ 27 - 0
classroom_app/Assets.xcassets/GoogleGLogo.imageset/Contents.json

@@ -0,0 +1,27 @@
1
+{
2
+  "images" : [
3
+    {
4
+      "filename" : "google_g.png",
5
+      "idiom" : "universal",
6
+      "scale" : "1x"
7
+    },
8
+    {
9
+      "filename" : "google_g@2x.png",
10
+      "idiom" : "universal",
11
+      "scale" : "2x"
12
+    },
13
+    {
14
+      "filename" : "google_g@3x.png",
15
+      "idiom" : "universal",
16
+      "scale" : "3x"
17
+    }
18
+  ],
19
+  "info" : {
20
+    "author" : "xcode",
21
+    "version" : 1
22
+  },
23
+  "properties" : {
24
+    "preserves-vector-representation" : false,
25
+    "template-rendering-intent" : "original"
26
+  }
27
+}

BIN
classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g.png


BIN
classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g@2x.png


BIN
classroom_app/Assets.xcassets/GoogleGLogo.imageset/google_g@3x.png


+ 23 - 0
classroom_app/Assets.xcassets/HeaderLogo.imageset/Contents.json

@@ -0,0 +1,23 @@
1
+{
2
+  "images": [
3
+    {
4
+      "idiom": "universal",
5
+      "filename": "HeaderLogo@1x.png",
6
+      "scale": "1x"
7
+    },
8
+    {
9
+      "idiom": "universal",
10
+      "filename": "HeaderLogo@2x.png",
11
+      "scale": "2x"
12
+    },
13
+    {
14
+      "idiom": "universal",
15
+      "filename": "HeaderLogo@3x.png",
16
+      "scale": "3x"
17
+    }
18
+  ],
19
+  "info": {
20
+    "version": 1,
21
+    "author": "xcode"
22
+  }
23
+}

BIN
classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@1x.png


BIN
classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@2x.png


BIN
classroom_app/Assets.xcassets/HeaderLogo.imageset/HeaderLogo@3x.png


+ 26 - 0
classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/Contents.json

@@ -0,0 +1,26 @@
1
+{
2
+  "images" : [
3
+    {
4
+      "filename" : "join_meetings_logo.png",
5
+      "idiom" : "universal",
6
+      "scale" : "1x"
7
+    },
8
+    {
9
+      "filename" : "join_meetings_logo@2x.png",
10
+      "idiom" : "universal",
11
+      "scale" : "2x"
12
+    },
13
+    {
14
+      "filename" : "join_meetings_logo@3x.png",
15
+      "idiom" : "universal",
16
+      "scale" : "3x"
17
+    }
18
+  ],
19
+  "info" : {
20
+    "author" : "xcode",
21
+    "version" : 1
22
+  },
23
+  "properties" : {
24
+    "template-rendering-intent" : "template"
25
+  }
26
+}

BIN
classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo.png


BIN
classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo@2x.png


BIN
classroom_app/Assets.xcassets/JoinMeetingsLogo.imageset/join_meetings_logo@3x.png


+ 26 - 0
classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/Contents.json

@@ -0,0 +1,26 @@
1
+{
2
+  "images": [
3
+    {
4
+      "filename": "sidebar_settings.png",
5
+      "idiom": "universal",
6
+      "scale": "1x"
7
+    },
8
+    {
9
+      "filename": "sidebar_settings@2x.png",
10
+      "idiom": "universal",
11
+      "scale": "2x"
12
+    },
13
+    {
14
+      "filename": "sidebar_settings@3x.png",
15
+      "idiom": "universal",
16
+      "scale": "3x"
17
+    }
18
+  ],
19
+  "info": {
20
+    "author": "xcode",
21
+    "version": 1
22
+  },
23
+  "properties": {
24
+    "template-rendering-intent": "template"
25
+  }
26
+}

BIN
classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings.png


BIN
classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings@2x.png


BIN
classroom_app/Assets.xcassets/SidebarSettingsLogo.imageset/sidebar_settings@3x.png


+ 444 - 0
classroom_app/Auth/GoogleOAuthService.swift

@@ -0,0 +1,444 @@
1
+import Foundation
2
+import CryptoKit
3
+import AppKit
4
+import Network
5
+
6
+struct GoogleOAuthTokens: Codable, Equatable {
7
+    var accessToken: String
8
+    var refreshToken: String?
9
+    var expiresAt: Date
10
+    var scope: String?
11
+    var tokenType: String?
12
+}
13
+
14
+struct GoogleUserProfile: Codable, Equatable {
15
+    var name: String?
16
+    var email: String?
17
+    var picture: String?
18
+}
19
+
20
+enum GoogleOAuthError: Error {
21
+    case missingClientId
22
+    case missingClientSecret
23
+    case invalidCallbackURL
24
+    case missingAuthorizationCode
25
+    case tokenExchangeFailed(String)
26
+    case unableToOpenBrowser
27
+    case authenticationTimedOut
28
+    case noStoredTokens
29
+}
30
+
31
+final class GoogleOAuthService: NSObject {
32
+    static let shared = GoogleOAuthService()
33
+
34
+    // Stored in UserDefaults so you can configure without rebuilding.
35
+    // Put your OAuth Desktop client ID here (from Google Cloud Console).
36
+    private let clientIdDefaultsKey = "google.oauth.clientId"
37
+    private let clientSecretDefaultsKey = "google.oauth.clientSecret"
38
+    private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
39
+    private let bundledClientSecret = "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"
40
+
41
+    // Calendar is needed for schedule. Profile/email make login feel complete in-app.
42
+    private let scopes = [
43
+        "openid",
44
+        "email",
45
+        "profile",
46
+        "https://www.googleapis.com/auth/calendar.events"
47
+    ]
48
+
49
+    private let tokenStore = KeychainTokenStore()
50
+    private override init() {}
51
+
52
+    func configuredClientId() -> String? {
53
+        let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
54
+        if let value, value.isEmpty == false { return value }
55
+        return bundledClientId
56
+    }
57
+
58
+    func setClientIdForTesting(_ clientId: String) {
59
+        UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
60
+    }
61
+
62
+    func configuredClientSecret() -> String? {
63
+        let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
64
+        if let value, value.isEmpty == false { return value }
65
+        return bundledClientSecret
66
+    }
67
+
68
+    func setClientSecretForTesting(_ clientSecret: String) {
69
+        UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
70
+    }
71
+
72
+    func signOut() throws {
73
+        try tokenStore.deleteTokens()
74
+    }
75
+
76
+    func loadTokens() -> GoogleOAuthTokens? {
77
+        try? tokenStore.readTokens()
78
+    }
79
+
80
+    func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
81
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
82
+        request.httpMethod = "GET"
83
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
84
+
85
+        let (data, response) = try await URLSession.shared.data(for: request)
86
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
87
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
88
+            throw GoogleOAuthError.tokenExchangeFailed(details)
89
+        }
90
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
91
+    }
92
+
93
+    func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
94
+        if var tokens = try tokenStore.readTokens() {
95
+            if tokens.expiresAt.timeIntervalSinceNow > 60 {
96
+                return tokens.accessToken
97
+            }
98
+            if let refreshed = try await refreshTokens(tokens) {
99
+                tokens = refreshed
100
+                try tokenStore.writeTokens(tokens)
101
+                return tokens.accessToken
102
+            }
103
+        }
104
+
105
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
106
+        try tokenStore.writeTokens(tokens)
107
+        return tokens.accessToken
108
+    }
109
+
110
+    // MARK: - Interactive sign-in (Authorization Code + PKCE)
111
+
112
+    private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
113
+        _ = presentingWindow
114
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
115
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
116
+        let codeVerifier = Self.randomURLSafeString(length: 64)
117
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
118
+        let state = Self.randomURLSafeString(length: 32)
119
+
120
+        let loopback = try await OAuthLoopbackServer.start()
121
+        defer { loopback.stop() }
122
+        let redirectURI = loopback.redirectURI
123
+
124
+        var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
125
+        components.queryItems = [
126
+            URLQueryItem(name: "client_id", value: clientId),
127
+            URLQueryItem(name: "redirect_uri", value: redirectURI),
128
+            URLQueryItem(name: "response_type", value: "code"),
129
+            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
130
+            URLQueryItem(name: "state", value: state),
131
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
132
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
133
+            URLQueryItem(name: "access_type", value: "offline"),
134
+            URLQueryItem(name: "prompt", value: "consent")
135
+        ]
136
+
137
+        guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
138
+        guard NSWorkspace.shared.open(authURL) else { throw GoogleOAuthError.unableToOpenBrowser }
139
+        let callbackURL = try await loopback.waitForCallback()
140
+
141
+        guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
142
+            .queryItems?.first(where: { $0.name == "state" })?.value,
143
+              returnedState == state else {
144
+            throw GoogleOAuthError.invalidCallbackURL
145
+        }
146
+
147
+        guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
148
+            .queryItems?.first(where: { $0.name == "code" })?.value,
149
+              code.isEmpty == false else {
150
+            throw GoogleOAuthError.missingAuthorizationCode
151
+        }
152
+
153
+        return try await exchangeCodeForTokens(
154
+            code: code,
155
+            codeVerifier: codeVerifier,
156
+            redirectURI: redirectURI,
157
+            clientId: clientId,
158
+            clientSecret: clientSecret
159
+        )
160
+    }
161
+
162
+    private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
163
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
164
+        request.httpMethod = "POST"
165
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
166
+        request.httpBody = Self.formURLEncoded([
167
+            "client_id": clientId,
168
+            "client_secret": clientSecret,
169
+            "code": code,
170
+            "code_verifier": codeVerifier,
171
+            "redirect_uri": redirectURI,
172
+            "grant_type": "authorization_code"
173
+        ])
174
+
175
+        let (data, response) = try await URLSession.shared.data(for: request)
176
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
177
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
178
+            throw GoogleOAuthError.tokenExchangeFailed(details)
179
+        }
180
+
181
+        struct TokenResponse: Decodable {
182
+            let access_token: String
183
+            let expires_in: Double
184
+            let refresh_token: String?
185
+            let scope: String?
186
+            let token_type: String?
187
+        }
188
+
189
+        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
190
+        return GoogleOAuthTokens(
191
+            accessToken: decoded.access_token,
192
+            refreshToken: decoded.refresh_token,
193
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
194
+            scope: decoded.scope,
195
+            tokenType: decoded.token_type
196
+        )
197
+    }
198
+
199
+    private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
200
+        guard let refreshToken = tokens.refreshToken else { return nil }
201
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
202
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
203
+
204
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
205
+        request.httpMethod = "POST"
206
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
207
+        request.httpBody = Self.formURLEncoded([
208
+            "client_id": clientId,
209
+            "client_secret": clientSecret,
210
+            "refresh_token": refreshToken,
211
+            "grant_type": "refresh_token"
212
+        ])
213
+
214
+        let (data, response) = try await URLSession.shared.data(for: request)
215
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
216
+            return nil
217
+        }
218
+
219
+        struct RefreshResponse: Decodable {
220
+            let access_token: String
221
+            let expires_in: Double
222
+            let scope: String?
223
+            let token_type: String?
224
+        }
225
+
226
+        let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
227
+        return GoogleOAuthTokens(
228
+            accessToken: decoded.access_token,
229
+            refreshToken: refreshToken,
230
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
231
+            scope: decoded.scope ?? tokens.scope,
232
+            tokenType: decoded.token_type ?? tokens.tokenType
233
+        )
234
+    }
235
+
236
+    // MARK: - Helpers
237
+
238
+    private static func pkceChallenge(for verifier: String) -> String {
239
+        let data = Data(verifier.utf8)
240
+        let digest = SHA256.hash(data: data)
241
+        return Data(digest).base64URLEncodedString()
242
+    }
243
+
244
+    private static func randomURLSafeString(length: Int) -> String {
245
+        var bytes = [UInt8](repeating: 0, count: length)
246
+        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
247
+        return Data(bytes).base64URLEncodedString()
248
+    }
249
+
250
+    private static func formURLEncoded(_ params: [String: String]) -> Data {
251
+        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
252
+        let pairs = params.map { key, value -> String in
253
+            let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
254
+            let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
255
+            return "\(k)=\(v)"
256
+        }
257
+        .joined(separator: "&")
258
+        return Data(pairs.utf8)
259
+    }
260
+}
261
+
262
+private extension Data {
263
+    func base64URLEncodedString() -> String {
264
+        let s = base64EncodedString()
265
+        return s
266
+            .replacingOccurrences(of: "+", with: "-")
267
+            .replacingOccurrences(of: "/", with: "_")
268
+            .replacingOccurrences(of: "=", with: "")
269
+    }
270
+}
271
+
272
+private final class OAuthLoopbackServer {
273
+    private let queue = DispatchQueue(label: "google.oauth.loopback.server")
274
+    private let listener: NWListener
275
+    private var readyContinuation: CheckedContinuation<Void, Error>?
276
+    private var callbackContinuation: CheckedContinuation<URL, Error>?
277
+    private var callbackURL: URL?
278
+
279
+    private init(listener: NWListener) {
280
+        self.listener = listener
281
+    }
282
+
283
+    static func start() async throws -> OAuthLoopbackServer {
284
+        let listener = try NWListener(using: .tcp, on: .any)
285
+        let server = OAuthLoopbackServer(listener: listener)
286
+        try await server.startListening()
287
+        return server
288
+    }
289
+
290
+    var redirectURI: String {
291
+        let port = listener.port?.rawValue ?? 0
292
+        return "http://127.0.0.1:\(port)/oauth2redirect"
293
+    }
294
+
295
+    func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
296
+        try await withThrowingTaskGroup(of: URL.self) { group in
297
+            group.addTask { [weak self] in
298
+                guard let self else { throw GoogleOAuthError.invalidCallbackURL }
299
+                return try await self.awaitCallback()
300
+            }
301
+            group.addTask {
302
+                try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
303
+                throw GoogleOAuthError.authenticationTimedOut
304
+            }
305
+
306
+            let url = try await group.next()!
307
+            group.cancelAll()
308
+            return url
309
+        }
310
+    }
311
+
312
+    func stop() {
313
+        queue.async {
314
+            self.listener.cancel()
315
+            if let callbackContinuation = self.callbackContinuation {
316
+                self.callbackContinuation = nil
317
+                callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
318
+            }
319
+        }
320
+    }
321
+
322
+    private func startListening() async throws {
323
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
324
+            queue.async {
325
+                self.readyContinuation = continuation
326
+                self.listener.stateUpdateHandler = { [weak self] state in
327
+                    guard let self else { return }
328
+                    switch state {
329
+                    case .ready:
330
+                        if let readyContinuation = self.readyContinuation {
331
+                            self.readyContinuation = nil
332
+                            readyContinuation.resume()
333
+                        }
334
+                    case .failed(let error):
335
+                        if let readyContinuation = self.readyContinuation {
336
+                            self.readyContinuation = nil
337
+                            readyContinuation.resume(throwing: error)
338
+                        }
339
+                    case .cancelled:
340
+                        if let readyContinuation = self.readyContinuation {
341
+                            self.readyContinuation = nil
342
+                            readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
343
+                        }
344
+                    default:
345
+                        break
346
+                    }
347
+                }
348
+                self.listener.newConnectionHandler = { [weak self] connection in
349
+                    self?.handle(connection: connection)
350
+                }
351
+                self.listener.start(queue: self.queue)
352
+            }
353
+        }
354
+    }
355
+
356
+    private func awaitCallback() async throws -> URL {
357
+        if let callbackURL { return callbackURL }
358
+        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
359
+            queue.async {
360
+                if let callbackURL = self.callbackURL {
361
+                    continuation.resume(returning: callbackURL)
362
+                    return
363
+                }
364
+                self.callbackContinuation = continuation
365
+            }
366
+        }
367
+    }
368
+
369
+    private func handle(connection: NWConnection) {
370
+        connection.start(queue: queue)
371
+        connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
372
+            guard let self else { return }
373
+            let requestLine = data
374
+                .flatMap { String(data: $0, encoding: .utf8) }?
375
+                .split(separator: "\r\n", omittingEmptySubsequences: false)
376
+                .first
377
+                .map(String.init)
378
+
379
+            var parsedURL: URL?
380
+            if let requestLine {
381
+                let parts = requestLine.split(separator: " ")
382
+                if parts.count >= 2 {
383
+                    let pathAndQuery = String(parts[1])
384
+                    parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
385
+                }
386
+            }
387
+
388
+            self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
389
+
390
+            if let parsedURL {
391
+                self.callbackURL = parsedURL
392
+                DispatchQueue.main.async {
393
+                    // Bring the app back to foreground once OAuth redirects.
394
+                    NSApp.activate(ignoringOtherApps: true)
395
+                }
396
+                if let continuation = self.callbackContinuation {
397
+                    self.callbackContinuation = nil
398
+                    continuation.resume(returning: parsedURL)
399
+                }
400
+                self.listener.cancel()
401
+            }
402
+            connection.cancel()
403
+        }
404
+    }
405
+
406
+    private func sendHTTPResponse(connection: NWConnection, success: Bool) {
407
+        let body = success
408
+            ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
409
+            : "<html><body><h3>Authentication failed</h3></body></html>"
410
+        let response = """
411
+        HTTP/1.1 200 OK\r
412
+        Content-Type: text/html; charset=utf-8\r
413
+        Content-Length: \(body.utf8.count)\r
414
+        Connection: close\r
415
+        \r
416
+        \(body)
417
+        """
418
+        connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
419
+    }
420
+}
421
+
422
+extension GoogleOAuthError: LocalizedError {
423
+    var errorDescription: String? {
424
+        switch self {
425
+        case .missingClientId:
426
+            return "Missing Google OAuth Client ID."
427
+        case .missingClientSecret:
428
+            return "Missing Google OAuth Client Secret."
429
+        case .invalidCallbackURL:
430
+            return "Invalid OAuth callback URL."
431
+        case .missingAuthorizationCode:
432
+            return "Google did not return an authorization code."
433
+        case .tokenExchangeFailed(let details):
434
+            return "Token exchange failed: \(details)"
435
+        case .unableToOpenBrowser:
436
+            return "Could not open browser for Google sign-in."
437
+        case .authenticationTimedOut:
438
+            return "Google sign-in timed out."
439
+        case .noStoredTokens:
440
+            return "No stored Google tokens found."
441
+        }
442
+    }
443
+}
444
+

+ 30 - 0
classroom_app/Auth/KeychainTokenStore.swift

@@ -0,0 +1,30 @@
1
+import Foundation
2
+
3
+/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
4
+/// This avoids macOS keychain unlock prompts during development/test runs.
5
+final class KeychainTokenStore {
6
+    private let defaultsKey: String
7
+    private let defaults: UserDefaults
8
+
9
+    init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
10
+         account: String = "googleOAuthTokens",
11
+         defaults: UserDefaults = .standard) {
12
+        self.defaultsKey = "\(service).\(account)"
13
+        self.defaults = defaults
14
+    }
15
+
16
+    func readTokens() throws -> GoogleOAuthTokens? {
17
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
18
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
19
+    }
20
+
21
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
22
+        let data = try JSONEncoder().encode(tokens)
23
+        defaults.set(data, forKey: defaultsKey)
24
+    }
25
+
26
+    func deleteTokens() throws {
27
+        defaults.removeObject(forKey: defaultsKey)
28
+    }
29
+}
30
+

+ 323 - 0
classroom_app/Google/GoogleCalendarClient.swift

@@ -0,0 +1,323 @@
1
+import Foundation
2
+
3
+enum GoogleCalendarClientError: Error {
4
+    case invalidResponse
5
+    case httpStatus(Int, String)
6
+    case decodeFailed(String)
7
+}
8
+
9
+final class GoogleCalendarClient {
10
+    struct Options: Sendable {
11
+        var daysAhead: Int
12
+        var maxResults: Int
13
+        var includeNonMeetEvents: Bool
14
+
15
+        init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
16
+            self.daysAhead = daysAhead
17
+            self.maxResults = maxResults
18
+            self.includeNonMeetEvents = includeNonMeetEvents
19
+        }
20
+    }
21
+
22
+    private let session: URLSession
23
+
24
+    init(session: URLSession = .shared) {
25
+        self.session = session
26
+    }
27
+
28
+    func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
29
+        try await fetchUpcomingMeetings(accessToken: accessToken, options: Options())
30
+    }
31
+
32
+    func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
33
+        let now = Date()
34
+        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
35
+        let formatter = ISO8601DateFormatter()
36
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
37
+        let totalLimit = max(1, options.maxResults)
38
+        let pageSize = min(250, totalLimit)
39
+        var nextPageToken: String?
40
+        var meetings: [ScheduledMeeting] = []
41
+
42
+        repeat {
43
+            var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
44
+            var queryItems = [
45
+                URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
46
+                URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
47
+                URLQueryItem(name: "singleEvents", value: "true"),
48
+                URLQueryItem(name: "orderBy", value: "startTime"),
49
+                URLQueryItem(name: "maxResults", value: String(pageSize)),
50
+                URLQueryItem(name: "conferenceDataVersion", value: "1")
51
+            ]
52
+            if let nextPageToken {
53
+                queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
54
+            }
55
+            components.queryItems = queryItems
56
+
57
+            var request = URLRequest(url: components.url!)
58
+            request.httpMethod = "GET"
59
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
60
+
61
+            let (data, response) = try await session.data(for: request)
62
+            guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
63
+            guard (200..<300).contains(http.statusCode) else {
64
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
65
+                throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
66
+            }
67
+
68
+            let decoded: EventsList
69
+            do {
70
+                decoded = try JSONDecoder().decode(EventsList.self, from: data)
71
+            } catch {
72
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
73
+                throw GoogleCalendarClientError.decodeFailed(raw)
74
+            }
75
+
76
+            let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
77
+            let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
78
+            let subtitle = item.organizer?.displayName ?? item.organizer?.email
79
+
80
+            guard let start = item.start?.resolvedDate,
81
+                  let end = item.end?.resolvedDate else { return nil }
82
+
83
+            let isAllDay = item.start?.date != nil
84
+
85
+            let meetURL: URL? = {
86
+                if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
87
+                let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
88
+                if let uri = entry?.uri, let u = URL(string: uri) { return u }
89
+                if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
90
+                return nil
91
+            }()
92
+
93
+            if meetURL == nil, options.includeNonMeetEvents == false { return nil }
94
+            guard let meetURL else { return nil }
95
+
96
+            return ScheduledMeeting(
97
+                id: item.id ?? UUID().uuidString,
98
+                title: (title?.isEmpty == false) ? title! : "Untitled meeting",
99
+                subtitle: subtitle,
100
+                startDate: start,
101
+                endDate: end,
102
+                meetURL: meetURL,
103
+                isAllDay: isAllDay
104
+            )
105
+            }
106
+
107
+            meetings.append(contentsOf: pageMeetings)
108
+            nextPageToken = decoded.nextPageToken
109
+        } while nextPageToken != nil && meetings.count < totalLimit
110
+
111
+        if meetings.count > totalLimit {
112
+            meetings = Array(meetings.prefix(totalLimit))
113
+        }
114
+        return meetings
115
+    }
116
+
117
+    func createEvent(accessToken: String,
118
+                     title: String,
119
+                     description: String?,
120
+                     start: Date,
121
+                     end: Date,
122
+                     timeZone: TimeZone = .current) async throws -> ScheduledMeeting {
123
+        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
124
+        components.queryItems = [
125
+            URLQueryItem(name: "conferenceDataVersion", value: "1")
126
+        ]
127
+
128
+        let requestID = UUID().uuidString
129
+        let body = CreateEventRequest(
130
+            summary: title,
131
+            description: description,
132
+            start: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: start), timeZone: timeZone.identifier),
133
+            end: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: end), timeZone: timeZone.identifier),
134
+            conferenceData: CreateEventRequest.ConferenceData(
135
+                createRequest: CreateEventRequest.CreateRequest(
136
+                    requestId: requestID,
137
+                    conferenceSolutionKey: CreateEventRequest.ConferenceSolutionKey(type: "hangoutsMeet")
138
+                )
139
+            )
140
+        )
141
+
142
+        var request = URLRequest(url: components.url!)
143
+        request.httpMethod = "POST"
144
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
145
+        request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
146
+        request.httpBody = try JSONEncoder().encode(body)
147
+
148
+        let (data, response) = try await session.data(for: request)
149
+        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
150
+        guard (200..<300).contains(http.statusCode) else {
151
+            let raw = String(data: data, encoding: .utf8) ?? "<no body>"
152
+            throw GoogleCalendarClientError.httpStatus(http.statusCode, raw)
153
+        }
154
+
155
+        let created: EventItem
156
+        do {
157
+            created = try JSONDecoder().decode(EventItem.self, from: data)
158
+        } catch {
159
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
160
+            throw GoogleCalendarClientError.decodeFailed(raw)
161
+        }
162
+
163
+        guard let startDate = created.start?.resolvedDate,
164
+              let endDate = created.end?.resolvedDate else {
165
+            throw GoogleCalendarClientError.decodeFailed("Created event missing start/end.")
166
+        }
167
+
168
+        let isAllDay = created.start?.date != nil
169
+        let meetURL: URL? = {
170
+            if let hangout = created.hangoutLink, let u = URL(string: hangout) { return u }
171
+            let entry = created.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
172
+            if let uri = entry?.uri, let u = URL(string: uri) { return u }
173
+            if let htmlLink = created.htmlLink, let u = URL(string: htmlLink) { return u }
174
+            return nil
175
+        }()
176
+        guard let meetURL else {
177
+            throw GoogleCalendarClientError.decodeFailed("Created event missing Meet/HTML link.")
178
+        }
179
+
180
+        let cleanedTitle = created.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
181
+        let subtitle = created.organizer?.displayName ?? created.organizer?.email
182
+        return ScheduledMeeting(
183
+            id: created.id ?? UUID().uuidString,
184
+            title: (cleanedTitle?.isEmpty == false) ? cleanedTitle! : "Untitled meeting",
185
+            subtitle: subtitle,
186
+            startDate: startDate,
187
+            endDate: endDate,
188
+            meetURL: meetURL,
189
+            isAllDay: isAllDay
190
+        )
191
+    }
192
+}
193
+
194
+extension GoogleCalendarClientError: LocalizedError {
195
+    var errorDescription: String? {
196
+        switch self {
197
+        case .invalidResponse:
198
+            return "Google Calendar returned an invalid response."
199
+        case let .httpStatus(status, body):
200
+            return "Google Calendar API error (\(status)): \(body)"
201
+        case let .decodeFailed(raw):
202
+            return "Failed to parse Google Calendar events: \(raw)"
203
+        }
204
+    }
205
+}
206
+
207
+// MARK: - Calendar API models
208
+
209
+private struct EventsList: Decodable {
210
+    let items: [EventItem]
211
+    let nextPageToken: String?
212
+}
213
+
214
+private struct EventItem: Decodable {
215
+    let id: String?
216
+    let summary: String?
217
+    let hangoutLink: String?
218
+    let htmlLink: String?
219
+    let organizer: Organizer?
220
+    let start: EventDateTime?
221
+    let end: EventDateTime?
222
+    let conferenceData: ConferenceData?
223
+}
224
+
225
+private struct Organizer: Decodable {
226
+    let displayName: String?
227
+    let email: String?
228
+}
229
+
230
+private struct EventDateTime: Decodable {
231
+    let dateTime: String?
232
+    let date: String?
233
+    let timeZone: String?
234
+
235
+    var resolvedDate: Date? {
236
+        if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) {
237
+            return parsed
238
+        }
239
+        if let date, let parsed = DateFormatter.googleAllDay.date(from: date) {
240
+            return parsed
241
+        }
242
+        return nil
243
+    }
244
+
245
+    private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? {
246
+        if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) {
247
+            return dt
248
+        }
249
+
250
+        // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field.
251
+        let formatter = DateFormatter()
252
+        formatter.calendar = Calendar(identifier: .gregorian)
253
+        formatter.locale = Locale(identifier: "en_US_POSIX")
254
+        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
255
+        if let timeZone, let tz = TimeZone(identifier: timeZone) {
256
+            formatter.timeZone = tz
257
+        } else {
258
+            formatter.timeZone = TimeZone.current
259
+        }
260
+        return formatter.date(from: raw)
261
+    }
262
+}
263
+
264
+private struct ConferenceData: Decodable {
265
+    let entryPoints: [EntryPoint]?
266
+}
267
+
268
+private struct EntryPoint: Decodable {
269
+    let entryPointType: String?
270
+    let uri: String?
271
+}
272
+
273
+private struct CreateEventRequest: Encodable {
274
+    struct EventDateTime: Encodable {
275
+        let dateTime: String
276
+        let timeZone: String
277
+    }
278
+
279
+    struct ConferenceSolutionKey: Encodable {
280
+        let type: String
281
+    }
282
+
283
+    struct CreateRequest: Encodable {
284
+        let requestId: String
285
+        let conferenceSolutionKey: ConferenceSolutionKey
286
+    }
287
+
288
+    struct ConferenceData: Encodable {
289
+        let createRequest: CreateRequest
290
+    }
291
+
292
+    let summary: String
293
+    let description: String?
294
+    let start: EventDateTime
295
+    let end: EventDateTime
296
+    let conferenceData: ConferenceData
297
+}
298
+
299
+private extension ISO8601DateFormatter {
300
+    static let fractional: ISO8601DateFormatter = {
301
+        let f = ISO8601DateFormatter()
302
+        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
303
+        return f
304
+    }()
305
+
306
+    static let nonFractional: ISO8601DateFormatter = {
307
+        let f = ISO8601DateFormatter()
308
+        f.formatOptions = [.withInternetDateTime]
309
+        return f
310
+    }()
311
+}
312
+
313
+private extension DateFormatter {
314
+    static let googleAllDay: DateFormatter = {
315
+        let f = DateFormatter()
316
+        f.calendar = Calendar(identifier: .gregorian)
317
+        f.locale = Locale(identifier: "en_US_POSIX")
318
+        f.timeZone = TimeZone(secondsFromGMT: 0)
319
+        f.dateFormat = "yyyy-MM-dd"
320
+        return f
321
+    }()
322
+}
323
+

+ 50 - 0
classroom_app/Google/GoogleMeetClient.swift

@@ -0,0 +1,50 @@
1
+import Foundation
2
+
3
+enum GoogleMeetClientError: Error {
4
+    case invalidResponse
5
+    case httpStatus(Int)
6
+}
7
+
8
+/// Thin Meet REST API wrapper.
9
+/// Note: Meet REST API is best used for conferences/participants/artifacts, while scheduling comes from Calendar.
10
+final class GoogleMeetClient {
11
+    private let session: URLSession
12
+
13
+    init(session: URLSession = .shared) {
14
+        self.session = session
15
+    }
16
+
17
+    /// Lists conference records for a given meeting space resource name.
18
+    /// This is intentionally minimal scaffolding for phase 2 enrichment.
19
+    func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
20
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
21
+        components.queryItems = [
22
+            URLQueryItem(name: "pageSize", value: String(pageSize))
23
+        ]
24
+
25
+        var request = URLRequest(url: components.url!)
26
+        request.httpMethod = "GET"
27
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
28
+
29
+        let (data, response) = try await session.data(for: request)
30
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
31
+        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
32
+
33
+        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
34
+        return decoded.conferenceRecords ?? []
35
+    }
36
+}
37
+
38
+// MARK: - Minimal models (v2)
39
+
40
+struct ConferenceRecord: Decodable, Equatable {
41
+    let name: String?
42
+    let startTime: Date?
43
+    let endTime: Date?
44
+}
45
+
46
+private struct ListConferenceRecordsResponse: Decodable {
47
+    let conferenceRecords: [ConferenceRecord]?
48
+    let nextPageToken: String?
49
+}
50
+

+ 12 - 0
classroom_app/Models/ScheduledMeeting.swift

@@ -0,0 +1,12 @@
1
+import Foundation
2
+
3
+struct ScheduledMeeting: Identifiable, Equatable {
4
+    let id: String
5
+    let title: String
6
+    let subtitle: String?
7
+    let startDate: Date
8
+    let endDate: Date
9
+    let meetURL: URL
10
+    let isAllDay: Bool
11
+}
12
+

+ 82 - 0
classroom_app/StoreKit.storekit

@@ -0,0 +1,82 @@
1
+{
2
+  "identifier" : "7B5DA685-94A9-4A9B-86EA-F7D90A0D5249",
3
+  "nonRenewingSubscriptions" : [],
4
+  "products" : [
5
+    {
6
+      "displayPrice" : "1100.00",
7
+      "familyShareable" : false,
8
+      "internalID" : "F16C0A1F-5B83-41AB-A9F2-69157A11A11A",
9
+      "localizations" : [
10
+        {
11
+          "description" : "Unlock premium features with weekly access.",
12
+          "displayName" : "Premium Weekly",
13
+          "locale" : "en_US"
14
+        }
15
+      ],
16
+      "productID" : "com.mqldev.classroomapp.premium.weekly",
17
+      "referenceName" : "Premium Weekly",
18
+      "type" : "NonConsumable"
19
+    },
20
+    {
21
+      "displayPrice" : "2500.00",
22
+      "familyShareable" : false,
23
+      "internalID" : "B2B57D59-AE2B-4953-BF03-5D4AFECAC6C1",
24
+      "localizations" : [
25
+        {
26
+          "description" : "Unlock premium features with monthly access.",
27
+          "displayName" : "Premium Monthly",
28
+          "locale" : "en_US"
29
+        }
30
+      ],
31
+      "productID" : "com.mqldev.classroomapp.premium.monthly",
32
+      "referenceName" : "Premium Monthly",
33
+      "type" : "NonConsumable"
34
+    },
35
+    {
36
+      "displayPrice" : "9900.00",
37
+      "familyShareable" : false,
38
+      "internalID" : "C5694F51-47D8-4AFD-9D33-95A888527BB5",
39
+      "localizations" : [
40
+        {
41
+          "description" : "Unlock premium features with yearly access.",
42
+          "displayName" : "Premium Yearly",
43
+          "locale" : "en_US"
44
+        }
45
+      ],
46
+      "productID" : "com.mqldev.classroomapp.premium.yearly",
47
+      "referenceName" : "Premium Yearly",
48
+      "type" : "NonConsumable"
49
+    },
50
+    {
51
+      "displayPrice" : "14900.00",
52
+      "familyShareable" : false,
53
+      "internalID" : "7F9AA412-9F7A-4DF7-BCFB-DF308359DCEF",
54
+      "localizations" : [
55
+        {
56
+          "description" : "One-time premium purchase for lifetime access.",
57
+          "displayName" : "Premium Lifetime",
58
+          "locale" : "en_US"
59
+        }
60
+      ],
61
+      "productID" : "com.mqldev.classroomapp.premium.lifetime",
62
+      "referenceName" : "Premium Lifetime",
63
+      "type" : "NonConsumable"
64
+    }
65
+  ],
66
+  "settings" : {
67
+    "_applicationInternalID" : "A53B9DA3-4C5B-40F4-8452-AD3B8DDE5B9F",
68
+    "_developerTeamID" : "",
69
+    "_disableDialogs" : false,
70
+    "_failTransactionsEnabled" : false,
71
+    "_lastSynchronizedDate" : 0,
72
+    "_locale" : "en_PK",
73
+    "_renewalRate" : 0,
74
+    "_storefront" : "PAK",
75
+    "_timeRate" : 0
76
+  },
77
+  "subscriptionGroups" : [],
78
+  "version" : {
79
+    "major" : 3,
80
+    "minor" : 0
81
+  }
82
+}

File diff suppressed because it is too large
+ 7110 - 5
classroom_app/ViewController.swift


+ 18 - 0
classroom_app/classroom_app.entitlements

@@ -0,0 +1,18 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>com.apple.security.app-sandbox</key>
6
+	<true/>
7
+	<key>com.apple.security.network.client</key>
8
+	<true/>
9
+	<key>com.apple.security.network.server</key>
10
+	<true/>
11
+	<key>com.apple.security.device.camera</key>
12
+	<true/>
13
+	<key>com.apple.security.device.audio-input</key>
14
+	<true/>
15
+	<key>com.apple.security.files.user-selected.read-only</key>
16
+	<true/>
17
+</dict>
18
+</plist>