commit c60ea2d37009d3ff441d2008a9b40123afbc18a7 Author: warlock7 Date: Fri Oct 16 01:25:50 2026 +0530 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76ae268 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# 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 + +# Widget Preview related +.widget_preview/ + +# Firebase client config is added locally. Do not commit project ids or keys. +/google-services.json +android/app/google-services.json +ios/Runner/GoogleService-Info.plist +**/firebase_options.dart +**/*service-account*.json +**/*serviceAccount*.json diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..235886b --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# 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: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: android + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: ios + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + + # 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/README.md b/README.md new file mode 100644 index 0000000..5d849b7 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Kick Kings support + +Staff inbox for Kick Kings customer support. Players do not use this app. + +## Run + +```bash +flutter pub get +flutter run --dart-define=BASE_URL=https://kickkings.playpoolstudios.com +``` + +`BASE_URL` defaults to `https://kickkings.playpoolstudios.com`. Do not include a trailing slash. + +Android application id: `com.kickkings.admin`. + +iOS bundle id: `com.playpoolstudios.kickkings.support`. + +## Push + +`google-services.json` lives in the project root and is linked into the Android app. It is gitignored. Place `GoogleService-Info.plist` at `ios/Runner/GoogleService-Info.plist` when the iOS Firebase app exists. + +The Android Google Services plugin is applied when that JSON file is present. The iOS build copies `GoogleService-Info.plist` into the app when that file is present. Login and the inbox still work before those files are added; device registration is skipped. + +Do not commit Firebase project ids, API keys, or a service account. + +Release signing should use a production push entitlement. `ios/Runner/Runner.entitlements` is set to `development` for local builds. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0da40e9 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..86cace7 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +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 +app/google-services.json diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..08d204b --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// google-services.json is added locally and is not committed. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + +android { + namespace = "com.playpoolstudios.kickkings_support" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.kickkings.admin" + // 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 + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + 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") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..35e17d2 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/playpoolstudios/kickkings_support/MainActivity.kt b/android/app/src/main/kotlin/com/playpoolstudios/kickkings_support/MainActivity.kt new file mode 100644 index 0000000..d75d9aa --- /dev/null +++ b/android/app/src/main/kotlin/com/playpoolstudios/kickkings_support/MainActivity.kt @@ -0,0 +1,5 @@ +package com.playpoolstudios.kickkings_support + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/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/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/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-9.3.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..46daa4f --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,27 @@ +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 "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false + id("com.google.gms.google-services") version "4.5.0" apply false +} + +include(":app") diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..85b43a3 --- /dev/null +++ b/instructions.md @@ -0,0 +1,196 @@ +# Flutter prompt: Kick Kings support staff app + +Paste everything below this line into the Flutter agent. Do not add Firebase project ids, API keys, or service-account JSON to the app repo. The human will place `google-services.json` (Android) and `GoogleService-Info.plist` (iOS) in the Flutter project after creating those apps in the Firebase console. + +--- + +Build a Flutter staff app for Kick Kings customer support. It is an inbox for people who already have an admin-panel account. Players do not use this app. Do not implement player login or the `/api/support/player/` routes. + +## Stack + +- Flutter, null-safe Dart +- `firebase_core` and `firebase_messaging` for push +- `http` or `dio` for JSON +- Persist the bearer token in secure storage (`flutter_secure_storage`) +- Android and iOS + +The admin server base URL is a compile-time or settings value, for example `https://kickkings.playpoolstudios.com`. No trailing slash. Call it `baseUrl`. + +## Auth + +`POST {baseUrl}/api/auth/login` + +Headers: `Content-Type: application/json` + +```json +{ "username": "ada", "password": "secret" } +``` + +200: + +```json +{ + "ok": true, + "token": "", + "account": { + "id": "", + "username": "ada", + "isAdmin": false, + "permissions": { + "support": { "read": true, "write": true } + } + } +} +``` + +`permissions` also includes the other admin pages. This app only cares about `support`. + +401: `{ "error": "Invalid credentials" }` + +429: `{ "error": "Too many failed login attempts. Try again later.", "retryAfterSeconds": 900 }` + +After login, if `account.isAdmin` is false and `account.permissions.support.read` is not true, show “This account does not have support access” and do not enter the inbox. `isAdmin: true` can read and write. + +Send the token on every later request: + +`Authorization: Bearer ` + +The token is also an HttpOnly cookie on web. Ignore cookies in Flutter. The token does not expire until the server secret changes; still offer logout. + +Logout: + +1. `DELETE {baseUrl}/api/support/devices` with `{ "token": "" }` if you have one +2. Delete the bearer token locally +3. Return to the login screen + +Do not call `/api/auth/logout`. That only clears the browser cookie. + +## Inbox API + +All staff routes require the bearer token. 401 means the token is invalid: return to login. 403 means the account cannot do that action. + +List: + +`GET {baseUrl}/api/support/tickets?status=open&category=bug` + +`status` is optional: `open` or `closed`. Omit it for both. + +`category` is optional. Slugs: + +| slug | label | +| --- | --- | +| bug | Report Bug | +| cheating | Report cheating | +| withdrawal | Request withdrawal | +| crypto | Pay with crypto | +| other | Other | + +200: `{ "tickets": [ SupportTicket ] }` + +Open tickets are already sorted before closed ones, then by latest message. + +Thread: + +`GET {baseUrl}/api/support/tickets/{ticketId}` + +200: + +```json +{ + "ticket": {}, + "messages": [] +} +``` + +Reply (requires support write, or admin): + +`POST {baseUrl}/api/support/tickets/{ticketId}/messages` + +```json +{ "body": "We are looking into this." } +``` + +201 returns the same thread shape. Body is plain text, 1 to 4000 characters after trim. + +Close or reopen (requires write): + +`POST {baseUrl}/api/support/tickets/{ticketId}/status` + +```json +{ "status": "closed" } +``` + +`status` is `open` or `closed`. 200 returns the thread. + +Ticket: + +```json +{ + "id": "uuid", + "userId": 42, + "username": "playername", + "category": "bug", + "categoryLabel": "Report Bug", + "status": "open", + "createdAt": "2026-10-15T12:00:00.000Z", + "updatedAt": "2026-10-15T12:00:00.000Z", + "lastMessageAt": "2026-10-15T12:00:00.000Z", + "lastMessagePreview": "The match froze" +} +``` + +Message: + +```json +{ + "id": "uuid", + "ticketId": "uuid", + "authorKind": "player", + "authorName": "playername", + "body": "The match froze", + "createdAt": "2026-10-15T12:00:00.000Z" +} +``` + +`authorKind` is `player` or `staff`. Show player messages on the left and staff messages on the right. Use `categoryLabel` in the UI, not the slug, except when you need the slug for filters. + +Errors are `{ "error": "..." }` with 400, 401, 403, 404, or 503. + +## Screens + +1. Login: username, password, error text, submit. +2. Inbox: filter by status (open by default, plus closed and all) and by category. List shows category label, player username, status, and preview. Pull to refresh. +3. Thread: messages, reply box hidden when the account cannot write, and a Close / Reopen button hidden the same way. +4. Tapping a push notification opens that ticket (`ticketId` in the data payload). + +Poll the open list and the open thread every 5 seconds while the app is in the foreground, in addition to push. + +## Push (FCM) + +Initialize Firebase with the local `google-services.json` / `GoogleService-Info.plist`. Do not hardcode a server key. + +On login, after notification permission: + +1. `FirebaseMessaging.instance.getToken()` +2. `PUT {baseUrl}/api/support/devices` + +```json +{ "token": "", "platform": "android" } +``` + +`platform` is `android` or `ios`. 200: `{ "ok": true }`. + +3. Listen to `onTokenRefresh` and PUT again. + +Register a background handler with `FirebaseMessaging.onBackgroundMessage`. In the foreground, `FirebaseMessaging.onMessage` should refresh the inbox and, if the payload `ticketId` is the open thread, refresh that thread. Notification title is the category label. Data always includes `ticketId` and `category`. + +Request alert permission on iOS before `getToken`. On Android 13+, request `POST_NOTIFICATIONS`. + +If the user denies permission, the inbox still works; skip device registration. + +## Out of scope + +- Player ticket creation +- Any admin page other than support +- Sending pushes from the phone (the server sends them) +- Storing the Firebase service account in the app diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..c5f1ed4 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,35 @@ +**/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.* +Runner/GoogleService-Info.plist + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c8c356c --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,666 @@ +// !$*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 */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 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 = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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 */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 */, + A4E1F0C21B2D4A6E9C8B7D6E /* Copy GoogleService-Info.plist */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + 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 */ + A4E1F0C21B2D4A6E9C8B7D6E /* Copy GoogleService-Info.plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Copy GoogleService-Info.plist"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ -f \"${SRCROOT}/Runner/GoogleService-Info.plist\" ]; then\n cp \"${SRCROOT}/Runner/GoogleService-Info.plist\" \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist\"\nfi\n"; + }; + 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 */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.playpoolstudios.kickkings.support; + 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.playpoolstudios.kickkings.support.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.playpoolstudios.kickkings.support.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.playpoolstudios.kickkings.support.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 = 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; + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.playpoolstudios.kickkings.support; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.playpoolstudios.kickkings.support; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..98a8257 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // UIScene registers plugins after this method returns. Apple requires the + // notification-center delegate to be set before launch completes. + FLTFirebaseMessagingPlugin.configureNotificationCenterDelegate() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/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/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..972d568 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,74 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kick Kings + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Kick Kings + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/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/lib/api/support_api.dart b/lib/api/support_api.dart new file mode 100644 index 0000000..5ccbab7 --- /dev/null +++ b/lib/api/support_api.dart @@ -0,0 +1,175 @@ +import 'package:dio/dio.dart'; + +import '../config.dart'; +import '../models/api_exception.dart'; +import '../models/login_result.dart'; +import '../models/support_message.dart'; +import '../models/support_ticket.dart'; + +class SupportApi { + SupportApi({Dio? dio, String? origin, this.onUnauthorized}) + : dio = + dio ?? + Dio( + BaseOptions( + baseUrl: origin ?? baseUrl, + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + headers: const { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + ), + ) { + this.dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + final token = authToken; + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + onError: (error, handler) { + final apiError = _exceptionFor(error); + final sentAuth = + error.requestOptions.headers['Authorization'] != null; + if (apiError.isUnauthorized && sentAuth) { + onUnauthorized?.call(); + } + handler.reject( + DioException( + requestOptions: error.requestOptions, + response: error.response, + type: error.type, + error: apiError, + message: apiError.message, + ), + ); + }, + ), + ); + } + + final Dio dio; + String? authToken; + void Function()? onUnauthorized; + + Future login(String username, String password) { + return _send( + dio.post( + '/api/auth/login', + data: {'username': username, 'password': password}, + ), + (data) { + if (data is! Map) { + throw const FormatException('Unexpected login payload'); + } + return LoginResult.fromJson(Map.from(data)); + }, + ); + } + + Future> listTickets({String? status, String? category}) { + return _send( + dio.get( + '/api/support/tickets', + queryParameters: ticketListQuery(status: status, category: category), + ), + (data) { + if (data is! Map || data['tickets'] is! List) { + throw const FormatException('Unexpected ticket list'); + } + return [ + for (final item in data['tickets'] as List) + if (item is Map) + SupportTicket.fromJson(Map.from(item)), + ]; + }, + ); + } + + Future getThread(String ticketId) { + return _send(dio.get('/api/support/tickets/$ticketId'), _thread); + } + + Future reply(String ticketId, String body) { + return _send( + dio.post( + '/api/support/tickets/$ticketId/messages', + data: {'body': body}, + ), + _thread, + ); + } + + Future setStatus(String ticketId, String status) { + return _send( + dio.post( + '/api/support/tickets/$ticketId/status', + data: {'status': status}, + ), + _thread, + ); + } + + Future registerDevice({ + required String token, + required String platform, + }) { + return _send( + dio.put( + '/api/support/devices', + data: {'token': token, 'platform': platform}, + ), + (_) {}, + ); + } + + Future unregisterDevice(String token) { + return _send( + dio.delete('/api/support/devices', data: {'token': token}), + (_) {}, + ); + } + + SupportThread _thread(Object? data) { + if (data is! Map) { + throw const FormatException('Unexpected thread payload'); + } + return SupportThread.fromJson(Map.from(data)); + } + + Future _send( + Future> call, + T Function(Object? data) parse, + ) async { + try { + final response = await call; + return parse(response.data); + } on DioException catch (error) { + final wrapped = error.error; + if (wrapped is ApiException) { + throw wrapped; + } + throw _exceptionFor(error); + } on FormatException { + throw const ApiException(message: 'Unexpected response from the server.'); + } + } + + ApiException _exceptionFor(DioException error) { + final offline = + error.type == DioExceptionType.connectionError || + error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout; + return ApiException.fromBody( + statusCode: error.response?.statusCode, + body: error.response?.data, + fallback: offline + ? 'Could not reach the server.' + : 'Something went wrong.', + ); + } +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..5a3a52b --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; + +import 'api/support_api.dart'; +import 'app_scope.dart'; +import 'inbox.dart'; +import 'push.dart'; +import 'screens/inbox_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/thread_screen.dart'; +import 'session.dart'; + +class KickKingsApp extends StatefulWidget { + const KickKingsApp({ + super.key, + required this.firebaseReady, + this.session, + this.inbox, + this.push, + this.api, + }); + + final bool firebaseReady; + final SessionController? session; + final InboxController? inbox; + final PushService? push; + final SupportApi? api; + + @override + State createState() => _KickKingsAppState(); +} + +class _KickKingsAppState extends State { + final _navigatorKey = GlobalKey(); + final _messengerKey = GlobalKey(); + late final RouteObserver> _routeObserver; + late final SupportApi _api; + late final SessionController _session; + late final InboxController _inbox; + late final PushService _push; + var _wasSignedIn = false; + var _openScheduled = false; + + @override + void initState() { + super.initState(); + _routeObserver = RouteObserver>(); + _api = widget.api ?? SupportApi(); + _session = + widget.session ?? SessionController(store: SessionStore(), api: _api); + _inbox = widget.inbox ?? InboxController(api: _api); + _push = widget.push ?? PushService(firebaseReady: widget.firebaseReady); + _api.onUnauthorized = _session.handleUnauthorized; + _session.addListener(_onSession); + unawaited(_boot()); + } + + Future _boot() async { + await _session.restore(); + await _push.listen( + onOpenTicket: _rememberTicket, + onForegroundMessage: _onForegroundMessage, + onToken: _registerToken, + isSignedIn: () => _session.isSignedIn, + ); + if (_session.isSignedIn) { + await _registerCurrentDevice(); + } + _openPendingTicket(); + } + + void _onSession() { + final signedIn = _session.isSignedIn; + if (signedIn && !_wasSignedIn) { + unawaited(_registerCurrentDevice()); + _openPendingTicket(); + } + if (!signedIn && _wasSignedIn) { + _inbox.reset(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigatorKey.currentState?.popUntil((route) => route.isFirst); + }); + } + _wasSignedIn = signedIn; + } + + Future _registerCurrentDevice() async { + if (!_session.isSignedIn) { + return; + } + try { + await _push.register(_registerToken); + } on Object { + // A denied permission or a missing Firebase config leaves the inbox usable. + } + } + + Future _registerToken(String token, String platform) async { + if (!_session.isSignedIn) { + return; + } + await _api.registerDevice(token: token, platform: platform); + await _session.saveFcmToken(token); + } + + void _onForegroundMessage(RemoteMessage message) { + if (!_session.isSignedIn) { + return; + } + unawaited(_inbox.refreshList()); + final ticketId = message.data['ticketId']; + if (ticketId is String && + ticketId.isNotEmpty && + ticketId == _inbox.activeTicketId) { + unawaited(_inbox.refreshThread()); + } + final title = message.notification?.title; + if (title != null && title.isNotEmpty) { + _messengerKey.currentState?.showSnackBar(SnackBar(content: Text(title))); + } + } + + void _rememberTicket(String ticketId) { + _session.pendingTicketId = ticketId; + _openPendingTicket(); + } + + void _openPendingTicket() { + if (_openScheduled) { + return; + } + final ticketId = _session.pendingTicketId; + if (ticketId == null || !_session.isSignedIn) { + return; + } + _openScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _openScheduled = false; + if (!mounted || + _session.pendingTicketId != ticketId || + !_session.isSignedIn) { + return; + } + if (_inbox.activeTicketId == ticketId) { + _session.pendingTicketId = null; + unawaited(_inbox.refreshThread()); + return; + } + _session.pendingTicketId = null; + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => ThreadScreen(ticketId: ticketId), + ), + ); + }); + } + + @override + void dispose() { + _session.removeListener(_onSession); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScope( + session: _session, + inbox: _inbox, + routeObserver: _routeObserver, + child: MaterialApp( + title: 'Kick Kings', + navigatorKey: _navigatorKey, + scaffoldMessengerKey: _messengerKey, + navigatorObservers: [_routeObserver], + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: ListenableBuilder( + listenable: _session, + builder: (context, _) { + if (!_session.ready) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + if (!_session.isSignedIn) { + return const LoginScreen(); + } + return const InboxScreen(); + }, + ), + ), + ); + } +} + +ThemeData _theme(Brightness brightness) { + final scheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF0F6B4C), + brightness: brightness, + ); + return ThemeData( + colorScheme: scheme, + useMaterial3: true, + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + ), + ); +} diff --git a/lib/app_scope.dart b/lib/app_scope.dart new file mode 100644 index 0000000..2fd5757 --- /dev/null +++ b/lib/app_scope.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'inbox.dart'; +import 'session.dart'; + +class AppScope extends InheritedWidget { + const AppScope({ + super.key, + required this.session, + required this.inbox, + required this.routeObserver, + required super.child, + }); + + final SessionController session; + final InboxController inbox; + final RouteObserver> routeObserver; + + static AppScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'AppScope is missing'); + return scope!; + } + + @override + bool updateShouldNotify(AppScope oldWidget) { + return session != oldWidget.session || + inbox != oldWidget.inbox || + routeObserver != oldWidget.routeObserver; + } +} diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..396b478 --- /dev/null +++ b/lib/config.dart @@ -0,0 +1,32 @@ +const defaultBaseUrl = 'https://kickkings.playpoolstudios.com'; + +const supportCategoryLabels = { + 'bug': 'Report Bug', + 'cheating': 'Report cheating', + 'withdrawal': 'Request withdrawal', + 'crypto': 'Pay with crypto', + 'other': 'Other', +}; + +const _rawBaseUrl = String.fromEnvironment( + 'BASE_URL', + defaultValue: defaultBaseUrl, +); + +/// Admin server origin with no trailing slash. +final String baseUrl = normalizeBaseUrl(_rawBaseUrl); + +String normalizeBaseUrl(String value) { + var url = value.trim(); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + return url; +} + +Map ticketListQuery({String? status, String? category}) { + return { + if (status != null && status.isNotEmpty) 'status': status, + if (category != null && category.isNotEmpty) 'category': category, + }; +} diff --git a/lib/format.dart b/lib/format.dart new file mode 100644 index 0000000..0394221 --- /dev/null +++ b/lib/format.dart @@ -0,0 +1,21 @@ +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +String formatTimestamp(DateTime value) { + final local = value.toLocal(); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '${local.day} ${_months[local.month - 1]} $hour:$minute'; +} diff --git a/lib/inbox.dart b/lib/inbox.dart new file mode 100644 index 0000000..1e50fcd --- /dev/null +++ b/lib/inbox.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'api/support_api.dart'; +import 'models/api_exception.dart'; +import 'models/support_message.dart'; +import 'models/support_ticket.dart'; + +enum TicketStatusFilter { open, closed, all } + +class InboxController extends ChangeNotifier { + InboxController({required this.api}); + + final SupportApi api; + + TicketStatusFilter statusFilter = TicketStatusFilter.open; + String? category; + + List tickets = []; + bool loading = false; + String? error; + + String? activeTicketId; + SupportTicket? threadTicket; + List messages = []; + bool threadLoading = false; + String? threadError; + + int _listGeneration = 0; + int _threadGeneration = 0; + + String? get statusQuery { + switch (statusFilter) { + case TicketStatusFilter.open: + return 'open'; + case TicketStatusFilter.closed: + return 'closed'; + case TicketStatusFilter.all: + return null; + } + } + + Future refreshList({bool showLoading = false}) async { + final generation = ++_listGeneration; + if (showLoading) { + loading = true; + error = null; + notifyListeners(); + } + try { + final next = await api.listTickets( + status: statusQuery, + category: category, + ); + if (generation != _listGeneration) { + return; + } + tickets = next; + error = null; + } on ApiException catch (caught) { + if (generation != _listGeneration || caught.isUnauthorized) { + return; + } + error = caught.message; + } finally { + if (generation == _listGeneration) { + loading = false; + notifyListeners(); + } + } + } + + Future refreshThread({bool showLoading = false}) async { + final ticketId = activeTicketId; + if (ticketId == null) { + return; + } + final generation = ++_threadGeneration; + if (showLoading) { + threadLoading = true; + threadError = null; + notifyListeners(); + } + try { + final thread = await api.getThread(ticketId); + if (generation != _threadGeneration || activeTicketId != ticketId) { + return; + } + _applyThread(thread); + threadError = null; + } on ApiException catch (caught) { + if (generation != _threadGeneration || caught.isUnauthorized) { + return; + } + threadError = caught.message; + } finally { + if (generation == _threadGeneration) { + threadLoading = false; + notifyListeners(); + } + } + } + + Future sendReply(String raw) async { + final ticketId = activeTicketId; + if (ticketId == null) { + return 'No ticket is open.'; + } + final validation = validateReplyBody(raw); + if (validation != null) { + return validation; + } + final generation = ++_threadGeneration; + try { + final thread = await api.reply(ticketId, raw.trim()); + if (generation != _threadGeneration || activeTicketId != ticketId) { + return null; + } + _applyThread(thread); + threadError = null; + notifyListeners(); + return null; + } on ApiException catch (caught) { + if (caught.isUnauthorized) { + return null; + } + return caught.message; + } + } + + Future setStatus(String status) async { + final ticketId = activeTicketId; + if (ticketId == null) { + return 'No ticket is open.'; + } + final generation = ++_threadGeneration; + try { + final thread = await api.setStatus(ticketId, status); + if (generation != _threadGeneration || activeTicketId != ticketId) { + return null; + } + _applyThread(thread); + threadError = null; + notifyListeners(); + return null; + } on ApiException catch (caught) { + if (caught.isUnauthorized) { + return null; + } + return caught.message; + } + } + + void setStatusFilter(TicketStatusFilter value) { + if (statusFilter == value) { + return; + } + statusFilter = value; + tickets = []; + unawaited(refreshList(showLoading: true)); + } + + void setCategory(String? value) { + final next = (value == null || value.isEmpty) ? null : value; + if (category == next) { + return; + } + category = next; + tickets = []; + unawaited(refreshList(showLoading: true)); + } + + void reset() { + _listGeneration++; + _threadGeneration++; + statusFilter = TicketStatusFilter.open; + category = null; + tickets = []; + loading = false; + error = null; + activeTicketId = null; + threadTicket = null; + messages = []; + threadLoading = false; + threadError = null; + notifyListeners(); + } + + void _applyThread(SupportThread thread) { + threadTicket = thread.ticket; + messages = thread.messages; + final index = tickets.indexWhere((ticket) => ticket.id == thread.ticket.id); + if (index >= 0) { + tickets = [ + for (var i = 0; i < tickets.length; i++) + if (i == index) thread.ticket else tickets[i], + ]; + } + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..fc06191 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,30 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; + +import 'app.dart'; + +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage _) async { + try { + await Firebase.initializeApp(); + } on Object { + // The system still shows the notification when Firebase is not configured. + } +} + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final firebaseReady = await _initializeFirebase(); + runApp(KickKingsApp(firebaseReady: firebaseReady)); +} + +Future _initializeFirebase() async { + try { + await Firebase.initializeApp(); + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + return true; + } on Object { + return false; + } +} diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..bd1fb1d --- /dev/null +++ b/lib/models/account.dart @@ -0,0 +1,50 @@ +class Account { + const Account({ + required this.id, + required this.username, + required this.isAdmin, + required this.permissions, + }); + + final String id; + final String username; + final bool isAdmin; + final Map permissions; + + bool get canReadSupport => isAdmin || _supportFlag('read'); + + bool get canWriteSupport => isAdmin || _supportFlag('write'); + + bool _supportFlag(String key) { + final support = permissions['support']; + if (support is Map) { + return support[key] == true; + } + return false; + } + + factory Account.fromJson(Map json) { + return Account( + id: json['id']?.toString() ?? '', + username: json['username'] as String? ?? '', + isAdmin: json['isAdmin'] == true, + permissions: _stringKeyMap(json['permissions']), + ); + } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'isAdmin': isAdmin, + 'permissions': permissions, + }; + } +} + +Map _stringKeyMap(Object? value) { + if (value is! Map) { + return {}; + } + return value.map((key, item) => MapEntry(key.toString(), item)); +} diff --git a/lib/models/api_exception.dart b/lib/models/api_exception.dart new file mode 100644 index 0000000..b4ea89e --- /dev/null +++ b/lib/models/api_exception.dart @@ -0,0 +1,45 @@ +class ApiException implements Exception { + const ApiException({ + this.statusCode, + required this.message, + this.retryAfterSeconds, + }); + + final int? statusCode; + final String message; + final int? retryAfterSeconds; + + bool get isUnauthorized => statusCode == 401; + + @override + String toString() => message; + + static ApiException fromBody({ + int? statusCode, + Object? body, + String fallback = 'Something went wrong.', + }) { + var message = fallback; + int? retryAfterSeconds; + if (body is Map) { + final error = body['error']; + if (error is String && error.trim().isNotEmpty) { + message = error.trim(); + } + final retry = body['retryAfterSeconds']; + if (retry is int) { + retryAfterSeconds = retry; + } else if (retry is num) { + retryAfterSeconds = retry.toInt(); + } + } + if (retryAfterSeconds != null) { + message = '$message Retry after $retryAfterSeconds seconds.'; + } + return ApiException( + statusCode: statusCode, + message: message, + retryAfterSeconds: retryAfterSeconds, + ); + } +} diff --git a/lib/models/login_result.dart b/lib/models/login_result.dart new file mode 100644 index 0000000..9812916 --- /dev/null +++ b/lib/models/login_result.dart @@ -0,0 +1,20 @@ +import 'account.dart'; + +class LoginResult { + const LoginResult({required this.token, required this.account}); + + final String token; + final Account account; + + factory LoginResult.fromJson(Map json) { + final token = json['token']; + final account = json['account']; + if (token is! String || token.isEmpty || account is! Map) { + throw const FormatException('Unexpected login payload'); + } + return LoginResult( + token: token, + account: Account.fromJson(Map.from(account)), + ); + } +} diff --git a/lib/models/support_message.dart b/lib/models/support_message.dart new file mode 100644 index 0000000..0769a32 --- /dev/null +++ b/lib/models/support_message.dart @@ -0,0 +1,69 @@ +import 'support_ticket.dart'; + +const maxReplyLength = 4000; + +class SupportMessage { + const SupportMessage({ + required this.id, + required this.ticketId, + required this.authorKind, + required this.authorName, + required this.body, + required this.createdAt, + }); + + final String id; + final String ticketId; + final String authorKind; + final String authorName; + final String body; + final DateTime createdAt; + + bool get isStaff => authorKind == 'staff'; + + factory SupportMessage.fromJson(Map json) { + return SupportMessage( + id: json['id'] as String, + ticketId: json['ticketId'] as String, + authorKind: json['authorKind'] as String? ?? 'player', + authorName: json['authorName'] as String? ?? '', + body: json['body'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } +} + +class SupportThread { + const SupportThread({required this.ticket, required this.messages}); + + final SupportTicket ticket; + final List messages; + + factory SupportThread.fromJson(Map json) { + final ticketJson = json['ticket']; + final messagesJson = json['messages']; + if (ticketJson is! Map || messagesJson is! List) { + throw const FormatException('Unexpected thread payload'); + } + return SupportThread( + ticket: SupportTicket.fromJson(Map.from(ticketJson)), + messages: [ + for (final item in messagesJson) + if (item is Map) + SupportMessage.fromJson(Map.from(item)), + ], + ); + } +} + +/// Returns an error message when [raw] is not 1–4000 characters after trim. +String? validateReplyBody(String raw) { + final body = raw.trim(); + if (body.isEmpty) { + return 'Enter a message.'; + } + if (body.length > maxReplyLength) { + return 'Message must be $maxReplyLength characters or fewer.'; + } + return null; +} diff --git a/lib/models/support_ticket.dart b/lib/models/support_ticket.dart new file mode 100644 index 0000000..eaa2cfe --- /dev/null +++ b/lib/models/support_ticket.dart @@ -0,0 +1,47 @@ +class SupportTicket { + const SupportTicket({ + required this.id, + required this.userId, + required this.username, + required this.category, + required this.categoryLabel, + required this.status, + required this.createdAt, + required this.updatedAt, + required this.lastMessageAt, + required this.lastMessagePreview, + }); + + final String id; + final int userId; + final String username; + final String category; + final String categoryLabel; + final String status; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime lastMessageAt; + final String lastMessagePreview; + + bool get isClosed => status == 'closed'; + + String get displayCategory => + categoryLabel.isNotEmpty ? categoryLabel : category; + + String get statusLabel => isClosed ? 'Closed' : 'Open'; + + factory SupportTicket.fromJson(Map json) { + return SupportTicket( + id: json['id'] as String, + userId: (json['userId'] as num).toInt(), + username: json['username'] as String? ?? '', + category: json['category'] as String? ?? '', + categoryLabel: json['categoryLabel'] as String? ?? '', + status: json['status'] as String? ?? 'open', + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + lastMessageAt: DateTime.parse(json['lastMessageAt'] as String), + lastMessagePreview: json['lastMessagePreview'] as String? ?? '', + ); + } +} diff --git a/lib/polling.dart b/lib/polling.dart new file mode 100644 index 0000000..cc73069 --- /dev/null +++ b/lib/polling.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +const pollInterval = Duration(seconds: 5); + +/// Calls [onPoll] every five seconds while the app is resumed. +class ForegroundPoll extends StatefulWidget { + const ForegroundPoll({super.key, required this.onPoll, required this.child}); + + final Future Function() onPoll; + final Widget child; + + @override + State createState() => _ForegroundPollState(); +} + +class _ForegroundPollState extends State + with WidgetsBindingObserver { + Timer? _timer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _start(); + } + + @override + void dispose() { + _stop(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + _start(); + unawaited(widget.onPoll()); + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _stop(); + case AppLifecycleState.inactive: + break; + } + } + + void _start() { + _timer ??= Timer.periodic(pollInterval, (_) { + unawaited(widget.onPoll()); + }); + } + + void _stop() { + _timer?.cancel(); + _timer = null; + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/push.dart b/lib/push.dart new file mode 100644 index 0000000..4bbe12e --- /dev/null +++ b/lib/push.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; + +typedef FcmTokenCallback = Future Function(String token, String platform); + +class PushService { + PushService({required this.firebaseReady}); + + final bool firebaseReady; + final _subscriptions = >[]; + bool _listening = false; + + Future listen({ + required void Function(String ticketId) onOpenTicket, + required void Function(RemoteMessage message) onForegroundMessage, + required FcmTokenCallback onToken, + required bool Function() isSignedIn, + }) async { + if (!firebaseReady || _listening) { + return; + } + _listening = true; + _subscriptions + ..add( + FirebaseMessaging.instance.onTokenRefresh.listen((token) async { + try { + if (!isSignedIn()) { + return; + } + final settings = await FirebaseMessaging.instance + .getNotificationSettings(); + if (!_allowsNotifications(settings.authorizationStatus)) { + return; + } + await onToken(token, _platform); + } on Object { + // Keep the previous registration when a token refresh cannot be saved. + } + }), + ) + ..add(FirebaseMessaging.onMessage.listen(onForegroundMessage)) + ..add( + FirebaseMessaging.onMessageOpenedApp.listen((message) { + final ticketId = _ticketId(message); + if (ticketId != null) { + onOpenTicket(ticketId); + } + }), + ); + try { + final initial = await FirebaseMessaging.instance.getInitialMessage(); + if (initial != null) { + final ticketId = _ticketId(initial); + if (ticketId != null) { + onOpenTicket(ticketId); + } + } + } on Object { + // Opening from a notification can wait until the next tap. + } + } + + Future register(FcmTokenCallback onToken) async { + if (!firebaseReady) { + return; + } + try { + final settings = await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + if (!_allowsNotifications(settings.authorizationStatus)) { + return; + } + final token = await FirebaseMessaging.instance.getToken(); + if (token == null || token.isEmpty) { + return; + } + await onToken(token, _platform); + } on Object { + // Denied permission or a missing Firebase file should not block the inbox. + } + } + + String? _ticketId(RemoteMessage message) { + final value = message.data['ticketId']; + if (value is String && value.isNotEmpty) { + return value; + } + return null; + } + + bool _allowsNotifications(AuthorizationStatus status) { + return status == AuthorizationStatus.authorized || + status == AuthorizationStatus.provisional; + } + + String get _platform => Platform.isIOS ? 'ios' : 'android'; +} diff --git a/lib/screens/inbox_screen.dart b/lib/screens/inbox_screen.dart new file mode 100644 index 0000000..e3de210 --- /dev/null +++ b/lib/screens/inbox_screen.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../app_scope.dart'; +import '../config.dart'; +import '../format.dart'; +import '../inbox.dart'; +import '../polling.dart'; +import 'thread_screen.dart'; + +class InboxScreen extends StatefulWidget { + const InboxScreen({super.key}); + + @override + State createState() => _InboxScreenState(); +} + +class _InboxScreenState extends State { + var _started = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) { + return; + } + _started = true; + unawaited(AppScope.of(context).inbox.refreshList(showLoading: true)); + } + + Future _logout() async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Log out?'), + content: const Text( + 'This device will stop receiving support notifications.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Log out'), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) { + return; + } + final session = AppScope.of(context).session; + await session.signOut(); + } + + @override + Widget build(BuildContext context) { + final inbox = AppScope.of(context).inbox; + return ForegroundPoll( + onPoll: () => inbox.refreshList(), + child: Scaffold( + appBar: AppBar( + title: const Text('Inbox'), + actions: [ + IconButton( + tooltip: 'Log out', + onPressed: _logout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: Column( + children: [ + _Filters(inbox: inbox), + const Divider(height: 1), + Expanded( + child: ListenableBuilder( + listenable: inbox, + builder: (context, _) { + return RefreshIndicator( + onRefresh: () => inbox.refreshList(), + child: _TicketList(inbox: inbox), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _Filters extends StatelessWidget { + const _Filters({required this.inbox}); + + final InboxController inbox; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: inbox, + builder: (context, _) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: TicketStatusFilter.open, + label: Text('Open'), + ), + ButtonSegment( + value: TicketStatusFilter.closed, + label: Text('Closed'), + ), + ButtonSegment( + value: TicketStatusFilter.all, + label: Text('All'), + ), + ], + selected: {inbox.statusFilter}, + onSelectionChanged: (selection) { + inbox.setStatusFilter(selection.first); + }, + ), + const SizedBox(height: 12), + DropdownButton( + isExpanded: true, + value: inbox.category, + items: [ + const DropdownMenuItem( + value: null, + child: Text('All categories'), + ), + for (final entry in supportCategoryLabels.entries) + DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ), + ], + onChanged: inbox.setCategory, + ), + ], + ), + ); + }, + ); + } +} + +class _TicketList extends StatelessWidget { + const _TicketList({required this.inbox}); + + final InboxController inbox; + + @override + Widget build(BuildContext context) { + if (inbox.loading && inbox.tickets.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (inbox.error != null && inbox.tickets.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text(inbox.error!, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => inbox.refreshList(showLoading: true), + child: const Text('Try again'), + ), + ], + ), + ), + ], + ); + } + if (inbox.tickets.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + Padding( + padding: EdgeInsets.all(24), + child: Text('No tickets', textAlign: TextAlign.center), + ), + ], + ); + } + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: inbox.tickets.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final ticket = inbox.tickets[index]; + return ListTile( + title: Text(ticket.displayCategory), + subtitle: Text( + '${ticket.username}\n${ticket.lastMessagePreview}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + isThreeLine: true, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _StatusChip(closed: ticket.isClosed, label: ticket.statusLabel), + const SizedBox(height: 6), + Text( + formatTimestamp(ticket.lastMessageAt), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadScreen(ticketId: ticket.id), + ), + ); + }, + ); + }, + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.closed, required this.label}); + + final bool closed; + final String label; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: closed + ? scheme.surfaceContainerHighest + : scheme.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Text(label, style: Theme.of(context).textTheme.labelSmall), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..e47d331 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../app_scope.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + var _submitting = false; + String? _error; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + final username = _usernameController.text.trim(); + final password = _passwordController.text; + if (username.isEmpty || password.isEmpty) { + setState(() => _error = 'Enter your username and password.'); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + final session = AppScope.of(context).session; + final error = await session.signIn(username, password); + if (!mounted) { + return; + } + setState(() { + _submitting = false; + _error = error; + }); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.support_agent, size: 48, color: scheme.primary), + const SizedBox(height: 12), + Text( + 'Kick Kings', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + 'Support staff', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: scheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextField( + controller: _usernameController, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + TextField( + controller: _passwordController, + autofillHints: const [AutofillHints.password], + obscureText: true, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _submit(), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: TextStyle(color: scheme.error)), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/thread_screen.dart b/lib/screens/thread_screen.dart new file mode 100644 index 0000000..6bc4605 --- /dev/null +++ b/lib/screens/thread_screen.dart @@ -0,0 +1,328 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../app_scope.dart'; +import '../format.dart'; +import '../inbox.dart'; +import '../models/support_message.dart'; +import '../polling.dart'; + +class ThreadScreen extends StatefulWidget { + const ThreadScreen({super.key, required this.ticketId}); + + final String ticketId; + + @override + State createState() => _ThreadScreenState(); +} + +class _ThreadScreenState extends State with RouteAware { + final _replyController = TextEditingController(); + final _scrollController = ScrollController(); + InboxController? _inbox; + RouteObserver>? _routeObserver; + var _started = false; + var _sending = false; + var _updatingStatus = false; + var _stickToBottom = true; + String? _actionError; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = AppScope.of(context); + final route = ModalRoute.of(context); + _routeObserver ??= scope.routeObserver; + if (route is ModalRoute) { + scope.routeObserver.subscribe(this, route); + } + _inbox ??= scope.inbox; + if (_started) { + return; + } + _started = true; + _inbox!.activeTicketId = widget.ticketId; + _inbox!.addListener(_onInbox); + unawaited(_inbox!.refreshThread(showLoading: true)); + } + + @override + void didPopNext() { + _inbox?.activeTicketId = widget.ticketId; + final refresh = _inbox?.refreshThread(); + if (refresh != null) { + unawaited(refresh); + } + } + + @override + void dispose() { + _routeObserver?.unsubscribe(this); + _inbox?.removeListener(_onInbox); + if (_inbox?.activeTicketId == widget.ticketId) { + _inbox?.activeTicketId = null; + } + _replyController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onInbox() { + if (!mounted) { + return; + } + final stick = _stickToBottom || _nearBottom(); + setState(() {}); + if (stick) { + _stickToBottom = false; + _jumpToBottom(); + } + } + + bool _nearBottom() { + if (!_scrollController.hasClients) { + return true; + } + final position = _scrollController.position; + return position.maxScrollExtent - position.pixels < 80; + } + + void _jumpToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + Future _send() async { + final inbox = _inbox; + if (inbox == null || _sending) { + return; + } + setState(() { + _sending = true; + _actionError = null; + }); + _stickToBottom = true; + final error = await inbox.sendReply(_replyController.text); + if (!mounted) { + return; + } + setState(() { + _sending = false; + _actionError = error; + if (error == null) { + _replyController.clear(); + } + }); + } + + Future _toggleStatus() async { + final inbox = _inbox; + final ticket = inbox?.threadTicket; + if (inbox == null || ticket == null || _updatingStatus) { + return; + } + setState(() { + _updatingStatus = true; + _actionError = null; + }); + final error = await inbox.setStatus(ticket.isClosed ? 'open' : 'closed'); + if (!mounted) { + return; + } + setState(() { + _updatingStatus = false; + _actionError = error; + }); + } + + @override + Widget build(BuildContext context) { + final scope = AppScope.of(context); + final inbox = scope.inbox; + final canWrite = scope.session.account?.canWriteSupport ?? false; + final ticket = inbox.threadTicket?.id == widget.ticketId + ? inbox.threadTicket + : null; + final messages = ticket == null ? const [] : inbox.messages; + + return ForegroundPoll( + onPoll: () => inbox.refreshThread(), + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ticket?.displayCategory ?? 'Ticket'), + if (ticket != null) + Text( + ticket.username, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + if (canWrite && ticket != null) + TextButton( + onPressed: _updatingStatus ? null : _toggleStatus, + child: Text(ticket.isClosed ? 'Reopen' : 'Close'), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: _messages( + context, + inbox, + messages, + hasTicket: ticket != null, + ), + ), + if (_actionError != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + _actionError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (canWrite && ticket != null) _composer(context), + ], + ), + ), + ); + } + + Widget _messages( + BuildContext context, + InboxController inbox, + List messages, { + required bool hasTicket, + }) { + if ((!hasTicket && inbox.threadError == null) || + (inbox.threadLoading && messages.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } + if (inbox.threadError != null && messages.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(inbox.threadError!, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => inbox.refreshThread(showLoading: true), + child: const Text('Try again'), + ), + ], + ), + ), + ); + } + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + return _Bubble(message: messages[index]); + }, + ); + } + + Widget _composer(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _replyController, + minLines: 1, + maxLines: 5, + maxLength: maxReplyLength, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: 'Reply', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: 'Send', + onPressed: _sending ? null : _send, + icon: _sending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.message}); + + final SupportMessage message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final staff = message.isStaff; + final alignment = staff ? Alignment.centerRight : Alignment.centerLeft; + final color = staff + ? scheme.primaryContainer + : scheme.surfaceContainerHighest; + final onColor = staff ? scheme.onPrimaryContainer : scheme.onSurface; + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.78, + ), + child: Card( + color: color, + elevation: 0, + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.authorName, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(color: onColor), + ), + const SizedBox(height: 4), + Text(message.body, style: TextStyle(color: onColor)), + const SizedBox(height: 6), + Text( + formatTimestamp(message.createdAt), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: onColor.withValues(alpha: 0.7)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/session.dart b/lib/session.dart new file mode 100644 index 0000000..2add00a --- /dev/null +++ b/lib/session.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'api/support_api.dart'; +import 'models/account.dart'; +import 'models/api_exception.dart'; + +class SessionStore { + SessionStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const tokenKey = 'auth_token'; + static const accountKey = 'account_json'; + static const fcmTokenKey = 'fcm_token'; + + final FlutterSecureStorage _storage; + + Future readToken() => _storage.read(key: tokenKey); + + Future readAccount() async { + final raw = await _storage.read(key: accountKey); + if (raw == null || raw.isEmpty) { + return null; + } + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return null; + } + return Account.fromJson(Map.from(decoded)); + } + + Future readFcmToken() => _storage.read(key: fcmTokenKey); + + Future writeSession(String token, Account account) async { + await _storage.write(key: tokenKey, value: token); + await _storage.write(key: accountKey, value: jsonEncode(account.toJson())); + } + + Future writeFcmToken(String token) { + return _storage.write(key: fcmTokenKey, value: token); + } + + Future clear() async { + await _storage.delete(key: tokenKey); + await _storage.delete(key: accountKey); + await _storage.delete(key: fcmTokenKey); + } +} + +class SessionController extends ChangeNotifier { + SessionController({required this.store, required this.api}); + + final SessionStore store; + final SupportApi api; + + Account? account; + String? token; + String? fcmToken; + String? pendingTicketId; + bool ready = false; + + bool get isSignedIn => + token != null && account != null && account!.canReadSupport; + + Future restore() async { + try { + token = await store.readToken(); + account = await store.readAccount(); + fcmToken = await store.readFcmToken(); + if (token == null || account == null || !account!.canReadSupport) { + token = null; + account = null; + } + api.authToken = token; + } finally { + ready = true; + notifyListeners(); + } + } + + /// Returns an error message, or null when the inbox can open. + Future signIn(String username, String password) async { + try { + final result = await api.login(username.trim(), password); + if (!result.account.canReadSupport) { + return 'This account does not have support access'; + } + await store.writeSession(result.token, result.account); + token = result.token; + account = result.account; + api.authToken = token; + notifyListeners(); + return null; + } on ApiException catch (error) { + return error.message; + } on Object { + return 'Could not save this session.'; + } + } + + Future saveFcmToken(String value) async { + fcmToken = value; + await store.writeFcmToken(value); + } + + Future signOut() async { + final deviceToken = fcmToken; + if (deviceToken != null && deviceToken.isNotEmpty && token != null) { + try { + await api.unregisterDevice(deviceToken); + } on ApiException { + // Local logout still proceeds when the device delete fails. + } + } + token = null; + account = null; + fcmToken = null; + api.authToken = null; + await store.clear(); + notifyListeners(); + } + + void handleUnauthorized() { + if (token == null && account == null) { + return; + } + token = null; + account = null; + fcmToken = null; + api.authToken = null; + notifyListeners(); + unawaited(store.clear()); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..9e15feb --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,570 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: f6966125633a34f82d9be2ee895b755cff3554087b1d00a9eb884e5947f4bca9 + url: "https://pub.dev" + source: hosted + version: "1.3.77" + 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: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + 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: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "5fbbaa2aeb08a52feee559f92a62f5a6b04c9bdff81c8f608636771cf0d7b680" + url: "https://pub.dev" + source: hosted + version: "4.15.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "8ed84f5001dbd7a0390e2818d46bc858ceebb970edededdfc8d6552c76fc2f6e" + url: "https://pub.dev" + source: hosted + version: "3.12.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "47a8142a0b897783103b01018ad69f123b819b0120483847bde30d1b2fa89b36" + url: "https://pub.dev" + source: hosted + version: "16.7.0" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: f9707e185b8ce1155d58e7e29348245072d99981f899fa8d3214abb2652c4ccb + url: "https://pub.dev" + source: hosted + version: "4.10.0" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "311b16c75fc645c7a70c9ade38908abb12310dfce449fc3d3cee1771f196351e" + url: "https://pub.dev" + source: hosted + version: "4.2.5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + 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" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + 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: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + 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: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + 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: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.4 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..f02aa2b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,93 @@ +name: kickkings_support +description: "Kick Kings support staff inbox." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + dio: ^5.11.1 + flutter_secure_storage: ^11.2.0 + firebase_core: ^4.15.0 + firebase_messaging: ^16.7.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/account_test.dart b/test/account_test.dart new file mode 100644 index 0000000..ba03564 --- /dev/null +++ b/test/account_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kickkings_support/models/account.dart'; + +void main() { + test('admins can read and write support', () { + const account = Account( + id: '1', + username: 'ada', + isAdmin: true, + permissions: {}, + ); + + expect(account.canReadSupport, isTrue); + expect(account.canWriteSupport, isTrue); + }); + + test('support read does not grant write', () { + final account = Account.fromJson({ + 'id': '1', + 'username': 'ada', + 'isAdmin': false, + 'permissions': { + 'support': {'read': true, 'write': false}, + }, + }); + + expect(account.canReadSupport, isTrue); + expect(account.canWriteSupport, isFalse); + }); + + test('missing support permission cannot enter the inbox', () { + final account = Account.fromJson({ + 'id': '1', + 'username': 'ada', + 'isAdmin': false, + 'permissions': { + 'players': {'read': true}, + }, + }); + + expect(account.canReadSupport, isFalse); + expect(account.canWriteSupport, isFalse); + }); + + test('account json round trip keeps support flags', () { + final account = Account.fromJson({ + 'id': 'acct', + 'username': 'ada', + 'isAdmin': false, + 'permissions': { + 'support': {'read': true, 'write': true}, + }, + }); + + final restored = Account.fromJson(account.toJson()); + expect(restored.canReadSupport, isTrue); + expect(restored.canWriteSupport, isTrue); + expect(restored.username, 'ada'); + }); +} diff --git a/test/api_exception_test.dart b/test/api_exception_test.dart new file mode 100644 index 0000000..dd78f93 --- /dev/null +++ b/test/api_exception_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kickkings_support/models/api_exception.dart'; + +void main() { + test('reads the error string', () { + final error = ApiException.fromBody( + statusCode: 401, + body: {'error': 'Invalid credentials'}, + ); + + expect(error.statusCode, 401); + expect(error.message, 'Invalid credentials'); + expect(error.isUnauthorized, isTrue); + }); + + test('appends retryAfterSeconds', () { + final error = ApiException.fromBody( + statusCode: 429, + body: { + 'error': 'Too many failed login attempts. Try again later.', + 'retryAfterSeconds': 900, + }, + ); + + expect(error.retryAfterSeconds, 900); + expect( + error.message, + 'Too many failed login attempts. Try again later. Retry after 900 seconds.', + ); + }); + + test('uses the fallback when the body has no error', () { + final error = ApiException.fromBody( + statusCode: 503, + body: 'unavailable', + fallback: 'Could not reach the server.', + ); + + expect(error.message, 'Could not reach the server.'); + expect(error.retryAfterSeconds, isNull); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 0000000..7a622ea --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kickkings_support/config.dart'; +import 'package:kickkings_support/models/login_result.dart'; +import 'package:kickkings_support/models/support_message.dart'; +import 'package:kickkings_support/models/support_ticket.dart'; + +void main() { + test('normalizes a base url', () { + expect( + normalizeBaseUrl('https://kickkings.playpoolstudios.com/'), + 'https://kickkings.playpoolstudios.com', + ); + expect(normalizeBaseUrl(' https://example.com/// '), 'https://example.com'); + }); + + test('ticket query omits empty filters', () { + expect(ticketListQuery(status: 'open'), {'status': 'open'}); + expect(ticketListQuery(status: 'closed', category: 'bug'), { + 'status': 'closed', + 'category': 'bug', + }); + expect(ticketListQuery(), isEmpty); + }); + + test('parses a ticket and a thread', () { + final ticket = SupportTicket.fromJson({ + 'id': 'ticket-1', + 'userId': 42, + 'username': 'playername', + 'category': 'bug', + 'categoryLabel': 'Report Bug', + 'status': 'open', + 'createdAt': '2026-10-15T12:00:00.000Z', + 'updatedAt': '2026-10-15T12:00:00.000Z', + 'lastMessageAt': '2026-10-15T12:05:00.000Z', + 'lastMessagePreview': 'The match froze', + }); + + expect(ticket.userId, 42); + expect(ticket.displayCategory, 'Report Bug'); + expect(ticket.isClosed, isFalse); + expect( + ticket.lastMessageAt.toUtc().toIso8601String(), + '2026-10-15T12:05:00.000Z', + ); + + final thread = SupportThread.fromJson({ + 'ticket': { + 'id': 'ticket-1', + 'userId': 42, + 'username': 'playername', + 'category': 'bug', + 'categoryLabel': 'Report Bug', + 'status': 'closed', + 'createdAt': '2026-10-15T12:00:00.000Z', + 'updatedAt': '2026-10-15T12:10:00.000Z', + 'lastMessageAt': '2026-10-15T12:10:00.000Z', + 'lastMessagePreview': 'We are looking into this.', + }, + 'messages': [ + { + 'id': 'message-1', + 'ticketId': 'ticket-1', + 'authorKind': 'player', + 'authorName': 'playername', + 'body': 'The match froze', + 'createdAt': '2026-10-15T12:00:00.000Z', + }, + { + 'id': 'message-2', + 'ticketId': 'ticket-1', + 'authorKind': 'staff', + 'authorName': 'ada', + 'body': 'We are looking into this.', + 'createdAt': '2026-10-15T12:10:00.000Z', + }, + ], + }); + + expect(thread.ticket.isClosed, isTrue); + expect(thread.messages, hasLength(2)); + expect(thread.messages.first.isStaff, isFalse); + expect(thread.messages.last.isStaff, isTrue); + }); + + test('parses a login result', () { + final result = LoginResult.fromJson({ + 'ok': true, + 'token': 'session-token', + 'account': { + 'id': 'acct', + 'username': 'ada', + 'isAdmin': false, + 'permissions': { + 'support': {'read': true, 'write': true}, + }, + }, + }); + + expect(result.token, 'session-token'); + expect(result.account.canWriteSupport, isTrue); + }); +} diff --git a/test/reply_validation_test.dart b/test/reply_validation_test.dart new file mode 100644 index 0000000..1c7f6b9 --- /dev/null +++ b/test/reply_validation_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kickkings_support/models/support_message.dart'; + +void main() { + test('rejects an empty reply', () { + expect(validateReplyBody(' '), 'Enter a message.'); + }); + + test('accepts a trimmed reply up to 4000 characters', () { + expect(validateReplyBody(' hello '), isNull); + expect(validateReplyBody('a' * maxReplyLength), isNull); + }); + + test('rejects a reply over 4000 characters', () { + expect( + validateReplyBody('a' * (maxReplyLength + 1)), + 'Message must be 4000 characters or fewer.', + ); + }); +}