diff --git a/leaderboard_app/.gitignore b/leaderboard_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/leaderboard_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/leaderboard_app/.metadata b/leaderboard_app/.metadata new file mode 100644 index 0000000..131e057 --- /dev/null +++ b/leaderboard_app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "02da4cc00db9eb97fc48e89d319ef48518c2440a" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: android + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: ios + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: linux + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: macos + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: web + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + - platform: windows + create_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + base_revision: 02da4cc00db9eb97fc48e89d319ef48518c2440a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/leaderboard_app/README.md b/leaderboard_app/README.md new file mode 100644 index 0000000..2ae7317 --- /dev/null +++ b/leaderboard_app/README.md @@ -0,0 +1,170 @@ +# leaderboard_app + +Flutter application with backend integration using `dio` + `retrofit` for typed HTTP APIs. + +## Backend Integration + +We use: + +* `dio` for HTTP transport, interceptors, timeouts. +* `retrofit` for declarative REST interface generation (`lib/services/core/rest_client.dart`). +* `build_runner` + `retrofit_generator` (and `json_serializable` if/when model code generation is added). + +### Generating code + +Run code generation after updating API interface annotations: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +### Using the REST client + +```dart +import 'package:leaderboard_app/services/core/dio_provider.dart'; +import 'package:leaderboard_app/services/core/rest_client.dart'; + +final dio = await DioProvider.getInstance(); +final api = RestClient(dio); + +final start = await api.startVerification({'leetcodeUsername': 'someUser'}); +final status = await api.getVerificationStatus('someUser'); +``` + +Auth tokens (JWT) are automatically attached from `SharedPreferences` via an interceptor in `DioProvider`. + +### Environment / Base URL + +Centralized in `lib/config/api_config.dart`. + +Default baked-in base URL (when no override is supplied): + +``` +http://140.238.213.170:3002/api +``` + +Override at build/run time: + +```bash +flutter run --dart-define=API_BASE_URL=https://your.api.host/api +``` + +Release / CI example: + +```bash +flutter build apk --dart-define=API_BASE_URL=https://prod.api.host/api +``` + +Trailing slashes are trimmed automatically. Keep `/api` if your backend routes are under that prefix. + +### Adding new endpoints + +1. Edit `lib/services/core/rest_client.dart` – add a method with appropriate HTTP verb annotation. +2. Run the build command above to regenerate `rest_client.g.dart`. +3. Consume the new method from services or providers. + +### Logging & Retry + +`DioProvider` adds a lightweight log interceptor and simple retry (only once) for idempotent GET requests on connection errors. + +--- + +Generated code (`rest_client.g.dart`) should not be manually edited. + + +## Building a Release APK / Sharing the App + +1. (Optional) Override the API base URL at build time (recommended for different envs): + +```bash +flutter build apk --release --dart-define=API_BASE_URL=https://prod.api.host/api +``` + +If you omit `--dart-define` the baked-in default from `ApiConfig` is used. + +2. The unsigned release APK will be at: + +``` +build\app\outputs\flutter-apk\app-release.apk +``` + +3. (Recommended) Create a keystore and configure signing in `android/key.properties` + `build.gradle` to avoid Play Store rejection and to allow in-place upgrades. + +### Example keystore creation (run once) + +```bash +keytool -genkey -v -keystore my-release-key.keystore -alias upload -keyalg RSA -keysize 2048 -validity 10000 +``` + +Place the keystore under `android/` (never commit to VCS) and add a `key.properties`: + +``` +storePassword=YOUR_STORE_PASSWORD +keyPassword=YOUR_KEY_PASSWORD +keyAlias=upload +storeFile=../my-release-key.keystore +``` + +Then update `android/app/build.gradle` signingConfigs + buildTypes (if not already present). + +### Distributing for quick tests + +You can directly share `app-release.apk` with testers (they must enable install from unknown sources). For Play Store publishing prefer an AAB: + +```bash +flutter build appbundle --dart-define=API_BASE_URL=https://prod.api.host/api +``` + +## Troubleshooting: "Cannot reach server. Check BASE_URL..." + +This message originates from `ErrorUtils.fromDio` when the `DioExceptionType.connectionError` occurs. Common causes: + +| Cause | Fix | +|-------|-----| +| Device has no internet | Ensure Wi‑Fi/data works (open a website) | +| Backend URL wrong or down | Open the URL in mobile Chrome to verify response | +| Using `localhost` / private IP not reachable externally | Use a public/stable host or expose via tunneling (ngrok, Cloudflare) | +| HTTP blocked (if you switch to HTTPS only) | Ensure correct scheme in `API_BASE_URL` | +| Missing INTERNET permission | Manifest now includes `` | + +To quickly verify the URL the app is using, add a temporary log: + +```dart +print('API base URL: ' + ApiConfig.baseUrl); +``` + +Or run with an override: + +```bash +flutter run --release --dart-define=API_BASE_URL=https://your-temp-api/api +``` + +If the backend uses a self-signed certificate, Android may reject it—use a valid cert (Let's Encrypt) for production. + +## Future Enhancements (Optional) + +* Add build flavors: dev / staging / prod with per-flavor `--dart-define` presets. +* Add environment banner in-app for non-prod. +* Implement exponential backoff retries for transient network errors. +* Add Sentry or similar for error monitoring. + +## Splash Screen & Offline Handling + +The app shows a native splash (configured via `flutter_native_splash`) while core services initialize. For returning users (flag stored in `SharedPreferences` as `returningUser`), dashboard data is preloaded (daily question, submissions if verified, leaderboard) before removing the splash to deliver a populated home view quickly. + +If there's no network connectivity at launch, a dedicated offline screen (`NoInternetPage`) is displayed. Connectivity is monitored with `connectivity_plus` through `ConnectivityProvider`; once a connection becomes available the app automatically proceeds with initialization and dismisses the splash. + +Update splash assets/colors in `pubspec.yaml` under `flutter_native_splash:` then regenerate: + +```bash +flutter pub run flutter_native_splash:create +``` + +Key files: + +* `lib/main.dart` – splash preservation & initialization logic (`_AppInitializer`). +* `lib/provider/connectivity_provider.dart` – connectivity listener. +* `lib/pages/no_internet_page.dart` – offline UI. + +To disable preloading behavior simply remove the `dashboardProvider.loadAll()` call in `_preload()`. + diff --git a/leaderboard_app/analysis_options.yaml b/leaderboard_app/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/leaderboard_app/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/leaderboard_app/android/.gitignore b/leaderboard_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/leaderboard_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/leaderboard_app/android/app/build.gradle.kts b/leaderboard_app/android/app/build.gradle.kts new file mode 100644 index 0000000..fa19038 --- /dev/null +++ b/leaderboard_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.dscvit.leeterboard" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.dscvit.leeterboard" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/leaderboard_app/android/app/src/debug/AndroidManifest.xml b/leaderboard_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/leaderboard_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/leaderboard_app/android/app/src/main/AndroidManifest.xml b/leaderboard_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2f6c491 --- /dev/null +++ b/leaderboard_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/android/app/src/main/kotlin/com/example/leaderboard_app/MainActivity.kt b/leaderboard_app/android/app/src/main/kotlin/com/example/leaderboard_app/MainActivity.kt new file mode 100644 index 0000000..b6659cc --- /dev/null +++ b/leaderboard_app/android/app/src/main/kotlin/com/example/leaderboard_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.dscvit.leeterboard + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/leaderboard_app/android/app/src/main/res/drawable-hdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..89ce080 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/leaderboard_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..739120d Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-hdpi/splash.png b/leaderboard_app/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..89ce080 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-mdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..e958d01 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/leaderboard_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e7a2590 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-mdpi/splash.png b/leaderboard_app/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..e958d01 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..89ce080 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..e958d01 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..faa50f8 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..a5e1dee Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..d29a8ea Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-v21/background.png b/leaderboard_app/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-v21/background.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-v21/launch_background.xml b/leaderboard_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/drawable-xhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..faa50f8 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..27708dd Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xhdpi/splash.png b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..faa50f8 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..a5e1dee Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e774c13 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/splash.png b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..a5e1dee Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..d29a8ea Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..89ce080 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/splash.png b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..d29a8ea Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable/background.png b/leaderboard_app/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/drawable/background.png differ diff --git a/leaderboard_app/android/app/src/main/res/drawable/launch_background.xml b/leaderboard_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/leaderboard_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5f349f7 --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/leaderboard_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/leaderboard_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c460d92 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/leaderboard_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/leaderboard_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d44ee45 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/leaderboard_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/leaderboard_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a810e2e Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/leaderboard_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/leaderboard_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..250106f Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/leaderboard_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/leaderboard_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b39c836 Binary files /dev/null and b/leaderboard_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/leaderboard_app/android/app/src/main/res/values-night-v31/styles.xml b/leaderboard_app/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..c23369c --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/values-night/styles.xml b/leaderboard_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..3c4a1fe --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/values-v31/styles.xml b/leaderboard_app/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..a32dc35 --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/values/colors.xml b/leaderboard_app/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..beab31f --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/leaderboard_app/android/app/src/main/res/values/styles.xml b/leaderboard_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..847e1be --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/leaderboard_app/android/app/src/main/res/xml/network_security_config.xml b/leaderboard_app/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d985794 --- /dev/null +++ b/leaderboard_app/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/leaderboard_app/android/app/src/profile/AndroidManifest.xml b/leaderboard_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/leaderboard_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/leaderboard_app/android/build.gradle.kts b/leaderboard_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/leaderboard_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/leaderboard_app/android/gradle.properties b/leaderboard_app/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/leaderboard_app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/leaderboard_app/android/gradle/wrapper/gradle-wrapper.properties b/leaderboard_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/leaderboard_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/leaderboard_app/android/settings.gradle.kts b/leaderboard_app/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/leaderboard_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/leaderboard_app/assets/icons/LL_Logo.png b/leaderboard_app/assets/icons/LL_Logo.png new file mode 100644 index 0000000..3ab3f1f Binary files /dev/null and b/leaderboard_app/assets/icons/LL_Logo.png differ diff --git a/leaderboard_app/assets/icons/LL_Logo.svg b/leaderboard_app/assets/icons/LL_Logo.svg new file mode 100644 index 0000000..397b49c --- /dev/null +++ b/leaderboard_app/assets/icons/LL_Logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/assets/icons/google.png b/leaderboard_app/assets/icons/google.png new file mode 100644 index 0000000..64b0e95 Binary files /dev/null and b/leaderboard_app/assets/icons/google.png differ diff --git a/leaderboard_app/devtools_options.yaml b/leaderboard_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/leaderboard_app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/leaderboard_app/fonts/AlumniSans-Italic-VariableFont_wght.ttf b/leaderboard_app/fonts/AlumniSans-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..8ee842b Binary files /dev/null and b/leaderboard_app/fonts/AlumniSans-Italic-VariableFont_wght.ttf differ diff --git a/leaderboard_app/fonts/AlumniSans-VariableFont_wght.ttf b/leaderboard_app/fonts/AlumniSans-VariableFont_wght.ttf new file mode 100644 index 0000000..f68b7da Binary files /dev/null and b/leaderboard_app/fonts/AlumniSans-VariableFont_wght.ttf differ diff --git a/leaderboard_app/ios/.gitignore b/leaderboard_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/leaderboard_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/leaderboard_app/ios/Flutter/AppFrameworkInfo.plist b/leaderboard_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/leaderboard_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/leaderboard_app/ios/Flutter/Debug.xcconfig b/leaderboard_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/leaderboard_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/leaderboard_app/ios/Flutter/Release.xcconfig b/leaderboard_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/leaderboard_app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/leaderboard_app/ios/Runner.xcodeproj/project.pbxproj b/leaderboard_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7861f5d --- /dev/null +++ b/leaderboard_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/leaderboard_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/leaderboard_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/leaderboard_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/leaderboard_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/leaderboard_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/leaderboard_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/leaderboard_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/leaderboard_app/ios/Runner/AppDelegate.swift b/leaderboard_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/leaderboard_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..33cc734 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..b8d9cb8 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..56f3844 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..069f74b Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..029b47f Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..5cd5764 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..d72c0c1 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..56f3844 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..a0de470 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..e6357d0 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..c22d7c9 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..945f28b Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..7b6b3ae Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..21ea12b Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..e6357d0 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..4c39751 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..c460d92 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..250106f Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..0eb4751 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..0a08685 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..f42e99b Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..9f447e1 --- /dev/null +++ b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..00cabce --- /dev/null +++ b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..e958d01 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..faa50f8 Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..a5e1dee Binary files /dev/null and b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/leaderboard_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/leaderboard_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/leaderboard_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..5a37630 --- /dev/null +++ b/leaderboard_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/ios/Runner/Base.lproj/Main.storyboard b/leaderboard_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/leaderboard_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/ios/Runner/Info.plist b/leaderboard_app/ios/Runner/Info.plist new file mode 100644 index 0000000..849c51a --- /dev/null +++ b/leaderboard_app/ios/Runner/Info.plist @@ -0,0 +1,69 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Leaderboard App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + leaderboard_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + UIStatusBarHidden + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/leaderboard_app/ios/Runner/Runner-Bridging-Header.h b/leaderboard_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/leaderboard_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/leaderboard_app/ios/RunnerTests/RunnerTests.swift b/leaderboard_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/leaderboard_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/leaderboard_app/lib/chatpage-components/chat_view.dart b/leaderboard_app/lib/chatpage-components/chat_view.dart new file mode 100644 index 0000000..8854775 --- /dev/null +++ b/leaderboard_app/lib/chatpage-components/chat_view.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/pages/groupinfo_page.dart'; +import 'package:provider/provider.dart'; +import '../provider/chat_provider.dart'; +import 'message_list.dart'; +import 'user_input.dart'; + +class ChatView extends StatefulWidget { + final String groupId; + final String groupName; + + const ChatView({super.key, required this.groupId, required this.groupName}); + + @override + State createState() => _ChatViewState(); +} + +class _ChatViewState extends State { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final FocusNode myFocusNode = FocusNode(); + bool _didInitialAutoScroll = false; // guard to only auto-scroll once after history loads + + @override + void initState() { + super.initState(); + myFocusNode.addListener(() { + if (myFocusNode.hasFocus) { + Future.delayed(const Duration(milliseconds: 300), scrollDown); + } + }); + Future.delayed(const Duration(milliseconds: 500), scrollDown); + // Hook into provider after first frame to attach incoming message callback + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final chat = context.read(); + chat.onIncomingMessage = (gid) { + if (gid == widget.groupId) { + WidgetsBinding.instance.addPostFrameCallback((_) => scrollDown()); + } + }; + }); + } + + void scrollDown() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent + 80, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + void dispose() { + try { + final chat = context.read(); + if (chat.onIncomingMessage != null) chat.onIncomingMessage = null; + } catch (_) {} + _messageController.dispose(); + _scrollController.dispose(); + myFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + final chat = Provider.of(context); // watch + // Attempt initial auto-scroll when messages first arrive + if (!_didInitialAutoScroll) { + final msgs = chat.getMessages(widget.groupId); + if (msgs.isNotEmpty) { + // schedule after current frame so ListView has dimensions + WidgetsBinding.instance.addPostFrameCallback((_) => scrollDown()); + _didInitialAutoScroll = true; + } + } + + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: theme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: const BackButton(), + titleSpacing: 0, + title: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GroupInfoPage(groupId: widget.groupId, initialName: widget.groupName), + ), + ).then((result) { + if (result is Map && (result['leftGroup'] == true || result['deletedGroup'] == true)) { + if (mounted) Navigator.of(context).pop(result); + } + }); + }, + child: Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.grey, + child: Icon(Icons.group, color: Colors.white), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Text( + widget.groupName, + style: TextStyle( + color: theme.primary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(width: 8), + if (chat.isConnecting) + const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), + if (!chat.isConnecting && !chat.isConnected) + const Icon(Icons.cloud_off, color: Colors.red, size: 16), + ]), + ], + ), + ], + ), + ), + ), + body: Column( + children: [ + Expanded( + child: MessageList( + groupId: widget.groupId, + scrollController: _scrollController, + scrollDownCallback: scrollDown, + ), + ), + UserInput( + groupId: widget.groupId, + messageController: _messageController, + focusNode: myFocusNode, + scrollDownCallback: scrollDown, + ), + ], + ), + ), + ); + } +} diff --git a/leaderboard_app/lib/chatpage-components/message_list.dart b/leaderboard_app/lib/chatpage-components/message_list.dart new file mode 100644 index 0000000..55d0f2f --- /dev/null +++ b/leaderboard_app/lib/chatpage-components/message_list.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:pixelarticons/pixel.dart'; +import 'package:provider/provider.dart'; +import 'package:chat_bubbles/chat_bubbles.dart'; +import '../provider/chat_provider.dart'; + +/// MessageList features: +/// 1. Bubble tails only on the last message in a consecutive block from the same sender. +/// 2. Only other users' names are shown (current user's name omitted) above the first bubble in their consecutive block. +/// 3. Swipe left on any message to reveal timestamps for all messages; swipe right to hide. +class MessageList extends StatefulWidget { + final String groupId; + final ScrollController scrollController; + final VoidCallback scrollDownCallback; + + const MessageList({ + super.key, + required this.groupId, + required this.scrollController, + required this.scrollDownCallback, + }); + + @override + State createState() => _MessageListState(); +} + +class _MessageListState extends State { + /// Global toggle: when true show timestamp for all messages. + bool _showAllTimes = false; + + void _setShowAllTimes(bool value) { + if (_showAllTimes == value) return; + setState(() => _showAllTimes = value); + } + + @override + Widget build(BuildContext context) { + final provider = Provider.of(context); + final messages = provider.getMessages(widget.groupId); + + if (messages.isEmpty) { + return Center( + child: Text( + "No messages yet", + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ); + } + + return ListView.builder( + controller: widget.scrollController, + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + final isMe = msg["isMe"] == true; + final isSystem = msg["senderID"] == "system"; + final isImage = msg["type"] == "image"; + + if (isSystem) return _SystemMessage(msg: msg); + if (isImage) return _ImageMessage(msg: msg, isMe: isMe); + + // Determine tail: only last in consecutive block by same sender. + final currentSender = msg["senderID"]; + bool hasTail = true; + if (index < messages.length - 1) { + final next = messages[index + 1]; + if (next["senderID"] == currentSender) { + hasTail = false; + } + } + + final prevSender = index > 0 ? messages[index - 1]["senderID"] : null; + final showName = prevSender != msg["senderID"]; // only first in block + + return _TextMessage( + msg: msg, + isMe: isMe, + tail: hasTail, + showTime: _showAllTimes, + showName: showName, + onSwipeLeft: () => _setShowAllTimes(true), + onSwipeRight: () => _setShowAllTimes(false), + ); + }, + ); + } +} + +class _SystemMessage extends StatelessWidget { + final Map msg; + const _SystemMessage({required this.msg}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(msg["icon"] ?? Icons.info, size: 18, color: Colors.white), + const SizedBox(width: 6), + Text( + msg["message"] ?? "", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + const SizedBox(width: 6), + Text( + msg["timestamp"] ?? "", + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _ImageMessage extends StatelessWidget { + final Map msg; + final bool isMe; + const _ImageMessage({required this.msg, required this.isMe}); + + @override + Widget build(BuildContext context) { + final bubbleColor = isMe ? const Color(0xFFE3C17D) : Colors.grey.shade900; + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + width: 180, + height: 180, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon(Pixel.image, size: 66, color: Colors.grey), + ), + ), + const SizedBox(height: 4), + Text( + msg["timestamp"] ?? "", + style: TextStyle(fontSize: 12, color: isMe ? Colors.black54 : Colors.white54), + ), + ], + ), + ), + ); + } +} + +class _TextMessage extends StatefulWidget { + final Map msg; + final bool isMe; + final bool tail; + final bool showTime; // global toggle + final bool showName; + final VoidCallback onSwipeLeft; // show all times + final VoidCallback onSwipeRight; // hide all times + + const _TextMessage({ + required this.msg, + required this.isMe, + required this.tail, + required this.showTime, + required this.showName, + required this.onSwipeLeft, + required this.onSwipeRight, + }); + + @override + State<_TextMessage> createState() => _TextMessageState(); +} + +class _TextMessageState extends State<_TextMessage> { + double _dragX = 0; // negative when swiping left + + static const double _revealThreshold = -30; // pixels + static const double _hideThreshold = 30; // for right swipe (unused mostly) + static const double _minDrag = -90; // allow slight off-screen travel + static const double _maxDrag = 40; + + void _onDragUpdate(DragUpdateDetails d) { + setState(() { + _dragX += d.delta.dx; + if (_dragX < _minDrag) _dragX = _minDrag; + if (_dragX > _maxDrag) _dragX = _maxDrag; + }); + } + + void _onDragEnd(DragEndDetails d) { + final velocity = d.primaryVelocity ?? 0; + if (_dragX <= _revealThreshold || velocity < -600) { + widget.onSwipeLeft(); + } else if (_dragX >= _hideThreshold || velocity > 600) { + widget.onSwipeRight(); + } + // snap back + setState(() => _dragX = 0); + } + + @override + Widget build(BuildContext context) { + final isMe = widget.isMe; + final bubbleColor = isMe ? const Color(0xFFE3C17D) : Colors.grey.shade900; + final textColor = isMe ? Colors.black : Colors.white; + // Username color for other users set to grey500 as requested + final nameColor = isMe ? Colors.black : Colors.grey.shade500; + + // Base offset when timestamps visible (shift left a bit to emphasize reveal) + final baseShift = widget.showTime ? -12.0 : 0.0; + final effectiveShift = baseShift + _dragX; // _dragX usually 0 except during gesture + + return GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (widget.showName && !isMe) + Transform.translate( + offset: Offset(effectiveShift, 0), + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 20), + Text( + widget.msg["senderName"] ?? '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: nameColor, + ), + ), + ], + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Transform.translate( + offset: Offset(effectiveShift, 0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: BubbleSpecialThree( + text: widget.msg["message"] ?? '', + color: bubbleColor, + isSender: isMe, + tail: widget.tail, + textStyle: TextStyle( + color: textColor, + fontSize: 16, + ), + ), + ), + ), + if (widget.showTime) ...[ + Spacer(), + Align( + alignment: Alignment.center, + child: Text( + widget.msg["timestamp"] ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.white54, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/leaderboard_app/lib/chatpage-components/user_input.dart b/leaderboard_app/lib/chatpage-components/user_input.dart new file mode 100644 index 0000000..59114da --- /dev/null +++ b/leaderboard_app/lib/chatpage-components/user_input.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:pixelarticons/pixel.dart'; +import 'package:provider/provider.dart'; +import '../provider/chat_provider.dart'; + +class UserInput extends StatelessWidget { + final String groupId; + final TextEditingController messageController; + final FocusNode focusNode; + final VoidCallback scrollDownCallback; + + const UserInput({ + super.key, + required this.groupId, + required this.messageController, + required this.focusNode, + required this.scrollDownCallback, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + final provider = Provider.of(context); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(24), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40, maxHeight: 150), + child: Scrollbar( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: messageController, + focusNode: focusNode, + maxLines: null, + keyboardType: TextInputType.multiline, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: "Type a message...", + hintStyle: TextStyle(color: Colors.white54), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: CircleAvatar( + backgroundColor: theme.primary, + radius: 22, + child: IconButton( + onPressed: () { + provider.sendMessage(groupId, messageController.text.trim()); + messageController.clear(); + scrollDownCallback(); + }, + icon: const Icon(Pixel.arrowup, color: Colors.black), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/config/api_config.dart b/leaderboard_app/lib/config/api_config.dart new file mode 100644 index 0000000..bbe7779 --- /dev/null +++ b/leaderboard_app/lib/config/api_config.dart @@ -0,0 +1,28 @@ +// Centralized API configuration for backend base URL. + +/// Priority order: +/// 1. Compile-time override via --dart-define=API_BASE_URL=... +/// 2. Baked-in default (production/dev fallback): http://140.238.213.170:3002/api +/// +/// If you need per-platform localhost behavior again, reintroduce the previous +/// logic or create an Environment enum. +class ApiConfig { + static const String _dartDefineBaseUrl = String.fromEnvironment('API_BASE_URL'); + + /// Returns the base URL (without trailing slash) to use for HTTP requests. + static String get baseUrl { + if (_dartDefineBaseUrl.isNotEmpty) return _dartDefineBaseUrl.rstrip('/'); + return 'http://140.238.213.170:3002/api'; + } +} + +extension _StringTrim on String { + String rstrip([String pattern = '/']) { + if (isEmpty) return this; + var result = this; + while (result.endsWith(pattern)) { + result = result.substring(0, result.length - pattern.length); + } + return result; + } +} diff --git a/leaderboard_app/lib/dashboard-components/compact_calendar.dart b/leaderboard_app/lib/dashboard-components/compact_calendar.dart new file mode 100644 index 0000000..eed6031 --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/compact_calendar.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +class CompactCalendar extends StatefulWidget { + const CompactCalendar({super.key}); + + @override + State createState() => _CompactCalendarState(); +} + +class _CompactCalendarState extends State { + DateTime _selectedDate = DateTime.now(); + + final List _months = const [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + final List _weekdays = const [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + ]; + + final List _years = List.generate(50, (i) => 2000 + i); // 2000–2049 + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + int year = _selectedDate.year; + int month = _selectedDate.month; + + DateTime firstOfMonth = DateTime(year, month, 1); + int weekdayOffset = firstOfMonth.weekday == 7 ? 0 : firstOfMonth.weekday; + int daysInMonth = DateTime(year, month + 1, 0).day; + + List dayWidgets = []; + + // Blank slots before the month starts + for (int i = 1; i < weekdayOffset; i++) { + dayWidgets.add(Container()); + } + + // Day buttons + for (int i = 1; i <= daysInMonth; i++) { + dayWidgets.add( + Container( + margin: const EdgeInsets.all(2), + alignment: Alignment.center, + decoration: BoxDecoration( + color: i == _selectedDate.day ? colors.secondary : Colors.transparent, + shape: BoxShape.circle, + ), + child: Text( + "$i", + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(14), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Month + Year dropdowns + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DropdownButton( + dropdownColor: Colors.grey[850], + value: month, + style: const TextStyle(color: Colors.white), + underline: Container(), + items: List.generate(12, (index) { + return DropdownMenuItem( + value: index + 1, + child: Text(_months[index]), + ); + }), + onChanged: (val) { + if (val != null) { + setState(() { + _selectedDate = DateTime(year, val, 1); + }); + } + }, + ), + const SizedBox(width: 16), + DropdownButton( + dropdownColor: Colors.grey[850], + value: year, + style: const TextStyle(color: Colors.white), + underline: Container(), + items: _years.map((yr) { + return DropdownMenuItem(value: yr, child: Text(yr.toString())); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() { + _selectedDate = DateTime(val, month, 1); + }); + } + }, + ), + ], + ), + const SizedBox(height: 10), + + // Weekday headers + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _weekdays.map((day) { + return Expanded( + child: Center( + child: Text( + day, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + ); + }).toList(), + ), + + const SizedBox(height: 6), + + // Calendar grid + GridView.count( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + crossAxisCount: 7, + children: dayWidgets, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/dashboard-components/daily_activity.dart b/leaderboard_app/lib/dashboard-components/daily_activity.dart new file mode 100644 index 0000000..4bedf6a --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/daily_activity.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LeetCodeDailyCard extends StatelessWidget { + final DailyQuestion? daily; + const LeetCodeDailyCard({super.key, required this.daily}); + + @override + Widget build(BuildContext context) { + final dq = daily; + return Container( + padding: const EdgeInsets.all(14), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(8), + ), + child: dq == null + ? const Text( + 'Daily question unavailable', + style: TextStyle(color: Colors.white70, fontSize: 14), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), // nudge text downward + child: Text( + dq.questionTitle, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.25, + ), + overflow: TextOverflow.ellipsis, + maxLines: 3, + softWrap: true, + ), + ), + ), + const SizedBox(width: 12), + if (dq.difficulty.trim().isNotEmpty) + Align( + alignment: Alignment.center, + child: _DifficultyPill(dq.difficulty), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + ), + onPressed: dq.questionLink.isEmpty + ? null + : () async { + final url = Uri.parse(dq.questionLink); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + // ignore: avoid_print + print('Could not launch $url'); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: const [ + Text('Go to Question', style: TextStyle(fontWeight: FontWeight.w600)), + SizedBox(width: 6), + Icon(Icons.north_east, size: 18, color: Colors.black), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _DifficultyPill extends StatelessWidget { + final String raw; + const _DifficultyPill(this.raw); + + @override + Widget build(BuildContext context) { + final diff = raw.toLowerCase(); + final Color bg = diff == 'easy' + ? const Color(0xFF6BC864) + : diff == 'medium' + ? const Color(0xFFFFC44E) + : const Color(0xFFFF2727); + final label = raw.isEmpty ? raw : raw[0].toUpperCase() + raw.substring(1).toLowerCase(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: const TextStyle(color: Colors.black, fontSize: 12, fontWeight: FontWeight.w500), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/dashboard-components/leaderboard_table.dart b/leaderboard_app/lib/dashboard-components/leaderboard_table.dart new file mode 100644 index 0000000..a4b0bf2 --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/leaderboard_table.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; + +class LeaderboardTable extends StatelessWidget { + final List users; + const LeaderboardTable({super.key, required this.users}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + color: Colors.grey[850], + child: DataTable( + columnSpacing: 10, + dataRowMinHeight: 32, + dataRowMaxHeight: 36, + headingRowHeight: 32, + headingRowColor: WidgetStateProperty.all( + Colors.grey[900], + ), + columns: const [ + DataColumn( + label: Text( + "Place", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Player", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Streak", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Solved", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + rows: List.generate( + users.length, + (index) => DataRow( + cells: [ + DataCell( + Text( + "${index + 1}", + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataCell( + Text( + users[index].username, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataCell(Text( + "${users[index].streak}", + style: const TextStyle(color: Colors.white, fontSize: 12), + )), + DataCell(Text( + "${users[index].totalSolved}", + style: const TextStyle(color: Colors.white, fontSize: 12), + )), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/dashboard-components/problem_table.dart b/leaderboard_app/lib/dashboard-components/problem_table.dart new file mode 100644 index 0000000..85c81cf --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/problem_table.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; + +class ProblemTable extends StatelessWidget { + final List submissions; + const ProblemTable({super.key, required this.submissions}); + + @override + Widget build(BuildContext context) { + if (submissions.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + 'No recent accepted submissions', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + ), + // Use SingleChildScrollView horizontally if titles overflow available width + child: LayoutBuilder( + builder: (context, constraints) { + final maxTitleWidth = (constraints.maxWidth - 12*4) * 0.45; // heuristic for title column + return DataTable( + columnSpacing: 12, + dataRowMinHeight: 32, + dataRowMaxHeight: 36, + headingRowHeight: 32, + headingRowColor: WidgetStateProperty.all( + Colors.grey[900], + ), + columns: const [ + DataColumn( + label: Text( + "No.", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Title", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Accuracy", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataColumn( + label: Text( + "Level", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + rows: List.generate( + submissions.length, + (index) => DataRow( + cells: [ + DataCell( + Text( + "${index + 1}", + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + DataCell( + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxTitleWidth.clamp(60, 260)), + child: Text( + submissions[index].title, + style: const TextStyle(color: Colors.white, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ), + DataCell( + Text( + "${submissions[index].acRate.toStringAsFixed(0)}%", + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + DataCell( + Builder(builder: (context) { + final diff = submissions[index].difficulty.toLowerCase(); + final Color bg = diff == 'easy' + ? const Color(0xFF6BC864) + : diff == 'medium' + ? const Color(0xFFFFC44E) + : const Color(0xFFFF2727); + final raw = submissions[index].difficulty.trim(); + final label = raw.isEmpty + ? raw + : raw[0].toUpperCase() + raw.substring(1).toLowerCase(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: bg, + // Squircle / boxy-pill: moderate radius + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + }), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/leaderboard_app/lib/dashboard-components/week_view.dart b/leaderboard_app/lib/dashboard-components/week_view.dart new file mode 100644 index 0000000..704a1c5 --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/week_view.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +class WeekView extends StatefulWidget { + const WeekView({super.key}); + + @override + State createState() => _WeekViewState(); +} + +class _WeekViewState extends State { + ColorScheme get colors => Theme.of(context).colorScheme; + final List days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + late final int todayIndex; + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + DateTime now = DateTime.now(); + todayIndex = now.weekday % 7; // DateTime.weekday: Mon=1..Sun=7, so mod 7 gives Sun=0..Sat=6 + _scrollController = ScrollController(); + + // Wait for the first frame then scroll to the current day + WidgetsBinding.instance.addPostFrameCallback((_) { + double position = todayIndex * 72.0; // approx item width (60 + margin 12) + _scrollController.animateTo( + position, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + ); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + DateTime now = DateTime.now(); + String dateText = "${_monthName(now.month)} ${now.day}, ${now.year}"; + + return Container( + padding: const EdgeInsets.all(14), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Center( + child: Text( + dateText, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 80, + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: days.length, + itemBuilder: (context, index) { + bool isToday = index == todayIndex; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 6), + width: 60, + decoration: BoxDecoration( + color: isToday ? colors.secondary : Colors.grey[900], + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: Colors.white), + const SizedBox(height: 4), + Text( + days[index], + style: const TextStyle(color: Colors.white), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + String _monthName(int month) { + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + return months[month - 1]; + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/dashboard-components/weekly_stats.dart b/leaderboard_app/lib/dashboard-components/weekly_stats.dart new file mode 100644 index 0000000..08925d2 --- /dev/null +++ b/leaderboard_app/lib/dashboard-components/weekly_stats.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class WeeklyStats extends StatelessWidget { + const WeeklyStats({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + const Center( + child: Text( + "This Week", + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + const SizedBox(height: 10), + _buildBar("Easy", 0.8, Colors.green), + _buildBar("Medium", 0.8, Colors.amber), + _buildBar("Hard", 0.8, Colors.red), + ], + ), + ); + } + + static Widget _buildBar(String label, double value, Color color) { + return Row( + children: [ + SizedBox( + width: 60, + child: Text(label, style: const TextStyle(color: Colors.white)), + ), + Expanded( + child: LinearProgressIndicator( + value: value, + color: color, + backgroundColor: Colors.white24, + ), + ), + ], + ); + } +} diff --git a/leaderboard_app/lib/main.dart b/leaderboard_app/lib/main.dart new file mode 100644 index 0000000..4c4cbf9 --- /dev/null +++ b/leaderboard_app/lib/main.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:leaderboard_app/provider/chatlists_provider.dart'; +import 'package:leaderboard_app/provider/chat_provider.dart'; +import 'package:leaderboard_app/provider/theme_provider.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:leaderboard_app/router/app_router.dart'; +import 'package:leaderboard_app/services/auth/auth_service.dart'; +import 'package:leaderboard_app/services/dashboard/dashboard_service.dart'; +import 'package:leaderboard_app/services/leetcode/leetcode_service.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; +import 'package:go_router/go_router.dart'; +import 'package:leaderboard_app/provider/group_provider.dart'; +import 'package:leaderboard_app/provider/dashboard_provider.dart'; +import 'package:leaderboard_app/provider/group_membership_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:leaderboard_app/provider/connectivity_provider.dart'; +import 'package:leaderboard_app/pages/no_internet_page.dart'; + +void main() { + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + runApp(const Bootstrap()); +} + +class Bootstrap extends StatelessWidget { + const Bootstrap({super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _bootstrapServices(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: CircularProgressIndicator())), + ); + } + + final data = snapshot.data!; + final authService = data.authService; + final dashboardService = data.dashboardService; + final leetCodeService = data.leetCodeService; + final groupService = data.groupService; + final userService = data.userService; + final router = createRouter(); + + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (_) => ChatListProvider()), + ChangeNotifierProvider(create: (_) => UserProvider()), + ChangeNotifierProvider(create: (_) => ChatProvider()), + ChangeNotifierProvider(create: (_) => ConnectivityProvider()), + ChangeNotifierProvider(create: (ctx) => GroupProvider(groupService)), + ChangeNotifierProvider(create: (ctx) => DashboardProvider( + service: dashboardService, + userProvider: ctx.read(), + userService: userService, + )), + ChangeNotifierProvider( + create: (ctx) => GroupMembershipProvider( + service: groupService, userProvider: ctx.read())), + Provider.value(value: authService), + Provider.value(value: dashboardService), + Provider.value(value: leetCodeService), + Provider.value(value: groupService), + Provider.value(value: userService), + ], + child: _AppInitializer(router: router), + ); + }, + ); + } +} + +class _BootstrapData { + final AuthService authService; + final DashboardService dashboardService; + final LeetCodeService leetCodeService; + final GroupService groupService; + final UserService userService; + _BootstrapData({ + required this.authService, + required this.dashboardService, + required this.leetCodeService, + required this.groupService, + required this.userService, + }); +} + +Future<_BootstrapData> _bootstrapServices() async { + final results = await Future.wait([ + AuthService.create(), + DashboardService.create(), + LeetCodeService.create(), + GroupService.create(), + UserService.create(), + ]); + return _BootstrapData( + authService: results[0] as AuthService, + dashboardService: results[1] as DashboardService, + leetCodeService: results[2] as LeetCodeService, + groupService: results[3] as GroupService, + userService: results[4] as UserService, + ); +} + +class _AppInitializer extends StatefulWidget { + final GoRouter router; + const _AppInitializer({required this.router}); + + @override + State<_AppInitializer> createState() => _AppInitializerState(); +} + +class _AppInitializerState extends State<_AppInitializer> { + bool _ready = false; + bool _offline = false; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + // If not logged in, skip any data preload and go straight to router + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('authToken') ?? ''; + + if (token.isEmpty) { + if (mounted) { + setState(() => _ready = true); + FlutterNativeSplash.remove(); + } + return; + } + + final connectivity = context.read(); + // Wait a tick for connectivity to initialize + await Future.delayed(const Duration(milliseconds: 50)); + _offline = !connectivity.isOnline; + if (!_offline) { + await _preload(); + } + if (mounted) { + setState(() { + _ready = true; + }); + FlutterNativeSplash.remove(); + } + // Listen for connectivity changes to leave offline screen automatically + connectivity.addListener(() async { + if (mounted && _offline && connectivity.isOnline) { + setState(() { + _offline = false; + _ready = false; // show loading while we fetch + }); + await _preload(); + if (mounted) { + setState(() { + _ready = true; + }); + } + } + }); + } + + Future _preload() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('authToken') ?? ''; + if (token.isEmpty) { + // Not logged in; nothing to preload. + return; + } + final returning = prefs.getBool('returningUser') ?? false; + final userProvider = context.read(); + final dashboardProvider = context.read(); + final userService = context.read(); + + // Always fetch profile if logged in (token check can happen in router later but we attempt anyway) + try { + await userProvider.fetchProfile(userService); + } catch (_) {} + + if (returning) { + try { + await dashboardProvider.loadAll(); + } catch (_) {} + } + + // Mark as returning after first launch completion + if (!returning) { + await prefs.setBool('returningUser', true); + } + } + + @override + Widget build(BuildContext context) { + final connectivity = context.watch(); + if (!connectivity.isOnline || _offline) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: NoInternetPage(), + ); + } + if (!_ready) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: CircularProgressIndicator())), + ); + } + return MainApp(router: widget.router); + } +} + +class MainApp extends StatelessWidget { + final GoRouter router; + const MainApp({super.key, required this.router}); + + @override + Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + + return MaterialApp.router( + debugShowCheckedModeBanner: false, + theme: themeProvider.themeData, + routerConfig: router, + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/models/api_wrappers.dart b/leaderboard_app/lib/models/api_wrappers.dart new file mode 100644 index 0000000..fac8847 --- /dev/null +++ b/leaderboard_app/lib/models/api_wrappers.dart @@ -0,0 +1,267 @@ +// Generic-ish API response helpers and specific wrapper models for +// various backend endpoints. These map the `data` object of the backend +// responses so UI/widgets can depend on strongly typed structures. +// +// Each backend response uses the envelope: +// { +// "success": bool, +// "message": String, +// "timestamp": String (ISO)?, +// "data": { ... } // shape varies per endpoint +// } +// +// We provide a light-weight [ApiEnvelope] plus concrete data wrappers. + + +import 'auth_models.dart'; +import 'dashboard_models.dart'; +import 'group_models.dart'; +import 'verification_models.dart'; + +DateTime? _parseTime(dynamic v) { + if (v == null) return null; + if (v is DateTime) return v; + return DateTime.tryParse(v.toString()); +} + +/// Base envelope for an API response. The [data] field is left dynamic; most +/// callers should prefer using the concrete `XYZResponse` classes below. +class ApiEnvelope { + final bool success; + final String message; + final DateTime? timestamp; + final T? data; + + ApiEnvelope({ + required this.success, + required this.message, + required this.timestamp, + required this.data, + }); + + factory ApiEnvelope.fromJson( + Map json, { + T Function(Object? json)? parse, + }) { + final rawData = json['data']; + return ApiEnvelope( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + data: parse != null ? parse(rawData) : rawData as T?, + ); + } +} + +/// User profile (`GET /user/profile`) +class UserProfileResponse { + final bool success; + final String message; + final DateTime? timestamp; + final User user; + + UserProfileResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.user, + }); + + factory UserProfileResponse.fromJson(Map json) { + final data = (json['data'] ?? json) as Map; + final userJson = (data['user'] ?? data) as Map; + return UserProfileResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + user: User.fromJson(userJson), + ); + } +} + +/// Group list (`GET /groups`) +class GroupsResponse { + final bool success; + final String message; + final DateTime? timestamp; + final PagedGroups groups; + + GroupsResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.groups, + }); + + factory GroupsResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + return GroupsResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + groups: PagedGroups.fromJson(data), + ); + } +} + +/// Single group (`GET /groups/:id`, create, update) +class GroupResponse { + final bool success; + final String message; + final DateTime? timestamp; + final Group group; + + GroupResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.group, + }); + + factory GroupResponse.fromJson(Map json) { + final data = (json['data'] ?? json) as Map; + final groupJson = (data['group'] ?? data) as Map; + return GroupResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + group: Group.fromJson(groupJson), + ); + } +} + +/// LeetCode verification start (`POST /leetcode/connect`) +class VerificationStartResponse { + final bool success; + final String message; + final DateTime? timestamp; + final VerificationStart data; + + VerificationStartResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.data, + }); + + factory VerificationStartResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + return VerificationStartResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + data: VerificationStart.fromJson(data), + ); + } +} + +/// LeetCode verification status (`GET /leetcode/status`) +class VerificationStatusResponse { + final bool success; + final String message; + final DateTime? timestamp; + final VerificationStatus data; + + VerificationStatusResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.data, + }); + + factory VerificationStatusResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + return VerificationStatusResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + data: VerificationStatus.fromJson(data), + ); + } +} + +/// Dashboard submissions (`GET /dashboard/submissions`) +class SubmissionsResponse { + final bool success; + final String message; + final DateTime? timestamp; + final List submissions; + final int count; + final int limit; + + SubmissionsResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.submissions, + required this.count, + required this.limit, + }); + + factory SubmissionsResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + final list = (data['submissions'] as List?)?.cast>() ?? const []; + return SubmissionsResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + submissions: list.map(SubmissionItem.fromJson).toList(growable: false), + count: (data['count'] as num?)?.toInt() ?? list.length, + limit: (data['limit'] as num?)?.toInt() ?? list.length, + ); + } +} + +/// Daily question (`GET /dashboard/daily`) +class DailyQuestionResponse { + final bool success; + final String message; + final DateTime? timestamp; + final DailyQuestion dailyQuestion; + + DailyQuestionResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.dailyQuestion, + }); + + factory DailyQuestionResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + return DailyQuestionResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + dailyQuestion: DailyQuestion.fromJson((data['dailyQuestion'] ?? {}) as Map), + ); + } +} + +/// Top users leaderboard (`GET /dashboard/leaderboard` or `/user/leaderboard`) +class TopUsersResponse { + final bool success; + final String message; + final DateTime? timestamp; + final List leaderboard; + final int count; + + TopUsersResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.leaderboard, + required this.count, + }); + + factory TopUsersResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + final list = (data['leaderboard'] as List?)?.cast>() ?? const []; + return TopUsersResponse( + success: json['success'] == true, + message: (json['message'] ?? '') as String, + timestamp: _parseTime(json['timestamp']), + leaderboard: list.map(TopUser.fromJson).toList(growable: false), + count: (data['count'] as num?)?.toInt() ?? list.length, + ); + } +} diff --git a/leaderboard_app/lib/models/auth_models.dart b/leaderboard_app/lib/models/auth_models.dart new file mode 100644 index 0000000..bbc22a1 --- /dev/null +++ b/leaderboard_app/lib/models/auth_models.dart @@ -0,0 +1,58 @@ +class AuthResponse { + final bool success; + final String message; + final String token; + final String? refreshToken; + final User user; + + AuthResponse({ + required this.success, + required this.message, + required this.token, + this.refreshToken, + required this.user, + }); + + factory AuthResponse.fromJson(Map json) { + final data = (json['data'] ?? {}) as Map; + final userJson = (data['user'] ?? json['user'] ?? {}) as Map; + final token = (data['token'] ?? json['token'] ?? '') as String; + final refresh = (data['refreshToken'] ?? data['refresh_token'] ?? json['refreshToken'] ?? json['refresh_token'] ?? '') as String; + return AuthResponse( + success: json['success'] == true || json['ok'] == true, + message: (json['message'] ?? json['msg'] ?? '') as String, + token: token, + refreshToken: refresh.isEmpty ? null : refresh, + user: User.fromJson(userJson), + ); + } +} + +class User { + final String id; + final String username; + final String? email; + final String? leetcodeHandle; + final bool leetcodeVerified; + final int streak; + + User({ + required this.id, + required this.username, + required this.email, + required this.leetcodeHandle, + required this.leetcodeVerified, + required this.streak, + }); + + factory User.fromJson(Map json) { + return User( + id: (json['id'] ?? json['_id'] ?? '').toString(), + username: json['username'] ?? '', + email: json['email'], + leetcodeHandle: json['leetcodeHandle'], + leetcodeVerified: json['leetcodeVerified'] == true, + streak: (json['streak'] is int) ? json['streak'] as int : int.tryParse('${json['streak'] ?? 0}') ?? 0, + ); + } +} diff --git a/leaderboard_app/lib/models/chat_message.dart b/leaderboard_app/lib/models/chat_message.dart new file mode 100644 index 0000000..a57915e --- /dev/null +++ b/leaderboard_app/lib/models/chat_message.dart @@ -0,0 +1,36 @@ +class ChatMessage { + final String id; + final String groupId; + final String senderId; + final String senderName; + final String message; + final DateTime timestamp; + + ChatMessage({ + required this.id, + required this.groupId, + required this.senderId, + required this.senderName, + required this.message, + required this.timestamp, + }); + + factory ChatMessage.fromSocket(Map raw) { + final sender = raw['sender']; + return ChatMessage( + id: (raw['id'] ?? raw['_id'] ?? DateTime.now().millisecondsSinceEpoch.toString()).toString(), + groupId: (raw['groupId'] ?? '').toString(), + senderId: sender is Map ? (sender['id'] ?? sender['_id'] ?? '').toString() : '', + senderName: sender is Map ? (sender['username'] ?? 'User').toString() : 'User', + message: (raw['message'] ?? '').toString(), + timestamp: _parseTs(raw['timestamp']), + ); + } + + static DateTime _parseTs(dynamic v) { + if (v == null) return DateTime.now(); + if (v is int) return DateTime.fromMillisecondsSinceEpoch(v); + if (v is String) return DateTime.tryParse(v) ?? DateTime.now(); + return DateTime.now(); + } +} diff --git a/leaderboard_app/lib/models/chat_message_dto.dart b/leaderboard_app/lib/models/chat_message_dto.dart new file mode 100644 index 0000000..a07c0c0 --- /dev/null +++ b/leaderboard_app/lib/models/chat_message_dto.dart @@ -0,0 +1,41 @@ +import 'chat_message.dart'; + +/// DTO for messages retrieved via REST (history endpoint) +class ChatMessageDto { + final String id; + final String content; + final String senderId; + final String groupId; + final DateTime createdAt; + final String? senderName; + + ChatMessageDto({ + required this.id, + required this.content, + required this.senderId, + required this.groupId, + required this.createdAt, + this.senderName, + }); + + factory ChatMessageDto.fromJson(Map json) { + final sender = json['sender']; + return ChatMessageDto( + id: (json['id'] ?? json['_id']).toString(), + content: (json['content'] ?? json['message'] ?? '').toString(), + senderId: (json['senderId'] ?? (sender is Map ? sender['id'] ?? sender['_id'] : '')).toString(), + groupId: (json['groupId'] ?? '').toString(), + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + senderName: sender is Map ? (sender['username'] ?? sender['name'])?.toString() : null, + ); + } + + ChatMessage toDomain() => ChatMessage( + id: id, + groupId: groupId, + senderId: senderId, + senderName: senderName ?? 'User', + message: content, + timestamp: createdAt, + ); +} \ No newline at end of file diff --git a/leaderboard_app/lib/models/dashboard_models.dart b/leaderboard_app/lib/models/dashboard_models.dart new file mode 100644 index 0000000..9fd83d3 --- /dev/null +++ b/leaderboard_app/lib/models/dashboard_models.dart @@ -0,0 +1,67 @@ +class SubmissionItem { + final String title; + final String titleSlug; + final String statusDisplay; + final String lang; + final double acRate; + final String difficulty; + final DateTime timestamp; + + SubmissionItem({ + required this.title, + required this.titleSlug, + required this.statusDisplay, + required this.lang, + required this.acRate, + required this.difficulty, + required this.timestamp, + }); + + factory SubmissionItem.fromJson(Map json) => SubmissionItem( + title: json['title'] ?? '', + titleSlug: json['titleSlug'] ?? '', + statusDisplay: json['statusDisplay'] ?? '', + lang: json['lang'] ?? '', + acRate: (json['acRate'] ?? 0).toDouble(), + difficulty: json['difficulty'] ?? '', + timestamp: DateTime.fromMillisecondsSinceEpoch( + int.tryParse(json['timestamp']?.toString() ?? '0')! * 1000, + isUtc: true, + ), + ); +} + +class DailyQuestion { + final String questionLink; + final String questionTitle; + final String difficulty; + final DateTime date; + + DailyQuestion({ + required this.questionLink, + required this.questionTitle, + required this.difficulty, + required this.date, + }); + + factory DailyQuestion.fromJson(Map json) => DailyQuestion( + questionLink: json['questionLink'] ?? '', + questionTitle: json['questionTitle'] ?? '', + difficulty: json['difficulty'] ?? '', + date: DateTime.tryParse(json['date'] ?? '') ?? DateTime.now(), + ); +} + +class TopUser { + final String username; + final int streak; + final int totalSolved; + + TopUser({required this.username, required this.streak, required this.totalSolved}); + + factory TopUser.fromJson(Map json) => TopUser( + username: json['username'] ?? '', + streak: (json['streak'] ?? 0) as int, + totalSolved: (json['totalSolved'] ?? 0) as int, + ); +} diff --git a/leaderboard_app/lib/models/group_models.dart b/leaderboard_app/lib/models/group_models.dart new file mode 100644 index 0000000..2a54a43 --- /dev/null +++ b/leaderboard_app/lib/models/group_models.dart @@ -0,0 +1,199 @@ +// Model classes for Group APIs + +class Group { + final String id; + final String name; + final String? description; + final bool isPrivate; + final int? maxMembers; + final String? createdBy; + final DateTime? createdAt; + final DateTime? updatedAt; + final List members; + final GroupCreator? creator; + + Group({ + required this.id, + required this.name, + this.description, + required this.isPrivate, + this.maxMembers, + this.createdBy, + this.createdAt, + this.updatedAt, + this.members = const [], + this.creator, + }); + + factory Group.fromJson(Map json) { + final membersRaw = (json['members'] as List?)?.cast>() ?? const []; + return Group( + id: (json['id'] ?? json['_id'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + description: json['description'] as String?, + isPrivate: json['isPrivate'] == true, + maxMembers: (json['maxMembers'] as num?)?.toInt(), + createdBy: (json['createdBy'] ?? json['ownerId'])?.toString(), + createdAt: _tryDate(json['createdAt']), + updatedAt: _tryDate(json['updatedAt']), + members: membersRaw.map(GroupMember.fromJson).toList(growable: false), + creator: json['creator'] != null ? GroupCreator.fromJson(json['creator'] as Map) : null, + ); + } + + static DateTime? _tryDate(dynamic v) { + if (v == null) return null; + if (v is DateTime) return v; + return DateTime.tryParse(v.toString()); + } +} + +class GroupMember { + final String id; + final String userId; + final String groupId; + final String role; // OWNER, ADMIN, MODERATOR, MEMBER + final int xp; + final DateTime? joinedAt; + final GroupMemberUser? user; + + GroupMember({ + required this.id, + required this.userId, + required this.groupId, + required this.role, + required this.xp, + this.joinedAt, + this.user, + }); + + factory GroupMember.fromJson(Map json) { + return GroupMember( + id: (json['id'] ?? json['_id'] ?? '').toString(), + userId: (json['userId'] ?? '').toString(), + groupId: (json['groupId'] ?? '').toString(), + role: (json['role'] ?? 'MEMBER').toString(), + xp: (json['xp'] as num?)?.toInt() ?? 0, + joinedAt: Group._tryDate(json['joinedAt']), + user: json['user'] != null ? GroupMemberUser.fromJson(json['user'] as Map) : null, + ); + } +} + +/// Optional strongly typed role enum. Use [GroupRoleExt.parse] to convert +/// backend string values without throwing. +enum GroupRole { owner, admin, moderator, member } + +extension GroupRoleExt on GroupRole { + static GroupRole parse(String? raw) { + switch (raw?.toUpperCase()) { + case 'OWNER': + return GroupRole.owner; + case 'ADMIN': + return GroupRole.admin; + case 'MODERATOR': + return GroupRole.moderator; + default: + return GroupRole.member; + } + } + + String get asApiValue { + switch (this) { + case GroupRole.owner: + return 'OWNER'; + case GroupRole.admin: + return 'ADMIN'; + case GroupRole.moderator: + return 'MODERATOR'; + case GroupRole.member: + return 'MEMBER'; + } + } +} + + +class GroupCreator { + final String id; + final String username; + final String? email; + + GroupCreator({required this.id, required this.username, this.email}); + + factory GroupCreator.fromJson(Map json) => GroupCreator( + id: (json['id'] ?? json['_id'] ?? '').toString(), + username: (json['username'] ?? '').toString(), + email: json['email'] as String?, + ); +} + +class GroupMemberUser { + final String id; + final String username; + final String? leetcodeHandle; + final bool leetcodeVerified; + final int streak; // daily streak count + final int totalSolved; // total solved problems + + GroupMemberUser({ + required this.id, + required this.username, + this.leetcodeHandle, + required this.leetcodeVerified, + this.streak = 0, + this.totalSolved = 0, + }); + + factory GroupMemberUser.fromJson(Map json) => GroupMemberUser( + id: (json['id'] ?? json['_id'] ?? '').toString(), + username: (json['username'] ?? '').toString(), + leetcodeHandle: json['leetcodeHandle'] as String?, + leetcodeVerified: json['leetcodeVerified'] == true, + // Support multiple possible key names from different endpoints + streak: (json['streak'] ?? json['currentStreak'] ?? json['dailyStreak']) is num + ? ((json['streak'] ?? json['currentStreak'] ?? json['dailyStreak']) as num).toInt() + : 0, + totalSolved: (json['totalSolved'] ?? json['solved'] ?? json['problemsSolved']) is num + ? ((json['totalSolved'] ?? json['solved'] ?? json['problemsSolved']) as num).toInt() + : 0, + ); +} + +class Pagination { + final int currentPage; + final int totalPages; + final int totalCount; + final bool hasNext; + final bool hasPrev; + + Pagination({ + required this.currentPage, + required this.totalPages, + required this.totalCount, + required this.hasNext, + required this.hasPrev, + }); + + factory Pagination.fromJson(Map json) => Pagination( + currentPage: (json['currentPage'] as num?)?.toInt() ?? 1, + totalPages: (json['totalPages'] as num?)?.toInt() ?? 1, + totalCount: (json['totalCount'] as num?)?.toInt() ?? 0, + hasNext: json['hasNext'] == true, + hasPrev: json['hasPrev'] == true, + ); +} + +class PagedGroups { + final List groups; + final Pagination? pagination; + + PagedGroups({required this.groups, this.pagination}); + + factory PagedGroups.fromJson(Map json) { + final groupsRaw = (json['groups'] as List?)?.cast>() ?? const []; + return PagedGroups( + groups: groupsRaw.map(Group.fromJson).toList(growable: false), + pagination: json['pagination'] != null ? Pagination.fromJson(json['pagination'] as Map) : null, + ); + } +} diff --git a/leaderboard_app/lib/models/models.dart b/leaderboard_app/lib/models/models.dart new file mode 100644 index 0000000..66b242c --- /dev/null +++ b/leaderboard_app/lib/models/models.dart @@ -0,0 +1,8 @@ +// Barrel export for all model files. Import this file when you need access +// to multiple backend models in a feature/widget. + +export 'auth_models.dart'; +export 'dashboard_models.dart'; +export 'group_models.dart'; +export 'verification_models.dart'; +export 'api_wrappers.dart'; diff --git a/leaderboard_app/lib/models/submissions_models.dart b/leaderboard_app/lib/models/submissions_models.dart new file mode 100644 index 0000000..cd2a99c --- /dev/null +++ b/leaderboard_app/lib/models/submissions_models.dart @@ -0,0 +1,126 @@ +import 'dashboard_models.dart'; + +class SubmissionsResponse { + final bool success; + final String message; + final DateTime timestamp; + final SubmissionData data; + + SubmissionsResponse({ + required this.success, + required this.message, + required this.timestamp, + required this.data, + }); + + factory SubmissionsResponse.fromJson(Map json) => SubmissionsResponse( + success: json['success'] == true || json['ok'] == true, + message: json['message']?.toString() ?? '', + timestamp: _parseTimestamp(json['timestamp']), + data: SubmissionData.fromJson((json['data'] ?? json) as Map), + ); + + static DateTime _parseTimestamp(dynamic ts) { + if (ts == null) return DateTime.now(); + if (ts is int) { + // assume ms if large, else seconds + if (ts > 2000000000) return DateTime.fromMillisecondsSinceEpoch(ts, isUtc: true); + return DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: true); + } + if (ts is String) { + final i = int.tryParse(ts); + if (i != null) return _parseTimestamp(i); + return DateTime.tryParse(ts) ?? DateTime.now(); + } + return DateTime.now(); + } +} + +class SubmissionData { + final List submissions; + final int count; + final int limit; + + SubmissionData({ + required this.submissions, + required this.count, + required this.limit, + }); + + factory SubmissionData.fromJson(Map json) { + final raw = json['submissions'] ?? json['items'] ?? json['results']; + final list = raw is List ? raw : []; + return SubmissionData( + submissions: list.map((e) => SubmissionEntry.fromJson(e as Map)).toList(), + count: (json['count'] ?? list.length) as int, + limit: (json['limit'] ?? list.length) as int, + ); + } +} + +class SubmissionEntry { + final String title; + final String titleSlug; + final String timestamp; // keep raw for reference + final StatusDisplay statusDisplay; + final Lang lang; + final double acRate; + final Difficulty difficulty; + + SubmissionEntry({ + required this.title, + required this.titleSlug, + required this.timestamp, + required this.statusDisplay, + required this.lang, + required this.acRate, + required this.difficulty, + }); + + factory SubmissionEntry.fromJson(Map json) => SubmissionEntry( + title: json['title']?.toString() ?? '', + titleSlug: json['titleSlug']?.toString() ?? '', + timestamp: json['timestamp']?.toString() ?? '0', + statusDisplay: _parseStatus(json['statusDisplay']), + lang: _parseLang(json['lang']), + acRate: (json['acRate'] ?? 0).toDouble(), + difficulty: _parseDifficulty(json['difficulty']), + ); + + SubmissionItem toSubmissionItem() => SubmissionItem( + title: title, + titleSlug: titleSlug, + statusDisplay: statusDisplay.name.toLowerCase(), + lang: lang.name.toLowerCase(), + acRate: acRate, + difficulty: difficulty.name.toLowerCase(), + timestamp: DateTime.fromMillisecondsSinceEpoch( + int.tryParse(timestamp) != null ? int.parse(timestamp) * 1000 : 0, + isUtc: true, + ), + ); + + static StatusDisplay _parseStatus(dynamic v) { + final s = v?.toString().toLowerCase(); + if (s == 'accepted' || s == 'ac') return StatusDisplay.ACCEPTED; + return StatusDisplay.ACCEPTED; // only one variant currently + } + + static Lang _parseLang(dynamic v) { + final s = v?.toString().toLowerCase(); + if (s == 'python3' || s == 'python') return Lang.PYTHON3; + return Lang.PYTHON3; // default + } + + static Difficulty _parseDifficulty(dynamic v) { + final s = v?.toString().toLowerCase(); + if (s == 'easy') return Difficulty.EASY; + if (s == 'medium') return Difficulty.MEDIUM; + if (s == 'hard') return Difficulty.HARD; + return Difficulty.EASY; + } +} + +enum Difficulty { EASY, HARD, MEDIUM } +enum Lang { PYTHON3 } +enum StatusDisplay { ACCEPTED } diff --git a/leaderboard_app/lib/models/verification_models.dart b/leaderboard_app/lib/models/verification_models.dart new file mode 100644 index 0000000..3975b0d --- /dev/null +++ b/leaderboard_app/lib/models/verification_models.dart @@ -0,0 +1,43 @@ +// Models specifically for LeetCode verification endpoints. + +class VerificationStart { + final String verificationCode; + final String leetcodeUsername; + final String instructions; + final int timeoutInSeconds; + final int pollIntervalInSeconds; + + VerificationStart({ + required this.verificationCode, + required this.leetcodeUsername, + required this.instructions, + required this.timeoutInSeconds, + required this.pollIntervalInSeconds, + }); + + factory VerificationStart.fromJson(Map json) => VerificationStart( + verificationCode: (json['verificationCode'] ?? '') as String, + leetcodeUsername: (json['leetcodeUsername'] ?? '') as String, + instructions: (json['instructions'] ?? '') as String, + timeoutInSeconds: (json['timeoutInSeconds'] as num?)?.toInt() ?? 0, + pollIntervalInSeconds: (json['pollIntervalInSeconds'] as num?)?.toInt() ?? 0, + ); +} + +class VerificationStatus { + final bool isVerified; + final bool isInProgress; + final String? leetcodeHandle; + + VerificationStatus({ + required this.isVerified, + required this.isInProgress, + required this.leetcodeHandle, + }); + + factory VerificationStatus.fromJson(Map json) => VerificationStatus( + isVerified: json['isVerified'] == true, + isInProgress: json['isInProgress'] == true, + leetcodeHandle: json['leetcodeHandle'] as String?, + ); +} diff --git a/leaderboard_app/lib/pages/chat_gate.dart b/leaderboard_app/lib/pages/chat_gate.dart new file mode 100644 index 0000000..b00cd3c --- /dev/null +++ b/leaderboard_app/lib/pages/chat_gate.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:leaderboard_app/provider/group_membership_provider.dart'; +import 'package:leaderboard_app/pages/chat_page.dart'; +import 'package:leaderboard_app/pages/groupinfo_page.dart'; + +/// Decides whether to show chat directly or group info depending on membership. +class ChatGate extends StatefulWidget { + final String groupId; + final String? groupName; + const ChatGate({super.key, required this.groupId, this.groupName}); + + @override + State createState() => _ChatGateState(); +} + +class _ChatGateState extends State { + @override + void initState() { + super.initState(); + // Kick off membership check + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().check(widget.groupId); + }); + } + + @override + Widget build(BuildContext context) { + final membership = context.watch(); + switch (membership.status) { + case GroupMembershipStatus.loading: + return const Scaffold(body: Center(child: CircularProgressIndicator())); + case GroupMembershipStatus.error: + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(membership.error ?? 'Error'), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => membership.check(widget.groupId), + child: const Text('Retry'), + ), + ], + ), + ), + ); + case GroupMembershipStatus.member: + return ChatPage(groupId: widget.groupId, groupName: widget.groupName ?? membership.group?.name ?? 'Group'); + case GroupMembershipStatus.notMember: + return GroupInfoPage(groupId: widget.groupId, initialName: widget.groupName ?? membership.group?.name); + } + } +} diff --git a/leaderboard_app/lib/pages/chat_page.dart b/leaderboard_app/lib/pages/chat_page.dart new file mode 100644 index 0000000..c5d44ae --- /dev/null +++ b/leaderboard_app/lib/pages/chat_page.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/chatpage-components/chat_view.dart'; +import 'package:provider/provider.dart'; +import '../provider/chat_provider.dart'; + +class ChatPage extends StatelessWidget { + final String groupId; + final String groupName; + + const ChatPage({super.key, required this.groupId, required this.groupName}); + + @override + Widget build(BuildContext context) { + final chatProv = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) => chatProv.joinGroup(context, groupId)); + return ChatView(groupId: groupId, groupName: groupName); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/chatlists_page.dart b/leaderboard_app/lib/pages/chatlists_page.dart new file mode 100644 index 0000000..12949e6 --- /dev/null +++ b/leaderboard_app/lib/pages/chatlists_page.dart @@ -0,0 +1,601 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:leaderboard_app/provider/chatlists_provider.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; +import 'package:provider/provider.dart'; +import 'groupinfo_page.dart'; +import 'chat_page.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; + +class ChatlistsPage extends StatefulWidget { + const ChatlistsPage({super.key}); + + @override + State createState() => _ChatlistsPageState(); +} + +class _ChatlistsPageState extends State { + bool _loadedOnce = false; + final TextEditingController _searchController = TextEditingController(); + Timer? _debounce; + String _searchQuery = ''; + // Filter state: true = Joined, false = Not Joined + bool _showJoined = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _loadedOnce) return; + _loadedOnce = true; + final chatProvider = context.read(); + if (!chatProvider.isLoading && chatProvider.chatGroups.isEmpty && chatProvider.error == null) { + final svc = context.read(); + chatProvider.loadPublicGroups(svc); + } + }); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value) { + setState(() => _searchQuery = value.trim()); + // Debounce network search to avoid spamming backend + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), () { + if (!mounted) return; + final svc = context.read(); + context.read().loadPublicGroups(svc, search: _searchQuery.isEmpty ? null : _searchQuery); + }); + } + + Future _refresh() async { + final svc = context.read(); + await context.read().loadPublicGroups(svc, search: _searchQuery.isEmpty ? null : _searchQuery); + } + + void _showCreateGroupSheet() { + final nameController = TextEditingController(); + final descController = TextEditingController(); + final maxMembersController = TextEditingController(); + bool isPrivate = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return StatefulBuilder( + builder: (context, setSheetState) { + final theme = Theme.of(context).colorScheme; + final provider = context.watch(); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + left: 16, + right: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Create Group', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: theme.primary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + color: theme.primary, + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: nameController, + textInputAction: TextInputAction.next, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Name *', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descController, + maxLines: 3, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Description', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: maxMembersController, + keyboardType: TextInputType.number, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Max Members (optional)', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Private', style: TextStyle(color: theme.primary.withOpacity(0.7))), + Switch( + value: isPrivate, + onChanged: (v) => setSheetState(() => isPrivate = v), + activeColor: theme.secondary, + ), + ], + ), + ], + ), + if (provider.createError != null) ...[ + const SizedBox(height: 4), + Text(provider.createError!, style: const TextStyle(color: Colors.redAccent, fontSize: 12)), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: provider.isCreating + ? null + : () async { + final name = nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Name is required'))); + return; + } + final maxMembers = int.tryParse(maxMembersController.text.trim()); + final svc = context.read(); + final created = await provider.createNewGroup( + svc, + name: name, + description: descController.text.trim().isEmpty ? null : descController.text.trim(), + isPrivate: isPrivate, + maxMembers: maxMembers, + ); + if (created != null && mounted) { + Navigator.pop(context); // close sheet + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Group created')), + ); + // Refresh lists to reflect membership and server-derived fields + // (e.g., lastMessage, counts, privacy flags, etc.) + // Uses current search filter. + // Fire-and-forget; UI already shows snackbar feedback. + unawaited(_refresh()); + } + }, + icon: provider.isCreating + ? SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.onSecondary), + ) + : const Icon(Icons.check), + label: Text(provider.isCreating ? 'Creating...' : 'Create'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + final chatProvider = Provider.of(context); + final rawGroups = chatProvider.chatGroups; + int joinedCount = 0; + int notJoinedCount = 0; + for (final g in rawGroups) { + final isMember = g['isMember'] == true; + if (isMember) { + joinedCount++; } else { notJoinedCount++; } + } + final groups = rawGroups.where((g) => _showJoined ? g['isMember'] == true : g['isMember'] != true).toList(); + + return Scaffold( + backgroundColor: theme.surface, + body: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Padding( + padding: const EdgeInsets.only(left: 16, top: 16, right: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // SVG Icon + SizedBox( + width: 35, + height: 35, + child: SvgPicture.asset( + 'assets/icons/LL_Logo.svg', + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 10), + Text( + 'Chats', + style: TextStyle( + color: theme.primary, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + + // Search + Add + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16), + child: TextField( + controller: _searchController, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + hintText: "Search", + hintStyle: TextStyle( + color: theme.primary.withOpacity(0.5), + ), + filled: true, + fillColor: Colors.grey.shade900, + // Reduced vertical padding to make the bar slightly shorter + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + // Removed search icon per request + // prefixIcon: Icon(Icons.search, color: theme.primary), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + tooltip: 'Clear', + icon: Icon(Icons.close, color: theme.primary.withOpacity(0.7)), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + ), + onChanged: _onSearchChanged, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _onSearchChanged(_searchController.text), + ), + ), + ), + const SizedBox(width: 10), + Padding( + padding: const EdgeInsets.only(right: 16), + child: GestureDetector( + onTap: _showCreateGroupSheet, + child: CircleAvatar( + backgroundColor: Colors.white, + child: const Icon(Icons.add, color: Colors.black), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // Joined / Not Joined filter buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _buildFilterButton( + label: 'Joined', + count: joinedCount, + selected: _showJoined, + onTap: () { + if (!_showJoined) setState(() => _showJoined = true); + }, + ), + const SizedBox(width: 12), + _buildFilterButton( + label: 'Not Joined', + count: notJoinedCount, + selected: !_showJoined, + onTap: () { + if (_showJoined) setState(() => _showJoined = false); + }, + ), + ], + ), + ), + const SizedBox(height: 12), + + // Group List + Expanded( + child: RefreshIndicator( + onRefresh: _refresh, + child: Builder( + builder: (context) { + if (chatProvider.isLoading && groups.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (chatProvider.error != null) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const SizedBox(height: 120), + Center( + child: Text( + chatProvider.error!, + style: TextStyle(color: theme.primary), + ), + ), + ], + ); + } + if (groups.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const SizedBox(height: 120), + Center( + child: Text( + _showJoined ? 'No joined groups' : 'No groups available', + style: TextStyle(color: theme.primary), + ), + ), + ], + ); + } + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: groups.length, + itemBuilder: (context, index) { + final group = groups[index]; + final groupId = group['groupId']?.toString() ?? ''; + final groupName = group['name']?.toString() ?? 'Unnamed Group'; + return Column( + children: [ + InkWell( + splashColor: const Color(0xFF705B37).withOpacity(0.35), + highlightColor: const Color(0xFF705B37).withOpacity(0.25), + onTap: () async { + chatProvider.markGroupAsRead(groupId); + // Determine if user already member; best effort by fetching group + final groupSvc = context.read(); + final userId = context.read()?.user?.id; + bool isMember = false; + try { + final g = await groupSvc.getGroupById(groupId); + if (userId != null) { + isMember = g.members.any((m) => m.userId == userId); + } + } catch (_) { + // fallback: show info page + } + if (!mounted) return; + if (isMember) { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPage(groupId: groupId, groupName: groupName), + ), + ); + if (result is Map && (result['deletedGroup'] == true || result['leftGroup'] == true || result['updated'] == true)) { + await _refresh(); + } + } else { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupInfoPage(groupId: groupId, initialName: groupName), + ), + ); + // If group was deleted or membership changed, refresh list on return + if (result is Map && (result['deletedGroup'] == true || result['leftGroup'] == true || result['updated'] == true)) { + await _refresh(); + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const CircleAvatar( + radius: 24, + backgroundColor: Colors.grey, + child: Icon(Icons.group, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + groupName, + style: TextStyle(color: theme.primary, fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + group['lastMessage'] ?? '', + style: TextStyle(color: theme.primary.withOpacity(0.7), fontSize: 13, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Time text + if ((group['time']?.toString() ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + group['time']?.toString() ?? '', + style: TextStyle(color: theme.primary.withOpacity(0.6), fontSize: 12), + ), + ), + // Privacy pill + Builder(builder: (ctx) { + final isPrivate = group['isPrivate'] == true; + final label = isPrivate ? 'Private' : 'Public'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: theme.primary.withOpacity(0.35), width: 1.5), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: theme.primary.withOpacity(0.85), + letterSpacing: 0.2, + ), + ), + ); + }), + ], + ), + const SizedBox(height: 8), + if (group['unread'] == true) + CircleAvatar(radius: 6, backgroundColor: theme.secondary), + ], + ), + ], + ), + ), + ), + Divider(height: 1, thickness: 0.6, color: Colors.grey.shade800), + ], + ); + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// Helper widget builder for filter buttons +extension _ChatFilters on _ChatlistsPageState { + Widget _buildFilterButton({required String label, required int count, required bool selected, required VoidCallback onTap}) { + const activeColor = Color(0xFFF6C155); // #f6c155 + final inactiveBorder = Colors.white.withOpacity(0.1); + return Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: 36, + child: Material( + color: selected ? activeColor : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide(color: selected ? activeColor : inactiveBorder, width: 1), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + splashColor: Colors.white24, + child: Center( + child: RichText( + text: TextSpan( + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + color: selected ? Colors.black : Colors.white70, + ), + children: [ + TextSpan(text: label), + const TextSpan(text: ' '), + TextSpan( + text: count.toString(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: selected ? Colors.black : Colors.white60, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/dashboard_page.dart b/leaderboard_app/lib/pages/dashboard_page.dart new file mode 100644 index 0000000..bf43e4d --- /dev/null +++ b/leaderboard_app/lib/pages/dashboard_page.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +// import 'package:leaderboard_app/dashboard-components/compact_calendar.dart'; // removed widget +import 'package:leaderboard_app/dashboard-components/leaderboard_table.dart'; +import 'package:leaderboard_app/dashboard-components/problem_table.dart'; +import 'package:leaderboard_app/dashboard-components/daily_activity.dart'; +// import 'package:leaderboard_app/dashboard-components/week_view.dart'; // removed widget +// import 'package:leaderboard_app/dashboard-components/weekly_stats.dart'; // removed widget +import 'package:leaderboard_app/provider/user_provider.dart'; +// models via provider components +// import 'package:leaderboard_app/models/dashboard_models.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; +import 'package:leaderboard_app/provider/dashboard_provider.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class DashboardPage extends StatefulWidget { + const DashboardPage({super.key}); + + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + String? _error; // page-level error + + @override + void initState() { + super.initState(); + // Start loading all dashboard data via provider + WidgetsBinding.instance.addPostFrameCallback((_) { + final dp = context.read(); + dp.loadAll(); + }); + // Also load user profile once if not available + WidgetsBinding.instance.addPostFrameCallback((_) { + final up = context.read(); + if (up.user == null) { + final us = context.read(); + up.fetchProfile(us); + } + }); + } + + // legacy loader removed; using DashboardProvider + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + double maxWidth = constraints.maxWidth < 400 + ? constraints.maxWidth + : 400; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + children: [ + // Header (now reactive using Consumer) + Consumer( + builder: (context, user, _) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + color: colors.tertiary.withOpacity(0.15), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: colors.surface, + child: Icon(Icons.person, color: colors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name, + style: TextStyle( + color: colors.primary, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + Text( + user.email, + style: TextStyle( + color: colors.primary.withOpacity(0.7), + fontSize: 12, + ), + ), + ], + ), + ), + _buildHeaderButton( + Icons.local_fire_department, + "${user.streak}", + Color(0xFFF6C156), + ), + ], + ), + ), + ), + + // Scrollable Content + Expanded( + child: RefreshIndicator( + onRefresh: () async { + // Refresh all dashboard data (daily question, submissions, leaderboard) + await context.read().loadAll(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Consumer( + builder: (_, dp, __) => dp.loadingDaily + ? _loadingCard(height: 90) + : LeetCodeDailyCard(daily: dp.daily), + ), + const SizedBox(height: 10), + Consumer( + builder: (_, dp, __) => dp.loadingLeaders + ? _loadingCard(height: 180) + : LeaderboardTable(users: dp.leaderboard), + ), + const SizedBox(height: 10), + Consumer( + builder: (_, dp, __) { + if (dp.loadingSubs) return _loadingCard(height: 180); + if (!dp.isVerified) { + return _verifyCard(context); + } + if (dp.errorSubs != null) { + return _errorCard(dp.errorSubs!); + } + return ProblemTable(submissions: dp.submissions); + }, + ), + const SizedBox(height: 10), + // Removed WeeklyStats and CompactCalendar per request + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ], + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + static Widget _buildHeaderButton(IconData icon, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, color: Colors.white, size: 16), + const SizedBox(width: 4), + Text(label, style: const TextStyle(color: Colors.white)), + ], + ), + ); + } + + Widget _loadingCard({double height = 120}) { + return Container( + width: double.infinity, + height: height, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + Widget _verifyCard(BuildContext context) { + // Single-line actionable tile styled like the provided screenshot. + return GestureDetector( + onTap: () => context.push('/verify'), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Leading icon (generic code icon since no LeetCode asset present) + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.link, color: Colors.white70, size: 18), + ), + const SizedBox(width: 12), + // Texts + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + 'Connect to LeetCode', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + SizedBox(height: 2), + Text( + 'See recent submissions & streaks', + style: TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + // Red error / attention indicator + Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Text( + '!', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), + const Icon(Icons.chevron_right, color: Colors.white38, size: 22), + ], + ), + ), + ); + } + + Widget _errorCard(String message) { + return Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.redAccent.withOpacity(0.4)), + ), + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Unable to load submissions', + style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 6), + Text(message, style: const TextStyle(color: Colors.white70, fontSize: 12)), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/groupinfo_page.dart b/leaderboard_app/lib/pages/groupinfo_page.dart new file mode 100644 index 0000000..8aba0b1 --- /dev/null +++ b/leaderboard_app/lib/pages/groupinfo_page.dart @@ -0,0 +1,667 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/group_models.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; +import 'package:leaderboard_app/services/dashboard/dashboard_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:leaderboard_app/provider/chatlists_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:leaderboard_app/provider/group_membership_provider.dart'; + +class GroupInfoPage extends StatefulWidget { + final String groupId; + final String? initialName; + + const GroupInfoPage({super.key, required this.groupId, this.initialName}); + + @override + State createState() => _GroupInfoPageState(); +} + +class _GroupInfoPageState extends State { + Group? _group; + bool _loading = true; + String? _error; + bool _mutating = false; + String? _currentUserId; + // Hydrated user stats from global leaderboard (username -> (streak, solved)) + final Map _userStats = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + _currentUserId = context.read().user?.id; + final svc = context.read(); + final g = await svc.getGroupById(widget.groupId); + // Hydrate streak / solved from dashboard leaderboard (best-effort) + try { + final dash = context.read(); + final lb = await dash.getLeaderboard(); + _userStats + ..clear() + ..addEntries(lb.map((u) => MapEntry(u.username.toLowerCase(), (u.streak, u.totalSolved)))); + } catch (_) { + // ignore hydration errors silently + } + if (!mounted) return; + setState(() => _group = g); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Failed to load group'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + bool get _isMember { + final uid = _currentUserId; + if (uid == null || _group == null) return false; + return _group!.members.any((m) => m.userId == uid); + } + + bool get _isOwner { + final uid = _currentUserId; + final g = _group; + if (uid == null || g == null) return false; + if (g.creator?.id == uid || g.createdBy == uid) return true; + return g.members.any((m) => m.userId == uid && m.role.toUpperCase() == 'OWNER'); + } + + bool get _isAdmin { + final uid = _currentUserId; + final g = _group; + if (uid == null || g == null) return false; + return g.members.any((m) => m.userId == uid && (m.role.toUpperCase() == 'ADMIN' || m.role.toUpperCase() == 'MODERATOR')); + } + + bool _canManage(GroupMember target) { + // Owner can manage anyone except themselves + if (_isOwner) { + // Prevent self demotion via manage menu (handled elsewhere) by disallowing actions on OWNER role belonging to current user. + return !(target.role.toUpperCase() == 'OWNER' && target.userId == _currentUserId); + } + // Admins can manage only regular members (not owner, not other admins/mods) + if (_isAdmin) { + final role = target.role.toUpperCase(); + return role != 'OWNER' && role != 'ADMIN' && role != 'MODERATOR'; + } + return false; // members cannot manage anyone + } + + Future _joinLeave() async { + if (_group == null) return; + setState(() => _mutating = true); + try { + final svc = context.read(); + if (_isMember) { + await svc.leaveGroup(_group!.id); + // After leaving, refresh public group listing so counts & membership reflect change + try { + final chatListProv = context.read(); + if (chatListProv != null) { + chatListProv.loadPublicGroups(svc); + } + } catch (_) {} + // Pop immediately with result so upstream (e.g., ChatPage) can react + if (mounted) Navigator.of(context).pop({'leftGroup': true, 'groupId': _group!.id}); + return; // skip reloading after leave + } else { + await svc.joinGroup(_group!.id); + // Inform membership provider (if in tree) so gate can switch to chat + final membershipProv = context.read(); + membershipProv?.markJoined(); + // Refresh public groups so membership filter updates automatically + try { + final chatListProv = context.read(); + if (chatListProv != null) { + chatListProv.loadPublicGroups(svc); + } + } catch (_) {} + } + await _load(); + } catch (e) { + setState(() => _error = 'Operation failed'); + } finally { + setState(() => _mutating = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + final name = _group?.name ?? widget.initialName ?? 'Group'; + + return Scaffold( + backgroundColor: theme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + leading: const BackButton(), + elevation: 0, + actions: [ + if (_loading) + const Padding( + padding: EdgeInsets.only(right: 12), + child: Center(child: SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))), + ), + // Only the owner should see the 3-dot menu (admins no longer see it) + if (!_loading && _group != null && _isOwner) + PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 'edit': + await _showEditGroupDialog(); + break; + case 'delete': + await _confirmDelete(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'edit', child: Text('Edit Group')), + const PopupMenuItem(value: 'delete', child: Text('Delete Group')), + ], + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!, style: const TextStyle(color: Colors.redAccent))) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 10), + + const CircleAvatar( + radius: 50, + backgroundColor: Colors.grey, + child: Icon( + Icons.group, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text(name, style: const TextStyle(color: Colors.white, fontSize: 16)), + const SizedBox(height: 8), + if (_group?.description != null) + Text( + _group!.description!, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + // Join button (only when not already a member). Leave button moved below leaderboard. + if (!_isMember) + Center( + child: FractionallySizedBox( + widthFactor: 0.5, // half-width similar to leave button + child: ElevatedButton( + onPressed: _mutating ? null : _joinLeave, + style: ElevatedButton.styleFrom( + backgroundColor: theme.secondary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 8), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, 36), + ), + child: Text(_mutating ? 'Joining...' : 'Join Group'), + ), + ), + ), + const SizedBox(height: 16), + _membersCard(_group?.members ?? const []), + const SizedBox(height: 16), + _xpTable(_group?.members ?? const []), + const SizedBox(height: 16), + // Red leave button placed below the top players table as requested. + if (_isMember) + Center( + child: FractionallySizedBox( + widthFactor: 0.5, // half of available width + child: ElevatedButton( + onPressed: _mutating ? null : _joinLeave, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + disabledBackgroundColor: Colors.redAccent.withOpacity(0.5), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 8), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, 36), + ), + child: Text(_mutating ? 'Leaving...' : 'Leave Group'), + ), + ), + ), + const SizedBox(height: 200), + ], + ), + ), + ); + } + + Widget _membersCard(List members) { + // Sort members: Owner first, then Admin/Moderator, then Member; alphabetically within each tier + int roleRank(String role) { + final r = role.toUpperCase(); + if (r == 'OWNER') return 0; + if (r == 'ADMIN' || r == 'MODERATOR') return 1; + return 2; // MEMBER or anything else + } + final sorted = [...members] + ..sort((a, b) { + final ar = roleRank(a.role); + final br = roleRank(b.role); + if (ar != br) return ar.compareTo(br); + final aName = (a.user?.username ?? a.userId).toLowerCase(); + final bName = (b.user?.username ?? b.userId).toLowerCase(); + return aName.compareTo(bName); + }); + + return Container( + width: double.infinity, + padding: EdgeInsets.zero, // Removed padding so divider lines span edge-to-edge + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Local padding for header only (keeps container itself unpadded for full-width lines) + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text('Members', style: TextStyle(fontSize: 18)), + ), + // Top divider above the first member row + Divider( + color: Colors.grey.shade500, + height: 1, + thickness: 1, + ), + for (int i = 0; i < sorted.length; i++) ...[ + Padding( + // Maintain horizontal padding for content while allowing dividers to stretch full width + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Colors.grey.shade700, + child: Text( + (sorted[i].user?.username.isNotEmpty == true) ? sorted[i].user!.username[0] : '?', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 12), + Text( + sorted[i].user?.username ?? sorted[i].userId, + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + sorted[i].role, + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade300, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ), + PopupMenuButton( + tooltip: 'Member actions', + icon: const Icon(Icons.chevron_right, color: Colors.white70, size: 20), + onSelected: (value) async { + final m = sorted[i]; + switch (value) { + case 'remove': + if (_canManage(m)) await _removeMember(m); + break; + case 'promote': + if (_canManage(m)) await _changeRole(m, 'ADMIN'); + break; + case 'demote': + if (_canManage(m)) await _changeRole(m, 'MEMBER'); + break; + } + }, + itemBuilder: (context) { + final m = sorted[i]; + final can = _canManage(m); + if (!can) { + return const [ + PopupMenuItem( + enabled: false, + child: Text('No actions available'), + ), + ]; + } + return [ + const PopupMenuItem(value: 'remove', child: Text('Remove')), + if (m.role.toUpperCase() == 'MEMBER') const PopupMenuItem(value: 'promote', child: Text('Promote to Admin')), + if (m.role.toUpperCase() == 'ADMIN' || m.role.toUpperCase() == 'MODERATOR') const PopupMenuItem(value: 'demote', child: Text('Demote to Member')), + ]; + }, + ), + ], + ), + ), + if (i < sorted.length - 1) + Divider( + color: Colors.grey.shade800, + height: 1, + thickness: 1, + ), + ], + ], + ), + ); + } + + Widget _xpTable(List members) { + // Sort by hydrated streak desc, then solved desc, then xp desc, then username asc as final tie-breaker + final sorted = [...members]; + sorted.sort((a, b) { + String aName = (a.user?.username ?? a.userId).toLowerCase(); + String bName = (b.user?.username ?? b.userId).toLowerCase(); + final aStats = _userStats[aName]; + final bStats = _userStats[bName]; + final aStreak = aStats?.$1 ?? a.user?.streak ?? 0; + final bStreak = bStats?.$1 ?? b.user?.streak ?? 0; + if (bStreak != aStreak) return bStreak.compareTo(aStreak); + final aSolved = aStats?.$2 ?? a.user?.totalSolved ?? 0; + final bSolved = bStats?.$2 ?? b.user?.totalSolved ?? 0; + if (bSolved != aSolved) return bSolved.compareTo(aSolved); + if (b.xp != a.xp) return b.xp.compareTo(a.xp); + return aName.compareTo(bName); + }); + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + color: Colors.grey.shade900, + child: LayoutBuilder( + builder: (context, constraints) => ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: DataTable( + columnSpacing: 16, + dataRowMinHeight: 32, + dataRowMaxHeight: 40, + headingRowHeight: 32, + headingRowColor: MaterialStateProperty.all(Colors.grey[850]), + columns: const [ + DataColumn(label: Text('Place', style: TextStyle(color: Colors.white, fontSize: 12))), + DataColumn(label: Text('Player', style: TextStyle(color: Colors.white, fontSize: 12))), + DataColumn(label: Text('Streak', style: TextStyle(color: Colors.white, fontSize: 12))), + DataColumn(label: Text('Solved', style: TextStyle(color: Colors.white, fontSize: 12))), + ], + rows: List.generate(sorted.length, (i) { + final m = sorted[i]; + final uname = (m.user?.username ?? m.userId).toLowerCase(); + final hydrated = _userStats[uname]; + final streak = hydrated != null ? hydrated.$1 : (m.user?.streak ?? 0); + final solved = hydrated != null ? hydrated.$2 : (m.user?.totalSolved ?? 0); + final streakDisplay = streak == 0 ? '—' : '$streak'; + final solvedDisplay = solved == 0 ? '—' : '$solved'; + return DataRow(cells: [ + DataCell(Text('${i + 1}', style: const TextStyle(color: Colors.white, fontSize: 12))), + DataCell(Text(m.user?.username ?? m.userId, style: const TextStyle(color: Colors.white, fontSize: 12))), + DataCell(Text(streakDisplay, style: const TextStyle(color: Colors.white, fontSize: 12))), + DataCell(Text(solvedDisplay, style: const TextStyle(color: Colors.white, fontSize: 12))), + ]); + }), + ), + ), + ), + ), + ); + } + + Future _showEditGroupDialog() async { + if (_group == null) return; + final nameController = TextEditingController(text: _group!.name); + final descController = TextEditingController(text: _group!.description ?? ''); + final maxMembersController = TextEditingController(text: _group!.maxMembers?.toString() ?? ''); + bool isPrivate = _group!.isPrivate; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return StatefulBuilder( + builder: (context, setSheetState) { + final theme = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + left: 16, + right: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Edit Group', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: theme.primary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + color: theme.primary, + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: nameController, + textInputAction: TextInputAction.next, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Name *', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descController, + maxLines: 3, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Description', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: maxMembersController, + keyboardType: TextInputType.number, + style: TextStyle(color: theme.primary), + decoration: InputDecoration( + labelText: 'Max Members (optional)', + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Private', style: TextStyle(color: theme.primary.withOpacity(0.7))), + Switch( + value: isPrivate, + onChanged: (v) => setSheetState(() => isPrivate = v), + activeColor: theme.secondary, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _mutating + ? null + : () async { + final name = nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Name is required'))); + return; + } + setState(() => _mutating = true); + final maxMembers = int.tryParse(maxMembersController.text.trim()); + try { + final svc = context.read(); + final g = await svc.updateGroup( + _group!.id, + name: name, + description: descController.text.trim().isEmpty ? null : descController.text.trim(), + isPrivate: isPrivate, + maxMembers: maxMembers, + ); + if (mounted) { + context.read()?.updateGroupMeta(groupId: g.id, name: g.name, isPrivate: g.isPrivate); + } + await _load(); + if (mounted) Navigator.pop(context); + } catch (_) { + if (mounted) setState(() => _error = 'Failed to update group'); + } finally { + if (mounted) setState(() => _mutating = false); + } + }, + icon: _mutating + ? SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.onSecondary), + ) + : const Icon(Icons.check), + label: Text(_mutating ? 'Saving...' : 'Save Changes'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + }, + ); + }, + ); + } + + Future _confirmDelete() async { + if (_group == null) return; + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text('Delete Group'), + content: const Text('Are you sure you want to delete this group? This cannot be undone.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), + ], + ), + ); + if (ok != true) return; + setState(() => _mutating = true); + try { + final svc = context.read(); + await svc.deleteGroup(_group!.id); + // Update chat list provider so list reflects deletion + if (mounted) { + final chatListProv = context.read(); + chatListProv?.removeGroup(_group!.id); + } + if (!mounted) return; + // Return a signal so the previous page can refresh its data. + Navigator.of(context).pop({'deletedGroup': true, 'groupId': _group!.id}); + } catch (_) { + setState(() => _error = 'Failed to delete group'); + } finally { + setState(() => _mutating = false); + } + } + + Future _removeMember(GroupMember m) async { + if (_group == null) return; + setState(() => _mutating = true); + try { + final svc = context.read(); + await svc.removeMember(_group!.id, m.userId); + await _load(); + } catch (_) { + setState(() => _error = 'Failed to remove member'); + } finally { + setState(() => _mutating = false); + } + } + + Future _changeRole(GroupMember m, String role) async { + if (_group == null) return; + setState(() => _mutating = true); + try { + final svc = context.read(); + await svc.updateMemberRole(_group!.id, m.userId, role); + await _load(); + } catch (_) { + setState(() => _error = 'Failed to update role'); + } finally { + setState(() => _mutating = false); + } + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/home_page.dart b/leaderboard_app/lib/pages/home_page.dart new file mode 100644 index 0000000..b1461e7 --- /dev/null +++ b/leaderboard_app/lib/pages/home_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/pages/dashboard_page.dart'; +import 'package:leaderboard_app/pages/chatlists_page.dart'; +import 'package:leaderboard_app/pages/settings_page.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _selectedIndex = 0; + + List get _pages => [ + const DashboardPage(), + ChatlistsPage(), + SettingsPage(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: _pages[_selectedIndex], + bottomNavigationBar: Theme( + data: Theme.of(context).copyWith( + canvasColor: Colors.grey[900], + primaryColor: Colors.amber, + textTheme: Theme.of(context).textTheme.copyWith( + bodySmall: const TextStyle(color: Colors.white), + ), + ), + child: BottomNavigationBar( + selectedItemColor: Color(0xFFF6C155), + unselectedItemColor: Colors.white, + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.fixed, + currentIndex: _selectedIndex, + onTap: (index) { + setState(() { + _selectedIndex = index; + }); + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.explore, size: 28), + label: 'Discover', + ), + BottomNavigationBarItem( + icon: Icon(Icons.chat, size: 28), + label: 'Chat', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings, size: 28), + label: 'Settings', + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/leetcode_verification_page.dart b/leaderboard_app/lib/pages/leetcode_verification_page.dart new file mode 100644 index 0000000..b82f5d4 --- /dev/null +++ b/leaderboard_app/lib/pages/leetcode_verification_page.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:leaderboard_app/services/core/error_utils.dart'; +import 'package:leaderboard_app/services/leetcode/leetcode_service.dart'; +import 'package:provider/provider.dart'; + +class LeetCodeVerificationPage extends StatefulWidget { + const LeetCodeVerificationPage({super.key}); + + @override + State createState() => _LeetCodeVerificationPageState(); +} + +class _LeetCodeVerificationPageState extends State { + final _usernameCtrl = TextEditingController(); + bool _loading = false; + String? _error; + String? _verificationCode; + String? _instructions; + Timer? _pollTimer; + int _secondsLeft = 0; + + @override + void dispose() { + _pollTimer?.cancel(); + _usernameCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: const Color(0xFF141316), + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Container( + child: const Center( + child: Text( + 'Verify LeetCode', + style: TextStyle( + fontSize: 36, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + height: screenHeight * 0.80, + padding: const EdgeInsets.all(35), + decoration: const BoxDecoration( + color: Color(0xff11b1a1d), + borderRadius: BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + TextField( + controller: _usernameCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'LeetCode username', + hintStyle: TextStyle(color: Colors.grey.withOpacity(0.28)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ), + SizedBox( + width: double.infinity, + height: 45, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF6C156), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: _loading ? null : _startVerification, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + ) + : const Text( + 'Start Verification', + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 16), + if (_verificationCode != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF141316), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Verification Code', + style: TextStyle(color: Colors.grey[300], fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + SelectableText( + _verificationCode!, + style: const TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 12), + Text( + _instructions ?? 'Set this as your LeetCode Real Name, then wait for verification.', + style: TextStyle(color: Colors.grey[400]), + ), + const SizedBox(height: 8), + if (_secondsLeft > 0) + Text( + 'Auto-checking... $_secondsLeft s left', + style: const TextStyle(color: Color(0xFFF6C156)), + ), + ], + ), + ), + ], + const Spacer(), + TextButton( + onPressed: () => context.go('/'), + child: const Text('Skip for now', style: TextStyle(color: Color(0xFFF6C156))), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Future _startVerification() async { + final username = _usernameCtrl.text.trim(); + if (username.isEmpty) { + setState(() => _error = 'Enter your LeetCode username'); + return; + } + setState(() { + _loading = true; + _error = null; + }); + try { + final service = context.read(); + final resp = await service.startVerification(username); + final status = await service.getStatus(); + if (status.isVerified) { + if (!mounted) return; + context.go('/'); + return; + } + setState(() { + _verificationCode = resp.verificationCode; + _instructions = resp.instructions; + _secondsLeft = (resp.timeoutInSeconds ?? 120); + }); + _startPolling(); + } on DioException catch (e) { + setState(() => _error = ErrorUtils.fromDio(e)); + } catch (_) { + setState(() => _error = 'Something went wrong'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _startPolling() { + _pollTimer?.cancel(); + // Poll every 5s until verified or timeout + _pollTimer = Timer.periodic(const Duration(seconds: 5), (t) async { + setState(() { + _secondsLeft = (_secondsLeft - 5).clamp(0, 9999); + }); + try { + final status = await context.read().getStatus(); + if (status.isVerified) { + t.cancel(); + if (!mounted) return; + context.go('/'); + } else if (_secondsLeft <= 0) { + t.cancel(); + if (mounted) { + setState(() => _error = 'Verification timed out. Try again.'); + } + } + } catch (e) { + // Ignore transient errors and keep polling until timeout + } + }); + } +} + diff --git a/leaderboard_app/lib/pages/no_internet_page.dart b/leaderboard_app/lib/pages/no_internet_page.dart new file mode 100644 index 0000000..3f1dc14 --- /dev/null +++ b/leaderboard_app/lib/pages/no_internet_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class NoInternetPage extends StatelessWidget { + const NoInternetPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 96, color: Colors.grey), + const SizedBox(height: 24), + const Text('No Internet Connection', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 12), + const Text('Please check your connection. The app will continue once you are back online.', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70)), + const SizedBox(height: 32), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFF6C156), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + onPressed: () { + // Simply trigger a rebuild; actual status is managed by provider listening. + (context as Element).markNeedsBuild(); + }, + child: const Text('Retry'), + ) + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/settings_page.dart b/leaderboard_app/lib/pages/settings_page.dart new file mode 100644 index 0000000..893c450 --- /dev/null +++ b/leaderboard_app/lib/pages/settings_page.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:leaderboard_app/services/auth/auth_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; +import 'package:leaderboard_app/provider/chat_provider.dart'; +import 'package:leaderboard_app/provider/chatlists_provider.dart'; +import 'package:provider/provider.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + // trigger profile fetch if not yet + final up = context.watch(); + if (up.user == null && !up.isLoading && up.error == null) { + // fire and forget + final svc = context.read(); + up.fetchProfile(svc); + } + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.only(left:16, right:16, top:16, bottom:24), + children: [ + // Title with SVG icon (match Chats styling) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 35, + height: 35, + child: SvgPicture.asset( + 'assets/icons/LL_Logo.svg', + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 10), + Text( + 'Settings', + style: TextStyle( + color: colors.primary, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 20), + + // ====== Personal Details ====== + Text( + 'My Account', + style: TextStyle(color: colors.primary, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 10), + + Consumer( + builder: (context, user, _) { + // We no longer display separate first/last name fields – just show the full username. + final name = (user.name).trim(); + final username = name.isNotEmpty ? name : '-'; + final email = (user.email).isNotEmpty ? user.email : '-'; + final streak = user.streak; + final handle = user.user?.leetcodeHandle; + final verified = user.user?.leetcodeVerified == true; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.tertiary.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: CircleAvatar( + radius: 50, + backgroundColor: colors.tertiary.withOpacity(0.3), + child: Icon( + Icons.person, + size: 40, + color: colors.primary, + ), + ), + ), + const SizedBox(height: 10), + + // Username + _buildDisplayTile('Username', '@$username', colors), + Divider( + height: 1, + thickness: 0.6, + color: colors.primary.withOpacity(0.3), + ), + _buildDisplayTile('Email', email, colors), + Divider( + height: 1, + thickness: 0.6, + color: colors.primary.withOpacity(0.3), + ), + _buildDisplayTile('Streak', streak.toString(), colors), + Divider( + height: 1, + thickness: 0.6, + color: colors.primary.withOpacity(0.3), + ), + SizedBox(height: 12), + // LeetCode handle & verify section + if (verified) + _buildDisplayTile('LeetCode', handle ?? '-', colors) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LeetCode', + style: TextStyle(color: colors.onSurface, fontSize: 12, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colors.tertiary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text(handle ?? 'Not linked', style: TextStyle(color: colors.primary, fontSize: 14, fontWeight: FontWeight.w500)), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + // Navigate to the dedicated verification page used in signup/login flow + onPressed: () => context.push('/verify'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.secondary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ), + child: const Text('Verify'), + ), + ], + ), + ], + ), + SizedBox(height: 12), + Divider( + height: 1, + thickness: 0.6, + color: colors.primary.withOpacity(0.3), + ), + ], + ), + ); + }, + ), + + const SizedBox(height: 25), + // Removed password & authentication section per request + + // ====== Logout button (full-width) ====== + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.logout), + label: const Text( + 'Log out', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + onPressed: () async { + // Clear auth token + any persisted data + await context.read().logout(); + // Reset in-memory chat state so previous session messages/groups disappear + try { + context.read().reset(); + context.read().reset(); + } catch (_) {} + if (!context.mounted) return; + context.go('/signin'); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ), + ); + } + + // Removed in-dialog verification flow; navigation now uses dedicated page '/verify' + + // Label outside, grey pill only around value + Widget _buildDisplayTile(String title, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(color: colors.onSurface, fontSize: 12, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colors.tertiary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + value, + style: TextStyle(color: colors.primary, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/signin_page.dart b/leaderboard_app/lib/pages/signin_page.dart new file mode 100644 index 0000000..19e52f6 --- /dev/null +++ b/leaderboard_app/lib/pages/signin_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:go_router/go_router.dart'; +import 'package:leaderboard_app/services/auth/auth_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; +import 'package:provider/provider.dart'; +import 'package:leaderboard_app/services/core/error_utils.dart'; + +class SignInPage extends StatefulWidget { + const SignInPage({super.key}); + + @override + State createState() => _SignInPageState(); +} + +class _SignInPageState extends State { + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _loading = false; + String? _error; + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return GestureDetector( + onTap: () { + FocusScope.of(context).unfocus(); + }, + child: Scaffold( + backgroundColor: const Color(0xFF141316), + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Top title + Padding( + padding: const EdgeInsets.only(bottom: 20,), + child: Container( + child: const Center( + child: Text( + 'Sign In', + style: TextStyle( + fontSize: 36, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + height: screenHeight * 0.80, + padding: const EdgeInsets.all(35), + decoration: BoxDecoration( + color: const Color(0xff11b1a1d), + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + TextField( + controller: _emailCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'Email', + hintStyle: TextStyle(color: Colors.grey.withOpacity(0.28)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 16), + TextField( + obscureText: true, + controller: _passwordCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'Password', + hintStyle: TextStyle(color: Colors.grey.withOpacity(0.28)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 20), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ), + SizedBox( + width: double.infinity, + height: 45, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF6C156), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: _loading ? null : _onSignIn, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + ) + : const Text( + 'Sign In', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "New here? ", + style: TextStyle(color: Colors.white), + ), + GestureDetector( + onTap: () { + context.go('/signup'); + }, + child: const Text( + "Sign up", + style: TextStyle( + color: Color(0xFFF6C156), + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + const Spacer(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Future _onSignIn() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final authService = context.read(); + final res = await authService.signIn(email: _emailCtrl.text.trim(), password: _passwordCtrl.text); + // Update user provider + context.read().updateUser( + name: res.user.username, + email: res.user.email ?? '', + streak: res.user.streak, + ); + // Fetch current profile to check verification + final profile = await authService.getUserProfile(); + // Also refresh provider from UserService for consistency + try { await context.read().fetchProfile(context.read()); } catch (_) {} + if (!mounted) return; + if (!profile.leetcodeVerified) { + context.go('/verify'); + } else { + context.go('/'); + } + } on DioException catch (e) { + setState(() { + _error = ErrorUtils.fromDio(e); + }); + } catch (e) { + setState(() { + _error = 'Something went wrong'; + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/pages/signup_page.dart b/leaderboard_app/lib/pages/signup_page.dart new file mode 100644 index 0000000..994ffd2 --- /dev/null +++ b/leaderboard_app/lib/pages/signup_page.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:go_router/go_router.dart'; +import 'package:leaderboard_app/services/auth/auth_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; +import 'package:provider/provider.dart'; +import 'package:leaderboard_app/services/core/error_utils.dart'; + +class SignUpPage extends StatefulWidget { + const SignUpPage({super.key}); + + @override + State createState() => _SignUpPageState(); +} + +class _SignUpPageState extends State { + final _usernameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _loading = false; + String? _error; + + @override + void dispose() { + _usernameCtrl.dispose(); + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return GestureDetector( + onTap: () { + FocusScope.of(context).unfocus(); + }, + child: Scaffold( + backgroundColor: const Color(0xFF141316), + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Top title + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Container( + child: const Center( + child: Text( + 'Sign Up', + style: TextStyle( + fontSize: 36, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + height: screenHeight * 0.80, + padding: const EdgeInsets.all(35), + decoration: BoxDecoration( + color: const Color(0xff11b1a1d), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + TextField( + controller: _usernameCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'Username', + hintStyle: TextStyle( + color: Colors.grey.withOpacity(0.28), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _emailCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'Email', + hintStyle: TextStyle( + color: Colors.grey.withOpacity(0.28), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 10), + TextField( + obscureText: true, + controller: _passwordCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF141316), + hintText: 'Password', + hintStyle: TextStyle( + color: Colors.grey.withOpacity(0.28), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 40), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ), + SizedBox( + width: double.infinity, + height: 45, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF6C156), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: _loading ? null : _onSignUp, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + ) + : const Text( + 'Get Started', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Already have an account? ", + style: TextStyle(color: Colors.white), + ), + GestureDetector( + onTap: () { + context.go('/signin'); + }, + child: const Text( + "Sign in", + style: TextStyle( + color: Color(0xFFF6C156), + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + const Spacer(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + bool _validate() { + final username = _usernameCtrl.text.trim(); + final email = _emailCtrl.text.trim(); + final password = _passwordCtrl.text; + if (username.isEmpty || email.isEmpty || password.isEmpty) { + setState(() => _error = 'Username, email and password are required'); + return false; + } + if (!RegExp(r"^[^@\s]+@[^@\s]+\.[^@\s]+$").hasMatch(email)) { + setState(() => _error = 'Enter a valid email'); + return false; + } + if (password.length < 6) { + setState(() => _error = 'Password must be at least 6 characters'); + return false; + } + return true; + } + + Future _onSignUp() async { + if (!_validate()) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final authService = context.read(); + final res = await authService.signUp( + username: _usernameCtrl.text.trim(), + email: _emailCtrl.text.trim(), + password: _passwordCtrl.text, + ); + context.read().updateUser( + name: res.user.username, + email: res.user.email ?? '', + streak: res.user.streak, + ); + // Check verification status + final profile = await authService.getUserProfile(); + try { await context.read().fetchProfile(context.read()); } catch (_) {} + if (!mounted) return; + if (!profile.leetcodeVerified) { + context.go('/verify'); + } else { + context.go('/'); + } + } on DioException catch (e) { + setState(() { + _error = ErrorUtils.fromDio(e); + }); + } catch (_) { + setState(() { + _error = 'Something went wrong'; + }); + } finally { + if (mounted) { + setState(() => _loading = false); + } + } + } +} diff --git a/leaderboard_app/lib/pages/widgets/group_form_dialog.dart b/leaderboard_app/lib/pages/widgets/group_form_dialog.dart new file mode 100644 index 0000000..58f2333 --- /dev/null +++ b/leaderboard_app/lib/pages/widgets/group_form_dialog.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; + +/// Reusable group form dialog/sheet content used for both create & edit flows. +/// Mirrors styling of the create group bottom sheet (rounded fields on dark bg). +class GroupFormDialog extends StatefulWidget { + final String title; + final String? initialName; + final String? initialDescription; + final bool initialPrivate; + final int? initialMaxMembers; // kept for symmetry (unused in edit currently) + final bool showMaxMembers; + final bool showPrivateToggle; + final Future Function({required String name, String? description, required bool isPrivate}) onSubmit; + + const GroupFormDialog({ + super.key, + required this.title, + this.initialName, + this.initialDescription, + this.initialPrivate = false, + this.initialMaxMembers, + this.showMaxMembers = false, + this.showPrivateToggle = true, + required this.onSubmit, + }); + + @override + State createState() => _GroupFormDialogState(); +} + +class _GroupFormDialogState extends State { + late final TextEditingController _nameCtrl; + late final TextEditingController _descCtrl; + late final TextEditingController _maxCtrl; + bool _isPrivate = false; + bool _submitting = false; + String? _error; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.initialName ?? ''); + _descCtrl = TextEditingController(text: widget.initialDescription ?? ''); + _maxCtrl = TextEditingController(text: widget.initialMaxMembers?.toString() ?? ''); + _isPrivate = widget.initialPrivate; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _descCtrl.dispose(); + _maxCtrl.dispose(); + super.dispose(); + } + + InputDecoration _fieldDecoration(BuildContext context, String label) { + final theme = Theme.of(context).colorScheme; + return InputDecoration( + labelText: label, + labelStyle: TextStyle(color: theme.primary.withOpacity(0.7)), + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ); + } + + Future _submit() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Name is required'); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + final ok = await widget.onSubmit( + name: name, + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + isPrivate: _isPrivate); + if (!mounted) return; + setState(() => _submitting = false); + if (ok) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + return Dialog( + backgroundColor: Theme.of(context).colorScheme.surface, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: theme.primary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + color: theme.primary, + onPressed: () => Navigator.pop(context, false), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _nameCtrl, + textInputAction: TextInputAction.next, + style: TextStyle(color: theme.primary), + decoration: _fieldDecoration(context, 'Name *'), + ), + const SizedBox(height: 12), + TextField( + controller: _descCtrl, + maxLines: 3, + style: TextStyle(color: theme.primary), + decoration: _fieldDecoration(context, 'Description'), + ), + if (widget.showMaxMembers) ...[ + const SizedBox(height: 12), + TextField( + controller: _maxCtrl, + keyboardType: TextInputType.number, + style: TextStyle(color: theme.primary), + decoration: _fieldDecoration(context, 'Max Members (optional)'), + ), + ], + if (widget.showPrivateToggle) ...[ + const SizedBox(height: 12), + Row( + children: [ + Text('Private', style: TextStyle(color: theme.primary.withOpacity(0.7))), + const SizedBox(width: 8), + Switch( + value: _isPrivate, + activeColor: theme.secondary, + onChanged: (v) => setState(() => _isPrivate = v), + ), + ], + ), + ], + if (_error != null) ...[ + const SizedBox(height: 6), + Text(_error!, style: const TextStyle(color: Colors.redAccent, fontSize: 12)), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onSecondary, + ), + ) + : const Icon(Icons.check), + label: Text(_submitting ? 'Saving...' : 'Save'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.secondary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/provider/chat_provider.dart b/leaderboard_app/lib/provider/chat_provider.dart new file mode 100644 index 0000000..83e56ab --- /dev/null +++ b/leaderboard_app/lib/provider/chat_provider.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/chat_message.dart'; +import 'package:leaderboard_app/services/chat/chat_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; +import 'user_provider.dart'; + +/// ChatProvider integrates REST history + Socket.IO realtime events. +class ChatProvider extends ChangeNotifier { + final Map>> _groupMessages = {}; + final Map _groupAttachmentVisibility = {}; + final Set _joinedGroups = {}; + final Map _groupCurrentPage = {}; // page loaded so far (starts at 1) + final Map _groupHasMore = {}; // whether more pages available + bool isLoadingMore(String groupId) => _loadingMoreGroups.contains(groupId); + final Set _loadingMoreGroups = {}; + + StreamSubscription? _sub; + + bool _connecting = false; + bool _connected = false; + String? _connError; + String? _currentUserId; + String? _currentUsername; + void Function(String groupId)? onIncomingMessage; // UI hook for auto-scroll + + // Public getters + bool get isConnecting => _connecting; + bool get isConnected => _connected; + String? get connectionError => _connError; + String get currentUserID => _currentUserId ?? ''; + String get currentUsername => _currentUsername ?? ''; + + List> getMessages(String groupId) => _groupMessages[groupId] ?? const []; + bool getAttachmentOptionsVisibility(String groupId) => _groupAttachmentVisibility[groupId] ?? false; + + Future initIfNeeded([BuildContext? context]) async { + if (_currentUserId != null) return; + final prefs = await SharedPreferences.getInstance(); + _currentUserId = prefs.getString('userId'); + _currentUsername = prefs.getString('username') ?? prefs.getString('name'); + if ((_currentUserId == null || _currentUsername == null) && context != null) { + try { + final userProv = context.read(); + if (_currentUserId == null) _currentUserId = userProv.user?.id; + if (_currentUsername == null) _currentUsername = userProv.user?.username; + } catch (_) {} + } + _currentUserId ??= 'me'; + _currentUsername ??= 'You'; + // ignore: avoid_print + print('[CHAT] init userId=$_currentUserId username=$_currentUsername'); + } + + Future _ensureSocket() async { + if (_connected || _connecting) return; + _connecting = true; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('authToken'); + try { + await ChatService.instance.ensureConnected(authToken: token); + _connected = true; + _connError = null; + _sub ??= ChatService.instance.messagesStream.listen(_handleIncomingMessage); + } catch (e) { + _connError = e.toString(); + } finally { + _connecting = false; + notifyListeners(); + } + } + + Future joinGroup(BuildContext context, String groupId) async { + await initIfNeeded(context); + await _ensureSocket(); + if (_joinedGroups.contains(groupId)) return; + _joinedGroups.add(groupId); + _groupMessages.putIfAbsent(groupId, () => []); + _groupAttachmentVisibility.putIfAbsent(groupId, () => false); + + // Fetch history (page=1) + try { + final history = await ChatService.instance.fetchHistory(groupId, page: 1); + final mapped = history.map(_toMap).toList(); + _groupMessages[groupId] = mapped; + _groupCurrentPage[groupId] = 1; + _groupHasMore[groupId] = history.length >= 50; // heuristic based on limit + } catch (e) { + // Optionally add a system message + _groupMessages[groupId]?.add({ + 'id': 'err-${DateTime.now().millisecondsSinceEpoch}', + 'groupId': groupId, + 'message': 'Failed to load history: $e', + 'timestamp': _formatTimestamp(DateTime.now()), + 'senderID': 'system', + 'senderName': 'System', + }); + } + // Join via socket after history + ChatService.instance.joinGroup(groupId); + // debug log + // ignore: avoid_print + print('[CHAT] joinGroup requested for $groupId'); + notifyListeners(); + } + + Future sendMessage(String groupId, String text) async { + final raw = text; + if (raw.trim().isEmpty) { + // ignore: avoid_print + print('[CHAT][SEND] Abort empty text'); + return; + } + // Ensure user + socket + await initIfNeeded(); + if (!ChatService.instance.isConnected) { + // ignore: avoid_print + print('[CHAT][SEND] Socket not connected – attempting ensureSocket'); + await _ensureSocket(); + } + if (!ChatService.instance.isConnected) { + // ignore: avoid_print + print('[CHAT][SEND] Still not connected after ensureSocket'); + } + // Ensure group joined (lightweight: if not joined, just emit join now) + if (!_joinedGroups.contains(groupId)) { + // ignore: avoid_print + print('[CHAT][SEND] Group $groupId not joined yet. Joining via socket (no history fetch).'); + _joinedGroups.add(groupId); + _groupMessages.putIfAbsent(groupId, () => []); + ChatService.instance.joinGroup(groupId); + } + final trimmed = raw.trim(); + // ignore: avoid_print + print('[CHAT][SEND] Attempt send group=$groupId len=${trimmed.length} user=$currentUserID'); + final ok = await ChatService.instance.sendMessage( + groupId, + trimmed, + sender: { + 'id': currentUserID, + 'username': currentUsername.isEmpty ? 'You' : currentUsername, + }, + ); + if (!ok) { + // ignore: avoid_print + print('[CHAT][SEND] Failed path reached; appending system error message'); + final list = (_groupMessages[groupId] ??= []); + list.add({ + 'id': 'err-${DateTime.now().microsecondsSinceEpoch}', + 'groupId': groupId, + 'message': 'Failed to send message.', + 'timestamp': _formatTimestamp(DateTime.now()), + 'senderID': 'system', + 'senderName': 'System', + 'senderColor': Colors.red, + }); + notifyListeners(); + } + } + + Future loadMore(String groupId) async { + if (!(_groupHasMore[groupId] ?? false)) return; + if (_loadingMoreGroups.contains(groupId)) return; + final next = (_groupCurrentPage[groupId] ?? 1) + 1; + _loadingMoreGroups.add(groupId); + notifyListeners(); + try { + final history = await ChatService.instance.fetchHistory(groupId, page: next); + if (history.isEmpty) { + _groupHasMore[groupId] = false; + } else { + final list = _groupMessages[groupId] ??= []; + final existingIds = list.map((e) => e['id']).toSet(); + final newOnes = history.where((m) => !existingIds.contains(m.id)).map(_toMap); + list.insertAll(0, newOnes); // prepend older messages + _groupCurrentPage[groupId] = next; + _groupHasMore[groupId] = history.length >= 50; + } + } catch (_) { + // swallow or add a system message if desired + } finally { + _loadingMoreGroups.remove(groupId); + notifyListeners(); + } + } + + void _handleIncomingMessage(ChatMessage m) { + final list = (_groupMessages[m.groupId] ??= []); + // Dedupe exact id + if (list.any((e) => e['id'] == m.id)) return; + list.add(_toMap(m)); + notifyListeners(); + // Trigger UI callback after listeners update + try { + onIncomingMessage?.call(m.groupId); + } catch (_) {} + } + + Map _toMap(ChatMessage m) { + final bool isMe = (m.senderId.isNotEmpty && m.senderId == currentUserID) || + (m.senderName.toLowerCase() == currentUsername.toLowerCase()); + // ignore: avoid_print + print('[CHAT] map message id=${m.id} senderId=${m.senderId} senderName=${m.senderName} isMe=$isMe currentUser=$currentUserID/$currentUsername'); + return { + 'id': m.id, + 'groupId': m.groupId, + 'message': m.message, + // ensure local time for display + 'timestamp': _formatTimestamp(m.timestamp.toLocal()), + 'senderID': m.senderId, + 'senderName': isMe ? 'You' : m.senderName, + 'senderColor': isMe ? Colors.black : Colors.white, + 'isMe': isMe, + }; + } + + void toggleAttachmentOptions(String groupId) { + _groupAttachmentVisibility[groupId] = !(_groupAttachmentVisibility[groupId] ?? false); + notifyListeners(); + } + + String _formatTimestamp(DateTime dt) { + // Normalize to local just in case caller forgets + dt = dt.toLocal(); + final h24 = dt.hour; + final h = h24 == 0 ? 12 : (h24 > 12 ? h24 - 12 : h24); + final m = dt.minute.toString().padLeft(2, '0'); + final ampm = h24 >= 12 ? 'pm' : 'am'; + return '$h:$m $ampm'; + } + + /// Reset all volatile chat-related state. Call this on user logout to ensure + /// no data from a previous session is visible after re-authentication. + void reset() { + _groupMessages.clear(); + _groupAttachmentVisibility.clear(); + _joinedGroups.clear(); + _groupCurrentPage.clear(); + _groupHasMore.clear(); + _loadingMoreGroups.clear(); + _currentUserId = null; + _currentUsername = null; + _connError = null; + // Disconnect socket so that next authenticated session re-establishes + // a new connection with the correct token / identity. + try { ChatService.instance.disconnect(); } catch (_) {} + notifyListeners(); + } + + @override + void dispose() { + _sub?.cancel(); + ChatService.instance.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/provider/chatlists_provider.dart b/leaderboard_app/lib/provider/chatlists_provider.dart new file mode 100644 index 0000000..1da474c --- /dev/null +++ b/leaderboard_app/lib/provider/chatlists_provider.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; + +class ChatListProvider extends ChangeNotifier { + /// List of group chats + List> _chatGroups = []; + bool _isLoading = false; + String? _error; + + // Creation state + bool _isCreating = false; + String? _createError; + + List> get chatGroups => _chatGroups; + bool get isLoading => _isLoading; + String? get error => _error; + bool get isCreating => _isCreating; + String? get createError => _createError; + + /// Load dummy group chats + void loadDummyGroups() { + _chatGroups = List.generate( + 5, + (index) => { + "groupId": "group_$index", + "name": "Group ${index + 1}", + "lastMessage": "This is the latest message in Group ${index + 1}", + "time": "12:${30 + index} pm", + "members": List.generate( + 4, + (mIndex) => { + "uid": "uid_${index}_${mIndex}", + "name": "Member ${mIndex + 1}", + }, + ), + "unread": index % 2 == 0, // alternate unread status + }, + ); + notifyListeners(); + } + + /// Load groups from backend (public groups) and merge with user's joined groups (including private) + Future loadPublicGroups(GroupService service, {int page = 1, int limit = 10, String? search}) async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + final paged = await service.getAllGroups(page: page, limit: limit, search: search); + final myGroups = await service.getMyGroups(); + + // Map by id to merge (my groups take precedence for member list completeness / privacy visibility) + final Map> merged = {}; + + void addOrUpdate(group, {bool isMember = false}) { + merged[group.id] = { + 'groupId': group.id, + 'name': group.name, + 'lastMessage': '', + 'time': '', + 'isPrivate': group.isPrivate, + 'members': group.members.map((m) => { + 'uid': m.userId, + 'name': m.user?.username ?? m.userId, + }).toList(), + 'unread': false, + 'favourite': false, + 'isMember': isMember, + }; + } + + for (final g in paged.groups) { + addOrUpdate(g, isMember: false); // unknown membership until merged with myGroups + } + for (final g in myGroups) { + addOrUpdate(g, isMember: true); // mark membership + } + + _chatGroups = merged.values.toList(); + } catch (e) { + _error = 'Failed to load groups'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Create a new group and add to list (optimistically inserts at top) + Future?> createNewGroup(GroupService service, {required String name, String? description, bool isPrivate = false, int? maxMembers}) async { + if (_isCreating) return null; // prevent duplicate taps + _isCreating = true; + _createError = null; + notifyListeners(); + try { + final group = await service.createGroup(name: name, description: description, isPrivate: isPrivate, maxMembers: maxMembers); + final map = { + 'groupId': group.id, + 'name': group.name, + 'lastMessage': '', + 'time': '', + 'isPrivate': group.isPrivate, + 'members': group.members.map((m) => { + 'uid': m.userId, + 'name': m.user?.username ?? m.userId, + }).toList(), + 'unread': false, + 'favourite': false, + }; + _chatGroups = [map, ..._chatGroups]; + return map; + } catch (e) { + _createError = 'Failed to create group'; + return null; + } finally { + _isCreating = false; + notifyListeners(); + } + } + + /// Mark a group as read + void markGroupAsRead(String groupId) { + final index = _chatGroups.indexWhere((group) => group["groupId"] == groupId); + if (index != -1) { + _chatGroups[index]["unread"] = false; + notifyListeners(); + } + } + + /// Update last message for a group + void updateLastMessage(String groupId, String message, String time) { + final index = _chatGroups.indexWhere((group) => group["groupId"] == groupId); + if (index != -1) { + _chatGroups[index]["lastMessage"] = message; + _chatGroups[index]["time"] = time; + notifyListeners(); + } + } + + /// Remove a group from the list (e.g., after deletion) + void removeGroup(String groupId) { + final beforeLen = _chatGroups.length; + _chatGroups.removeWhere((g) => g['groupId'] == groupId); + if (beforeLen != _chatGroups.length) { + notifyListeners(); + } + } + + /// Update group metadata (e.g., after editing name / privacy) + void updateGroupMeta({required String groupId, String? name, bool? isPrivate}) { + final index = _chatGroups.indexWhere((g) => g['groupId'] == groupId); + if (index == -1) return; + bool changed = false; + if (name != null && name.isNotEmpty && _chatGroups[index]['name'] != name) { + _chatGroups[index]['name'] = name; + changed = true; + } + if (isPrivate != null && _chatGroups[index]['isPrivate'] != isPrivate) { + _chatGroups[index]['isPrivate'] = isPrivate; + changed = true; + } + if (changed) notifyListeners(); + } + + /// Clear all loaded groups and transient loading / error flags. + void reset() { + _chatGroups = []; + _isLoading = false; + _error = null; + _isCreating = false; + _createError = null; + notifyListeners(); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/provider/connectivity_provider.dart b/leaderboard_app/lib/provider/connectivity_provider.dart new file mode 100644 index 0000000..6c99dcc --- /dev/null +++ b/leaderboard_app/lib/provider/connectivity_provider.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; + +class ConnectivityProvider extends ChangeNotifier { + final Connectivity _connectivity = Connectivity(); + late final StreamSubscription> _sub; + bool _isOnline = true; // assume online until checked + bool get isOnline => _isOnline; + + ConnectivityProvider() { + _init(); + } + + Future _init() async { + await _checkInitial(); + _sub = _connectivity.onConnectivityChanged.listen(_handleChange); + } + + Future _checkInitial() async { + try { + final results = await _connectivity.checkConnectivity(); + _updateStatus(results); + } catch (_) { + // ignore + } + } + + void _handleChange(List results) { + _updateStatus(results); + } + + void _updateStatus(List results) { + final online = results.any((r) => r != ConnectivityResult.none); + if (online != _isOnline) { + _isOnline = online; + notifyListeners(); + } + } + + @override + void dispose() { + try { + _sub.cancel(); + } catch (_) {} + super.dispose(); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/provider/dashboard_provider.dart b/leaderboard_app/lib/provider/dashboard_provider.dart new file mode 100644 index 0000000..28f2750 --- /dev/null +++ b/leaderboard_app/lib/provider/dashboard_provider.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; +import 'package:leaderboard_app/services/dashboard/dashboard_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; + +class DashboardProvider extends ChangeNotifier { + final DashboardService service; + final UserProvider userProvider; + final UserService? userService; // optional for profile refresh + + DashboardProvider({required this.service, required this.userProvider, this.userService}) { + // Listen for changes to user verification so we can (re)load submissions + // when the user becomes verified after the initial dashboard load. + userProvider.addListener(_handleUserChange); + } + + bool loadingDaily = false; + bool loadingSubs = false; + bool loadingLeaders = false; + + String? errorDaily; + String? errorSubs; + String? errorLeaders; + + DailyQuestion? daily; + List submissions = const []; + List leaderboard = const []; + + bool get isVerified => userProvider.user?.leetcodeVerified == true; + + Future loadAll() async { + await Future.wait([ + loadDaily(), + loadSubmissions(), + loadLeaderboard(), + ]); + } + + Future loadDaily() async { + loadingDaily = true; + errorDaily = null; + notifyListeners(); + try { + daily = await service.getDailyQuestion(); + } catch (_) { + errorDaily = 'Failed to load today\'s question'; + } finally { + loadingDaily = false; + notifyListeners(); + } + } + + Future loadSubmissions() async { + loadingSubs = true; + errorSubs = null; + notifyListeners(); + try { + if (!isVerified) { + submissions = const []; + } else { + submissions = await service.getUserSubmissions(); + // After fetching submissions, refresh user profile to update streak if service available. + if (userService != null) { + try { + await userProvider.fetchProfile(userService!); + } catch (_) { + // ignore profile refresh errors + } + } + } + } catch (_) { + errorSubs = 'Failed to load recent submissions'; + } finally { + loadingSubs = false; + notifyListeners(); + } + } + + Future loadLeaderboard() async { + loadingLeaders = true; + errorLeaders = null; + notifyListeners(); + try { + leaderboard = await service.getTopUsers(); + } catch (_) { + errorLeaders = 'Failed to load leaderboard'; + } finally { + loadingLeaders = false; + notifyListeners(); + } + } + + void _handleUserChange() { + // If user became verified and we have not loaded submissions yet, load them. + if (isVerified) { + if (submissions.isEmpty && !loadingSubs) { + // Fire and forget; internal method handles notify + errors. + loadSubmissions(); + } + } else { + // User no longer verified (or logged out): clear submissions. + if (submissions.isNotEmpty) { + submissions = const []; + notifyListeners(); + } + } + } + + @override + void dispose() { + userProvider.removeListener(_handleUserChange); + super.dispose(); + } +} diff --git a/leaderboard_app/lib/provider/group_membership_provider.dart b/leaderboard_app/lib/provider/group_membership_provider.dart new file mode 100644 index 0000000..139621d --- /dev/null +++ b/leaderboard_app/lib/provider/group_membership_provider.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; +import 'package:leaderboard_app/models/group_models.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; +import 'package:leaderboard_app/provider/user_provider.dart'; + +enum GroupMembershipStatus { loading, member, notMember, error } + +class GroupMembershipProvider extends ChangeNotifier { + final GroupService service; + final UserProvider userProvider; + + GroupMembershipProvider({required this.service, required this.userProvider}); + + GroupMembershipStatus _status = GroupMembershipStatus.loading; + GroupMembershipStatus get status => _status; + + Group? _group; + Group? get group => _group; + + String? _error; + String? get error => _error; + + Future check(String groupId) async { + _status = GroupMembershipStatus.loading; + _error = null; + notifyListeners(); + try { + final g = await service.getGroupById(groupId); + _group = g; + final uid = userProvider.user?.id; + if (uid != null && g.members.any((m) => m.userId == uid)) { + _status = GroupMembershipStatus.member; + } else { + _status = GroupMembershipStatus.notMember; + } + } catch (e) { + _error = 'Failed to load group'; + _status = GroupMembershipStatus.error; + } finally { + notifyListeners(); + } + } + + void markJoined() { + if (_status != GroupMembershipStatus.member) { + _status = GroupMembershipStatus.member; + notifyListeners(); + } + } +} diff --git a/leaderboard_app/lib/provider/group_provider.dart b/leaderboard_app/lib/provider/group_provider.dart new file mode 100644 index 0000000..e24d333 --- /dev/null +++ b/leaderboard_app/lib/provider/group_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/group_models.dart'; +import 'package:leaderboard_app/services/groups/group_service.dart'; + +class GroupProvider extends ChangeNotifier { + final GroupService service; + GroupProvider(this.service); + + bool _loading = false; + String? _error; + List _myGroups = const []; + + bool get isLoading => _loading; + String? get error => _error; + List get myGroups => _myGroups; + + Future loadMyGroups() async { + _loading = true; + _error = null; + notifyListeners(); + try { + _myGroups = await service.getMyGroups(); + } catch (e) { + _error = 'Failed to load my groups'; + } finally { + _loading = false; + notifyListeners(); + } + } + + Future createGroup({required String name, String? description, bool isPrivate = false, int? maxMembers}) async { + try { + final g = await service.createGroup(name: name, description: description, isPrivate: isPrivate, maxMembers: maxMembers); + _myGroups = [..._myGroups, g]; + notifyListeners(); + return g; + } catch (e) { + _error = 'Failed to create group'; + notifyListeners(); + return null; + } + } +} diff --git a/leaderboard_app/lib/provider/theme_provider.dart b/leaderboard_app/lib/provider/theme_provider.dart new file mode 100644 index 0000000..125da80 --- /dev/null +++ b/leaderboard_app/lib/provider/theme_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class ThemeProvider extends ChangeNotifier { + // Default dark theme configuration + final ThemeData _themeData = ThemeData( + colorScheme: const ColorScheme.dark( + surface: Colors.black, // background containers + primary: Colors.grey, // text & icons + secondary:Color(0xFFF6C156), // buttons, highlights + tertiary: Colors.grey, // progress bar track, muted UI + inversePrimary: Color(0xFFF6C156), // badge/gold accent + ), + fontFamily: 'PixelifySans', + ); + + ThemeData get themeData => _themeData; +} \ No newline at end of file diff --git a/leaderboard_app/lib/provider/user_provider.dart b/leaderboard_app/lib/provider/user_provider.dart new file mode 100644 index 0000000..d85f655 --- /dev/null +++ b/leaderboard_app/lib/provider/user_provider.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:leaderboard_app/models/auth_models.dart'; +import 'package:leaderboard_app/services/user/user_service.dart'; + +class UserProvider extends ChangeNotifier { + User? _user; + bool _loading = false; + String? _error; + + String get name => _user?.username ?? 'First Last'; + String get email => _user?.email ?? 'username@email.com'; + int get streak => _user?.streak ?? 0; + User? get user => _user; + bool get isLoading => _loading; + String? get error => _error; + + void setLeetCodeStatus({String? handle, bool? verified}) { + if (_user == null) return; + _user = User( + id: _user!.id, + username: _user!.username, + email: _user!.email, + leetcodeHandle: handle ?? _user!.leetcodeHandle, + leetcodeVerified: verified ?? _user!.leetcodeVerified, + streak: _user!.streak, + ); + notifyListeners(); + } + + void updateUser({required String name, required String email, required int streak}) { + _user = User( + id: _user?.id ?? '', + username: name, + email: email, + leetcodeHandle: _user?.leetcodeHandle, + leetcodeVerified: _user?.leetcodeVerified ?? false, + streak: streak, + ); + notifyListeners(); + } + + Future fetchProfile(UserService service) async { + _loading = true; + _error = null; + notifyListeners(); + try { + _user = await service.getProfile(); + } catch (e) { + _error = 'Failed to load profile'; + } finally { + _loading = false; + notifyListeners(); + } + } + + Future updateStreakRemote(UserService service, int newStreak) async { + try { + await service.updateStreak(newStreak); + if (_user != null) { + _user = User( + id: _user!.id, + username: _user!.username, + email: _user!.email, + leetcodeHandle: _user!.leetcodeHandle, + leetcodeVerified: _user!.leetcodeVerified, + streak: newStreak, + ); + notifyListeners(); + } + } catch (e) { + // swallow error for now + } + } +} diff --git a/leaderboard_app/lib/router/app_router.dart b/leaderboard_app/lib/router/app_router.dart new file mode 100644 index 0000000..f40b10a --- /dev/null +++ b/leaderboard_app/lib/router/app_router.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:leaderboard_app/pages/home_page.dart'; +import 'package:leaderboard_app/pages/signin_page.dart'; +import 'package:leaderboard_app/pages/signup_page.dart'; +import 'package:leaderboard_app/pages/leetcode_verification_page.dart'; +import 'package:leaderboard_app/pages/chat_gate.dart'; + +Future _isLoggedIn() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('authToken'); + return token != null && token.isNotEmpty; +} + +GoRouter createRouter() { + return GoRouter( + initialLocation: '/signin', + refreshListenable: _RouterRefresh(), + redirect: (context, state) async { + final loggedIn = await _isLoggedIn(); + final atAuth = state.matchedLocation == '/signin' || state.matchedLocation == '/signup'; + if (!loggedIn && !atAuth) return '/signin'; + if (loggedIn && atAuth) return '/'; + return null; + }, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/signin', + builder: (context, state) => const SignInPage(), + ), + GoRoute( + path: '/signup', + builder: (context, state) => const SignUpPage(), + ), + GoRoute( + path: '/verify', + builder: (context, state) => const LeetCodeVerificationPage(), + ), + GoRoute( + path: '/chat/:groupId', + builder: (context, state) { + final groupId = state.pathParameters['groupId'] ?? ''; + final name = state.uri.queryParameters['name']; + return ChatGate(groupId: groupId, groupName: name); + }, + ), + ], + ); +} + +// A simple ChangeNotifier to trigger router refresh on auth changes +class _RouterRefresh extends ChangeNotifier { + _RouterRefresh() { + // no-op. In a real app you could listen to auth provider. + } +} diff --git a/leaderboard_app/lib/services/auth/auth_service.dart b/leaderboard_app/lib/services/auth/auth_service.dart new file mode 100644 index 0000000..21c93bb --- /dev/null +++ b/leaderboard_app/lib/services/auth/auth_service.dart @@ -0,0 +1,87 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/models/auth_models.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:leaderboard_app/services/core/dio_provider.dart'; +import 'package:leaderboard_app/services/chat/chat_service.dart'; +import 'package:leaderboard_app/services/core/token_manager.dart'; + +class AuthService { + final Dio _dio; + AuthService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return AuthService(client.dio); + } + + Future signUp({required String username, required String email, required String password}) async { + final res = await _dio.post('/auth/signup', data: { + 'username': username, + 'email': email, + 'password': password, + }); + final response = AuthResponse.fromJson(res.data as Map); + if (response.token.isEmpty) { + // Some backends do not return JWT on signup; try to login immediately. + final login = await signIn(email: email, password: password); + return login; + } else { + await _saveAuth(response.token, refreshToken: response.refreshToken); + // Initialize a fresh socket connection with the new token. + try { await ChatService.instance.connectWithToken(response.token); } catch (_) {} + return response; + } + } + + Future signIn({required String email, required String password}) async { + final res = await _dio.post('/auth/login', data: { + 'email': email, + 'password': password, + }); + final response = AuthResponse.fromJson(res.data as Map); + if (response.token.isEmpty) { + throw DioException(requestOptions: res.requestOptions, response: res, message: 'Token missing in response'); + } + await _saveAuth(response.token, refreshToken: response.refreshToken); + // After storing token, connect socket with new identity. + try { await ChatService.instance.connectWithToken(response.token); } catch (_) {} + return response; + } + + Future logout() async { + final prefs = await SharedPreferences.getInstance(); + // Clear all persisted user-specific data on logout to avoid leaking + // authentication state or cached profile details between accounts. + // If in the future some keys should persist across logins (e.g. theme), + // fetch their values first and re-set them after clear(). + await prefs.clear(); + await TokenManager.clearTokens(); + DioProvider.reset(); + // Proactively disconnect socket (in case ChatProvider not yet instantiated to reset it) + try { ChatService.instance.disconnect(); } catch (_) {} + // Also reset any cached singletons that embed auth headers (e.g. Dio). + try { + // ignore: avoid_dynamic_calls + // Access the private singleton via reflection isn't possible; expose a static reset instead if needed. + } catch (_) {} + } + + Future> getProfile() async { + final res = await _dio.get('/user/profile'); + return (res.data as Map)['data'] as Map; + } + + // New: typed user profile fetcher. + Future getUserProfile() async { + final res = await _dio.get('/user/profile'); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final userJson = (data['user'] ?? data) as Map; + return User.fromJson(userJson); + } + + Future _saveAuth(String token, {String? refreshToken}) async { + await TokenManager.saveTokens(accessToken: token, refreshToken: refreshToken); + } +} diff --git a/leaderboard_app/lib/services/auth/auth_state.dart b/leaderboard_app/lib/services/auth/auth_state.dart new file mode 100644 index 0000000..08c7cea --- /dev/null +++ b/leaderboard_app/lib/services/auth/auth_state.dart @@ -0,0 +1,10 @@ +enum AuthStatus { loggedIn, loggedOut } + +class AuthState { + final AuthStatus status; + + AuthState({required this.status}); + + bool get isLoggedIn => status == AuthStatus.loggedIn; + bool get isLoggedOut => status == AuthStatus.loggedOut; +} \ No newline at end of file diff --git a/leaderboard_app/lib/services/chat/chat_service.dart b/leaderboard_app/lib/services/chat/chat_service.dart new file mode 100644 index 0000000..d509574 --- /dev/null +++ b/leaderboard_app/lib/services/chat/chat_service.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'package:socket_io_client/socket_io_client.dart' as io; +import 'package:leaderboard_app/config/api_config.dart'; +import 'package:leaderboard_app/services/core/dio_provider.dart'; +import 'package:leaderboard_app/models/chat_message.dart'; +import 'package:leaderboard_app/models/chat_message_dto.dart'; + +typedef MessageHandler = void Function(ChatMessage message); + +class ChatService { + ChatService._(); + static final ChatService instance = ChatService._(); + + io.Socket? _socket; + bool get isConnected => _socket?.connected == true; + bool _connecting = false; + bool get isConnecting => _connecting; + String? lastError; + + final _messageController = StreamController.broadcast(); + Stream get messagesStream => _messageController.stream; + + /// Establish the socket connection (idempotent). Provide [authToken] for + /// backend auth if required. + Future ensureConnected({String? authToken}) async { + if (isConnected || _connecting) return; + _connecting = true; + lastError = null; + final base = ApiConfig.baseUrl; // e.g. http://host:port/api + final wsBase = base.replaceFirst('/api', ''); + try { + final socket = io.io(wsBase, { + 'transports': ['websocket'], + 'autoConnect': false, + // Backend guide: JWT via auth.token + if (authToken != null) 'auth': {'token': authToken}, + }); + final completer = Completer(); + socket.on('connect', (_) { + _attachCommonListeners(socket); + completer.complete(); + }); + socket.on('connect_error', (err) { + lastError = err.toString(); + if (!completer.isCompleted) completer.completeError(err); + }); + socket.connect(); + _socket = socket; + await completer.future.timeout(const Duration(seconds: 8)); + } catch (e) { + lastError = e.toString(); + rethrow; + } finally { + _connecting = false; + } + } + + Map _normalizeSocketPayload(Map raw) { + // Backend receive_message: { id, groupId, message, sender, timestamp } + if (raw.containsKey('content') && !raw.containsKey('message')) { + raw['message'] = raw['content']; + } + if (raw.containsKey('createdAt') && !raw.containsKey('timestamp')) { + raw['timestamp'] = raw['createdAt']; + } + // Ensure sender structure + if (raw['sender'] is! Map) { + final id = raw['senderId'] ?? raw['senderID']; + raw['sender'] = { + 'id': id, + 'username': raw['senderName'] ?? 'User', + }; + } + return raw; + } + + /// Ask server to subscribe to a group's room for realtime events. + void joinGroup(String groupId) { + if (!isConnected) return; + // Guide: join_group expects groupId as raw string, not object + _socket?.emit('join_group', groupId); + } + + /// Emit a message to server (server should broadcast back with `message:new`). + Future sendMessage(String groupId, String text, {Map? sender}) async { + if (text.trim().isEmpty) { + // ignore: avoid_print + print('[SOCKET][SEND] Abort: empty text'); + return false; + } + if (!isConnected) { + // ignore: avoid_print + print('[SOCKET][SEND] Not connected. Attempting lazy connect before send...'); + try { + // We cannot fetch token here directly; higher layer ensures ensureConnected. + // If still disconnected after this, fail. + } catch (e) { + // ignore: avoid_print + print('[SOCKET][SEND] Lazy connect exception: $e'); + } + if (!isConnected) { + // ignore: avoid_print + print('[SOCKET][SEND] Fail: socket still not connected'); + return false; + } + } + final payload = { + 'groupId': groupId, + 'message': text.trim(), + if (sender != null) 'sender': sender, + }; + try { + // Backend does not specify ack; emit fire-and-forget. + // ignore: avoid_print + print('[SOCKET][SEND] Emitting send_message payloadKeys=${payload.keys}'); + _socket?.emit('send_message', payload); + return true; + } catch (err) { + // ignore: avoid_print + print('[SOCKET][SEND] Exception while emitting: $err'); + return false; + } + } + + /// Retrieve paginated message history using the documented REST endpoint. + Future> fetchHistory(String groupId, {int page = 1, int limit = 50}) async { + final dio = await DioProvider.getInstance(); + final res = await dio.get('/messages/groups/$groupId', queryParameters: { + 'page': page, + 'limit': limit, + }); + final data = res.data; + final list = (data['data']?['messages'] ?? []) as List; + return list + .whereType>() + .map(ChatMessageDto.fromJson) + .map((dto) => dto.toDomain()) + .toList(); + } + + void dispose() { + _messageController.close(); + _socket?.dispose(); + } + + /// Explicitly disconnect socket (without closing stream) for logout so that + /// a subsequent login can establish a fresh authenticated connection. + void disconnect() { + try { + _socket?.disconnect(); + _socket?.destroy(); + } catch (_) {} + _socket = null; + } + + /// Force a brand-new socket connection using the supplied JWT. This should + /// be called immediately after a successful login to ensure the socket + /// authenticates as the new user and does not reuse prior connection state. + Future connectWithToken(String jwt, {List rejoinGroupIds = const []}) async { + // Tear down any existing connection fully. + disconnect(); + lastError = null; + _connecting = true; + final base = ApiConfig.baseUrl; + final wsBase = base.replaceFirst('/api', ''); + try { + final socket = io.io(wsBase, { + 'transports': ['websocket'], + 'autoConnect': false, + 'forceNew': true, // ensure a new engine.io session + 'auth': {'token': jwt}, + }); + final completer = Completer(); + socket.on('connect', (_) { + // ignore: avoid_print + print('[SOCKET] Connected (forceNew)'); + _attachCommonListeners(socket); + // Rejoin prior groups if provided + for (final gid in rejoinGroupIds) { + socket.emit('join_group', gid); + } + completer.complete(); + }); + socket.on('connect_error', (err) { + lastError = err.toString(); + if (!completer.isCompleted) completer.completeError(err); + }); + _socket = socket; + socket.connect(); + await completer.future.timeout(const Duration(seconds: 8)); + } catch (e) { + lastError = e.toString(); + rethrow; + } finally { + _connecting = false; + } + } + + /// Attach listeners common to both normal and forceNew connections. + void _attachCommonListeners(io.Socket socket) { + // Generic event tracer. + socket.onAny((event, data) { + // ignore: avoid_print + print('[SOCKET][EVENT] $event'); + }); + // Detailed receive logging. + socket.on('receive_message', (data) { + // ignore: avoid_print + print('[SOCKET][RECEIVE] raw=${data.runtimeType} -> $data'); + if (data is Map) { + try { + final msg = ChatMessage.fromSocket(_normalizeSocketPayload(Map.from(data))); + _messageController.add(msg); + } catch (e) { + // ignore: avoid_print + print('[SOCKET][RECEIVE] parse error: $e'); + } + } else { + // ignore: avoid_print + print('[SOCKET][RECEIVE] unexpected payload type; ignoring'); + } + }); + socket.on('joined_group', (d) { + // ignore: avoid_print + print('[SOCKET] joined_group: $d'); + }); + socket.on('error', (e) { + // ignore: avoid_print + print('[SOCKET] server_error: $e'); + }); + } +} \ No newline at end of file diff --git a/leaderboard_app/lib/services/core/api_client.dart b/leaderboard_app/lib/services/core/api_client.dart new file mode 100644 index 0000000..80e7f6a --- /dev/null +++ b/leaderboard_app/lib/services/core/api_client.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/config/api_config.dart'; +import 'package:leaderboard_app/services/core/dio_provider.dart'; + +class ApiClient { + static final String kBaseUrl = ApiConfig.baseUrl; + final Dio dio; + + ApiClient._internal(this.dio); + + static Future create({String? baseUrl}) async { + final dio = await DioProvider.getInstance(baseUrl: baseUrl ?? kBaseUrl); + return ApiClient._internal(dio); + } +} diff --git a/leaderboard_app/lib/services/core/dio_provider.dart b/leaderboard_app/lib/services/core/dio_provider.dart new file mode 100644 index 0000000..ffca666 --- /dev/null +++ b/leaderboard_app/lib/services/core/dio_provider.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; // narrow imports +import 'package:leaderboard_app/config/api_config.dart'; +import 'package:leaderboard_app/services/core/token_manager.dart'; + +/// Provides a configured singleton Dio instance with auth header + logging. +class DioProvider { + static Dio? _dio; + static Future? _refreshingFuture; + + static Future getInstance({String? baseUrl}) async { + if (_dio != null) return _dio!; + // Note: Token values are retrieved on-demand via TokenManager. + final dio = Dio( + BaseOptions( + baseUrl: baseUrl ?? ApiConfig.baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 60), + headers: {'Content-Type': 'application/json'}, + ), + ); + + dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async { + // Skip auth header for explicit opt-out + if (options.extra['skipAuth'] == true) { + handler.next(options); + return; + } + final token = await TokenManager.getAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, onError: (e, handler) async { + // Simple retry for idempotent GETs on network issues + if (_shouldRetry(e)) { + _retry(dio, e.requestOptions).then(handler.resolve).catchError((_) => handler.next(e)); + return; + } + + // Refresh on 401 Unauthorized, excluding auth endpoints to avoid loops + final status = e.response?.statusCode; + final path = e.requestOptions.path; + final isAuthPath = path.contains('/auth/login') || path.contains('/auth/signup') || path.contains('/auth/refresh'); + final alreadyRetried = e.requestOptions.extra['retried'] == true; + + if (status == 401 && !isAuthPath && !alreadyRetried) { + try { + final newToken = await _refreshTokenIfNeeded(dio); + if (newToken != null && newToken.isNotEmpty) { + // Clone and retry original request with new token + final opts = Options( + method: e.requestOptions.method, + headers: { + ...e.requestOptions.headers, + 'Authorization': 'Bearer $newToken', + }, + responseType: e.requestOptions.responseType, + contentType: e.requestOptions.contentType, + sendTimeout: e.requestOptions.sendTimeout, + receiveTimeout: e.requestOptions.receiveTimeout, + extra: { + ...e.requestOptions.extra, + 'retried': true, + }, + ); + final rerun = await dio.request( + e.requestOptions.path, + data: e.requestOptions.data, + queryParameters: e.requestOptions.queryParameters, + options: opts, + ); + handler.resolve(rerun); + return; + } + } catch (_) { + // fall through to next + } + } + + handler.next(e); + })); + + // Basic log interceptor (custom to avoid extra dependency) + dio.interceptors.add(_LogInterceptor()); + + _dio = dio; + return dio; + } + + static bool _shouldRetry(DioException e) { + final isGet = e.requestOptions.method == 'GET'; + final isNet = e.type == DioExceptionType.connectionError || e.type == DioExceptionType.receiveTimeout; + if (!(isGet && isNet)) return false; + final attempts = (e.requestOptions.extra['retryCount'] as int?) ?? 0; + return attempts < 1; // retry at most once + } + + static Future> _retry(Dio dio, RequestOptions requestOptions) async { + final attempts = (requestOptions.extra['retryCount'] as int?) ?? 0; + // brief backoff before retrying + await Future.delayed(Duration(milliseconds: 500 * (attempts + 1))); + final opts = Options( + method: requestOptions.method, + headers: requestOptions.headers, + responseType: requestOptions.responseType, + contentType: requestOptions.contentType, + sendTimeout: requestOptions.sendTimeout, + receiveTimeout: requestOptions.receiveTimeout, + extra: { + ...requestOptions.extra, + 'retryCount': attempts + 1, + }, + ); + return dio.request( + requestOptions.path, + data: requestOptions.data, + queryParameters: requestOptions.queryParameters, + options: opts, + ); + } + + /// Reset the cached Dio so a subsequent call to [getInstance] creates a + /// fresh client (used on logout to ensure new auth token picked up). + static void reset() { + _dio = null; + } + + /// Ensure only one refresh happens at a time. Returns new access token or null. + static Future _refreshTokenIfNeeded(Dio baseDio) async { + // If a refresh is already ongoing, await the same future + final ongoing = _refreshingFuture; + if (ongoing != null) { + return ongoing; + } + + final completer = Completer(); + _refreshingFuture = completer.future; + + try { + final newToken = await _callRefreshEndpoint(baseDio); + completer.complete(newToken); + return newToken; + } catch (err) { + completer.complete(null); + return null; + } finally { + _refreshingFuture = null; + } + } + + /// Call the refresh endpoint using a bare Dio to avoid interceptor loops. + static Future _callRefreshEndpoint(Dio baseDio) async { + final refreshToken = await TokenManager.getRefreshToken(); + final refreshDio = Dio( + BaseOptions( + baseUrl: baseDio.options.baseUrl, + connectTimeout: baseDio.options.connectTimeout, + receiveTimeout: baseDio.options.receiveTimeout, + headers: {'Content-Type': 'application/json'}, + ), + ); + + Response res; + try { + if (refreshToken != null && refreshToken.isNotEmpty) { + res = await refreshDio.post('/auth/refresh', data: {'refreshToken': refreshToken}); + } else { + // Some backends use HttpOnly cookie refresh; attempt without body + res = await refreshDio.post('/auth/refresh'); + } + } on DioException { + // Refresh failed + await TokenManager.clearTokens(); + return null; + } + + final data = res.data is Map + ? res.data as Map + : {}; + final payload = (data['data'] ?? data) as Map; + final newAccess = (payload['token'] ?? payload['accessToken'] ?? '') as String; + final newRefresh = (payload['refreshToken'] ?? payload['refresh_token'] ?? '') as String; + + if (newAccess.isEmpty) { + await TokenManager.clearTokens(); + return null; + } + + // Persist the new tokens + await TokenManager.saveTokens(accessToken: newAccess, refreshToken: newRefresh.isEmpty ? null : newRefresh); + return newAccess; + } +} + +class _LogInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (kIsWeb) { + // Avoid verbose logs in web release; adjust as needed. + } + debugPrint('[HTTP] => ${options.method} ${options.baseUrl}${options.path}'); + if (options.data != null) debugPrint(' Body: ${options.data}'); + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + debugPrint('[HTTP] <= ${response.statusCode} ${response.requestOptions.path}'); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + debugPrint('[HTTP] !! ${err.type} ${err.message} path=${err.requestOptions.path}'); + handler.next(err); + } +} + +void debugPrint(String message) { + // Lightweight wrapper to silence in release if desired. + if (kIsWeb || true) { + // ignore: avoid_print + print(message); + } +} diff --git a/leaderboard_app/lib/services/core/error_utils.dart b/leaderboard_app/lib/services/core/error_utils.dart new file mode 100644 index 0000000..7587969 --- /dev/null +++ b/leaderboard_app/lib/services/core/error_utils.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; + +class ErrorUtils { + static String fromDio(Object error) { + if (error is DioException) { + final code = error.response?.statusCode; + final data = error.response?.data; + if (data is Map) { + final msg = data['message'] ?? data['error'] ?? data['msg']; + if (msg is String && msg.isNotEmpty) { + return code != null ? '($code) $msg' : msg; + } + } else if (data is String && data.isNotEmpty) { + return code != null ? '($code) $data' : data; + } + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout) { + return 'Network timeout. Please try again.'; + } + if (error.type == DioExceptionType.connectionError) { + return 'Cannot reach server. Check BASE_URL and that the backend is running.'; + } + return code != null ? 'Request failed ($code)' : 'Request failed'; + } + return 'Unexpected error'; + } +} diff --git a/leaderboard_app/lib/services/core/health_service.dart b/leaderboard_app/lib/services/core/health_service.dart new file mode 100644 index 0000000..40174db --- /dev/null +++ b/leaderboard_app/lib/services/core/health_service.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; + +class HealthService { + final Dio _dio; + HealthService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return HealthService(client.dio); + } + + Future isHealthy() async { + try { + final res = await _dio.get('/health'); + return (res.statusCode ?? 200) >= 200 && (res.statusCode ?? 200) < 300; + } catch (_) { + return false; + } + } +} diff --git a/leaderboard_app/lib/services/core/rest_client.dart b/leaderboard_app/lib/services/core/rest_client.dart new file mode 100644 index 0000000..141cac1 --- /dev/null +++ b/leaderboard_app/lib/services/core/rest_client.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:leaderboard_app/models/verification_models.dart'; +import 'package:leaderboard_app/models/auth_models.dart'; + +part 'rest_client.g.dart'; + +@RestApi() +abstract class RestClient { + factory RestClient(Dio dio, {String baseUrl}) = _RestClient; + + // AUTH + @POST('/auth/signup') + Future signUp(@Body() Map body); + + @POST('/auth/login') + Future signIn(@Body() Map body); + + @GET('/user/profile') + Future> getProfile(); + + // VERIFICATION + @POST('/verification/start') + Future startVerification(@Body() Map body); + + @GET('/verification/status/{username}') + Future getVerificationStatus(@Path('username') String username); +} diff --git a/leaderboard_app/lib/services/core/rest_client.g.dart b/leaderboard_app/lib/services/core/rest_client.g.dart new file mode 100644 index 0000000..8c6c160 --- /dev/null +++ b/leaderboard_app/lib/services/core/rest_client.g.dart @@ -0,0 +1,184 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'rest_client.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter + +class _RestClient implements RestClient { + _RestClient(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future signUp(Map body) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(body); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/auth/signup', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late AuthResponse _value; + try { + _value = AuthResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future signIn(Map body) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(body); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/auth/login', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late AuthResponse _value; + try { + _value = AuthResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> getProfile() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/user/profile', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late Map _value; + try { + _value = _result.data!; + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future startVerification(Map body) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(body); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/verification/start', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late VerificationStart _value; + try { + _value = VerificationStart.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future getVerificationStatus(String username) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/verification/status/${username}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late VerificationStatus _value; + try { + _value = VerificationStatus.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/leaderboard_app/lib/services/core/token_manager.dart b/leaderboard_app/lib/services/core/token_manager.dart new file mode 100644 index 0000000..c122f8a --- /dev/null +++ b/leaderboard_app/lib/services/core/token_manager.dart @@ -0,0 +1,52 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Centralized helpers for reading/writing auth tokens. +/// Keys are kept backward compatible with existing code. +class TokenManager { + static const String _kAccessTokenKey = 'authToken'; + static const String _kRefreshTokenKey = 'refreshToken'; + + /// Returns the stored access token (JWT) or null if not set. + static Future getAccessToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(_kAccessTokenKey); + if (token == null || token.isEmpty) return null; + return token; + } + + /// Returns the stored refresh token or null if not present. + static Future getRefreshToken() async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(_kRefreshTokenKey); + if (token == null || token.isEmpty) return null; + return token; + } + + /// Persist both access and optional refresh tokens. + static Future saveTokens({required String accessToken, String? refreshToken}) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kAccessTokenKey, accessToken); + if (refreshToken != null && refreshToken.isNotEmpty) { + await prefs.setString(_kRefreshTokenKey, refreshToken); + } + } + + /// Update only the access token. + static Future saveAccessToken(String accessToken) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kAccessTokenKey, accessToken); + } + + /// Update only the refresh token. + static Future saveRefreshToken(String refreshToken) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kRefreshTokenKey, refreshToken); + } + + /// Remove tokens from storage. + static Future clearTokens() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kAccessTokenKey); + await prefs.remove(_kRefreshTokenKey); + } +} diff --git a/leaderboard_app/lib/services/dashboard/dashboard_service.dart b/leaderboard_app/lib/services/dashboard/dashboard_service.dart new file mode 100644 index 0000000..e0a0edc --- /dev/null +++ b/leaderboard_app/lib/services/dashboard/dashboard_service.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; +import 'package:leaderboard_app/models/submissions_models.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; + +class DashboardService { + final Dio _dio; + DashboardService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return DashboardService(client.dio); + } + + Future> getUserSubmissions() async { + final res = await _dio.get( + '/dashboard/submissions', + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final body = res.data as Map; + // Attempt structured parsing + try { + final parsed = SubmissionsResponse.fromJson(body); + return parsed.data.submissions.map((e) => e.toSubmissionItem()).toList(); + } catch (_) { + // Fallback to previous loose parsing strategy + final data = (body['data'] ?? body) as Map; + final raw = (data['submissions'] ?? data['items'] ?? data['results'] ?? data) as dynamic; + final list = raw is List + ? raw.cast>() + : (raw is Map && raw['submissions'] is List) + ? (raw['submissions'] as List).cast>() + : >[]; + return list.map(SubmissionItem.fromJson).toList(); + } + } + + Future getDailyQuestion() async { + final res = await _dio.get( + '/dashboard/daily', + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final dq = (data['dailyQuestion'] ?? data['daily'] ?? data['question'] ?? data) as dynamic; + if (dq is Map) return DailyQuestion.fromJson(dq); + return null; + } + + Future> getTopUsers() async { + final res = await _dio.get( + '/dashboard/leaderboard', + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final raw = (data['leaderboard'] ?? data['users'] ?? data['results'] ?? data) as dynamic; + final list = raw is List + ? raw.cast>() + : (raw is Map && raw['leaderboard'] is List) + ? (raw['leaderboard'] as List).cast>() + : >[]; + return list.map(TopUser.fromJson).toList(); + } + + // New: explicit getLeaderboard support. Tries /leaderboard first, falls back to /dashboard/leaderboard. + Future> getLeaderboard() async { + try { + final res = await _dio.get( + '/leaderboard', + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final raw = (data['leaderboard'] ?? data['users'] ?? []) as List; + return raw.cast>().map(TopUser.fromJson).toList(); + } on DioException catch (e) { + // Fallback to the existing dashboard endpoint for compatibility + if (e.response?.statusCode == 404 || e.type == DioExceptionType.unknown) { + return getTopUsers(); + } + rethrow; + } + } +} diff --git a/leaderboard_app/lib/services/groups/group_service.dart b/leaderboard_app/lib/services/groups/group_service.dart new file mode 100644 index 0000000..9a55d33 --- /dev/null +++ b/leaderboard_app/lib/services/groups/group_service.dart @@ -0,0 +1,116 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/models/group_models.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; + +class GroupService { + final Dio _dio; + GroupService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return GroupService(client.dio); + } + + // Public: Get all groups with pagination and optional search + Future getAllGroups({int page = 1, int limit = 10, String? search}) async { + final res = await _dio.get('/groups', queryParameters: { + 'page': page, + 'limit': limit, + if (search != null && search.isNotEmpty) 'search': search, + }); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return PagedGroups.fromJson(data); + } + + // Public: Get group by ID + Future getGroupById(String groupId) async { + final res = await _dio.get('/groups/$groupId'); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return Group.fromJson(data); + } + + // Public: Get group members + Future> getGroupMembers(String groupId) async { + final res = await _dio.get('/groups/$groupId/members'); + final body = res.data as Map; + final data = (body['data'] ?? body) as List; + return data.cast>().map(GroupMember.fromJson).toList(); + } + + // Protected: Create group + Future createGroup({required String name, String? description, bool isPrivate = false, int? maxMembers}) async { + final res = await _dio.post('/groups', data: { + 'name': name, + if (description != null) 'description': description, + 'isPrivate': isPrivate, + if (maxMembers != null) 'maxMembers': maxMembers, + }); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return Group.fromJson(data); + } + + // Protected: Get user's groups + Future> getMyGroups() async { + final res = await _dio.get('/groups/user/my-groups'); + final body = res.data as Map; + final data = (body['data'] ?? body); + if (data is List) { + return data.cast>().map(Group.fromJson).toList(); + } + if (data is Map && data['groups'] is List) { + return (data['groups'] as List).cast>().map(Group.fromJson).toList(); + } + return const []; + } + + // Protected: Update group + Future updateGroup(String groupId, {required String name, String? description, required bool isPrivate, int? maxMembers}) async { + final res = await _dio.put('/groups/$groupId', data: { + 'name': name, + if (description != null) 'description': description, + 'isPrivate': isPrivate, + if (maxMembers != null) 'maxMembers': maxMembers, + }); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return Group.fromJson(data); + } + + // Protected: Delete group + Future deleteGroup(String groupId) async { + await _dio.delete('/groups/$groupId'); + } + + // Membership: Join group + Future joinGroup(String groupId) async { + await _dio.post('/groups/$groupId/join'); + } + + // Membership: Leave group + Future leaveGroup(String groupId) async { + await _dio.delete('/groups/$groupId/leave'); + } + + // Management: Remove member + Future removeMember(String groupId, String userId) async { + await _dio.delete('/groups/$groupId/members/$userId'); + } + + // Management: Update member role + Future updateMemberRole(String groupId, String userId, String role) async { + await _dio.put('/groups/$groupId/members/$userId/role', data: { + 'role': role, + }); + } + + // Management: Transfer ownership + Future transferOwnership(String groupId, String newOwnerId) async { + await _dio.post('/groups/$groupId/transfer-ownership', data: { + 'newOwnerId': newOwnerId, + }); + } +} + diff --git a/leaderboard_app/lib/services/leetcode/leetcode_service.dart b/leaderboard_app/lib/services/leetcode/leetcode_service.dart new file mode 100644 index 0000000..845cef5 --- /dev/null +++ b/leaderboard_app/lib/services/leetcode/leetcode_service.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; + +class LeetCodeService { + final Dio _dio; + LeetCodeService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return LeetCodeService(client.dio); + } + + Future startVerification(String username) async { + final res = await _dio.post('/leetcode/connect', data: { + 'leetcodeUsername': username, + }); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return StartVerificationResponse.fromJson(data); + } + + Future getStatus() async { + final res = await _dio.get('/leetcode/status'); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + return LeetCodeVerificationStatus.fromJson(data); + } +} + +class LeetCodeVerificationStatus { + final bool isVerified; + final bool isInProgress; + final String? leetcodeHandle; + + LeetCodeVerificationStatus({ + required this.isVerified, + required this.isInProgress, + required this.leetcodeHandle, + }); + + factory LeetCodeVerificationStatus.fromJson(Map json) { + return LeetCodeVerificationStatus( + isVerified: json['isVerified'] == true, + isInProgress: json['isInProgress'] == true, + leetcodeHandle: json['leetcodeHandle'] as String?, + ); + } +} + +class StartVerificationResponse { + final String verificationCode; + final String leetcodeUsername; + final int? timeoutInSeconds; + final int? pollIntervalInSeconds; + final String? instructions; + + StartVerificationResponse({ + required this.verificationCode, + required this.leetcodeUsername, + this.timeoutInSeconds, + this.pollIntervalInSeconds, + this.instructions, + }); + + factory StartVerificationResponse.fromJson(Map json) { + return StartVerificationResponse( + verificationCode: (json['verificationCode'] ?? '') as String, + leetcodeUsername: (json['leetcodeUsername'] ?? '') as String, + timeoutInSeconds: (json['timeoutInSeconds'] as num?)?.toInt(), + pollIntervalInSeconds: (json['pollIntervalInSeconds'] as num?)?.toInt(), + instructions: json['instructions'] as String?, + ); + } +} diff --git a/leaderboard_app/lib/services/user/user_service.dart b/leaderboard_app/lib/services/user/user_service.dart new file mode 100644 index 0000000..7103f46 --- /dev/null +++ b/leaderboard_app/lib/services/user/user_service.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:leaderboard_app/models/auth_models.dart'; +import 'package:leaderboard_app/models/dashboard_models.dart'; +import 'package:leaderboard_app/services/core/api_client.dart'; + +class UserService { + final Dio _dio; + UserService(this._dio); + + static Future create() async { + final client = await ApiClient.create(); + return UserService(client.dio); + } + + Future getProfile() async { + final res = await _dio.get('/user/profile'); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final userJson = (data['user'] ?? data) as Map; + return User.fromJson(userJson); + } + + Future> getPublicLeaderboard() async { + final res = await _dio.get('/user/leaderboard'); + final body = res.data as Map; + final data = (body['data'] ?? body) as Map; + final list = (data['leaderboard'] ?? data['users'] ?? []) as List; + return list.cast>().map(TopUser.fromJson).toList(); + } + + Future updateStreak(int streak) async { + await _dio.patch('/user/streak', data: {'streak': streak}); + } +} diff --git a/leaderboard_app/lib/widgets/app_logo.dart b/leaderboard_app/lib/widgets/app_logo.dart new file mode 100644 index 0000000..e69de29 diff --git a/leaderboard_app/linux/.gitignore b/leaderboard_app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/leaderboard_app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/leaderboard_app/linux/CMakeLists.txt b/leaderboard_app/linux/CMakeLists.txt new file mode 100644 index 0000000..3e03366 --- /dev/null +++ b/leaderboard_app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "leaderboard_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.dscvit.leeterboard") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/leaderboard_app/linux/flutter/CMakeLists.txt b/leaderboard_app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/leaderboard_app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/leaderboard_app/linux/flutter/generated_plugin_registrant.cc b/leaderboard_app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..f6f23bf --- /dev/null +++ b/leaderboard_app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/leaderboard_app/linux/flutter/generated_plugin_registrant.h b/leaderboard_app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/leaderboard_app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/leaderboard_app/linux/flutter/generated_plugins.cmake b/leaderboard_app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f16b4c3 --- /dev/null +++ b/leaderboard_app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/leaderboard_app/linux/runner/CMakeLists.txt b/leaderboard_app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/leaderboard_app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/leaderboard_app/linux/runner/main.cc b/leaderboard_app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/leaderboard_app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/leaderboard_app/linux/runner/my_application.cc b/leaderboard_app/linux/runner/my_application.cc new file mode 100644 index 0000000..0129099 --- /dev/null +++ b/leaderboard_app/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "leaderboard_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "leaderboard_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/leaderboard_app/linux/runner/my_application.h b/leaderboard_app/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/leaderboard_app/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/leaderboard_app/macos/.gitignore b/leaderboard_app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/leaderboard_app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/leaderboard_app/macos/Flutter/Flutter-Debug.xcconfig b/leaderboard_app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/leaderboard_app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/leaderboard_app/macos/Flutter/Flutter-Release.xcconfig b/leaderboard_app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/leaderboard_app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/leaderboard_app/macos/Flutter/GeneratedPluginRegistrant.swift b/leaderboard_app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..bc90613 --- /dev/null +++ b/leaderboard_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import connectivity_plus +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/leaderboard_app/macos/Runner.xcodeproj/project.pbxproj b/leaderboard_app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1157963 --- /dev/null +++ b/leaderboard_app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* leaderboard_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "leaderboard_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* leaderboard_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* leaderboard_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/leaderboard_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/leaderboard_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/leaderboard_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/leaderboard_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/leaderboard_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/leaderboard_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/leaderboard_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/leaderboard_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/leaderboard_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/leaderboard_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/leaderboard_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3647d0 --- /dev/null +++ b/leaderboard_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/leaderboard_app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/leaderboard_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/leaderboard_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/leaderboard_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/leaderboard_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/leaderboard_app/macos/Runner/AppDelegate.swift b/leaderboard_app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/leaderboard_app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/leaderboard_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/leaderboard_app/macos/Runner/Base.lproj/MainMenu.xib b/leaderboard_app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/leaderboard_app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/leaderboard_app/macos/Runner/Configs/AppInfo.xcconfig b/leaderboard_app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..24cba56 --- /dev/null +++ b/leaderboard_app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = leaderboard_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.leaderboardApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/leaderboard_app/macos/Runner/Configs/Debug.xcconfig b/leaderboard_app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/leaderboard_app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/leaderboard_app/macos/Runner/Configs/Release.xcconfig b/leaderboard_app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/leaderboard_app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/leaderboard_app/macos/Runner/Configs/Warnings.xcconfig b/leaderboard_app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/leaderboard_app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/leaderboard_app/macos/Runner/DebugProfile.entitlements b/leaderboard_app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/leaderboard_app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/leaderboard_app/macos/Runner/Info.plist b/leaderboard_app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/leaderboard_app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/leaderboard_app/macos/Runner/MainFlutterWindow.swift b/leaderboard_app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/leaderboard_app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/leaderboard_app/macos/Runner/Release.entitlements b/leaderboard_app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/leaderboard_app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/leaderboard_app/macos/RunnerTests/RunnerTests.swift b/leaderboard_app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/leaderboard_app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/leaderboard_app/pubspec.lock b/leaderboard_app/pubspec.lock new file mode 100644 index 0000000..de23e1f --- /dev/null +++ b/leaderboard_app/pubspec.lock @@ -0,0 +1,1050 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + chat_bubbles: + dependency: "direct main" + description: + name: chat_bubbles + sha256: "902bc84e22f9ce6b993d015457067a37d37eb7478bd7b5b185712d5c5329b866" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + url: "https://pub.dev" + source: hosted + version: "2.4.6" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + highlight: + dependency: transitive + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + url: "https://pub.dev" + source: hosted + version: "11.0.1" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + pie_chart: + dependency: "direct main" + description: + name: pie_chart + sha256: "58e6a46999ac938bfa1c3e5be414d6e149f037647197dca03ba3614324c12c82" + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pixelarticons: + dependency: "direct main" + description: + name: pixelarticons + sha256: abe536bb9710cffc2a9a30d27666bbcd43742e391b6ba2f3de89313259d9f1c9 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + retrofit: + dependency: "direct main" + description: + name: retrofit + sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + retrofit_generator: + dependency: "direct dev" + description: + name: retrofit_generator + sha256: "9abcf21acb95bf7040546eafff87f60cf0aee20b05101d71f99876fc4df1f522" + url: "https://pub.dev" + source: hosted + version: "9.7.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + url: "https://pub.dev" + source: hosted + version: "2.4.12" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b + url: "https://pub.dev" + source: hosted + version: "2.0.3+1" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" + url: "https://pub.dev" + source: hosted + version: "6.3.18" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0-288.0.dev <4.0.0" + flutter: ">=3.29.0" diff --git a/leaderboard_app/pubspec.yaml b/leaderboard_app/pubspec.yaml new file mode 100644 index 0000000..cfe067b --- /dev/null +++ b/leaderboard_app/pubspec.yaml @@ -0,0 +1,69 @@ +name: leaderboard_app +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.9.0-288.0.dev + +dependencies: + flutter: + sdk: flutter + flutter_highlight: ^0.7.0 + pie_chart: ^5.4.0 + pixelarticons: ^0.4.0 + provider: ^6.1.5 + dio: ^5.6.0 + retrofit: ^4.4.1 + json_annotation: ^4.9.0 + go_router: ^14.2.7 + shared_preferences: ^2.3.2 + url_launcher: ^6.3.0 + socket_io_client: ^2.0.3 + connectivity_plus: ^6.0.5 + chat_bubbles: ^1.7.0 + flutter_svg: ^2.0.10+1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + build_runner: ^2.4.13 + retrofit_generator: ^9.1.5 + json_serializable: ^6.8.0 + flutter_native_splash: ^2.4.1 + flutter_launcher_icons: ^0.13.1 + +flutter: + uses-material-design: true + + assets: + - assets/icons/ + + fonts: + - family: AlumniSans + fonts: + - asset: fonts/AlumniSans-VariableFont_wght.ttf + - asset: fonts/AlumniSans-Italic-VariableFont_wght.ttf + +flutter_native_splash: + color: "#000000" # Dark backdrop + image: assets/icons/LL_Logo.png # PNG version of logo (exported from SVG) + android_12: + image: assets/icons/LL_Logo.png + color: "#000000" + icon_background_color: "#000000" + ios: true + web: false + android: true + fullscreen: true + # Optionally keep branding image off for now + +# App icon generation config +flutter_icons: + android: true + ios: true + image_path: assets/icons/LL_Logo.png + adaptive_icon_background: "#000000" + adaptive_icon_foreground: assets/icons/LL_Logo.png + min_sdk_android: 21 \ No newline at end of file diff --git a/leaderboard_app/tool/pad_logo.dart b/leaderboard_app/tool/pad_logo.dart new file mode 100644 index 0000000..e69de29 diff --git a/leaderboard_app/web/favicon.png b/leaderboard_app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/leaderboard_app/web/favicon.png differ diff --git a/leaderboard_app/web/icons/Icon-192.png b/leaderboard_app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/leaderboard_app/web/icons/Icon-192.png differ diff --git a/leaderboard_app/web/icons/Icon-512.png b/leaderboard_app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/leaderboard_app/web/icons/Icon-512.png differ diff --git a/leaderboard_app/web/icons/Icon-maskable-192.png b/leaderboard_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/leaderboard_app/web/icons/Icon-maskable-192.png differ diff --git a/leaderboard_app/web/icons/Icon-maskable-512.png b/leaderboard_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/leaderboard_app/web/icons/Icon-maskable-512.png differ diff --git a/leaderboard_app/web/index.html b/leaderboard_app/web/index.html new file mode 100644 index 0000000..921b04e --- /dev/null +++ b/leaderboard_app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + leaderboard_app + + + + + + diff --git a/leaderboard_app/web/manifest.json b/leaderboard_app/web/manifest.json new file mode 100644 index 0000000..112a7bd --- /dev/null +++ b/leaderboard_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "leaderboard_app", + "short_name": "leaderboard_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/leaderboard_app/windows/.gitignore b/leaderboard_app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/leaderboard_app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/leaderboard_app/windows/CMakeLists.txt b/leaderboard_app/windows/CMakeLists.txt new file mode 100644 index 0000000..f7b848d --- /dev/null +++ b/leaderboard_app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(leaderboard_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "leaderboard_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/leaderboard_app/windows/flutter/CMakeLists.txt b/leaderboard_app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/leaderboard_app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/leaderboard_app/windows/flutter/generated_plugin_registrant.cc b/leaderboard_app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..5777988 --- /dev/null +++ b/leaderboard_app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/leaderboard_app/windows/flutter/generated_plugin_registrant.h b/leaderboard_app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/leaderboard_app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/leaderboard_app/windows/flutter/generated_plugins.cmake b/leaderboard_app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..3103206 --- /dev/null +++ b/leaderboard_app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/leaderboard_app/windows/runner/CMakeLists.txt b/leaderboard_app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/leaderboard_app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/leaderboard_app/windows/runner/Runner.rc b/leaderboard_app/windows/runner/Runner.rc new file mode 100644 index 0000000..6f9a0b5 --- /dev/null +++ b/leaderboard_app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "leaderboard_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "leaderboard_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "leaderboard_app.exe" "\0" + VALUE "ProductName", "leaderboard_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/leaderboard_app/windows/runner/flutter_window.cpp b/leaderboard_app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/leaderboard_app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/leaderboard_app/windows/runner/flutter_window.h b/leaderboard_app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/leaderboard_app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/leaderboard_app/windows/runner/main.cpp b/leaderboard_app/windows/runner/main.cpp new file mode 100644 index 0000000..bef765f --- /dev/null +++ b/leaderboard_app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"leaderboard_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/leaderboard_app/windows/runner/resource.h b/leaderboard_app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/leaderboard_app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/leaderboard_app/windows/runner/resources/app_icon.ico b/leaderboard_app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/leaderboard_app/windows/runner/resources/app_icon.ico differ diff --git a/leaderboard_app/windows/runner/runner.exe.manifest b/leaderboard_app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/leaderboard_app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/leaderboard_app/windows/runner/utils.cpp b/leaderboard_app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/leaderboard_app/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/leaderboard_app/windows/runner/utils.h b/leaderboard_app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/leaderboard_app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/leaderboard_app/windows/runner/win32_window.cpp b/leaderboard_app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/leaderboard_app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/leaderboard_app/windows/runner/win32_window.h b/leaderboard_app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/leaderboard_app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_