commit 031d4a4e17cfcfefeece20e020a8610e80678c5f Author: streaper2 Date: Sun Jan 18 13:38:09 2026 +0100 premier app version beta diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..596262b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter clean:*)", + "Bash(flutter pub get:*)", + "Bash(flutter run:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..8ee0d46 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2057a543d6b4005315f923612b136194dcb89d01" + channel: "main" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: android + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: ios + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: linux + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: macos + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: web + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + - platform: windows + create_revision: 2057a543d6b4005315f923612b136194dcb89d01 + base_revision: 2057a543d6b4005315f923612b136194dcb89d01 + + # 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/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2ba986f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02dde84 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Flutter application called "bully" targeting multiple platforms (Android, iOS, Linux, macOS, Web, Windows). + +## Build Commands + +```bash +# Get dependencies +flutter pub get + +# Run the app (debug mode) +flutter run + +# Run on a specific device +flutter run -d + +# Build for release +flutter build apk # Android +flutter build ios # iOS +flutter build web # Web +flutter build windows # Windows +flutter build macos # macOS +flutter build linux # Linux + +# Analyze code for issues +flutter analyze + +# Run all tests +flutter test + +# Run a single test file +flutter test test/widget_test.dart + +# Run tests with coverage +flutter test --coverage +``` + +## Features + +### Analyse de cibles de tir +- Support de cibles concentriques (anneaux) et silhouettes +- Chargement d'images de cibles depuis la galerie ou la caméra +- Détection automatique du centre et du rayon de la cible + +### Calibration des cibles +- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10) +- **Calibration individuelle des anneaux** : cliquer sur un anneau pour l'ajuster indépendamment +- Slider global pour redimensionner tous les anneaux proportionnellement +- Visualisation en temps réel des zones de score + +### Détection d'impacts +- **Ajout manuel** : cliquer sur l'image pour placer un impact +- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables + - Seuil de luminosité + - Taille min/max des impacts + - Circularité minimale + - Ratio de remplissage (distingue les trous pleins des cercles vides) +- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires + +### Calcul des scores +- Score automatique basé sur la position de l'impact dans les zones +- Cibles concentriques : score de 10 (centre) à 1 (bord externe) +- Support des anneaux calibrés individuellement pour un calcul précis +- Affichage du score total et de la distribution + +### Analyse de groupement +- Calcul du diamètre de groupement +- Visualisation du cercle de groupement +- Centre de groupement affiché + +### Statistiques +- **Zones chaudes (Heat Map)** : visualisation en brouillard avec gradient bleu (froid) à rouge (chaud) +- **Précision** : pourcentage de précision, score moyen +- **Écart-type** : dispersion horizontale et verticale +- **Distribution régionale** : répartition des tirs par quadrant +- Filtrage par période : session, semaine, mois, toutes les sessions + +### Historique des sessions +- Sauvegarde des sessions avec date, score, notes +- Visualisation des sessions passées +- Suppression de sessions + +### Interface utilisateur +- Thème sombre adapté au tir +- Support multilingue (Français) +- Interface responsive pour mobile et desktop + +## Architecture + +Standard Flutter project structure: +- `lib/` - Dart source code (entry point: `lib/main.dart`) +- `test/` - Widget and unit tests +- Platform directories: `android/`, `ios/`, `linux/`, `macos/`, `web/`, `windows/` + +The app uses `flutter_lints` for static analysis (configured in `analysis_options.yaml`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a52234 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# bully + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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 + +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..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..6237c24 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.bully" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.bully" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/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..86b2724 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/bully/MainActivity.kt b/android/app/src/main/kotlin/com/example/bully/MainActivity.kt new file mode 100644 index 0000000..dfaa7e2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/bully/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.bully + +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..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /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-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/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..a8734a5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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 */; }; + 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 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 */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.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 = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/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..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + 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..0ae7f1a --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bully + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + bully + 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 + + 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/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/app.dart b/lib/app.dart new file mode 100644 index 0000000..11c305c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'core/theme/app_theme.dart'; +import 'features/home/home_screen.dart'; + +class BullyApp extends StatelessWidget { + const BullyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Bully - Analyse de Cibles', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + home: const HomeScreen(), + ); + } +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..b20339f --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,48 @@ +class AppConstants { + AppConstants._(); + + // Database + static const String databaseName = 'bully_targets.db'; + static const int databaseVersion = 1; + + // Tables + static const String sessionsTable = 'sessions'; + static const String shotsTable = 'shots'; + + // Image processing + static const double minImpactRadius = 2.0; + static const double maxImpactRadius = 20.0; + static const int gaussianBlurSize = 5; + static const double houghCirclesDp = 1.0; + static const double houghCirclesMinDist = 20.0; + static const int houghCirclesParam1 = 50; + static const int houghCirclesParam2 = 30; + + // Scoring zones for concentric targets (10 zones, from center) + static const List concentricZoneRadii = [ + 0.05, // Zone 10 (bullseye) + 0.10, // Zone 9 + 0.15, // Zone 8 + 0.20, // Zone 7 + 0.25, // Zone 6 + 0.30, // Zone 5 + 0.40, // Zone 4 + 0.50, // Zone 3 + 0.65, // Zone 2 + 0.80, // Zone 1 + ]; + + // Silhouette scoring zones (as percentage of height from top) + static const Map silhouetteZones = { + 'head': 0.15, // Top 15% = head = 5 points + 'center': 0.45, // 15-45% = center mass = 5 points + 'body': 0.70, // 45-70% = body = 4 points + 'lower': 1.0, // 70-100% = lower = 3 points + }; + + // UI + static const double defaultPadding = 16.0; + static const double smallPadding = 8.0; + static const double largePadding = 24.0; + static const double borderRadius = 12.0; +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..e6b7d63 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static const Color primaryColor = Color(0xFF1E88E5); + static const Color secondaryColor = Color(0xFF43A047); + static const Color errorColor = Color(0xFFE53935); + static const Color warningColor = Color(0xFFFFA726); + static const Color successColor = Color(0xFF66BB6A); + + static const Color backgroundColor = Color(0xFFF5F5F5); + static const Color surfaceColor = Colors.white; + static const Color textPrimary = Color(0xFF212121); + static const Color textSecondary = Color(0xFF757575); + + // Impact colors for visualization + static const Color impactColor = Color(0xFFFF5722); + static const Color impactOutlineColor = Color(0xFFFFFFFF); + static const Color groupingCenterColor = Color(0xFF2196F3); + static const Color groupingCircleColor = Color(0x4D2196F3); + + // Score zone colors + static const List zoneColors = [ + Color(0xFFFFEB3B), // Zone 10 - Gold + Color(0xFFFFEB3B), // Zone 9 + Color(0xFFFF5722), // Zone 8 + Color(0xFFFF5722), // Zone 7 + Color(0xFF2196F3), // Zone 6 + Color(0xFF2196F3), // Zone 5 + Color(0xFF4CAF50), // Zone 4 + Color(0xFF4CAF50), // Zone 3 + Color(0xFFFFFFFF), // Zone 2 + Color(0xFFFFFFFF), // Zone 1 + ]; + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + scaffoldBackgroundColor: backgroundColor, + appBarTheme: const AppBarTheme( + elevation: 0, + centerTitle: true, + backgroundColor: primaryColor, + foregroundColor: Colors.white, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + elevation: 0, + centerTitle: true, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } +} diff --git a/lib/data/database/database_helper.dart b/lib/data/database/database_helper.dart new file mode 100644 index 0000000..71f97ae --- /dev/null +++ b/lib/data/database/database_helper.dart @@ -0,0 +1,254 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import '../models/session.dart'; +import '../models/shot.dart'; +import '../../core/constants/app_constants.dart'; + +class DatabaseHelper { + static DatabaseHelper? _instance; + static Database? _database; + + DatabaseHelper._internal(); + + factory DatabaseHelper() { + _instance ??= DatabaseHelper._internal(); + return _instance!; + } + + Future get database async { + _database ??= await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + final databasesPath = await getDatabasesPath(); + final path = join(databasesPath, AppConstants.databaseName); + + return await openDatabase( + path, + version: AppConstants.databaseVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + } + + Future _onCreate(Database db, int version) async { + await db.execute(''' + CREATE TABLE ${AppConstants.sessionsTable} ( + id TEXT PRIMARY KEY, + target_type TEXT NOT NULL, + image_path TEXT NOT NULL, + total_score INTEGER NOT NULL, + grouping_diameter REAL, + grouping_center_x REAL, + grouping_center_y REAL, + created_at TEXT NOT NULL, + notes TEXT, + target_center_x REAL, + target_center_y REAL, + target_radius REAL + ) + '''); + + await db.execute(''' + CREATE TABLE ${AppConstants.shotsTable} ( + id TEXT PRIMARY KEY, + x REAL NOT NULL, + y REAL NOT NULL, + score INTEGER NOT NULL, + session_id TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE + ) + '''); + + await db.execute(''' + CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id) + '''); + + await db.execute(''' + CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC) + '''); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // Handle future database migrations here + } + + // Session operations + Future insertSession(Session session) async { + final db = await database; + return await db.transaction((txn) async { + await txn.insert( + AppConstants.sessionsTable, + session.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + for (final shot in session.shots) { + // Ensure shot has correct session_id + final shotWithSessionId = shot.copyWith(sessionId: session.id); + await txn.insert( + AppConstants.shotsTable, + shotWithSessionId.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + return 1; + }); + } + + Future getSession(String id) async { + final db = await database; + final sessionMaps = await db.query( + AppConstants.sessionsTable, + where: 'id = ?', + whereArgs: [id], + ); + + if (sessionMaps.isEmpty) return null; + + final shotMaps = await db.query( + AppConstants.shotsTable, + where: 'session_id = ?', + whereArgs: [id], + ); + + final shots = shotMaps.map((map) => Shot.fromMap(map)).toList(); + return Session.fromMap(sessionMaps.first, shots); + } + + Future> getAllSessions({ + String? targetType, + int? limit, + int? offset, + }) async { + final db = await database; + + String? whereClause; + List? whereArgs; + + if (targetType != null) { + whereClause = 'target_type = ?'; + whereArgs = [targetType]; + } + + final sessionMaps = await db.query( + AppConstants.sessionsTable, + where: whereClause, + whereArgs: whereArgs, + orderBy: 'created_at DESC', + limit: limit, + offset: offset, + ); + + // First, check if there are orphaned shots (with empty session_id) + // and only one session - if so, assign them to that session + if (sessionMaps.length == 1) { + final orphanedShots = await db.query( + AppConstants.shotsTable, + where: 'session_id = ?', + whereArgs: [''], + ); + if (orphanedShots.isNotEmpty) { + await db.update( + AppConstants.shotsTable, + {'session_id': sessionMaps.first['id']}, + where: 'session_id = ?', + whereArgs: [''], + ); + } + } + + final sessions = []; + for (final sessionMap in sessionMaps) { + final sessionId = sessionMap['id'] as String; + + final shotMaps = await db.query( + AppConstants.shotsTable, + where: 'session_id = ?', + whereArgs: [sessionId], + ); + + final shots = shotMaps.map((map) => Shot.fromMap(map)).toList(); + sessions.add(Session.fromMap(sessionMap, shots)); + } + + return sessions; + } + + Future updateSession(Session session) async { + final db = await database; + return await db.transaction((txn) async { + await txn.update( + AppConstants.sessionsTable, + session.toMap(), + where: 'id = ?', + whereArgs: [session.id], + ); + + // Delete existing shots and insert new ones + await txn.delete( + AppConstants.shotsTable, + where: 'session_id = ?', + whereArgs: [session.id], + ); + + for (final shot in session.shots) { + // Ensure shot has correct session_id + final shotWithSessionId = shot.copyWith(sessionId: session.id); + await txn.insert( + AppConstants.shotsTable, + shotWithSessionId.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + return 1; + }); + } + + Future deleteSession(String id) async { + final db = await database; + return await db.delete( + AppConstants.sessionsTable, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future> getStatistics() async { + final db = await database; + + final totalSessions = Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.sessionsTable}'), + ) ?? 0; + + final totalShots = Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.shotsTable}'), + ) ?? 0; + + final avgScore = (await db.rawQuery( + 'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}', + )).first['avg'] as double? ?? 0.0; + + final bestScore = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}', + ), + ) ?? 0; + + return { + 'totalSessions': totalSessions, + 'totalShots': totalShots, + 'averageScore': avgScore, + 'bestScore': bestScore, + }; + } + + Future close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart new file mode 100644 index 0000000..73ada3c --- /dev/null +++ b/lib/data/models/session.dart @@ -0,0 +1,112 @@ +import 'shot.dart'; +import 'target_type.dart'; + +class Session { + final String id; + final TargetType targetType; + final String imagePath; + final List shots; + final int totalScore; + final double? groupingDiameter; + final double? groupingCenterX; + final double? groupingCenterY; + final DateTime createdAt; + final String? notes; + + // Target detection data + final double? targetCenterX; + final double? targetCenterY; + final double? targetRadius; + + Session({ + required this.id, + required this.targetType, + required this.imagePath, + required this.shots, + required this.totalScore, + this.groupingDiameter, + this.groupingCenterX, + this.groupingCenterY, + required this.createdAt, + this.notes, + this.targetCenterX, + this.targetCenterY, + this.targetRadius, + }); + + int get shotCount => shots.length; + + double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length; + + Session copyWith({ + String? id, + TargetType? targetType, + String? imagePath, + List? shots, + int? totalScore, + double? groupingDiameter, + double? groupingCenterX, + double? groupingCenterY, + DateTime? createdAt, + String? notes, + double? targetCenterX, + double? targetCenterY, + double? targetRadius, + }) { + return Session( + id: id ?? this.id, + targetType: targetType ?? this.targetType, + imagePath: imagePath ?? this.imagePath, + shots: shots ?? this.shots, + totalScore: totalScore ?? this.totalScore, + groupingDiameter: groupingDiameter ?? this.groupingDiameter, + groupingCenterX: groupingCenterX ?? this.groupingCenterX, + groupingCenterY: groupingCenterY ?? this.groupingCenterY, + createdAt: createdAt ?? this.createdAt, + notes: notes ?? this.notes, + targetCenterX: targetCenterX ?? this.targetCenterX, + targetCenterY: targetCenterY ?? this.targetCenterY, + targetRadius: targetRadius ?? this.targetRadius, + ); + } + + Map toMap() { + return { + 'id': id, + 'target_type': targetType.name, + 'image_path': imagePath, + 'total_score': totalScore, + 'grouping_diameter': groupingDiameter, + 'grouping_center_x': groupingCenterX, + 'grouping_center_y': groupingCenterY, + 'created_at': createdAt.toIso8601String(), + 'notes': notes, + 'target_center_x': targetCenterX, + 'target_center_y': targetCenterY, + 'target_radius': targetRadius, + }; + } + + factory Session.fromMap(Map map, List shots) { + return Session( + id: map['id'] as String, + targetType: TargetType.fromString(map['target_type'] as String), + imagePath: map['image_path'] as String, + shots: shots, + totalScore: map['total_score'] as int, + groupingDiameter: map['grouping_diameter'] as double?, + groupingCenterX: map['grouping_center_x'] as double?, + groupingCenterY: map['grouping_center_y'] as double?, + createdAt: DateTime.parse(map['created_at'] as String), + notes: map['notes'] as String?, + targetCenterX: map['target_center_x'] as double?, + targetCenterY: map['target_center_y'] as double?, + targetRadius: map['target_radius'] as double?, + ); + } + + @override + String toString() { + return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)'; + } +} diff --git a/lib/data/models/shot.dart b/lib/data/models/shot.dart new file mode 100644 index 0000000..fbb1616 --- /dev/null +++ b/lib/data/models/shot.dart @@ -0,0 +1,72 @@ +class Shot { + final String id; + final double x; // Relative position (0.0 - 1.0) + final double y; // Relative position (0.0 - 1.0) + final int score; + final String sessionId; + + Shot({ + required this.id, + required this.x, + required this.y, + required this.score, + required this.sessionId, + }); + + Shot copyWith({ + String? id, + double? x, + double? y, + int? score, + String? sessionId, + }) { + return Shot( + id: id ?? this.id, + x: x ?? this.x, + y: y ?? this.y, + score: score ?? this.score, + sessionId: sessionId ?? this.sessionId, + ); + } + + Map toMap() { + return { + 'id': id, + 'x': x, + 'y': y, + 'score': score, + 'session_id': sessionId, + }; + } + + factory Shot.fromMap(Map map) { + return Shot( + id: map['id'] as String, + x: (map['x'] as num).toDouble(), + y: (map['y'] as num).toDouble(), + score: map['score'] as int, + sessionId: map['session_id'] as String, + ); + } + + @override + String toString() { + return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Shot && + other.id == id && + other.x == x && + other.y == y && + other.score == score && + other.sessionId == sessionId; + } + + @override + int get hashCode { + return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode; + } +} diff --git a/lib/data/models/target.dart b/lib/data/models/target.dart new file mode 100644 index 0000000..1787d20 --- /dev/null +++ b/lib/data/models/target.dart @@ -0,0 +1,84 @@ +import 'target_type.dart'; + +class Target { + final String id; + final TargetType type; + final String imagePath; + final DateTime createdAt; + + // Detected target bounds (relative coordinates 0.0-1.0) + final double? centerX; + final double? centerY; + final double? radius; // For concentric targets + final double? width; // For silhouette targets + final double? height; // For silhouette targets + + Target({ + required this.id, + required this.type, + required this.imagePath, + required this.createdAt, + this.centerX, + this.centerY, + this.radius, + this.width, + this.height, + }); + + Target copyWith({ + String? id, + TargetType? type, + String? imagePath, + DateTime? createdAt, + double? centerX, + double? centerY, + double? radius, + double? width, + double? height, + }) { + return Target( + id: id ?? this.id, + type: type ?? this.type, + imagePath: imagePath ?? this.imagePath, + createdAt: createdAt ?? this.createdAt, + centerX: centerX ?? this.centerX, + centerY: centerY ?? this.centerY, + radius: radius ?? this.radius, + width: width ?? this.width, + height: height ?? this.height, + ); + } + + Map toMap() { + return { + 'id': id, + 'type': type.name, + 'image_path': imagePath, + 'created_at': createdAt.toIso8601String(), + 'center_x': centerX, + 'center_y': centerY, + 'radius': radius, + 'width': width, + 'height': height, + }; + } + + factory Target.fromMap(Map map) { + return Target( + id: map['id'] as String, + type: TargetType.fromString(map['type'] as String), + imagePath: map['image_path'] as String, + createdAt: DateTime.parse(map['created_at'] as String), + centerX: map['center_x'] as double?, + centerY: map['center_y'] as double?, + radius: map['radius'] as double?, + width: map['width'] as double?, + height: map['height'] as double?, + ); + } + + @override + String toString() { + return 'Target(id: $id, type: $type, imagePath: $imagePath, createdAt: $createdAt)'; + } +} diff --git a/lib/data/models/target_type.dart b/lib/data/models/target_type.dart new file mode 100644 index 0000000..0bcc0d0 --- /dev/null +++ b/lib/data/models/target_type.dart @@ -0,0 +1,16 @@ +enum TargetType { + concentric('Concentrique', 'Cible avec anneaux concentriques'), + silhouette('Silhouette', 'Cible en forme de silhouette'); + + final String displayName; + final String description; + + const TargetType(this.displayName, this.description); + + static TargetType fromString(String value) { + return TargetType.values.firstWhere( + (type) => type.name == value, + orElse: () => TargetType.concentric, + ); + } +} diff --git a/lib/data/repositories/session_repository.dart b/lib/data/repositories/session_repository.dart new file mode 100644 index 0000000..7912a78 --- /dev/null +++ b/lib/data/repositories/session_repository.dart @@ -0,0 +1,112 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:uuid/uuid.dart'; +import '../database/database_helper.dart'; +import '../models/session.dart'; +import '../models/shot.dart'; +import '../models/target_type.dart'; + +class SessionRepository { + final DatabaseHelper _databaseHelper; + final Uuid _uuid; + + SessionRepository({ + DatabaseHelper? databaseHelper, + Uuid? uuid, + }) : _databaseHelper = databaseHelper ?? DatabaseHelper(), + _uuid = uuid ?? const Uuid(); + + Future createSession({ + required TargetType targetType, + required String imagePath, + required List shots, + required int totalScore, + double? groupingDiameter, + double? groupingCenterX, + double? groupingCenterY, + String? notes, + double? targetCenterX, + double? targetCenterY, + double? targetRadius, + }) async { + // Copy image to app documents directory + final savedImagePath = await _saveImage(imagePath); + + final session = Session( + id: _uuid.v4(), + targetType: targetType, + imagePath: savedImagePath, + shots: shots, + totalScore: totalScore, + groupingDiameter: groupingDiameter, + groupingCenterX: groupingCenterX, + groupingCenterY: groupingCenterY, + createdAt: DateTime.now(), + notes: notes, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius, + ); + + await _databaseHelper.insertSession(session); + return session; + } + + Future _saveImage(String sourcePath) async { + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory(path.join(appDir.path, 'target_images')); + + if (!await imagesDir.exists()) { + await imagesDir.create(recursive: true); + } + + final fileName = '${_uuid.v4()}${path.extension(sourcePath)}'; + final destPath = path.join(imagesDir.path, fileName); + + final sourceFile = File(sourcePath); + await sourceFile.copy(destPath); + + return destPath; + } + + Future getSession(String id) async { + return await _databaseHelper.getSession(id); + } + + Future> getAllSessions({ + TargetType? targetType, + int? limit, + int? offset, + }) async { + return await _databaseHelper.getAllSessions( + targetType: targetType?.name, + limit: limit, + offset: offset, + ); + } + + Future updateSession(Session session) async { + await _databaseHelper.updateSession(session); + } + + Future deleteSession(String id) async { + final session = await getSession(id); + if (session != null) { + // Delete the image file + final imageFile = File(session.imagePath); + if (await imageFile.exists()) { + await imageFile.delete(); + } + } + await _databaseHelper.deleteSession(id); + } + + Future> getStatistics() async { + return await _databaseHelper.getStatistics(); + } + + String generateShotId() { + return _uuid.v4(); + } +} diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart new file mode 100644 index 0000000..a1646bb --- /dev/null +++ b/lib/features/analysis/analysis_provider.dart @@ -0,0 +1,431 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../data/models/session.dart'; +import '../../data/models/shot.dart'; +import '../../data/models/target_type.dart'; +import '../../data/repositories/session_repository.dart'; +import '../../services/target_detection_service.dart'; +import '../../services/score_calculator_service.dart'; +import '../../services/grouping_analyzer_service.dart'; + +enum AnalysisState { initial, loading, success, error } + +class AnalysisProvider extends ChangeNotifier { + final TargetDetectionService _detectionService; + final ScoreCalculatorService _scoreCalculatorService; + final GroupingAnalyzerService _groupingAnalyzerService; + final SessionRepository _sessionRepository; + final Uuid _uuid = const Uuid(); + + AnalysisProvider({ + required TargetDetectionService detectionService, + required ScoreCalculatorService scoreCalculatorService, + required GroupingAnalyzerService groupingAnalyzerService, + required SessionRepository sessionRepository, + }) : _detectionService = detectionService, + _scoreCalculatorService = scoreCalculatorService, + _groupingAnalyzerService = groupingAnalyzerService, + _sessionRepository = sessionRepository; + + AnalysisState _state = AnalysisState.initial; + String? _errorMessage; + String? _imagePath; + TargetType? _targetType; + + // Target detection results + double _targetCenterX = 0.5; + double _targetCenterY = 0.5; + double _targetRadius = 0.4; + int _ringCount = 10; + List? _ringRadii; // Individual ring radii multipliers + double _imageAspectRatio = 1.0; // width / height + + // Shots + List _shots = []; + + // Score results + ScoreResult? _scoreResult; + + // Grouping results + GroupingResult? _groupingResult; + + // Reference-based detection + List _referenceImpacts = []; + ImpactCharacteristics? _learnedCharacteristics; + + // Getters + AnalysisState get state => _state; + String? get errorMessage => _errorMessage; + String? get imagePath => _imagePath; + TargetType? get targetType => _targetType; + double get targetCenterX => _targetCenterX; + double get targetCenterY => _targetCenterY; + double get targetRadius => _targetRadius; + int get ringCount => _ringCount; + List? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null; + double get imageAspectRatio => _imageAspectRatio; + List get shots => List.unmodifiable(_shots); + ScoreResult? get scoreResult => _scoreResult; + GroupingResult? get groupingResult => _groupingResult; + + int get totalScore => _scoreResult?.totalScore ?? 0; + int get shotCount => _shots.length; + List get referenceImpacts => List.unmodifiable(_referenceImpacts); + ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics; + bool get hasLearnedCharacteristics => _learnedCharacteristics != null; + + /// Analyze an image + Future analyzeImage(String imagePath, TargetType targetType) async { + _state = AnalysisState.loading; + _imagePath = imagePath; + _targetType = targetType; + _errorMessage = null; + notifyListeners(); + + try { + // Load image to get dimensions + final file = File(imagePath); + final bytes = await file.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + _imageAspectRatio = frame.image.width / frame.image.height; + frame.image.dispose(); + + // Detect target and impacts + final result = _detectionService.detectTarget(imagePath, targetType); + + if (!result.success) { + _state = AnalysisState.error; + _errorMessage = result.errorMessage; + notifyListeners(); + return; + } + + _targetCenterX = result.centerX; + _targetCenterY = result.centerY; + _targetRadius = result.radius; + + // Create shots from detected impacts + _shots = result.impacts.map((impact) { + return Shot( + id: _uuid.v4(), + x: impact.x, + y: impact.y, + score: impact.suggestedScore, + sessionId: '', // Will be set when saving + ); + }).toList(); + + // Calculate scores + _recalculateScores(); + + // Calculate grouping + _recalculateGrouping(); + + _state = AnalysisState.success; + notifyListeners(); + } catch (e) { + _state = AnalysisState.error; + _errorMessage = 'Erreur d\'analyse: $e'; + notifyListeners(); + } + } + + /// Add a manual shot + void addShot(double x, double y) { + final score = _calculateShotScore(x, y); + final shot = Shot( + id: _uuid.v4(), + x: x, + y: y, + score: score, + sessionId: '', + ); + + _shots.add(shot); + _recalculateScores(); + _recalculateGrouping(); + notifyListeners(); + } + + /// Remove a shot + void removeShot(String shotId) { + _shots.removeWhere((shot) => shot.id == shotId); + _recalculateScores(); + _recalculateGrouping(); + notifyListeners(); + } + + /// Move a shot to a new position + void moveShot(String shotId, double newX, double newY) { + final index = _shots.indexWhere((shot) => shot.id == shotId); + if (index == -1) return; + + final newScore = _calculateShotScore(newX, newY); + _shots[index] = _shots[index].copyWith( + x: newX, + y: newY, + score: newScore, + ); + + _recalculateScores(); + _recalculateGrouping(); + notifyListeners(); + } + + /// Auto-detect impacts using image processing + Future autoDetectImpacts({ + int darkThreshold = 80, + int minImpactSize = 20, + int maxImpactSize = 500, + double minCircularity = 0.6, + double minFillRatio = 0.5, + bool clearExisting = false, + }) async { + if (_imagePath == null || _targetType == null) return 0; + + final settings = ImpactDetectionSettings( + darkThreshold: darkThreshold, + minImpactSize: minImpactSize, + maxImpactSize: maxImpactSize, + minCircularity: minCircularity, + minFillRatio: minFillRatio, + ); + + final detectedImpacts = _detectionService.detectImpactsOnly( + _imagePath!, + _targetType!, + _targetCenterX, + _targetCenterY, + _targetRadius, + _ringCount, + settings, + ); + + if (clearExisting) { + _shots.clear(); + } + + // Add detected impacts as shots + for (final impact in detectedImpacts) { + final score = _calculateShotScore(impact.x, impact.y); + final shot = Shot( + id: _uuid.v4(), + x: impact.x, + y: impact.y, + score: score, + sessionId: '', + ); + _shots.add(shot); + } + + _recalculateScores(); + _recalculateGrouping(); + notifyListeners(); + + return detectedImpacts.length; + } + + /// Add a reference impact for calibrated detection + void addReferenceImpact(double x, double y) { + final score = _calculateShotScore(x, y); + final shot = Shot( + id: _uuid.v4(), + x: x, + y: y, + score: score, + sessionId: '', + ); + _referenceImpacts.add(shot); + notifyListeners(); + } + + /// Remove a reference impact + void removeReferenceImpact(String shotId) { + _referenceImpacts.removeWhere((shot) => shot.id == shotId); + _learnedCharacteristics = null; + notifyListeners(); + } + + /// Clear all reference impacts + void clearReferenceImpacts() { + _referenceImpacts.clear(); + _learnedCharacteristics = null; + notifyListeners(); + } + + /// Learn characteristics from reference impacts + bool learnFromReferences() { + if (_imagePath == null || _referenceImpacts.length < 2) return false; + + final references = _referenceImpacts + .map((shot) => ReferenceImpact(x: shot.x, y: shot.y)) + .toList(); + + _learnedCharacteristics = _detectionService.analyzeReferenceImpacts( + _imagePath!, + references, + ); + + notifyListeners(); + return _learnedCharacteristics != null; + } + + /// Auto-detect impacts using learned reference characteristics + Future detectFromReferences({ + double tolerance = 2.0, + bool clearExisting = false, + }) async { + if (_imagePath == null || _targetType == null || _learnedCharacteristics == null) { + return 0; + } + + final detectedImpacts = _detectionService.detectImpactsFromReferences( + _imagePath!, + _targetType!, + _targetCenterX, + _targetCenterY, + _targetRadius, + _ringCount, + _learnedCharacteristics!, + tolerance: tolerance, + ); + + if (clearExisting) { + _shots.clear(); + } + + // Add detected impacts as shots + for (final impact in detectedImpacts) { + final score = _calculateShotScore(impact.x, impact.y); + final shot = Shot( + id: _uuid.v4(), + x: impact.x, + y: impact.y, + score: score, + sessionId: '', + ); + _shots.add(shot); + } + + _recalculateScores(); + _recalculateGrouping(); + notifyListeners(); + + return detectedImpacts.length; + } + + /// Adjust target position + void adjustTargetPosition(double centerX, double centerY, double radius, {int? ringCount, List? ringRadii}) { + _targetCenterX = centerX; + _targetCenterY = centerY; + _targetRadius = radius; + if (ringCount != null) { + _ringCount = ringCount; + } + if (ringRadii != null) { + _ringRadii = ringRadii; + } + + // Recalculate all shot scores based on new target position + _shots = _shots.map((shot) { + final newScore = _calculateShotScore(shot.x, shot.y); + return shot.copyWith(score: newScore); + }).toList(); + + _recalculateScores(); + notifyListeners(); + } + + int _calculateShotScore(double x, double y) { + if (_targetType == TargetType.concentric) { + return _scoreCalculatorService.calculateConcentricScore( + shotX: x, + shotY: y, + targetCenterX: _targetCenterX, + targetCenterY: _targetCenterY, + targetRadius: _targetRadius, + ringCount: _ringCount, + imageAspectRatio: _imageAspectRatio, + ringRadii: _ringRadii, + ); + } else { + return _scoreCalculatorService.calculateSilhouetteScore( + shotX: x, + shotY: y, + targetCenterX: _targetCenterX, + targetCenterY: _targetCenterY, + targetWidth: _targetRadius * 0.8, + targetHeight: _targetRadius * 2, + ); + } + } + + void _recalculateScores() { + if (_targetType == null) return; + + _scoreResult = _scoreCalculatorService.calculateScores( + shots: _shots, + targetType: _targetType!, + targetCenterX: _targetCenterX, + targetCenterY: _targetCenterY, + targetRadius: _targetRadius, + ringCount: _ringCount, + imageAspectRatio: _imageAspectRatio, + ringRadii: _ringRadii, + ); + } + + void _recalculateGrouping() { + _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); + } + + /// Save the session + Future saveSession({String? notes}) async { + if (_imagePath == null || _targetType == null) { + throw Exception('Cannot save: missing image or target type'); + } + + final session = await _sessionRepository.createSession( + targetType: _targetType!, + imagePath: _imagePath!, + shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(), + totalScore: totalScore, + groupingDiameter: _groupingResult?.diameter, + groupingCenterX: _groupingResult?.centerX, + groupingCenterY: _groupingResult?.centerY, + notes: notes, + targetCenterX: _targetCenterX, + targetCenterY: _targetCenterY, + targetRadius: _targetRadius, + ); + + // Update shots with session ID + _shots = session.shots; + notifyListeners(); + + return session; + } + + /// Reset the provider + void reset() { + _state = AnalysisState.initial; + _errorMessage = null; + _imagePath = null; + _targetType = null; + _targetCenterX = 0.5; + _targetCenterY = 0.5; + _targetRadius = 0.4; + _ringCount = 10; + _ringRadii = null; + _imageAspectRatio = 1.0; + _shots = []; + _scoreResult = null; + _groupingResult = null; + _referenceImpacts = []; + _learnedCharacteristics = null; + notifyListeners(); + } +} diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart new file mode 100644 index 0000000..7473ab6 --- /dev/null +++ b/lib/features/analysis/analysis_screen.dart @@ -0,0 +1,1066 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/target_type.dart'; +import '../../data/repositories/session_repository.dart'; +import '../../services/target_detection_service.dart'; +import '../../services/score_calculator_service.dart'; +import '../../services/grouping_analyzer_service.dart'; +import 'analysis_provider.dart'; +import 'widgets/target_overlay.dart'; +import 'widgets/target_calibration.dart'; +import 'widgets/score_card.dart'; +import 'widgets/grouping_stats.dart'; + +class AnalysisScreen extends StatelessWidget { + final String imagePath; + final TargetType targetType; + + const AnalysisScreen({ + super.key, + required this.imagePath, + required this.targetType, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => AnalysisProvider( + detectionService: context.read(), + scoreCalculatorService: context.read(), + groupingAnalyzerService: context.read(), + sessionRepository: context.read(), + )..analyzeImage(imagePath, targetType), + child: const _AnalysisScreenContent(), + ); + } +} + +class _AnalysisScreenContent extends StatefulWidget { + const _AnalysisScreenContent(); + + @override + State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState(); +} + +class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { + bool _isCalibrating = false; + bool _isSelectingReferences = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isCalibrating ? 'Calibration' : 'Analyse'), + actions: [ + Consumer( + builder: (context, provider, _) { + if (provider.state != AnalysisState.success) return const SizedBox.shrink(); + return IconButton( + icon: Icon(_isCalibrating ? Icons.check : Icons.tune), + onPressed: () { + setState(() => _isCalibrating = !_isCalibrating); + }, + tooltip: _isCalibrating ? 'Terminer calibration' : 'Calibrer la cible', + color: _isCalibrating ? AppTheme.successColor : null, + ); + }, + ), + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: () => _showHelpDialog(context), + tooltip: 'Aide', + ), + ], + ), + body: Consumer( + builder: (context, provider, _) { + switch (provider.state) { + case AnalysisState.initial: + case AnalysisState.loading: + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Analyse en cours...'), + ], + ), + ); + case AnalysisState.error: + return Center( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: AppTheme.errorColor), + const SizedBox(height: 16), + Text( + provider.errorMessage ?? 'Une erreur est survenue', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Retour'), + ), + ], + ), + ), + ); + case AnalysisState.success: + return _buildSuccessContent(context, provider); + } + }, + ), + floatingActionButton: Consumer( + builder: (context, provider, _) { + if (provider.state != AnalysisState.success) return const SizedBox.shrink(); + if (_isCalibrating) { + return FloatingActionButton.extended( + onPressed: () { + setState(() => _isCalibrating = false); + }, + backgroundColor: AppTheme.successColor, + icon: const Icon(Icons.check), + label: const Text('Valider'), + ); + } + return FloatingActionButton.extended( + onPressed: () => _saveSession(context, provider), + icon: const Icon(Icons.save), + label: const Text('Sauvegarder'), + ); + }, + ), + ); + } + + Widget _buildSuccessContent(BuildContext context, AnalysisProvider provider) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Calibration mode indicator + if (_isCalibrating) + Container( + color: AppTheme.warningColor, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: const Row( + children: [ + Icon(Icons.tune, color: Colors.white, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + 'Mode Calibration - Ajustez le centre et la taille de la cible', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + + // Reference selection mode indicator + if (_isSelectingReferences) + Container( + color: Colors.deepPurple, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + const Icon(Icons.touch_app, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Selectionnez ${provider.referenceImpacts.length}/3-4 impacts de reference', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + if (provider.referenceImpacts.isNotEmpty) + TextButton( + onPressed: () => provider.clearReferenceImpacts(), + child: const Text('Effacer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + + // Calibration sliders (shown only in calibration mode) + if (_isCalibrating) + Container( + color: Colors.black87, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + // Ring count slider + Row( + children: [ + const Icon(Icons.radio_button_unchecked, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Text('Anneaux:', style: TextStyle(color: Colors.white)), + Expanded( + child: Slider( + value: provider.ringCount.toDouble(), + min: 3, + max: 12, + divisions: 9, + label: '${provider.ringCount}', + activeColor: AppTheme.primaryColor, + onChanged: (value) { + provider.adjustTargetPosition( + provider.targetCenterX, + provider.targetCenterY, + provider.targetRadius, + ringCount: value.round(), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primaryColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${provider.ringCount}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ], + ), + // Target size slider + Row( + children: [ + const Icon(Icons.zoom_out_map, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Text('Taille:', style: TextStyle(color: Colors.white)), + Expanded( + child: Slider( + value: provider.targetRadius.clamp(0.05, 3.0), + min: 0.05, + max: 3.0, + label: '${(provider.targetRadius * 100).toStringAsFixed(0)}%', + activeColor: AppTheme.warningColor, + onChanged: (value) { + provider.adjustTargetPosition( + provider.targetCenterX, + provider.targetCenterY, + value, + ringCount: provider.ringCount, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.warningColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${(provider.targetRadius * 100).toStringAsFixed(0)}%', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ], + ), + ), + + // Target image with overlay or calibration + AspectRatio( + aspectRatio: provider.imageAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.imagePath!), + fit: BoxFit.fill, + ), + if (_isCalibrating) + TargetCalibration( + initialCenterX: provider.targetCenterX, + initialCenterY: provider.targetCenterY, + initialRadius: provider.targetRadius, + initialRingCount: provider.ringCount, + initialRingRadii: provider.ringRadii, + targetType: provider.targetType!, + onCalibrationChanged: (centerX, centerY, radius, ringCount, {List? ringRadii}) { + provider.adjustTargetPosition(centerX, centerY, radius, ringCount: ringCount, ringRadii: ringRadii); + }, + ) + else + TargetOverlay( + shots: provider.shots, + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + targetRadius: provider.targetRadius, + targetType: provider.targetType!, + ringCount: provider.ringCount, + ringRadii: provider.ringRadii, + onShotTapped: (shot) => _isSelectingReferences + ? null + : _showShotOptions(context, provider, shot.id), + onAddShot: (x, y) { + if (_isSelectingReferences) { + provider.addReferenceImpact(x, y); + } else { + provider.addShot(x, y); + } + }, + groupingCenterX: provider.groupingResult?.centerX, + groupingCenterY: provider.groupingResult?.centerY, + groupingDiameter: provider.groupingResult?.diameter, + referenceImpacts: _isSelectingReferences ? provider.referenceImpacts : null, + ), + ], + ), + ), + + // Info cards (hidden during calibration) + if (!_isCalibrating) + Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + children: [ + // Calibration button + Card( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + child: ListTile( + leading: const Icon(Icons.tune, color: AppTheme.primaryColor), + title: const Text('Calibrer la cible'), + subtitle: const Text('Ajustez le centre et la taille'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => setState(() => _isCalibrating = true), + ), + ), + const SizedBox(height: 12), + + // Score card + ScoreCard( + totalScore: provider.totalScore, + shotCount: provider.shotCount, + scoreResult: provider.scoreResult, + targetType: provider.targetType!, + ), + const SizedBox(height: 12), + + // Grouping stats + if (provider.groupingResult != null && provider.shotCount > 1) + GroupingStats( + groupingResult: provider.groupingResult!, + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + ), + + const SizedBox(height: 12), + + // Action buttons + _buildActionButtons(context, provider), + ], + ), + ) + else + // Calibration info + Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instructions de calibration', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 12), + _buildInstructionItem( + Icons.open_with, + 'Glissez le centre (croix bleue) pour positionner le centre de la cible', + ), + _buildInstructionItem( + Icons.zoom_out_map, + 'Glissez le bord (cercle orange) pour ajuster la taille de la cible', + ), + _buildInstructionItem( + Icons.visibility, + 'Les zones de score sont affichees en transparence', + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Centre: '), + Text( + '(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + Row( + children: [ + const Text('Rayon: '), + Text( + '${(provider.targetRadius * 100).toStringAsFixed(1)}%', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInstructionItem(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Expanded(child: Text(text)), + ], + ), + ); + } + + Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { + return Column( + children: [ + // Reference-based detection section + if (_isSelectingReferences) ...[ + Card( + color: Colors.deepPurple.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.touch_app, color: Colors.deepPurple), + const SizedBox(width: 8), + Text( + '${provider.referenceImpacts.length} reference(s) selectionnee(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Touchez l\'image pour marquer 3-4 impacts de reference. ' + 'L\'algorithme apprendra leurs caracteristiques pour detecter les autres.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() => _isSelectingReferences = false); + provider.clearReferenceImpacts(); + }, + child: const Text('Annuler'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: provider.referenceImpacts.length >= 2 + ? () => _showCalibratedDetectionDialog(context, provider) + : null, + icon: const Icon(Icons.auto_fix_high), + label: const Text('Detecter'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), + ] else ...[ + // Auto-detect buttons row + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showAutoDetectDialog(context, provider), + icon: const Icon(Icons.auto_fix_high), + label: const Text('Auto-Detection'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () => setState(() => _isSelectingReferences = true), + icon: const Icon(Icons.touch_app), + label: const Text('Par Reference'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + // Manual actions + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isSelectingReferences ? null : () => _showAddShotHint(context), + icon: const Icon(Icons.add_circle_outline), + label: const Text('Ajouter'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: provider.shotCount > 0 && !_isSelectingReferences + ? () => _showClearConfirmation(context, provider) + : null, + icon: const Icon(Icons.clear_all), + label: const Text('Effacer'), + ), + ), + ], + ), + ], + ); + } + + void _showHelpDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Aide'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('1. Calibrez d\'abord la cible en utilisant le bouton de calibration'), + SizedBox(height: 8), + Text('2. Appuyez sur l\'image pour ajouter un impact manuellement'), + SizedBox(height: 8), + Text('3. Appuyez sur un impact pour le modifier ou le supprimer'), + SizedBox(height: 8), + Text('4. Les cercles bleus indiquent le groupement de vos tirs'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showShotOptions(BuildContext context, AnalysisProvider provider, String shotId) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.delete, color: AppTheme.errorColor), + title: const Text('Supprimer cet impact'), + onTap: () { + provider.removeShot(shotId); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + void _showAddShotHint(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Appuyez sur l\'image pour ajouter un impact'), + duration: Duration(seconds: 2), + ), + ); + } + + void _showClearConfirmation(BuildContext context, AnalysisProvider provider) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer'), + content: const Text('Voulez-vous effacer tous les impacts?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () { + for (final shot in List.from(provider.shots)) { + provider.removeShot(shot.id); + } + Navigator.pop(context); + }, + child: const Text('Effacer', style: TextStyle(color: AppTheme.errorColor)), + ), + ], + ), + ); + } + + void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) { + int darkThreshold = 80; + int minImpactSize = 20; + int maxImpactSize = 500; + double minCircularity = 0.6; + double minFillRatio = 0.5; + bool clearExisting = true; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.auto_fix_high, color: AppTheme.primaryColor), + SizedBox(width: 8), + Text('Auto-Detection'), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ajustez les parametres de detection:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // Dark threshold slider + Text('Seuil de detection (zones sombres): $darkThreshold'), + Slider( + value: darkThreshold.toDouble(), + min: 20, + max: 150, + divisions: 13, + label: '$darkThreshold', + onChanged: (value) { + setState(() => darkThreshold = value.round()); + }, + ), + const Text( + 'Plus bas = detecte uniquement les zones tres sombres', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 12), + + // Circularity slider + Text('Circularite minimum: ${(minCircularity * 100).toStringAsFixed(0)}%'), + Slider( + value: minCircularity, + min: 0.3, + max: 0.9, + divisions: 12, + label: '${(minCircularity * 100).toStringAsFixed(0)}%', + onChanged: (value) { + setState(() => minCircularity = value); + }, + ), + const Text( + 'Plus haut = detecte uniquement les formes rondes', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 12), + + // Fill ratio slider + Text('Remplissage minimum: ${(minFillRatio * 100).toStringAsFixed(0)}%'), + Slider( + value: minFillRatio, + min: 0.3, + max: 0.9, + divisions: 12, + label: '${(minFillRatio * 100).toStringAsFixed(0)}%', + onChanged: (value) { + setState(() => minFillRatio = value); + }, + ), + const Text( + 'Plus haut = detecte les trous pleins (evite les cercles creux des chiffres)', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 12), + + // Min impact size slider + Text('Taille minimum (pixels): $minImpactSize'), + Slider( + value: minImpactSize.toDouble(), + min: 5, + max: 100, + divisions: 19, + label: '$minImpactSize', + onChanged: (value) { + setState(() => minImpactSize = value.round()); + }, + ), + const SizedBox(height: 12), + + // Max impact size slider + Text('Taille maximum (pixels): $maxImpactSize'), + Slider( + value: maxImpactSize.toDouble(), + min: 100, + max: 1000, + divisions: 18, + label: '$maxImpactSize', + onChanged: (value) { + setState(() => maxImpactSize = value.round()); + }, + ), + const SizedBox(height: 12), + + // Clear existing checkbox + CheckboxListTile( + title: const Text('Effacer les impacts existants'), + value: clearExisting, + onChanged: (value) { + setState(() => clearExisting = value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton.icon( + onPressed: () async { + Navigator.pop(context); + + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Detection en cours...'), + ], + ), + duration: Duration(seconds: 10), + ), + ); + + // Run detection + final count = await provider.autoDetectImpacts( + darkThreshold: darkThreshold, + minImpactSize: minImpactSize, + maxImpactSize: maxImpactSize, + minCircularity: minCircularity, + minFillRatio: minFillRatio, + clearExisting: clearExisting, + ); + + // Hide loading and show result + if (context.mounted) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count > 0 + ? '$count impact(s) detecte(s)' + : 'Aucun impact detecte. Essayez d\'ajuster les parametres.', + ), + backgroundColor: count > 0 ? AppTheme.successColor : AppTheme.warningColor, + ), + ); + } + }, + icon: const Icon(Icons.search), + label: const Text('Detecter'), + ), + ], + ), + ), + ); + } + + void _showCalibratedDetectionDialog(BuildContext context, AnalysisProvider provider) { + double tolerance = 2.0; + bool clearExisting = true; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.touch_app, color: Colors.deepPurple), + SizedBox(width: 8), + Text('Detection par Reference'), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Colors.deepPurple.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.deepPurple), + const SizedBox(width: 8), + Expanded( + child: Text( + '${provider.referenceImpacts.length} impacts de reference selectionnes', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + const Text( + 'L\'algorithme va analyser les caracteristiques des impacts ' + 'de reference (luminosite, taille, forme) et chercher des ' + 'impacts similaires sur toute l\'image.', + style: TextStyle(fontSize: 13), + ), + const SizedBox(height: 16), + + // Tolerance slider + Text('Tolerance: ${tolerance.toStringAsFixed(1)}x'), + Slider( + value: tolerance, + min: 1.0, + max: 5.0, + divisions: 8, + label: '${tolerance.toStringAsFixed(1)}x', + activeColor: Colors.deepPurple, + onChanged: (value) { + setState(() => tolerance = value); + }, + ), + const Text( + 'Tolerance basse = detection stricte (moins de faux positifs)\n' + 'Tolerance haute = detection large (plus de detections)', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 12), + + // Clear existing checkbox + CheckboxListTile( + title: const Text('Effacer les impacts existants'), + value: clearExisting, + activeColor: Colors.deepPurple, + onChanged: (value) { + setState(() => clearExisting = value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton.icon( + onPressed: () async { + Navigator.pop(context); + + // Learn from references + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Apprentissage des references...'), + ], + ), + duration: Duration(seconds: 10), + ), + ); + + final learned = provider.learnFromReferences(); + + if (!learned) { + if (context.mounted) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Impossible d\'analyser les references. Essayez de selectionner d\'autres impacts.'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + return; + } + + // Run calibrated detection + if (context.mounted) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Detection en cours...'), + ], + ), + duration: Duration(seconds: 10), + ), + ); + } + + final count = await provider.detectFromReferences( + tolerance: tolerance, + clearExisting: clearExisting, + ); + + // Exit reference mode + if (mounted) { + setState(() {}); + } + _isSelectingReferences = false; + provider.clearReferenceImpacts(); + + // Show result + if (context.mounted) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count > 0 + ? '$count impact(s) detecte(s) a partir des references' + : 'Aucun impact detecte. Essayez d\'augmenter la tolerance.', + ), + backgroundColor: count > 0 ? AppTheme.successColor : AppTheme.warningColor, + ), + ); + } + + // Trigger rebuild + if (mounted) { + this.setState(() {}); + } + }, + icon: const Icon(Icons.search), + label: const Text('Detecter'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } + + Future _saveSession(BuildContext context, AnalysisProvider provider) async { + final notesController = TextEditingController(); + + final shouldSave = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sauvegarder la session'), + content: TextField( + controller: notesController, + decoration: const InputDecoration( + labelText: 'Notes (optionnel)', + hintText: 'Ex: Entrainement, 10m, debout...', + ), + maxLines: 2, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + + if (shouldSave == true) { + try { + await provider.saveSession(notes: notesController.text.isEmpty ? null : notesController.text); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Session sauvegardee'), + backgroundColor: AppTheme.successColor, + ), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + } +} diff --git a/lib/features/analysis/widgets/grouping_stats.dart b/lib/features/analysis/widgets/grouping_stats.dart new file mode 100644 index 0000000..bf7d533 --- /dev/null +++ b/lib/features/analysis/widgets/grouping_stats.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../services/grouping_analyzer_service.dart'; + +class GroupingStats extends StatelessWidget { + final GroupingResult groupingResult; + final double targetCenterX; + final double targetCenterY; + + const GroupingStats({ + super.key, + required this.groupingResult, + required this.targetCenterX, + required this.targetCenterY, + }); + + @override + Widget build(BuildContext context) { + final offsetX = groupingResult.centerX - targetCenterX; + final offsetY = groupingResult.centerY - targetCenterY; + final offsetDescription = _getOffsetDescription(offsetX, offsetY); + + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.center_focus_strong, color: AppTheme.groupingCenterColor), + const SizedBox(width: 8), + Text( + 'Groupement', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + _buildQualityBadge(context), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStat( + context, + 'Diametre', + '${(groupingResult.diameter * 100).toStringAsFixed(1)}%', + icon: Icons.straighten, + ), + _buildStat( + context, + 'Dispersion', + '${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%', + icon: Icons.scatter_plot, + ), + _buildStat( + context, + 'Decalage', + offsetDescription, + icon: Icons.compare_arrows, + ), + ], + ), + const SizedBox(height: 12), + _buildOffsetIndicator(context, offsetX, offsetY), + ], + ), + ), + ); + } + + Widget _buildQualityBadge(BuildContext context) { + final rating = groupingResult.qualityRating; + final color = _getQualityColor(rating); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(5, (index) { + return Icon( + index < rating ? Icons.star : Icons.star_border, + size: 16, + color: color, + ); + }), + const SizedBox(width: 4), + Text( + groupingResult.qualityDescription, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ); + } + + Widget _buildStat( + BuildContext context, + String label, + String value, { + required IconData icon, + }) { + return Column( + children: [ + Icon(icon, size: 20, color: Colors.grey), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildOffsetIndicator(BuildContext context, double offsetX, double offsetY) { + return Container( + height: 80, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + // Grid lines + Center( + child: Container( + width: 1, + color: Colors.grey[300], + ), + ), + Center( + child: Container( + height: 1, + color: Colors.grey[300], + ), + ), + // Center point (target) + const Center( + child: Icon(Icons.add, size: 16, color: Colors.grey), + ), + // Grouping center + LayoutBuilder( + builder: (context, constraints) { + // Scale offset for visualization (max 40 pixels from center) + final maxOffset = 40.0; + final scaledX = (offsetX * 200).clamp(-maxOffset, maxOffset); + final scaledY = (offsetY * 200).clamp(-maxOffset, maxOffset); + + return Center( + child: Transform.translate( + offset: Offset(scaledX, scaledY), + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppTheme.groupingCenterColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ); + }, + ), + // Labels + Positioned( + top: 4, + left: 0, + right: 0, + child: Text( + 'Haut', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10, color: Colors.grey[600]), + ), + ), + Positioned( + bottom: 4, + left: 0, + right: 0, + child: Text( + 'Bas', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10, color: Colors.grey[600]), + ), + ), + Positioned( + left: 4, + top: 0, + bottom: 0, + child: Center( + child: Text( + 'G', + style: TextStyle(fontSize: 10, color: Colors.grey[600]), + ), + ), + ), + Positioned( + right: 4, + top: 0, + bottom: 0, + child: Center( + child: Text( + 'D', + style: TextStyle(fontSize: 10, color: Colors.grey[600]), + ), + ), + ), + ], + ), + ); + } + + Color _getQualityColor(int rating) { + switch (rating) { + case 5: + return AppTheme.successColor; + case 4: + return Colors.lightGreen; + case 3: + return AppTheme.warningColor; + case 2: + return Colors.orange; + default: + return AppTheme.errorColor; + } + } + + String _getOffsetDescription(double offsetX, double offsetY) { + if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) { + return 'Centre'; + } + + String vertical = ''; + String horizontal = ''; + + if (offsetY < -0.02) { + vertical = 'H'; + } else if (offsetY > 0.02) { + vertical = 'B'; + } + + if (offsetX < -0.02) { + horizontal = 'G'; + } else if (offsetX > 0.02) { + horizontal = 'D'; + } + + if (vertical.isNotEmpty && horizontal.isNotEmpty) { + return '$vertical-$horizontal'; + } + + return vertical.isNotEmpty ? vertical : horizontal; + } +} diff --git a/lib/features/analysis/widgets/score_card.dart b/lib/features/analysis/widgets/score_card.dart new file mode 100644 index 0000000..9854e0a --- /dev/null +++ b/lib/features/analysis/widgets/score_card.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/target_type.dart'; +import '../../../services/score_calculator_service.dart'; + +class ScoreCard extends StatelessWidget { + final int totalScore; + final int shotCount; + final ScoreResult? scoreResult; + final TargetType targetType; + + const ScoreCard({ + super.key, + required this.totalScore, + required this.shotCount, + this.scoreResult, + required this.targetType, + }); + + @override + Widget build(BuildContext context) { + final maxScore = targetType == TargetType.concentric ? 10 : 5; + + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.scoreboard, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Text( + 'Score', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildScoreStat( + context, + 'Total', + '$totalScore', + subtitle: '/ ${shotCount * maxScore}', + ), + _buildScoreStat( + context, + 'Impacts', + '$shotCount', + ), + _buildScoreStat( + context, + 'Moyenne', + shotCount > 0 + ? (totalScore / shotCount).toStringAsFixed(1) + : '-', + ), + if (scoreResult != null) + _buildScoreStat( + context, + 'Pourcentage', + '${scoreResult!.percentage.toStringAsFixed(0)}%', + ), + ], + ), + if (scoreResult != null && scoreResult!.scoreDistribution.isNotEmpty) ...[ + const SizedBox(height: 12), + const Divider(), + const SizedBox(height: 8), + Text( + 'Distribution des scores', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildScoreDistribution(context), + ], + ], + ), + ), + ); + } + + Widget _buildScoreStat( + BuildContext context, + String label, + String value, { + String? subtitle, + }) { + return Column( + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + if (subtitle != null) + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildScoreDistribution(BuildContext context) { + final maxScoreValue = targetType == TargetType.concentric ? 10 : 5; + final distribution = scoreResult!.scoreDistribution; + + return Wrap( + spacing: 8, + runSpacing: 4, + children: List.generate(maxScoreValue + 1, (index) { + final score = maxScoreValue - index; + final count = distribution[score] ?? 0; + + if (count == 0) return const SizedBox.shrink(); + + return Chip( + label: Text( + 'x$count', + style: const TextStyle(fontSize: 12), + ), + avatar: CircleAvatar( + radius: 12, + backgroundColor: _getScoreColor(score, maxScoreValue), + child: Text( + '$score', + style: const TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }), + ); + } + + Color _getScoreColor(int score, int maxScore) { + if (score == maxScore) return Colors.amber; + if (score >= maxScore * 0.8) return Colors.orange; + if (score >= maxScore * 0.6) return Colors.blue; + if (score >= maxScore * 0.4) return Colors.green; + if (score >= maxScore * 0.2) return Colors.grey; + return Colors.grey[400]!; + } +} diff --git a/lib/features/analysis/widgets/target_calibration.dart b/lib/features/analysis/widgets/target_calibration.dart new file mode 100644 index 0000000..19e9fa3 --- /dev/null +++ b/lib/features/analysis/widgets/target_calibration.dart @@ -0,0 +1,561 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/target_type.dart'; + +class TargetCalibration extends StatefulWidget { + final double initialCenterX; + final double initialCenterY; + final double initialRadius; + final int initialRingCount; + final TargetType targetType; + final List? initialRingRadii; // Individual ring radii multipliers + final Function(double centerX, double centerY, double radius, int ringCount, {List? ringRadii}) onCalibrationChanged; + + const TargetCalibration({ + super.key, + required this.initialCenterX, + required this.initialCenterY, + required this.initialRadius, + this.initialRingCount = 10, + required this.targetType, + this.initialRingRadii, + required this.onCalibrationChanged, + }); + + @override + State createState() => _TargetCalibrationState(); +} + +class _TargetCalibrationState extends State { + late double _centerX; + late double _centerY; + late double _radius; + late int _ringCount; + late List _ringRadii; // Multipliers for each ring (1.0 = normal) + + bool _isDraggingCenter = false; + bool _isDraggingRadius = false; + int? _selectedRingIndex; // Index of the ring being adjusted individually + + @override + void initState() { + super.initState(); + _centerX = widget.initialCenterX; + _centerY = widget.initialCenterY; + _radius = widget.initialRadius; + _ringCount = widget.initialRingCount; + _initRingRadii(); + } + + void _initRingRadii() { + if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { + _ringRadii = List.from(widget.initialRingRadii!); + } else { + // Initialize with default proportional radii + _ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); + } + } + + @override + void didUpdateWidget(TargetCalibration oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialRingCount != oldWidget.initialRingCount) { + _ringCount = widget.initialRingCount; + _initRingRadii(); + } + if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius && _selectedRingIndex == null) { + // Update from slider - scale all rings proportionally + final scale = widget.initialRadius / _radius; + _radius = widget.initialRadius; + // Ring radii are relative, so they don't need to change + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + + return GestureDetector( + onTapDown: (details) => _onTapDown(details, size), + onPanStart: (details) => _onPanStart(details, size), + onPanUpdate: (details) => _onPanUpdate(details, size), + onPanEnd: (_) => _onPanEnd(), + child: CustomPaint( + size: size, + painter: _CalibrationPainter( + centerX: _centerX, + centerY: _centerY, + radius: _radius, + ringCount: _ringCount, + ringRadii: _ringRadii, + targetType: widget.targetType, + isDraggingCenter: _isDraggingCenter, + isDraggingRadius: _isDraggingRadius, + selectedRingIndex: _selectedRingIndex, + ), + ), + ); + }, + ); + } + + void _onTapDown(TapDownDetails details, Size size) { + final tapX = details.localPosition.dx / size.width; + final tapY = details.localPosition.dy / size.height; + + // Check if tapping on a specific ring + final ringIndex = _findRingAtPosition(tapX, tapY, size); + if (ringIndex != null && ringIndex != _selectedRingIndex) { + setState(() { + _selectedRingIndex = ringIndex; + }); + } + } + + int? _findRingAtPosition(double tapX, double tapY, Size size) { + final minDim = math.min(size.width, size.height); + final distFromCenter = math.sqrt( + math.pow((tapX - _centerX) * size.width, 2) + + math.pow((tapY - _centerY) * size.height, 2) + ); + + // Check each ring from outside to inside + for (int i = _ringCount - 1; i >= 0; i--) { + final ringRadius = _radius * _ringRadii[i] * minDim; + final prevRingRadius = i > 0 ? _radius * _ringRadii[i - 1] * minDim : 0.0; + + // Check if tap is on this ring's edge (within tolerance) + final tolerance = 15.0; + if ((distFromCenter - ringRadius).abs() < tolerance) { + return i; + } + } + + return null; + } + + void _onPanStart(DragStartDetails details, Size size) { + final tapX = details.localPosition.dx / size.width; + final tapY = details.localPosition.dy / size.height; + + // Check if tapping on center handle + final distToCenter = _distance(tapX, tapY, _centerX, _centerY); + + // Check if tapping on radius handle (on the right edge of the outermost circle) + final minDim = math.min(size.width, size.height); + final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0); + final radiusHandleX = _centerX + outerRadius * minDim / size.width; + final radiusHandleY = _centerY; + final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0)); + + if (distToCenter < 0.05) { + setState(() { + _isDraggingCenter = true; + _selectedRingIndex = null; + }); + } else if (distToRadiusHandle < 0.05) { + setState(() { + _isDraggingRadius = true; + _selectedRingIndex = null; + }); + } else { + // Check if dragging a specific ring + final ringIndex = _findRingAtPosition(tapX, tapY, size); + if (ringIndex != null) { + setState(() { + _selectedRingIndex = ringIndex; + }); + } else if (distToCenter < _radius + 0.02) { + // Tapping inside the target - move center + setState(() { + _isDraggingCenter = true; + _selectedRingIndex = null; + }); + } + } + } + + void _onPanUpdate(DragUpdateDetails details, Size size) { + final deltaX = details.delta.dx / size.width; + final deltaY = details.delta.dy / size.height; + final minDim = math.min(size.width, size.height); + + setState(() { + if (_isDraggingCenter) { + // Move center + _centerX = _centerX + deltaX; + _centerY = _centerY + deltaY; + } else if (_isDraggingRadius) { + // Adjust outer radius (scales all rings proportionally) + final newRadius = _radius + deltaX * (size.width / minDim); + _radius = newRadius.clamp(0.05, 3.0); + } else if (_selectedRingIndex != null) { + // Adjust individual ring + final currentPos = details.localPosition; + final distFromCenter = math.sqrt( + math.pow(currentPos.dx - _centerX * size.width, 2) + + math.pow(currentPos.dy - _centerY * size.height, 2) + ); + + // Calculate new ring radius as proportion of base radius + final newRingRadius = distFromCenter / (minDim * _radius); + + // Get constraints from adjacent rings + final minAllowed = _selectedRingIndex! > 0 + ? _ringRadii[_selectedRingIndex! - 1] + 0.02 + : 0.05; + final maxAllowed = _selectedRingIndex! < _ringCount - 1 + ? _ringRadii[_selectedRingIndex! + 1] - 0.02 + : 1.5; + + _ringRadii[_selectedRingIndex!] = newRingRadius.clamp(minAllowed, maxAllowed); + } + }); + + widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii); + } + + void _onPanEnd() { + setState(() { + _isDraggingCenter = false; + _isDraggingRadius = false; + // Keep selected ring for visual feedback + }); + } + + double _distance(double x1, double y1, double x2, double y2) { + final dx = x1 - x2; + final dy = y1 - y2; + return (dx * dx + dy * dy); + } +} + +class _CalibrationPainter extends CustomPainter { + final double centerX; + final double centerY; + final double radius; + final int ringCount; + final List ringRadii; + final TargetType targetType; + final bool isDraggingCenter; + final bool isDraggingRadius; + final int? selectedRingIndex; + + _CalibrationPainter({ + required this.centerX, + required this.centerY, + required this.radius, + required this.ringCount, + required this.ringRadii, + required this.targetType, + required this.isDraggingCenter, + required this.isDraggingRadius, + this.selectedRingIndex, + }); + + @override + void paint(Canvas canvas, Size size) { + final centerPx = Offset(centerX * size.width, centerY * size.height); + final minDim = size.width < size.height ? size.width : size.height; + final baseRadiusPx = radius * minDim; + + if (targetType == TargetType.concentric) { + _drawConcentricZones(canvas, size, centerPx, baseRadiusPx); + } else { + _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); + } + + // Draw center handle + _drawCenterHandle(canvas, centerPx); + + // Draw radius handle (for outer ring) + _drawRadiusHandle(canvas, size, centerPx, baseRadiusPx); + + // Draw instructions + _drawInstructions(canvas, size); + } + + void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { + // Generate colors for zones + List zoneColors = []; + for (int i = 0; i < ringCount; i++) { + final ratio = i / ringCount; + if (ratio < 0.2) { + zoneColors.add(Colors.yellow.withValues(alpha: 0.3 - ratio * 0.5)); + } else if (ratio < 0.4) { + zoneColors.add(Colors.orange.withValues(alpha: 0.25 - ratio * 0.3)); + } else if (ratio < 0.6) { + zoneColors.add(Colors.blue.withValues(alpha: 0.2 - ratio * 0.2)); + } else if (ratio < 0.8) { + zoneColors.add(Colors.green.withValues(alpha: 0.15 - ratio * 0.1)); + } else { + zoneColors.add(Colors.white.withValues(alpha: 0.1)); + } + } + + final zonePaint = Paint()..style = PaintingStyle.fill; + final strokePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Colors.white.withValues(alpha: 0.6); + + final selectedStrokePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..color = Colors.cyan; + + // Draw from outside to inside + for (int i = ringCount - 1; i >= 0; i--) { + final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; + final zoneRadius = baseRadius * ringRadius; + + zonePaint.color = zoneColors[i]; + canvas.drawCircle(center, zoneRadius, zonePaint); + + // Highlight selected ring + if (selectedRingIndex == i) { + canvas.drawCircle(center, zoneRadius, selectedStrokePaint); + + // Draw drag handle on selected ring + _drawRingHandle(canvas, size, center, zoneRadius, i); + } else { + canvas.drawCircle(center, zoneRadius, strokePaint); + } + } + + // Draw zone labels (only if within visible area) + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + ); + + for (int i = 0; i < ringCount; i++) { + final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; + final prevRingRadius = i > 0 + ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) + : 0.0; + final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2; + + // Score: center = 10, decrement by 1 for each ring + final score = 10 - i; + + // Only draw label if it's within the visible area + final labelX = center.dx + zoneRadius; + if (labelX < 0 || labelX > size.width) continue; + + textPainter.text = TextSpan( + text: '$score', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 2), + ], + ), + ); + textPainter.layout(); + + // Draw label on the right side of each zone + final labelY = center.dy - textPainter.height / 2; + if (labelY >= 0 && labelY <= size.height) { + textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); + } + } + } + + void _drawRingHandle(Canvas canvas, Size size, Offset center, double ringRadius, int ringIndex) { + // Draw handle at the right edge of the selected ring + final handleX = center.dx + ringRadius; + final handleY = center.dy; + + if (handleX < 0 || handleX > size.width) return; + + final handlePos = Offset(handleX, handleY); + + // Handle background + final handlePaint = Paint() + ..color = Colors.cyan + ..style = PaintingStyle.fill; + canvas.drawCircle(handlePos, 12, handlePaint); + + // Arrow indicators + final arrowPaint = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + // Outward arrow + canvas.drawLine( + Offset(handlePos.dx + 3, handlePos.dy), + Offset(handlePos.dx + 7, handlePos.dy - 4), + arrowPaint, + ); + canvas.drawLine( + Offset(handlePos.dx + 3, handlePos.dy), + Offset(handlePos.dx + 7, handlePos.dy + 4), + arrowPaint, + ); + + // Inward arrow + canvas.drawLine( + Offset(handlePos.dx - 3, handlePos.dy), + Offset(handlePos.dx - 7, handlePos.dy - 4), + arrowPaint, + ); + canvas.drawLine( + Offset(handlePos.dx - 3, handlePos.dy), + Offset(handlePos.dx - 7, handlePos.dy + 4), + arrowPaint, + ); + } + + void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { + // Simplified silhouette zones + final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; + + // Draw silhouette outline (simplified as rectangle for now) + final silhouetteWidth = radius * 0.8; + final silhouetteHeight = radius * 2; + + paint.color = Colors.green.withValues(alpha: 0.5); + canvas.drawRect( + Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), + paint, + ); + } + + void _drawCenterHandle(Canvas canvas, Offset center) { + // Outer circle + final outerPaint = Paint() + ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + canvas.drawCircle(center, 15, outerPaint); + + // Inner dot + final innerPaint = Paint() + ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..style = PaintingStyle.fill; + canvas.drawCircle(center, 5, innerPaint); + + // Crosshair + final crossPaint = Paint() + ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..strokeWidth = 2; + canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint); + canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint); + canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint); + canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint); + } + + void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) { + // Radius handle on the right edge of the outermost ring + final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0; + final actualRadius = baseRadius * outerRingRadius; + final actualHandleX = center.dx + actualRadius; + final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20); + final clampedHandleY = center.dy.clamp(20.0, size.height - 20); + final handlePos = Offset(clampedHandleX, clampedHandleY); + + // Check if handle is clamped (radius extends beyond visible area) + final isClamped = actualHandleX > size.width - 20; + + final paint = Paint() + ..color = isDraggingRadius + ? AppTheme.successColor + : (isClamped ? Colors.orange : AppTheme.warningColor) + ..style = PaintingStyle.fill; + + // Draw handle as a small circle with arrows + canvas.drawCircle(handlePos, 14, paint); + + // Draw arrow indicators + final arrowPaint = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + // Left arrow + canvas.drawLine( + Offset(handlePos.dx - 4, handlePos.dy), + Offset(handlePos.dx - 8, handlePos.dy - 4), + arrowPaint, + ); + canvas.drawLine( + Offset(handlePos.dx - 4, handlePos.dy), + Offset(handlePos.dx - 8, handlePos.dy + 4), + arrowPaint, + ); + + // Right arrow + canvas.drawLine( + Offset(handlePos.dx + 4, handlePos.dy), + Offset(handlePos.dx + 8, handlePos.dy - 4), + arrowPaint, + ); + canvas.drawLine( + Offset(handlePos.dx + 4, handlePos.dy), + Offset(handlePos.dx + 8, handlePos.dy + 4), + arrowPaint, + ); + + // Label + final textPainter = TextPainter( + text: TextSpan( + text: 'GLOBAL', + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16), + ); + } + + void _drawInstructions(Canvas canvas, Size size) { + String instruction; + if (selectedRingIndex != null) { + instruction = 'Anneau ${10 - selectedRingIndex!} selectionne - Glissez pour ajuster'; + } else { + instruction = 'Touchez un anneau pour l\'ajuster individuellement'; + } + + final textPainter = TextPainter( + text: TextSpan( + text: instruction, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + backgroundColor: Colors.black54, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset((size.width - textPainter.width) / 2, size.height - 30), + ); + } + + @override + bool shouldRepaint(covariant _CalibrationPainter oldDelegate) { + return centerX != oldDelegate.centerX || + centerY != oldDelegate.centerY || + radius != oldDelegate.radius || + ringCount != oldDelegate.ringCount || + isDraggingCenter != oldDelegate.isDraggingCenter || + isDraggingRadius != oldDelegate.isDraggingRadius || + selectedRingIndex != oldDelegate.selectedRingIndex || + ringRadii != oldDelegate.ringRadii; + } +} diff --git a/lib/features/analysis/widgets/target_overlay.dart b/lib/features/analysis/widgets/target_overlay.dart new file mode 100644 index 0000000..061aa44 --- /dev/null +++ b/lib/features/analysis/widgets/target_overlay.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/shot.dart'; +import '../../../data/models/target_type.dart'; + +class TargetOverlay extends StatelessWidget { + final List shots; + final double targetCenterX; + final double targetCenterY; + final double targetRadius; + final TargetType targetType; + final int ringCount; + final List? ringRadii; // Individual ring radii multipliers + final void Function(Shot shot)? onShotTapped; + final void Function(double x, double y)? onAddShot; + final double? groupingCenterX; + final double? groupingCenterY; + final double? groupingDiameter; + final List? referenceImpacts; + + const TargetOverlay({ + super.key, + required this.shots, + required this.targetCenterX, + required this.targetCenterY, + required this.targetRadius, + required this.targetType, + this.ringCount = 10, + this.ringRadii, + this.onShotTapped, + this.onAddShot, + this.groupingCenterX, + this.groupingCenterY, + this.groupingDiameter, + this.referenceImpacts, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapUp: (details) { + if (onAddShot != null) { + final RenderBox box = context.findRenderObject() as RenderBox; + final localPosition = details.localPosition; + final relX = localPosition.dx / box.size.width; + final relY = localPosition.dy / box.size.height; + onAddShot!(relX, relY); + } + }, + child: CustomPaint( + painter: _TargetOverlayPainter( + shots: shots, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius, + targetType: targetType, + ringCount: ringCount, + ringRadii: ringRadii, + groupingCenterX: groupingCenterX, + groupingCenterY: groupingCenterY, + groupingDiameter: groupingDiameter, + referenceImpacts: referenceImpacts, + ), + child: Stack( + children: shots.map((shot) { + return Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: LayoutBuilder( + builder: (context, constraints) { + final x = shot.x * constraints.maxWidth; + final y = shot.y * constraints.maxHeight; + return Stack( + children: [ + Positioned( + left: x - 15, + top: y - 15, + child: GestureDetector( + onTap: () => onShotTapped?.call(shot), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + ), + ), + ); + } +} + +class _TargetOverlayPainter extends CustomPainter { + final List shots; + final double targetCenterX; + final double targetCenterY; + final double targetRadius; + final TargetType targetType; + final int ringCount; + final List? ringRadii; + final double? groupingCenterX; + final double? groupingCenterY; + final double? groupingDiameter; + final List? referenceImpacts; + + _TargetOverlayPainter({ + required this.shots, + required this.targetCenterX, + required this.targetCenterY, + required this.targetRadius, + required this.targetType, + this.ringCount = 10, + this.ringRadii, + this.groupingCenterX, + this.groupingCenterY, + this.groupingDiameter, + this.referenceImpacts, + }); + + @override + void paint(Canvas canvas, Size size) { + // Draw target center indicator + _drawTargetCenter(canvas, size); + + // Draw grouping circle + if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { + _drawGroupingCircle(canvas, size); + } + + // Draw impacts + for (final shot in shots) { + _drawImpact(canvas, size, shot); + } + + // Draw reference impacts (with different color) + if (referenceImpacts != null) { + for (final ref in referenceImpacts!) { + _drawReferenceImpact(canvas, size, ref); + } + } + } + + void _drawTargetCenter(Canvas canvas, Size size) { + final centerX = targetCenterX * size.width; + final centerY = targetCenterY * size.height; + final minDim = size.width < size.height ? size.width : size.height; + final maxRadius = targetRadius * minDim; + + final strokePaint = Paint() + ..color = Colors.green.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + // Draw concentric rings based on ringCount (with individual radii if provided) + for (int i = 0; i < ringCount; i++) { + final ringMultiplier = (ringRadii != null && ringRadii!.length == ringCount) + ? ringRadii![i] + : (i + 1) / ringCount; + final ringRadius = maxRadius * ringMultiplier; + canvas.drawCircle(Offset(centerX, centerY), ringRadius, strokePaint); + } + + // Draw score labels on rings (only if within visible area) + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + ); + + for (int i = 0; i < ringCount; i++) { + // Calculate zone center (midpoint between this ring and previous) + final currentMultiplier = (ringRadii != null && ringRadii!.length == ringCount) + ? ringRadii![i] + : (i + 1) / ringCount; + final prevMultiplier = i == 0 + ? 0.0 + : (ringRadii != null && ringRadii!.length == ringCount) + ? ringRadii![i - 1] + : i / ringCount; + final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2; + final score = 10 - i; + + // Only draw label if it's within the visible area + final labelX = centerX + zoneRadius; + if (labelX < 0 || labelX > size.width) continue; + + textPainter.text = TextSpan( + text: '$score', + style: TextStyle( + color: Colors.green.withValues(alpha: 0.8), + fontSize: 10, + fontWeight: FontWeight.bold, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 2), + ], + ), + ); + textPainter.layout(); + + // Draw label on the right side of each zone + final labelY = centerY - textPainter.height / 2; + if (labelY >= 0 && labelY <= size.height) { + textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); + } + } + + // Draw crosshair at center + final crosshairPaint = Paint() + ..color = Colors.green.withValues(alpha: 0.7) + ..strokeWidth = 1; + + canvas.drawLine( + Offset(centerX - 10, centerY), + Offset(centerX + 10, centerY), + crosshairPaint, + ); + canvas.drawLine( + Offset(centerX, centerY - 10), + Offset(centerX, centerY + 10), + crosshairPaint, + ); + } + + void _drawGroupingCircle(Canvas canvas, Size size) { + final centerX = groupingCenterX! * size.width; + final centerY = groupingCenterY! * size.height; + final diameter = groupingDiameter! * size.width.clamp(0, size.height); + + // Draw filled circle + final fillPaint = Paint() + ..color = AppTheme.groupingCircleColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(centerX, centerY), diameter / 2, fillPaint); + + // Draw outline + final strokePaint = Paint() + ..color = AppTheme.groupingCenterColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(Offset(centerX, centerY), diameter / 2, strokePaint); + + // Draw center point + final centerPaint = Paint() + ..color = AppTheme.groupingCenterColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(centerX, centerY), 4, centerPaint); + } + + void _drawImpact(Canvas canvas, Size size, Shot shot) { + final x = shot.x * size.width; + final y = shot.y * size.height; + + // Draw outer circle (white outline for visibility) + final outlinePaint = Paint() + ..color = AppTheme.impactOutlineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + canvas.drawCircle(Offset(x, y), 10, outlinePaint); + + // Draw impact marker + final impactPaint = Paint() + ..color = AppTheme.impactColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(x, y), 8, impactPaint); + + // Draw score number + final textPainter = TextPainter( + text: TextSpan( + text: '${shot.score}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + + void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) { + final x = ref.x * size.width; + final y = ref.y * size.height; + + // Draw outer circle (white outline for visibility) + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + canvas.drawCircle(Offset(x, y), 12, outlinePaint); + + // Draw reference marker (purple) + final refPaint = Paint() + ..color = Colors.deepPurple + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(x, y), 10, refPaint); + + // Draw "R" to indicate reference + final textPainter = TextPainter( + text: const TextSpan( + text: 'R', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + + @override + bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) { + return shots != oldDelegate.shots || + targetCenterX != oldDelegate.targetCenterX || + targetCenterY != oldDelegate.targetCenterY || + targetRadius != oldDelegate.targetRadius || + ringCount != oldDelegate.ringCount || + ringRadii != oldDelegate.ringRadii || + groupingCenterX != oldDelegate.groupingCenterX || + groupingCenterY != oldDelegate.groupingCenterY || + groupingDiameter != oldDelegate.groupingDiameter || + referenceImpacts != oldDelegate.referenceImpacts; + } +} diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart new file mode 100644 index 0000000..98f2b96 --- /dev/null +++ b/lib/features/capture/capture_screen.dart @@ -0,0 +1,249 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/target_type.dart'; +import '../analysis/analysis_screen.dart'; +import 'widgets/target_type_selector.dart'; +import 'widgets/image_source_button.dart'; + +class CaptureScreen extends StatefulWidget { + const CaptureScreen({super.key}); + + @override + State createState() => _CaptureScreenState(); +} + +class _CaptureScreenState extends State { + final ImagePicker _picker = ImagePicker(); + TargetType _selectedType = TargetType.concentric; + String? _selectedImagePath; + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Nouvelle Analyse'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Target type selection + _buildSectionTitle('Type de Cible'), + const SizedBox(height: 12), + TargetTypeSelector( + selectedType: _selectedType, + onTypeSelected: (type) { + setState(() => _selectedType = type); + }, + ), + const SizedBox(height: AppConstants.largePadding), + + // Image source selection + _buildSectionTitle('Source de l\'Image'), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ImageSourceButton( + icon: Icons.camera_alt, + label: 'Camera', + onPressed: _isLoading ? null : () => _captureImage(ImageSource.camera), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ImageSourceButton( + icon: Icons.photo_library, + label: 'Galerie', + onPressed: _isLoading ? null : () => _captureImage(ImageSource.gallery), + ), + ), + ], + ), + const SizedBox(height: AppConstants.largePadding), + + // Image preview + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (_selectedImagePath != null) + _buildImagePreview(), + + // Guide text + if (_selectedImagePath == null && !_isLoading) + _buildGuide(), + ], + ), + ), + floatingActionButton: _selectedImagePath != null + ? FloatingActionButton.extended( + onPressed: _analyzeImage, + icon: const Icon(Icons.analytics), + label: const Text('Analyser'), + ) + : null, + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildImagePreview() { + return Column( + children: [ + _buildSectionTitle('Apercu'), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + child: Stack( + children: [ + Image.file( + File(_selectedImagePath!), + fit: BoxFit.contain, + width: double.infinity, + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() => _selectedImagePath = null); + }, + style: IconButton.styleFrom( + backgroundColor: Colors.black54, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _buildFramingHints(), + ], + ); + } + + Widget _buildFramingHints() { + return Card( + color: AppTheme.warningColor.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppTheme.warningColor), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Assurez-vous que la cible est bien centree et visible.', + style: TextStyle(color: AppTheme.warningColor.withValues(alpha: 0.8)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildGuide() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + children: [ + Icon( + Icons.help_outline, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 12), + Text( + 'Conseils pour une bonne analyse', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + _buildGuideItem(Icons.crop_free, 'Cadrez la cible entiere dans l\'image'), + _buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'), + _buildGuideItem(Icons.straighten, 'Prenez la photo de face'), + _buildGuideItem(Icons.blur_off, 'Evitez les images floues'), + ], + ), + ), + ); + } + + Widget _buildGuideItem(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: AppTheme.primaryColor), + const SizedBox(width: 12), + Expanded(child: Text(text)), + ], + ), + ); + } + + Future _captureImage(ImageSource source) async { + setState(() => _isLoading = true); + + try { + final XFile? image = await _picker.pickImage( + source: source, + maxWidth: 2048, + maxHeight: 2048, + imageQuality: 90, + ); + + if (image != null) { + setState(() => _selectedImagePath = image.path); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors de la capture: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _analyzeImage() { + if (_selectedImagePath == null) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AnalysisScreen( + imagePath: _selectedImagePath!, + targetType: _selectedType, + ), + ), + ); + } +} diff --git a/lib/features/capture/widgets/image_source_button.dart b/lib/features/capture/widgets/image_source_button.dart new file mode 100644 index 0000000..c99e313 --- /dev/null +++ b/lib/features/capture/widgets/image_source_button.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; + +class ImageSourceButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + + const ImageSourceButton({ + super.key, + required this.icon, + required this.label, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + side: BorderSide(color: AppTheme.primaryColor), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 32, color: AppTheme.primaryColor), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/capture/widgets/target_type_selector.dart b/lib/features/capture/widgets/target_type_selector.dart new file mode 100644 index 0000000..3e22c6f --- /dev/null +++ b/lib/features/capture/widgets/target_type_selector.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/target_type.dart'; + +class TargetTypeSelector extends StatelessWidget { + final TargetType selectedType; + final ValueChanged onTypeSelected; + + const TargetTypeSelector({ + super.key, + required this.selectedType, + required this.onTypeSelected, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: TargetType.values.map((type) { + final isSelected = type == selectedType; + return Expanded( + child: Padding( + padding: EdgeInsets.only( + right: type != TargetType.values.last ? 12 : 0, + ), + child: _TargetTypeCard( + type: type, + isSelected: isSelected, + onTap: () => onTypeSelected(type), + ), + ), + ); + }).toList(), + ); + } +} + +class _TargetTypeCard extends StatelessWidget { + final TargetType type; + final bool isSelected; + final VoidCallback onTap; + + const _TargetTypeCard({ + required this.type, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryColor.withValues(alpha: 0.1) + : Colors.white, + border: Border.all( + color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + child: Column( + children: [ + Icon( + _getIcon(type), + size: 48, + color: isSelected ? AppTheme.primaryColor : Colors.grey[600], + ), + const SizedBox(height: 8), + Text( + type.displayName, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? AppTheme.primaryColor : Colors.grey[800], + ), + ), + const SizedBox(height: 4), + Text( + type.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + IconData _getIcon(TargetType type) { + switch (type) { + case TargetType.concentric: + return Icons.track_changes; + case TargetType.silhouette: + return Icons.person; + } + } +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart new file mode 100644 index 0000000..09fb329 --- /dev/null +++ b/lib/features/history/history_screen.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/session.dart'; +import '../../data/models/target_type.dart'; +import '../../data/repositories/session_repository.dart'; +import 'session_detail_screen.dart'; +import 'widgets/session_list_item.dart'; +import 'widgets/history_chart.dart'; + +class HistoryScreen extends StatefulWidget { + const HistoryScreen({super.key}); + + @override + State createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + List _sessions = []; + bool _isLoading = true; + TargetType? _filterType; + + @override + void initState() { + super.initState(); + _loadSessions(); + } + + Future _loadSessions() async { + setState(() => _isLoading = true); + + try { + final repository = context.read(); + final sessions = await repository.getAllSessions( + targetType: _filterType, + ); + + if (mounted) { + setState(() { + _sessions = sessions; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur de chargement: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Historique'), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filtrer', + onSelected: (type) { + setState(() => _filterType = type); + _loadSessions(); + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: null, + child: Text('Tous'), + ), + ...TargetType.values.map((type) => PopupMenuItem( + value: type, + child: Text(type.displayName), + )), + ], + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _sessions.isEmpty + ? _buildEmptyState() + : _buildContent(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Aucune session', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + _filterType != null + ? 'Aucune session de type ${_filterType!.displayName}' + : 'Commencez par analyser une cible', + style: TextStyle(color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildContent() { + return RefreshIndicator( + onRefresh: _loadSessions, + child: CustomScrollView( + slivers: [ + // Chart section + if (_sessions.length >= 2) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: HistoryChart(sessions: _sessions), + ), + ), + + // Filter indicator + if (_filterType != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding), + child: Chip( + label: Text('Filtre: ${_filterType!.displayName}'), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() => _filterType = null); + _loadSessions(); + }, + ), + ), + ), + + // Sessions list + SliverPadding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final session = _sessions[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SessionListItem( + session: session, + onTap: () => _openSessionDetail(session), + onDelete: () => _deleteSession(session), + ), + ); + }, + childCount: _sessions.length, + ), + ), + ), + ], + ), + ); + } + + void _openSessionDetail(Session session) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SessionDetailScreen(session: session), + ), + ); + _loadSessions(); // Refresh in case session was deleted + } + + Future _deleteSession(Session session) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer'), + content: Text( + 'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + try { + final repository = context.read(); + await repository.deleteSession(session.id); + _loadSessions(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Session supprimee')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + } +} diff --git a/lib/features/history/session_detail_screen.dart b/lib/features/history/session_detail_screen.dart new file mode 100644 index 0000000..a68743a --- /dev/null +++ b/lib/features/history/session_detail_screen.dart @@ -0,0 +1,246 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/session.dart'; +import '../../data/repositories/session_repository.dart'; +import '../../services/score_calculator_service.dart'; +import '../../services/grouping_analyzer_service.dart'; +import '../analysis/widgets/target_overlay.dart'; +import '../analysis/widgets/score_card.dart'; +import '../analysis/widgets/grouping_stats.dart'; +import '../statistics/statistics_screen.dart'; + +class SessionDetailScreen extends StatelessWidget { + final Session session; + + const SessionDetailScreen({ + super.key, + required this.session, + }); + + @override + Widget build(BuildContext context) { + final scoreCalculator = context.read(); + final groupingAnalyzer = context.read(); + + final scoreResult = scoreCalculator.calculateScores( + shots: session.shots, + targetType: session.targetType, + targetCenterX: session.targetCenterX ?? 0.5, + targetCenterY: session.targetCenterY ?? 0.5, + targetRadius: session.targetRadius ?? 0.4, + ); + + final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots); + + return Scaffold( + appBar: AppBar( + title: Text( + DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), + ), + actions: [ + IconButton( + icon: const Icon(Icons.analytics), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StatisticsScreen(singleSession: session), + ), + ), + tooltip: 'Statistiques', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _confirmDelete(context), + tooltip: 'Supprimer', + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Target image with overlay + AspectRatio( + aspectRatio: 1, + child: Stack( + fit: StackFit.expand, + children: [ + if (File(session.imagePath).existsSync()) + Image.file( + File(session.imagePath), + fit: BoxFit.contain, + ) + else + Container( + color: Colors.grey[200], + child: const Center( + child: Icon(Icons.image_not_supported, size: 64), + ), + ), + TargetOverlay( + shots: session.shots, + targetCenterX: session.targetCenterX ?? 0.5, + targetCenterY: session.targetCenterY ?? 0.5, + targetRadius: session.targetRadius ?? 0.4, + targetType: session.targetType, + groupingCenterX: session.groupingCenterX, + groupingCenterY: session.groupingCenterY, + groupingDiameter: session.groupingDiameter, + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Session info + _buildSessionInfo(context), + const SizedBox(height: 12), + + // Score card + ScoreCard( + totalScore: session.totalScore, + shotCount: session.shotCount, + scoreResult: scoreResult, + targetType: session.targetType, + ), + const SizedBox(height: 12), + + // Grouping stats + if (session.shotCount > 1) + GroupingStats( + groupingResult: groupingResult, + targetCenterX: session.targetCenterX ?? 0.5, + targetCenterY: session.targetCenterY ?? 0.5, + ), + + // Notes + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildNotesCard(context), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSessionInfo(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Row( + children: [ + Icon( + session.targetType == session.targetType + ? Icons.track_changes + : Icons.person, + color: AppTheme.primaryColor, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.targetType.displayName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR') + .format(session.createdAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildNotesCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.notes, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Text( + 'Notes', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(), + Text(session.notes!), + ], + ), + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer'), + content: const Text('Voulez-vous vraiment supprimer cette session?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + 'Supprimer', + style: TextStyle(color: AppTheme.errorColor), + ), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + final repository = context.read(); + await repository.deleteSession(session.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Session supprimee')), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } + } +} diff --git a/lib/features/history/widgets/history_chart.dart b/lib/features/history/widgets/history_chart.dart new file mode 100644 index 0000000..c0debe8 --- /dev/null +++ b/lib/features/history/widgets/history_chart.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/session.dart'; + +class HistoryChart extends StatelessWidget { + final List sessions; + + const HistoryChart({ + super.key, + required this.sessions, + }); + + @override + Widget build(BuildContext context) { + if (sessions.length < 2) { + return const SizedBox.shrink(); + } + + // Sort sessions by date and take last 10 + final sortedSessions = List.from(sessions) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final displaySessions = sortedSessions.length > 10 + ? sortedSessions.sublist(sortedSessions.length - 10) + : sortedSessions; + + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.show_chart, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Text( + 'Evolution des scores', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 20, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey[300], + strokeWidth: 1, + ); + }, + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= displaySessions.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + DateFormat('dd/MM').format(displaySessions[index].createdAt), + style: const TextStyle(fontSize: 10), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 20, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide(color: Colors.grey[300]!), + left: BorderSide(color: Colors.grey[300]!), + ), + ), + minX: 0, + maxX: (displaySessions.length - 1).toDouble(), + minY: 0, + maxY: _getMaxY(displaySessions), + lineBarsData: [ + // Score line + LineChartBarData( + spots: displaySessions.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), + entry.value.totalScore.toDouble(), + ); + }).toList(), + isCurved: true, + color: AppTheme.primaryColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 4, + color: AppTheme.primaryColor, + strokeWidth: 2, + strokeColor: Colors.white, + ); + }, + ), + belowBarData: BarAreaData( + show: true, + color: AppTheme.primaryColor.withValues(alpha: 0.1), + ), + ), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final session = displaySessions[spot.x.toInt()]; + return LineTooltipItem( + 'Score: ${session.totalScore}\n${session.shotCount} tirs', + const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + _buildLegend(context, displaySessions), + ], + ), + ), + ); + } + + double _getMaxY(List sessions) { + double maxScore = 0; + for (final session in sessions) { + if (session.totalScore > maxScore) { + maxScore = session.totalScore.toDouble(); + } + } + return (maxScore * 1.2).ceilToDouble(); + } + + Widget _buildLegend(BuildContext context, List displaySessions) { + final avgScore = displaySessions.fold(0, (sum, s) => sum + s.totalScore) / + displaySessions.length; + + final trend = displaySessions.length >= 2 + ? displaySessions.last.totalScore - displaySessions.first.totalScore + : 0; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildLegendItem( + context, + 'Moyenne', + avgScore.toStringAsFixed(1), + Icons.analytics, + AppTheme.primaryColor, + ), + _buildLegendItem( + context, + 'Tendance', + trend >= 0 ? '+$trend' : '$trend', + trend >= 0 ? Icons.trending_up : Icons.trending_down, + trend >= 0 ? AppTheme.successColor : AppTheme.errorColor, + ), + _buildLegendItem( + context, + 'Sessions', + '${displaySessions.length}', + Icons.list, + Colors.grey, + ), + ], + ); + } + + Widget _buildLegendItem( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + return Column( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} diff --git a/lib/features/history/widgets/session_list_item.dart b/lib/features/history/widgets/session_list_item.dart new file mode 100644 index 0000000..d2319b1 --- /dev/null +++ b/lib/features/history/widgets/session_list_item.dart @@ -0,0 +1,148 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/session.dart'; +import '../../../data/models/target_type.dart'; + +class SessionListItem extends StatelessWidget { + final Session session; + final VoidCallback? onTap; + final VoidCallback? onDelete; + + const SessionListItem({ + super.key, + required this.session, + this.onTap, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 60, + height: 60, + child: _buildThumbnail(), + ), + ), + const SizedBox(width: 12), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getTargetIcon(), + size: 16, + color: AppTheme.primaryColor, + ), + const SizedBox(width: 4), + Text( + session.targetType.displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), + style: Theme.of(context).textTheme.bodySmall, + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + session.notes!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // Score + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${session.totalScore}', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + Text( + '${session.shotCount} tirs', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + + // Delete button + if (onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: onDelete, + color: Colors.grey, + iconSize: 20, + ), + ], + ), + ), + ), + ); + } + + Widget _buildThumbnail() { + final file = File(session.imagePath); + + if (file.existsSync()) { + return Image.file( + file, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _buildPlaceholder(), + ); + } + + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + color: Colors.grey[200], + child: Icon( + _getTargetIcon(), + color: Colors.grey[400], + ), + ); + } + + IconData _getTargetIcon() { + switch (session.targetType) { + case TargetType.concentric: + return Icons.track_changes; + case TargetType.silhouette: + return Icons.person; + } + } +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart new file mode 100644 index 0000000..6f14bdb --- /dev/null +++ b/lib/features/home/home_screen.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/repositories/session_repository.dart'; +import '../capture/capture_screen.dart'; +import '../history/history_screen.dart'; +import '../statistics/statistics_screen.dart'; +import 'widgets/stats_card.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + Map? _stats; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + final repository = context.read(); + final stats = await repository.getStatistics(); + if (mounted) { + setState(() { + _stats = stats; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Bully'), + actions: [ + IconButton( + icon: const Icon(Icons.analytics), + onPressed: () => _navigateToStatistics(context), + tooltip: 'Statistiques', + ), + IconButton( + icon: const Icon(Icons.history), + onPressed: () => _navigateToHistory(context), + tooltip: 'Historique', + ), + ], + ), + body: RefreshIndicator( + onRefresh: _loadStats, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // App logo/header + _buildHeader(), + const SizedBox(height: AppConstants.largePadding), + + // Main action button + _buildMainActionButton(context), + const SizedBox(height: AppConstants.largePadding), + + // Statistics section + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_stats != null) + _buildStatsSection(), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.track_changes, + size: 64, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(height: 16), + Text( + 'Analyse de Cibles', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Scannez vos cibles et analysez vos performances', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildMainActionButton(BuildContext context) { + return ElevatedButton.icon( + onPressed: () => _navigateToCapture(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + icon: const Icon(Icons.add_a_photo, size: 28), + label: const Text( + 'Nouvelle Analyse', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildStatsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Statistiques', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: StatsCard( + icon: Icons.assessment, + title: 'Sessions', + value: '${_stats!['totalSessions']}', + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: StatsCard( + icon: Icons.gps_fixed, + title: 'Tirs', + value: '${_stats!['totalShots']}', + color: AppTheme.secondaryColor, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: StatsCard( + icon: Icons.trending_up, + title: 'Score Moyen', + value: (_stats!['averageScore'] as double).toStringAsFixed(1), + color: AppTheme.warningColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: StatsCard( + icon: Icons.emoji_events, + title: 'Meilleur', + value: '${_stats!['bestScore']}', + color: AppTheme.successColor, + ), + ), + ], + ), + ], + ); + } + + void _navigateToCapture(BuildContext context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CaptureScreen()), + ); + // Refresh stats when returning + _loadStats(); + } + + void _navigateToHistory(BuildContext context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const HistoryScreen()), + ); + // Refresh stats when returning + _loadStats(); + } + + void _navigateToStatistics(BuildContext context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const StatisticsScreen()), + ); + // Refresh stats when returning + _loadStats(); + } +} diff --git a/lib/features/home/widgets/stats_card.dart b/lib/features/home/widgets/stats_card.dart new file mode 100644 index 0000000..38034f2 --- /dev/null +++ b/lib/features/home/widgets/stats_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import '../../../core/constants/app_constants.dart'; + +class StatsCard extends StatelessWidget { + final IconData icon; + final String title; + final String value; + final Color color; + + const StatsCard({ + super.key, + required this.icon, + required this.title, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/statistics/statistics_screen.dart b/lib/features/statistics/statistics_screen.dart new file mode 100644 index 0000000..ebe6730 --- /dev/null +++ b/lib/features/statistics/statistics_screen.dart @@ -0,0 +1,717 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/session.dart'; +import '../../data/repositories/session_repository.dart'; +import '../../services/statistics_service.dart'; +import 'widgets/heat_map_widget.dart'; + +class StatisticsScreen extends StatefulWidget { + final Session? singleSession; // If provided, show stats for this session only + + const StatisticsScreen({super.key, this.singleSession}); + + @override + State createState() => _StatisticsScreenState(); +} + +class _StatisticsScreenState extends State { + final StatisticsService _statisticsService = StatisticsService(); + StatsPeriod _selectedPeriod = StatsPeriod.all; + SessionStatistics? _statistics; + bool _isLoading = true; + List _allSessions = []; + + @override + void initState() { + super.initState(); + // Use addPostFrameCallback to ensure context is available + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadStatistics(); + }); + } + + Future _loadStatistics() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + if (widget.singleSession != null) { + // Single session mode + _statistics = _statisticsService.calculateStatistics( + [widget.singleSession!], + period: StatsPeriod.session, + targetCenterX: widget.singleSession!.targetCenterX ?? 0.5, + targetCenterY: widget.singleSession!.targetCenterY ?? 0.5, + ); + } else { + // Load all sessions + final repository = context.read(); + _allSessions = await repository.getAllSessions(); + _calculateStats(); + } + } catch (e) { + debugPrint('Error loading statistics: $e'); + } + + if (mounted) { + setState(() => _isLoading = false); + } + } + + void _calculateStats() { + debugPrint('Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod'); + for (final session in _allSessions) { + debugPrint(' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}'); + } + _statistics = _statisticsService.calculateStatistics( + _allSessions, + period: _selectedPeriod, + ); + debugPrint('Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.singleSession != null ? 'Statistiques Session' : 'Statistiques'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _statistics == null || _statistics!.totalShots == 0 + ? _buildEmptyState() + : _buildStatistics(), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.analytics_outlined, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Aucune donnee disponible', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Effectuez des sessions de tir pour voir vos statistiques', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + const SizedBox(height: 16), + Text( + 'Sessions trouvees: ${_allSessions.length}', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12), + ), + if (_allSessions.isNotEmpty) + Text( + 'Tirs totaux: ${_allSessions.fold(0, (sum, s) => sum + s.shots.length)}', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12), + ), + ], + ), + ), + ); + } + + Widget _buildStatistics() { + return RefreshIndicator( + onRefresh: _loadStatistics, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Period filter (only for multi-session view) + if (widget.singleSession == null) _buildPeriodFilter(), + const SizedBox(height: 16), + + // Summary cards + _buildSummaryCards(), + const SizedBox(height: 24), + + // Heat Map + _buildHeatMapSection(), + const SizedBox(height: 24), + + // Precision stats + _buildPrecisionSection(), + const SizedBox(height: 24), + + // Standard deviation + _buildStdDevSection(), + const SizedBox(height: 24), + + // Regional distribution + _buildRegionalSection(), + ], + ), + ), + ); + } + + Widget _buildPeriodFilter() { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Periode', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: StatsPeriod.week, + label: Text('7 jours'), + icon: Icon(Icons.date_range), + ), + ButtonSegment( + value: StatsPeriod.month, + label: Text('30 jours'), + icon: Icon(Icons.calendar_month), + ), + ButtonSegment( + value: StatsPeriod.all, + label: Text('Tout'), + icon: Icon(Icons.all_inclusive), + ), + ], + selected: {_selectedPeriod}, + onSelectionChanged: (selection) { + setState(() { + _selectedPeriod = selection.first; + _calculateStats(); + }); + }, + ), + const SizedBox(height: 8), + Text( + '${_statistics!.sessions.length} session(s) - ${_statistics!.totalShots} tir(s)', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ], + ), + ), + ); + } + + Widget _buildSummaryCards() { + return Row( + children: [ + Expanded( + child: _StatCard( + icon: Icons.gps_fixed, + title: 'Tirs', + value: '${_statistics!.totalShots}', + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatCard( + icon: Icons.score, + title: 'Score Total', + value: '${_statistics!.totalScore}', + color: AppTheme.secondaryColor, + ), + ), + ], + ); + } + + Widget _buildHeatMapSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.grid_on, color: AppTheme.primaryColor), + const SizedBox(width: 8), + const Text( + 'Zones Chaudes', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Repartition de vos tirs sur la cible', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + const SizedBox(height: 16), + Center( + child: HeatMapWidget( + heatMap: _statistics!.heatMap, + size: MediaQuery.of(context).size.width - 80, + ), + ), + const SizedBox(height: 12), + // Legend - gradient bar + Container( + height: 24, + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: const LinearGradient( + colors: [ + Color(0xFF2196F3), // Blue (cold) + Color(0xFF00BCD4), // Cyan + Color(0xFFFFEB3B), // Yellow + Color(0xFFFF9800), // Orange + Color(0xFFFF1744), // Red (hot) + ], + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text('Peu', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + ), + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('Beaucoup', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildLegendItem(Color color, String label) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + border: Border.all(color: Colors.grey.shade400), + ), + ), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 10)), + ], + ), + ); + } + + Widget _buildPrecisionSection() { + final precision = _statistics!.precision; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.center_focus_strong, color: AppTheme.successColor), + const SizedBox(width: 8), + const Text( + 'Precision', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildPrecisionGauge( + 'Precision', + precision.precisionScore, + 'Distance moyenne du centre', + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildPrecisionGauge( + 'Regularite', + precision.consistencyScore, + 'Groupement des tirs', + ), + ), + ], + ), + const Divider(height: 32), + _buildStatRow('Distance moyenne du centre', + '${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%'), + _buildStatRow('Diametre de groupement', + '${(precision.groupingDiameter * 100).toStringAsFixed(1)}%'), + _buildStatRow('Score moyen', + _statistics!.avgScore.toStringAsFixed(2)), + _buildStatRow('Meilleur score', '${_statistics!.maxScore}'), + _buildStatRow('Plus bas score', '${_statistics!.minScore}'), + ], + ), + ), + ); + } + + Widget _buildPrecisionGauge(String title, double value, String subtitle) { + final color = value > 70 + ? AppTheme.successColor + : value > 40 + ? AppTheme.warningColor + : AppTheme.errorColor; + + return Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 80, + height: 80, + child: CircularProgressIndicator( + value: value / 100, + strokeWidth: 8, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + Text( + '${value.toStringAsFixed(0)}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + subtitle, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildStdDevSection() { + final stdDev = _statistics!.stdDev; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.stacked_line_chart, color: AppTheme.warningColor), + const SizedBox(width: 8), + const Text( + 'Ecart Type', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Mesure de la dispersion de vos tirs', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + const SizedBox(height: 16), + _buildStatRow('Ecart type X (horizontal)', + '${(stdDev.stdDevX * 100).toStringAsFixed(2)}%'), + _buildStatRow('Ecart type Y (vertical)', + '${(stdDev.stdDevY * 100).toStringAsFixed(2)}%'), + _buildStatRow('Ecart type radial', + '${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%'), + _buildStatRow('Ecart type score', + stdDev.stdDevScore.toStringAsFixed(2)), + const Divider(height: 24), + _buildStatRow('Position moyenne X', + '${(stdDev.meanX * 100).toStringAsFixed(1)}%'), + _buildStatRow('Position moyenne Y', + '${(stdDev.meanY * 100).toStringAsFixed(1)}%'), + _buildStatRow('Score moyen', + stdDev.meanScore.toStringAsFixed(2)), + ], + ), + ), + ); + } + + Widget _buildRegionalSection() { + final regional = _statistics!.regional; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.explore, color: AppTheme.secondaryColor), + const SizedBox(width: 8), + const Text( + 'Distribution Regionale', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const SizedBox(height: 16), + + // Dominant direction + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.compass_calibration, color: AppTheme.primaryColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Direction dominante'), + Text( + regional.dominantDirection, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Bias + if (regional.biasX.abs() > 0.02 || regional.biasY.abs() > 0.02) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.warningColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber, color: AppTheme.warningColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Biais detecte'), + Text( + _getBiasDescription(regional.biasX, regional.biasY), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Sector distribution + const Text('Repartition par secteur:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: regional.sectorDistribution.entries.map((entry) { + final percentage = _statistics!.totalShots > 0 + ? (entry.value / _statistics!.totalShots * 100) + : 0.0; + return _buildSectorChip(entry.key, entry.value, percentage); + }).toList(), + ), + + const SizedBox(height: 16), + + // Quadrant distribution + const Text('Repartition par quadrant:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _buildQuadrantGrid(regional.quadrantDistribution), + ], + ), + ), + ); + } + + Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ); + } + + Widget _buildSectorChip(String sector, int count, double percentage) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: count > 0 ? AppTheme.primaryColor.withValues(alpha: 0.1) : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300, + ), + ), + child: Text( + '$sector: $count (${percentage.toStringAsFixed(0)}%)', + style: TextStyle( + fontSize: 12, + color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade600, + ), + ), + ); + } + + Widget _buildQuadrantGrid(Map quadrants) { + return Table( + border: TableBorder.all(color: Colors.grey.shade300), + children: [ + TableRow( + children: [ + _buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0), + _buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0), + ], + ), + TableRow( + children: [ + _buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0), + _buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0), + ], + ), + ], + ); + } + + Widget _buildQuadrantCell(String label, int count) { + final percentage = _statistics!.totalShots > 0 + ? (count / _statistics!.totalShots * 100) + : 0.0; + final intensity = _statistics!.totalShots > 0 + ? count / _statistics!.totalShots + : 0.0; + + return Container( + padding: const EdgeInsets.all(16), + color: Color.lerp(Colors.white, AppTheme.primaryColor, intensity * 0.5), + child: Column( + children: [ + Text( + '$count', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + Text( + '${percentage.toStringAsFixed(0)}%', + style: TextStyle(color: Colors.grey.shade600), + ), + Text( + label, + style: const TextStyle(fontSize: 10), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + String _getBiasDescription(double biasX, double biasY) { + final descriptions = []; + + if (biasX.abs() > 0.02) { + descriptions.add(biasX > 0 ? 'vers la droite' : 'vers la gauche'); + } + if (biasY.abs() > 0.02) { + descriptions.add(biasY > 0 ? 'vers le bas' : 'vers le haut'); + } + + return 'Tendance ${descriptions.join(' et ')}'; + } +} + +class _StatCard extends StatelessWidget { + final IconData icon; + final String title; + final String value; + final Color color; + + const _StatCard({ + required this.icon, + required this.title, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + title, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/statistics/widgets/heat_map_widget.dart b/lib/features/statistics/widgets/heat_map_widget.dart new file mode 100644 index 0000000..1c98b8d --- /dev/null +++ b/lib/features/statistics/widgets/heat_map_widget.dart @@ -0,0 +1,232 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import '../../../services/statistics_service.dart'; + +class HeatMapWidget extends StatelessWidget { + final HeatMap heatMap; + final double size; + + const HeatMapWidget({ + super.key, + required this.heatMap, + this.size = 250, + }); + + @override + Widget build(BuildContext context) { + if (heatMap.zones.isEmpty || heatMap.totalShots == 0) { + return SizedBox( + width: size, + height: size, + child: const Center( + child: Text('Aucune donnee'), + ), + ); + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: CustomPaint( + size: Size(size, size), + painter: _HeatMapFogPainter(heatMap: heatMap), + ), + ), + ); + } +} + +class _HeatMapFogPainter extends CustomPainter { + final HeatMap heatMap; + + _HeatMapFogPainter({required this.heatMap}); + + @override + void paint(Canvas canvas, Size size) { + if (heatMap.zones.isEmpty) return; + + // Draw base background (cold blue) + final bgPaint = Paint() + ..color = const Color(0xFF1A237E).withValues(alpha: 0.3); // Dark blue base + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); + + final cellWidth = size.width / heatMap.gridSize; + final cellHeight = size.height / heatMap.gridSize; + + // Collect all shot positions with their intensities for fog effect + final hotSpots = <_HotSpot>[]; + + for (int row = 0; row < heatMap.gridSize; row++) { + for (int col = 0; col < heatMap.gridSize; col++) { + final zone = heatMap.zones[row][col]; + if (zone.shotCount > 0) { + hotSpots.add(_HotSpot( + x: (col + 0.5) * cellWidth, + y: (row + 0.5) * cellHeight, + intensity: zone.intensity, + shotCount: zone.shotCount, + )); + } + } + } + + // Draw fog effect using radial gradients for each hot spot + for (final spot in hotSpots) { + _drawFogSpot(canvas, size, spot, cellWidth, cellHeight); + } + + // Draw target overlay (concentric circles) + final center = Offset(size.width / 2, size.height / 2); + final maxRadius = size.width / 2; + final circlePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + for (int i = 1; i <= 5; i++) { + canvas.drawCircle(center, maxRadius * (i / 5), circlePaint); + } + + // Draw crosshair + canvas.drawLine( + Offset(center.dx, 0), + Offset(center.dx, size.height), + circlePaint, + ); + canvas.drawLine( + Offset(0, center.dy), + Offset(size.width, center.dy), + circlePaint, + ); + + // Draw shot counts + for (final spot in hotSpots) { + final textPainter = TextPainter( + text: TextSpan( + text: '${spot.shotCount}', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + shadows: [ + Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 4), + Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 2), + ], + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(spot.x - textPainter.width / 2, spot.y - textPainter.height / 2), + ); + } + } + + void _drawFogSpot(Canvas canvas, Size size, _HotSpot spot, double cellWidth, double cellHeight) { + // Calculate fog radius based on intensity and cell size + final baseRadius = math.max(cellWidth, cellHeight) * 1.5; + final radius = baseRadius * (0.5 + spot.intensity * 0.5); + + // Create gradient from hot (red/orange) to transparent + final gradient = ui.Gradient.radial( + Offset(spot.x, spot.y), + radius, + [ + _getHeatColor(spot.intensity).withValues(alpha: 0.7 * spot.intensity + 0.3), + _getHeatColor(spot.intensity * 0.5).withValues(alpha: 0.3 * spot.intensity), + Colors.transparent, + ], + [0.0, 0.5, 1.0], + ); + + final paint = Paint() + ..shader = gradient + ..blendMode = BlendMode.screen; // Additive blending for fog effect + + canvas.drawCircle(Offset(spot.x, spot.y), radius, paint); + + // Add a second layer for more intensity + if (spot.intensity > 0.3) { + final innerGradient = ui.Gradient.radial( + Offset(spot.x, spot.y), + radius * 0.6, + [ + _getHeatColor(spot.intensity).withValues(alpha: 0.5 * spot.intensity), + Colors.transparent, + ], + [0.0, 1.0], + ); + + final innerPaint = Paint() + ..shader = innerGradient + ..blendMode = BlendMode.screen; + + canvas.drawCircle(Offset(spot.x, spot.y), radius * 0.6, innerPaint); + } + } + + Color _getHeatColor(double intensity) { + // Gradient from blue (cold) to red (hot) + if (intensity <= 0) return const Color(0xFF2196F3); // Blue + if (intensity >= 1) return const Color(0xFFFF1744); // Red + + // Interpolate between blue -> cyan -> yellow -> orange -> red + if (intensity < 0.25) { + // Blue to Cyan + return Color.lerp( + const Color(0xFF2196F3), // Blue + const Color(0xFF00BCD4), // Cyan + intensity / 0.25, + )!; + } else if (intensity < 0.5) { + // Cyan to Yellow + return Color.lerp( + const Color(0xFF00BCD4), // Cyan + const Color(0xFFFFEB3B), // Yellow + (intensity - 0.25) / 0.25, + )!; + } else if (intensity < 0.75) { + // Yellow to Orange + return Color.lerp( + const Color(0xFFFFEB3B), // Yellow + const Color(0xFFFF9800), // Orange + (intensity - 0.5) / 0.25, + )!; + } else { + // Orange to Red + return Color.lerp( + const Color(0xFFFF9800), // Orange + const Color(0xFFFF1744), // Red + (intensity - 0.75) / 0.25, + )!; + } + } + + @override + bool shouldRepaint(covariant _HeatMapFogPainter oldDelegate) { + return heatMap != oldDelegate.heatMap; + } +} + +class _HotSpot { + final double x; + final double y; + final double intensity; + final int shotCount; + + _HotSpot({ + required this.x, + required this.y, + required this.intensity, + required this.shotCount, + }); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..fff5c25 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,54 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'app.dart'; +import 'data/repositories/session_repository.dart'; +import 'services/target_detection_service.dart'; +import 'services/score_calculator_service.dart'; +import 'services/grouping_analyzer_service.dart'; +import 'services/image_processing_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize date formatting for French locale + await initializeDateFormatting('fr_FR', null); + + // Initialize FFI for desktop platforms + if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + }; + + runApp( + MultiProvider( + providers: [ + Provider( + create: (_) => ImageProcessingService(), + ), + Provider( + create: (context) => TargetDetectionService( + imageProcessingService: context.read(), + ), + ), + Provider( + create: (_) => ScoreCalculatorService(), + ), + Provider( + create: (_) => GroupingAnalyzerService(), + ), + Provider( + create: (_) => SessionRepository(), + ), + ], + child: const BullyApp(), + ), + ); +} diff --git a/lib/services/grouping_analyzer_service.dart b/lib/services/grouping_analyzer_service.dart new file mode 100644 index 0000000..75bec4b --- /dev/null +++ b/lib/services/grouping_analyzer_service.dart @@ -0,0 +1,230 @@ +import 'dart:math' as math; +import '../data/models/shot.dart'; + +class GroupingResult { + final double centerX; // Center of the group (relative 0-1) + final double centerY; + final double diameter; // Maximum spread diameter (relative 0-1) + final double standardDeviation; // Dispersion metric + final double meanRadius; // Average distance from center + final int shotCount; + + GroupingResult({ + required this.centerX, + required this.centerY, + required this.diameter, + required this.standardDeviation, + required this.meanRadius, + required this.shotCount, + }); + + /// Grouping quality rating (1-5 stars) + int get qualityRating { + // Based on standard deviation relative to typical target size + if (standardDeviation < 0.02) return 5; + if (standardDeviation < 0.04) return 4; + if (standardDeviation < 0.06) return 3; + if (standardDeviation < 0.10) return 2; + return 1; + } + + String get qualityDescription { + switch (qualityRating) { + case 5: + return 'Excellent'; + case 4: + return 'Tres bien'; + case 3: + return 'Bien'; + case 2: + return 'Moyen'; + default: + return 'A ameliorer'; + } + } + + factory GroupingResult.empty() { + return GroupingResult( + centerX: 0.5, + centerY: 0.5, + diameter: 0, + standardDeviation: 0, + meanRadius: 0, + shotCount: 0, + ); + } +} + +class GroupingAnalyzerService { + /// Analyze the grouping of a list of shots + GroupingResult analyzeGrouping(List shots) { + if (shots.isEmpty) { + return GroupingResult.empty(); + } + + if (shots.length == 1) { + return GroupingResult( + centerX: shots.first.x, + centerY: shots.first.y, + diameter: 0, + standardDeviation: 0, + meanRadius: 0, + shotCount: 1, + ); + } + + // Calculate center of group (centroid) + double sumX = 0; + double sumY = 0; + + for (final shot in shots) { + sumX += shot.x; + sumY += shot.y; + } + + final centerX = sumX / shots.length; + final centerY = sumY / shots.length; + + // Calculate distances from center + final distances = []; + for (final shot in shots) { + final dx = shot.x - centerX; + final dy = shot.y - centerY; + distances.add(math.sqrt(dx * dx + dy * dy)); + } + + // Calculate mean radius + final meanRadius = distances.reduce((a, b) => a + b) / distances.length; + + // Calculate standard deviation + double sumSquaredDiff = 0; + for (final distance in distances) { + sumSquaredDiff += math.pow(distance - meanRadius, 2); + } + final standardDeviation = math.sqrt(sumSquaredDiff / distances.length); + + // Calculate maximum spread (diameter) + // Find the two points that are farthest apart + double maxDistance = 0; + + for (int i = 0; i < shots.length; i++) { + for (int j = i + 1; j < shots.length; j++) { + final dx = shots[i].x - shots[j].x; + final dy = shots[i].y - shots[j].y; + final distance = math.sqrt(dx * dx + dy * dy); + if (distance > maxDistance) { + maxDistance = distance; + } + } + } + + return GroupingResult( + centerX: centerX, + centerY: centerY, + diameter: maxDistance, + standardDeviation: standardDeviation, + meanRadius: meanRadius, + shotCount: shots.length, + ); + } + + /// Calculate offset from target center + (double, double) calculateOffset({ + required double groupCenterX, + required double groupCenterY, + required double targetCenterX, + required double targetCenterY, + }) { + return ( + groupCenterX - targetCenterX, + groupCenterY - targetCenterY, + ); + } + + /// Get directional offset description (e.g., "haut-gauche") + String getOffsetDescription(double offsetX, double offsetY) { + if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) { + return 'Centre'; + } + + String vertical = ''; + String horizontal = ''; + + if (offsetY < -0.02) { + vertical = 'Haut'; + } else if (offsetY > 0.02) { + vertical = 'Bas'; + } + + if (offsetX < -0.02) { + horizontal = 'Gauche'; + } else if (offsetX > 0.02) { + horizontal = 'Droite'; + } + + if (vertical.isNotEmpty && horizontal.isNotEmpty) { + return '$vertical-$horizontal'; + } + + return vertical.isNotEmpty ? vertical : horizontal; + } + + /// Analyze trend across multiple sessions + GroupingTrend analyzeTrend(List results) { + if (results.length < 2) { + return GroupingTrend( + improving: false, + averageDiameter: results.isEmpty ? 0 : results.first.diameter, + recentDiameter: results.isEmpty ? 0 : results.first.diameter, + improvementPercentage: 0, + ); + } + + // Calculate averages for first half vs second half + final midpoint = results.length ~/ 2; + final firstHalf = results.sublist(0, midpoint); + final secondHalf = results.sublist(midpoint); + + double firstHalfAvg = 0; + for (final r in firstHalf) { + firstHalfAvg += r.diameter; + } + firstHalfAvg /= firstHalf.length; + + double secondHalfAvg = 0; + for (final r in secondHalf) { + secondHalfAvg += r.diameter; + } + secondHalfAvg /= secondHalf.length; + + // Overall average + double totalAvg = 0; + for (final r in results) { + totalAvg += r.diameter; + } + totalAvg /= results.length; + + final improvement = ((firstHalfAvg - secondHalfAvg) / firstHalfAvg) * 100; + + return GroupingTrend( + improving: secondHalfAvg < firstHalfAvg, + averageDiameter: totalAvg, + recentDiameter: secondHalfAvg, + improvementPercentage: improvement, + ); + } +} + +class GroupingTrend { + final bool improving; + final double averageDiameter; + final double recentDiameter; + final double improvementPercentage; + + GroupingTrend({ + required this.improving, + required this.averageDiameter, + required this.recentDiameter, + required this.improvementPercentage, + }); +} diff --git a/lib/services/image_processing_service.dart b/lib/services/image_processing_service.dart new file mode 100644 index 0000000..937c3b2 --- /dev/null +++ b/lib/services/image_processing_service.dart @@ -0,0 +1,775 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'package:image/image.dart' as img; + +class DetectedCircle { + final double centerX; + final double centerY; + final double radius; + + DetectedCircle({ + required this.centerX, + required this.centerY, + required this.radius, + }); +} + +class DetectedImpact { + final double x; + final double y; + final double radius; + + DetectedImpact({ + required this.x, + required this.y, + required this.radius, + }); +} + +/// Image processing settings for impact detection +class ImpactDetectionSettings { + /// Threshold for dark spot detection (0-255, lower = darker) + final int darkThreshold; + + /// Minimum impact size in pixels + final int minImpactSize; + + /// Maximum impact size in pixels + final int maxImpactSize; + + /// Blur radius for noise reduction + final int blurRadius; + + /// Contrast enhancement factor + final double contrastFactor; + + /// Minimum circularity (0-1, 1 = perfect circle) + /// Used to filter out non-circular shapes like numbers + final double minCircularity; + + /// Maximum aspect ratio (width/height or height/width) + /// Used to filter out elongated shapes like numbers + final double maxAspectRatio; + + /// Minimum fill ratio (0-1, ~0.7 for filled circle, lower for rings/hollow shapes) + /// A bullet hole is FILLED, numbers on target are hollow rings + final double minFillRatio; + + const ImpactDetectionSettings({ + this.darkThreshold = 80, + this.minImpactSize = 20, + this.maxImpactSize = 500, + this.blurRadius = 2, + this.contrastFactor = 1.2, + this.minCircularity = 0.6, + this.maxAspectRatio = 2.0, + this.minFillRatio = 0.5, // Filled circles should have ratio > 0.5 + }); +} + +/// Reference impact for calibrated detection +class ReferenceImpact { + final double x; // Normalized 0-1 + final double y; // Normalized 0-1 + + const ReferenceImpact({required this.x, required this.y}); +} + +/// Characteristics learned from reference impacts +class ImpactCharacteristics { + final double avgLuminance; + final double luminanceStdDev; + final double avgSize; + final double sizeStdDev; + final double avgCircularity; + final double avgFillRatio; // How filled is the blob vs its bounding circle + final double avgDarkThreshold; // The threshold used to detect the blob + + const ImpactCharacteristics({ + required this.avgLuminance, + required this.luminanceStdDev, + required this.avgSize, + required this.sizeStdDev, + required this.avgCircularity, + required this.avgFillRatio, + required this.avgDarkThreshold, + }); + + @override + String toString() { + return 'ImpactCharacteristics(lum: ${avgLuminance.toStringAsFixed(1)} ± ${luminanceStdDev.toStringAsFixed(1)}, ' + 'size: ${avgSize.toStringAsFixed(1)} ± ${sizeStdDev.toStringAsFixed(1)}, ' + 'circ: ${avgCircularity.toStringAsFixed(2)}, fill: ${avgFillRatio.toStringAsFixed(2)})'; + } +} + +/// Service for image processing and impact detection +class ImageProcessingService { + /// Detect the main target circle from an image + DetectedCircle? detectMainTarget(String imagePath) { + // Return center of image as target (basic implementation) + // Could be enhanced with circle detection algorithm + return DetectedCircle( + centerX: 0.5, + centerY: 0.5, + radius: 0.4, + ); + } + + /// Detect impacts (bullet holes) from an image file + List detectImpacts(String imagePath) { + return detectImpactsWithSettings( + imagePath, + const ImpactDetectionSettings(), + ); + } + + /// Detect impacts with custom settings + List detectImpactsWithSettings( + String imagePath, + ImpactDetectionSettings settings, + ) { + try { + // Load the image + final file = File(imagePath); + final bytes = file.readAsBytesSync(); + final originalImage = img.decodeImage(bytes); + + if (originalImage == null) { + return []; + } + + // Resize for faster processing if image is too large + img.Image image; + final maxDimension = 1000; + if (originalImage.width > maxDimension || originalImage.height > maxDimension) { + final scale = maxDimension / math.max(originalImage.width, originalImage.height); + image = img.copyResize( + originalImage, + width: (originalImage.width * scale).round(), + height: (originalImage.height * scale).round(), + ); + } else { + image = originalImage; + } + + // Convert to grayscale + final grayscale = img.grayscale(image); + + // Apply gaussian blur to reduce noise + final blurred = img.gaussianBlur(grayscale, radius: settings.blurRadius); + + // Enhance contrast + final enhanced = img.adjustColor( + blurred, + contrast: settings.contrastFactor, + ); + + // Detect dark spots (potential impacts) + // Filter by circularity and fill ratio to avoid detecting numbers (hollow rings) + final impacts = _detectDarkSpots( + enhanced, + settings.darkThreshold, + settings.minImpactSize, + settings.maxImpactSize, + minCircularity: settings.minCircularity, + maxAspectRatio: settings.maxAspectRatio, + minFillRatio: settings.minFillRatio, + ); + + // Convert to relative coordinates + final width = originalImage.width.toDouble(); + final height = originalImage.height.toDouble(); + + return impacts.map((impact) { + return DetectedImpact( + x: impact.x / width, + y: impact.y / height, + radius: impact.radius, + ); + }).toList(); + } catch (e) { + print('Error detecting impacts: $e'); + return []; + } + } + + /// Analyze reference impacts to learn their characteristics + /// This actually finds the blob at each reference point and extracts its real properties + ImpactCharacteristics? analyzeReferenceImpacts( + String imagePath, + List references, { + int searchRadius = 30, + }) { + if (references.length < 2) return null; + + try { + final file = File(imagePath); + final bytes = file.readAsBytesSync(); + final originalImage = img.decodeImage(bytes); + if (originalImage == null) return null; + + // Resize for faster processing + img.Image image; + double scale = 1.0; + final maxDimension = 1000; + if (originalImage.width > maxDimension || originalImage.height > maxDimension) { + scale = maxDimension / math.max(originalImage.width, originalImage.height); + image = img.copyResize( + originalImage, + width: (originalImage.width * scale).round(), + height: (originalImage.height * scale).round(), + ); + } else { + image = originalImage; + } + + final grayscale = img.grayscale(image); + final blurred = img.gaussianBlur(grayscale, radius: 2); + final width = image.width; + final height = image.height; + + final luminances = []; + final sizes = []; + final circularities = []; + final fillRatios = []; + final thresholds = []; + + for (final ref in references) { + final centerX = (ref.x * width).round().clamp(0, width - 1); + final centerY = (ref.y * height).round().clamp(0, height - 1); + + // Find the darkest point in the search area (the center of the impact) + int darkestX = centerX; + int darkestY = centerY; + double darkestLum = 255; + + for (int dy = -searchRadius; dy <= searchRadius; dy++) { + for (int dx = -searchRadius; dx <= searchRadius; dx++) { + final px = centerX + dx; + final py = centerY + dy; + if (px < 0 || px >= width || py < 0 || py >= height) continue; + + final pixel = blurred.getPixel(px, py); + final lum = img.getLuminance(pixel).toDouble(); + if (lum < darkestLum) { + darkestLum = lum; + darkestX = px; + darkestY = py; + } + } + } + + // Now find the blob at the darkest point using adaptive threshold + // Start from the darkest point and expand until we find the boundary + final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height); + + if (blobResult != null) { + luminances.add(blobResult.avgLuminance); + sizes.add(blobResult.size.toDouble()); + circularities.add(blobResult.circularity); + fillRatios.add(blobResult.fillRatio); + thresholds.add(blobResult.threshold); + } + } + + if (luminances.isEmpty) return null; + + // Calculate statistics + final avgLum = luminances.reduce((a, b) => a + b) / luminances.length; + final avgSize = sizes.reduce((a, b) => a + b) / sizes.length; + final avgCirc = circularities.reduce((a, b) => a + b) / circularities.length; + final avgFill = fillRatios.reduce((a, b) => a + b) / fillRatios.length; + final avgThreshold = thresholds.reduce((a, b) => a + b) / thresholds.length; + + // Calculate standard deviations + double lumVariance = 0; + double sizeVariance = 0; + for (int i = 0; i < luminances.length; i++) { + lumVariance += math.pow(luminances[i] - avgLum, 2); + sizeVariance += math.pow(sizes[i] - avgSize, 2); + } + final lumStdDev = math.sqrt(lumVariance / luminances.length); + final sizeStdDev = math.sqrt(sizeVariance / sizes.length); + + return ImpactCharacteristics( + avgLuminance: avgLum, + luminanceStdDev: lumStdDev, + avgSize: avgSize, + sizeStdDev: sizeStdDev, + avgCircularity: avgCirc, + avgFillRatio: avgFill, + avgDarkThreshold: avgThreshold, + ); + } catch (e) { + print('Error analyzing reference impacts: $e'); + return null; + } + } + + /// Find a blob at a specific point and extract its characteristics + _BlobAnalysis? _findBlobAtPoint(img.Image image, int startX, int startY, int width, int height) { + // Get the luminance at the center point + final centerPixel = image.getPixel(startX, startY); + final centerLum = img.getLuminance(centerPixel).toDouble(); + + // Find the threshold by looking at the luminance gradient around the point + // Sample in expanding circles to find where the blob ends + double sumLum = centerLum; + int pixelCount = 1; + double maxRadius = 0; + + // Sample at different radii to find the edge + for (int r = 1; r <= 50; r++) { + double ringSum = 0; + int ringCount = 0; + + // Sample points on a ring + for (int i = 0; i < 16; i++) { + final angle = (i / 16) * 2 * math.pi; + final px = startX + (r * math.cos(angle)).round(); + final py = startY + (r * math.sin(angle)).round(); + if (px < 0 || px >= width || py < 0 || py >= height) continue; + + final pixel = image.getPixel(px, py); + final lum = img.getLuminance(pixel).toDouble(); + ringSum += lum; + ringCount++; + } + + if (ringCount > 0) { + final avgRingLum = ringSum / ringCount; + // If the ring is significantly brighter than the center, we've found the edge + if (avgRingLum > centerLum + 40) { + maxRadius = r.toDouble(); + break; + } + sumLum += ringSum; + pixelCount += ringCount; + } + } + + if (maxRadius < 3) return null; // Too small to be a valid blob + + // Calculate threshold as the midpoint between center and edge luminance + final edgeRadius = (maxRadius * 1.2).round(); + double edgeLum = 0; + int edgeCount = 0; + for (int i = 0; i < 16; i++) { + final angle = (i / 16) * 2 * math.pi; + final px = startX + (edgeRadius * math.cos(angle)).round(); + final py = startY + (edgeRadius * math.sin(angle)).round(); + if (px < 0 || px >= width || py < 0 || py >= height) continue; + final pixel = image.getPixel(px, py); + edgeLum += img.getLuminance(pixel).toDouble(); + edgeCount++; + } + if (edgeCount > 0) { + edgeLum /= edgeCount; + } + + final threshold = ((centerLum + edgeLum) / 2).round(); + + // Now do a flood fill with this threshold to get the actual blob + final mask = List.generate(height, (_) => List.filled(width, false)); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final pixel = image.getPixel(x, y); + final lum = img.getLuminance(pixel); + mask[y][x] = lum < threshold; + } + } + + final visited = List.generate(height, (_) => List.filled(width, false)); + + // Find the blob containing the start point + if (!mask[startY][startX]) { + // Start point might not be in mask, find nearest point that is + for (int r = 1; r <= 10; r++) { + bool found = false; + for (int dy = -r; dy <= r && !found; dy++) { + for (int dx = -r; dx <= r && !found; dx++) { + final px = startX + dx; + final py = startY + dy; + if (px >= 0 && px < width && py >= 0 && py < height && mask[py][px]) { + final blob = _floodFill(mask, visited, px, py, width, height); + + // Calculate fill ratio: actual pixels / bounding circle area + final boundingRadius = math.max(blob.radius, 1); + final boundingCircleArea = math.pi * boundingRadius * boundingRadius; + final fillRatio = (blob.size / boundingCircleArea).clamp(0.0, 1.0); + + return _BlobAnalysis( + avgLuminance: sumLum / pixelCount, + size: blob.size, + circularity: blob.circularity, + fillRatio: fillRatio, + threshold: threshold.toDouble(), + ); + } + } + } + } + return null; + } + + final blob = _floodFill(mask, visited, startX, startY, width, height); + + // Calculate fill ratio + final boundingRadius = math.max(blob.radius, 1); + final boundingCircleArea = math.pi * boundingRadius * boundingRadius; + final fillRatio = (blob.size / boundingCircleArea).clamp(0.0, 1.0); + + return _BlobAnalysis( + avgLuminance: sumLum / pixelCount, + size: blob.size, + circularity: blob.circularity, + fillRatio: fillRatio, + threshold: threshold.toDouble(), + ); + } + + /// Detect impacts based on reference characteristics with tolerance + List detectImpactsFromReferences( + String imagePath, + ImpactCharacteristics characteristics, { + double tolerance = 2.0, // Number of standard deviations + double minCircularity = 0.4, + }) { + try { + final file = File(imagePath); + final bytes = file.readAsBytesSync(); + final originalImage = img.decodeImage(bytes); + if (originalImage == null) return []; + + // Resize for faster processing + img.Image image; + double scale = 1.0; + final maxDimension = 1000; + if (originalImage.width > maxDimension || originalImage.height > maxDimension) { + scale = maxDimension / math.max(originalImage.width, originalImage.height); + image = img.copyResize( + originalImage, + width: (originalImage.width * scale).round(), + height: (originalImage.height * scale).round(), + ); + } else { + image = originalImage; + } + + final grayscale = img.grayscale(image); + final blurred = img.gaussianBlur(grayscale, radius: 2); + + // Use the threshold learned from references + final threshold = characteristics.avgDarkThreshold.round(); + + // Calculate size range based on learned characteristics + final minSize = (characteristics.avgSize / (tolerance * 2)).clamp(5, 10000).round(); + final maxSize = (characteristics.avgSize * tolerance * 2).clamp(10, 10000).round(); + + // Calculate minimum fill ratio based on learned characteristics + // Allow some variance but still filter out hollow shapes + final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.3, 0.9); + + // Detect blobs using the learned threshold + final impacts = _detectDarkSpots( + blurred, + threshold, + minSize, + maxSize, + minCircularity: math.max(characteristics.avgCircularity - 0.2, minCircularity), + minFillRatio: minFillRatio, + ); + + // Convert to relative coordinates + final width = originalImage.width.toDouble(); + final height = originalImage.height.toDouble(); + + return impacts.map((impact) { + return DetectedImpact( + x: impact.x / image.width, + y: impact.y / image.height, + radius: impact.radius / scale, + ); + }).toList(); + } catch (e) { + print('Error detecting impacts from references: $e'); + return []; + } + } + + /// Detect dark spots with adaptive luminance range + List<_Blob> _detectDarkSpotsAdaptive( + img.Image image, + int minLuminance, + int maxLuminance, + int minSize, + int maxSize, { + double minCircularity = 0.5, + double minFillRatio = 0.5, + }) { + final width = image.width; + final height = image.height; + + // Create binary mask of pixels within luminance range + final mask = List.generate(height, (_) => List.filled(width, false)); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final pixel = image.getPixel(x, y); + final luminance = img.getLuminance(pixel); + mask[y][x] = luminance >= minLuminance && luminance <= maxLuminance; + } + } + + // Find connected components + final visited = List.generate(height, (_) => List.filled(width, false)); + final blobs = <_Blob>[]; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (mask[y][x] && !visited[y][x]) { + final blob = _floodFill(mask, visited, x, y, width, height); + if (blob.size >= minSize && + blob.size <= maxSize && + blob.circularity >= minCircularity && + blob.fillRatio >= minFillRatio) { + blobs.add(blob); + } + } + } + } + + return _filterOverlappingBlobs(blobs); + } + + /// Detect dark spots in a grayscale image using blob detection + List<_Blob> _detectDarkSpots( + img.Image image, + int threshold, + int minSize, + int maxSize, { + double minCircularity = 0.6, + double maxAspectRatio = 2.0, + double minFillRatio = 0.5, + }) { + final width = image.width; + final height = image.height; + + // Create binary mask of dark pixels + final mask = List.generate(height, (_) => List.filled(width, false)); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final pixel = image.getPixel(x, y); + final luminance = img.getLuminance(pixel); + mask[y][x] = luminance < threshold; + } + } + + // Find connected components (blobs) + final visited = List.generate(height, (_) => List.filled(width, false)); + final blobs = <_Blob>[]; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (mask[y][x] && !visited[y][x]) { + final blob = _floodFill(mask, visited, x, y, width, height); + + // Filter by size + if (blob.size < minSize || blob.size > maxSize) continue; + + // Filter by circularity (reject non-circular shapes like numbers) + if (blob.circularity < minCircularity) continue; + + // Filter by aspect ratio (reject elongated shapes) + if (blob.aspectRatio > maxAspectRatio) continue; + + // Filter by fill ratio (reject hollow rings - numbers on target) + // A filled bullet hole should have fill ratio > 0.5 + // A hollow ring (like number "0" or "8") has a much lower fill ratio + if (blob.fillRatio < minFillRatio) continue; + + blobs.add(blob); + } + } + } + + // Filter overlapping blobs (keep larger ones) + final filteredBlobs = _filterOverlappingBlobs(blobs); + + return filteredBlobs; + } + + /// Flood fill to find connected component + _Blob _floodFill( + List> mask, + List> visited, + int startX, + int startY, + int width, + int height, + ) { + final stack = <_Point>[_Point(startX, startY)]; + final points = <_Point>[]; + + int minX = startX, maxX = startX; + int minY = startY, maxY = startY; + int perimeterCount = 0; + + while (stack.isNotEmpty) { + final point = stack.removeLast(); + final x = point.x; + final y = point.y; + + if (x < 0 || x >= width || y < 0 || y >= height) continue; + if (visited[y][x] || !mask[y][x]) continue; + + visited[y][x] = true; + points.add(point); + + minX = math.min(minX, x); + maxX = math.max(maxX, x); + minY = math.min(minY, y); + maxY = math.max(maxY, y); + + // Check if this is a perimeter pixel (has at least one non-blob neighbor) + bool isPerimeter = false; + for (final delta in [[-1, 0], [1, 0], [0, -1], [0, 1]]) { + final nx = x + delta[0]; + final ny = y + delta[1]; + if (nx < 0 || nx >= width || ny < 0 || ny >= height || !mask[ny][nx]) { + isPerimeter = true; + break; + } + } + if (isPerimeter) perimeterCount++; + + // Add neighbors (4-connectivity) + stack.add(_Point(x + 1, y)); + stack.add(_Point(x - 1, y)); + stack.add(_Point(x, y + 1)); + stack.add(_Point(x, y - 1)); + } + + // Calculate centroid + double sumX = 0, sumY = 0; + for (final p in points) { + sumX += p.x; + sumY += p.y; + } + + final centerX = points.isNotEmpty ? sumX / points.length : startX.toDouble(); + final centerY = points.isNotEmpty ? sumY / points.length : startY.toDouble(); + + // Calculate bounding box dimensions + final blobWidth = (maxX - minX + 1).toDouble(); + final blobHeight = (maxY - minY + 1).toDouble(); + + // Calculate approximate radius based on bounding box + final radius = math.max(blobWidth, blobHeight) / 2.0; + + // Calculate circularity: 4 * pi * area / perimeter^2 + // For a perfect circle, this equals 1 + final area = points.length.toDouble(); + final perimeter = perimeterCount.toDouble(); + final circularity = perimeter > 0 + ? (4 * math.pi * area) / (perimeter * perimeter) + : 0.0; + + // Calculate aspect ratio (always >= 1) + final aspectRatio = blobWidth > blobHeight + ? blobWidth / blobHeight + : blobHeight / blobWidth; + + // Calculate fill ratio: actual area vs bounding circle area + // A filled circle has fill ratio ~0.78 (pi/4), a ring/hollow circle has much lower + final boundingCircleArea = math.pi * radius * radius; + final fillRatio = boundingCircleArea > 0 ? (area / boundingCircleArea).clamp(0.0, 1.0) : 0.0; + + return _Blob( + x: centerX, + y: centerY, + radius: radius, + size: points.length, + circularity: circularity.clamp(0.0, 1.0), + aspectRatio: aspectRatio, + fillRatio: fillRatio, + ); + } + + /// Filter overlapping blobs, keeping the larger ones + List<_Blob> _filterOverlappingBlobs(List<_Blob> blobs) { + if (blobs.isEmpty) return []; + + // Sort by size (largest first) + blobs.sort((a, b) => b.size.compareTo(a.size)); + + final filtered = <_Blob>[]; + + for (final blob in blobs) { + bool overlaps = false; + + for (final existing in filtered) { + final dx = blob.x - existing.x; + final dy = blob.y - existing.y; + final distance = math.sqrt(dx * dx + dy * dy); + + // Check if blobs overlap + if (distance < (blob.radius + existing.radius) * 0.8) { + overlaps = true; + break; + } + } + + if (!overlaps) { + filtered.add(blob); + } + } + + return filtered; + } +} + +class _Point { + final int x; + final int y; + + _Point(this.x, this.y); +} + +class _Blob { + final double x; + final double y; + final double radius; + final int size; + final double circularity; // 0-1, 1 = perfect circle + final double aspectRatio; // width/height ratio + final double fillRatio; // How filled vs hollow the blob is + + _Blob({ + required this.x, + required this.y, + required this.radius, + required this.size, + required this.circularity, + required this.aspectRatio, + this.fillRatio = 1.0, + }); +} + +class _BlobAnalysis { + final double avgLuminance; + final int size; + final double circularity; + final double fillRatio; + final double threshold; + + _BlobAnalysis({ + required this.avgLuminance, + required this.size, + required this.circularity, + required this.fillRatio, + required this.threshold, + }); +} diff --git a/lib/services/score_calculator_service.dart b/lib/services/score_calculator_service.dart new file mode 100644 index 0000000..f4bd866 --- /dev/null +++ b/lib/services/score_calculator_service.dart @@ -0,0 +1,220 @@ +import 'dart:math' as math; +import '../data/models/shot.dart'; +import '../data/models/target_type.dart'; +import '../core/constants/app_constants.dart'; + +class ScoreResult { + final int totalScore; + final int maxPossibleScore; + final double percentage; + final Map scoreDistribution; // score -> count + final int shotCount; + + ScoreResult({ + required this.totalScore, + required this.maxPossibleScore, + required this.percentage, + required this.scoreDistribution, + required this.shotCount, + }); +} + +class ScoreCalculatorService { + /// Calculate score for a single shot on a concentric target + /// ringCount determines the number of scoring zones (default 10) + /// Center is always 10 (bullseye), each ring decrements by 1 + /// Example: 5 rings = 10, 9, 8, 7, 6 + /// imageAspectRatio is width/height to account for non-square images + int calculateConcentricScore({ + required double shotX, + required double shotY, + required double targetCenterX, + required double targetCenterY, + required double targetRadius, + int ringCount = 10, + double imageAspectRatio = 1.0, + List? ringRadii, // Optional individual ring radii multipliers + }) { + final dx = shotX - targetCenterX; + final dy = shotY - targetCenterY; + + // Account for aspect ratio to match visual representation + // The visual uses min(width, height) for the radius + double normalizedDistance; + if (imageAspectRatio >= 1.0) { + // Landscape or square: scale x by aspectRatio + normalizedDistance = math.sqrt(dx * dx * imageAspectRatio * imageAspectRatio + dy * dy) / targetRadius; + } else { + // Portrait: scale y by 1/aspectRatio + normalizedDistance = math.sqrt(dx * dx + dy * dy / (imageAspectRatio * imageAspectRatio)) / targetRadius; + } + + // Use custom ring radii if provided, otherwise use equal spacing + if (ringRadii != null && ringRadii.length == ringCount) { + for (int i = 0; i < ringCount; i++) { + if (normalizedDistance <= ringRadii[i]) { + // Center = 10, decrement by 1 for each ring + return 10 - i; + } + } + } else { + // Generate dynamic zone radii based on ring count + // Each zone has equal width + for (int i = 0; i < ringCount; i++) { + final zoneRadius = (i + 1) / ringCount; + if (normalizedDistance <= zoneRadius) { + // Center = 10, decrement by 1 for each ring + return 10 - i; + } + } + } + + return 0; // Outside target + } + + /// Calculate score for a single shot on a silhouette target + int calculateSilhouetteScore({ + required double shotX, + required double shotY, + required double targetCenterX, + required double targetCenterY, + required double targetWidth, + required double targetHeight, + }) { + // Check if shot is within silhouette bounds + final relativeX = (shotX - targetCenterX).abs() / (targetWidth / 2); + final relativeY = (shotY - (targetCenterY - targetHeight / 2)) / targetHeight; + + // Outside horizontal bounds + if (relativeX > 1.0) return 0; + + // Check vertical zones (from top of silhouette) + if (relativeY < 0) return 0; // Above silhouette + + if (relativeY <= AppConstants.silhouetteZones['head']!) { + // Head zone - 5 points, but narrower + if (relativeX <= 0.4) return 5; + } else if (relativeY <= AppConstants.silhouetteZones['center']!) { + // Center mass - 5 points + if (relativeX <= 0.6) return 5; + } else if (relativeY <= AppConstants.silhouetteZones['body']!) { + // Body - 4 points + if (relativeX <= 0.7) return 4; + } else if (relativeY <= AppConstants.silhouetteZones['lower']!) { + // Lower body - 3 points + if (relativeX <= 0.5) return 3; + } + + return 0; // Outside target + } + + /// Calculate scores for all shots + ScoreResult calculateScores({ + required List shots, + required TargetType targetType, + required double targetCenterX, + required double targetCenterY, + double? targetRadius, // For concentric + double? targetWidth, // For silhouette + double? targetHeight, // For silhouette + int ringCount = 10, // For concentric + double imageAspectRatio = 1.0, // For concentric + List? ringRadii, // For concentric with individual ring radii + }) { + final scoreDistribution = {}; + int totalScore = 0; + + for (final shot in shots) { + int score; + + if (targetType == TargetType.concentric) { + score = calculateConcentricScore( + shotX: shot.x, + shotY: shot.y, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius ?? 0.4, + ringCount: ringCount, + imageAspectRatio: imageAspectRatio, + ringRadii: ringRadii, + ); + } else { + score = calculateSilhouetteScore( + shotX: shot.x, + shotY: shot.y, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetWidth: targetWidth ?? 0.3, + targetHeight: targetHeight ?? 0.7, + ); + } + + totalScore += score; + scoreDistribution[score] = (scoreDistribution[score] ?? 0) + 1; + } + + final maxScore = targetType == TargetType.concentric ? 10 : 5; + final maxPossibleScore = shots.length * maxScore; + final percentage = maxPossibleScore > 0 + ? (totalScore / maxPossibleScore) * 100 + : 0.0; + + return ScoreResult( + totalScore: totalScore, + maxPossibleScore: maxPossibleScore, + percentage: percentage, + scoreDistribution: scoreDistribution, + shotCount: shots.length, + ); + } + + /// Recalculate a shot's score + Shot recalculateShot({ + required Shot shot, + required TargetType targetType, + required double targetCenterX, + required double targetCenterY, + double? targetRadius, + double? targetWidth, + double? targetHeight, + int ringCount = 10, + double imageAspectRatio = 1.0, + List? ringRadii, + }) { + int newScore; + + if (targetType == TargetType.concentric) { + newScore = calculateConcentricScore( + shotX: shot.x, + shotY: shot.y, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius ?? 0.4, + ringCount: ringCount, + imageAspectRatio: imageAspectRatio, + ringRadii: ringRadii, + ); + } else { + newScore = calculateSilhouetteScore( + shotX: shot.x, + shotY: shot.y, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetWidth: targetWidth ?? 0.3, + targetHeight: targetHeight ?? 0.7, + ); + } + + return shot.copyWith(score: newScore); + } + + /// Get score color index (0-9 for zone colors) + int getScoreColorIndex(int score, TargetType targetType) { + if (targetType == TargetType.concentric) { + return (10 - score).clamp(0, 9); + } else { + // Map silhouette scores (0-5) to color indices + return ((5 - score) * 2).clamp(0, 9); + } + } +} diff --git a/lib/services/statistics_service.dart b/lib/services/statistics_service.dart new file mode 100644 index 0000000..537f2f9 --- /dev/null +++ b/lib/services/statistics_service.dart @@ -0,0 +1,531 @@ +import 'dart:math' as math; +import '../data/models/session.dart'; +import '../data/models/shot.dart'; + +/// Time period for filtering statistics +enum StatsPeriod { + session, // Single session + week, // Last 7 days + month, // Last 30 days + all, // All time +} + +/// Heat zone data for a region of the target +class HeatZone { + final int row; + final int col; + final int shotCount; + final double intensity; // 0-1, normalized + final double avgScore; + + const HeatZone({ + required this.row, + required this.col, + required this.shotCount, + required this.intensity, + required this.avgScore, + }); +} + +/// Heat map grid for the target +class HeatMap { + final int gridSize; + final List> zones; + final int maxShotsInZone; + final int totalShots; + + const HeatMap({ + required this.gridSize, + required this.zones, + required this.maxShotsInZone, + required this.totalShots, + }); +} + +/// Precision statistics +class PrecisionStats { + /// Average distance from target center (0-1 normalized) + final double avgDistanceFromCenter; + + /// Grouping diameter (spread of shots) + final double groupingDiameter; + + /// Precision score (0-100, higher = better) + final double precisionScore; + + /// Consistency score based on standard deviation (0-100) + final double consistencyScore; + + const PrecisionStats({ + required this.avgDistanceFromCenter, + required this.groupingDiameter, + required this.precisionScore, + required this.consistencyScore, + }); +} + +/// Standard deviation statistics +class StdDevStats { + /// Standard deviation of X positions + final double stdDevX; + + /// Standard deviation of Y positions + final double stdDevY; + + /// Combined standard deviation (radial) + final double stdDevRadial; + + /// Standard deviation of scores + final double stdDevScore; + + /// Mean X position + final double meanX; + + /// Mean Y position + final double meanY; + + /// Mean score + final double meanScore; + + const StdDevStats({ + required this.stdDevX, + required this.stdDevY, + required this.stdDevRadial, + required this.stdDevScore, + required this.meanX, + required this.meanY, + required this.meanScore, + }); +} + +/// Regional distribution (quadrants or sectors) +class RegionalStats { + /// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right) + final Map quadrantDistribution; + + /// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center) + final Map sectorDistribution; + + /// Dominant direction (where most shots land) + final String dominantDirection; + + /// Bias offset from center + final double biasX; + final double biasY; + + const RegionalStats({ + required this.quadrantDistribution, + required this.sectorDistribution, + required this.dominantDirection, + required this.biasX, + required this.biasY, + }); +} + +/// Complete statistics result +class SessionStatistics { + final int totalShots; + final int totalScore; + final double avgScore; + final int maxScore; + final int minScore; + final HeatMap heatMap; + final PrecisionStats precision; + final StdDevStats stdDev; + final RegionalStats regional; + final List sessions; + final StatsPeriod period; + + const SessionStatistics({ + required this.totalShots, + required this.totalScore, + required this.avgScore, + required this.maxScore, + required this.minScore, + required this.heatMap, + required this.precision, + required this.stdDev, + required this.regional, + required this.sessions, + required this.period, + }); +} + +/// Service for calculating shooting statistics +class StatisticsService { + /// Calculate statistics for given sessions + SessionStatistics calculateStatistics( + List sessions, { + StatsPeriod period = StatsPeriod.all, + double targetCenterX = 0.5, + double targetCenterY = 0.5, + }) { + // Filter sessions by period + final filteredSessions = _filterByPeriod(sessions, period); + + // Collect all shots + final allShots = []; + for (final session in filteredSessions) { + allShots.addAll(session.shots); + } + + if (allShots.isEmpty) { + return _emptyStatistics(period, filteredSessions); + } + + // Calculate basic stats + final totalShots = allShots.length; + final totalScore = allShots.fold(0, (sum, shot) => sum + shot.score); + final avgScore = totalScore / totalShots; + final maxScore = allShots.map((s) => s.score).reduce(math.max); + final minScore = allShots.map((s) => s.score).reduce(math.min); + + // Calculate heat map + final heatMap = _calculateHeatMap(allShots, gridSize: 5); + + // Calculate precision + final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY); + + // Calculate standard deviation + final stdDev = _calculateStdDev(allShots); + + // Calculate regional distribution + final regional = _calculateRegional(allShots, targetCenterX, targetCenterY); + + return SessionStatistics( + totalShots: totalShots, + totalScore: totalScore, + avgScore: avgScore, + maxScore: maxScore, + minScore: minScore, + heatMap: heatMap, + precision: precision, + stdDev: stdDev, + regional: regional, + sessions: filteredSessions, + period: period, + ); + } + + /// Filter sessions by time period + List _filterByPeriod(List sessions, StatsPeriod period) { + if (period == StatsPeriod.all) return sessions; + + final now = DateTime.now(); + final cutoff = switch (period) { + StatsPeriod.session => now.subtract(const Duration(hours: 24)), + StatsPeriod.week => now.subtract(const Duration(days: 7)), + StatsPeriod.month => now.subtract(const Duration(days: 30)), + StatsPeriod.all => DateTime(1970), + }; + + return sessions.where((s) => s.createdAt.isAfter(cutoff)).toList(); + } + + /// Calculate heat map + HeatMap _calculateHeatMap(List shots, {int gridSize = 5}) { + // Initialize grid + final grid = List.generate( + gridSize, + (_) => List.generate(gridSize, (_) => []), + ); + + // Assign shots to grid cells + for (final shot in shots) { + final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1); + final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1); + grid[row][col].add(shot); + } + + // Find max count for normalization + int maxCount = 0; + for (final row in grid) { + for (final cell in row) { + if (cell.length > maxCount) maxCount = cell.length; + } + } + + // Create heat zones + final zones = >[]; + for (int row = 0; row < gridSize; row++) { + final rowZones = []; + for (int col = 0; col < gridSize; col++) { + final cellShots = grid[row][col]; + final avgScore = cellShots.isEmpty + ? 0.0 + : cellShots.fold(0, (sum, s) => sum + s.score) / cellShots.length; + + rowZones.add(HeatZone( + row: row, + col: col, + shotCount: cellShots.length, + intensity: maxCount > 0 ? cellShots.length / maxCount : 0, + avgScore: avgScore, + )); + } + zones.add(rowZones); + } + + return HeatMap( + gridSize: gridSize, + zones: zones, + maxShotsInZone: maxCount, + totalShots: shots.length, + ); + } + + /// Calculate precision statistics + PrecisionStats _calculatePrecision( + List shots, + double centerX, + double centerY, + ) { + if (shots.isEmpty) { + return const PrecisionStats( + avgDistanceFromCenter: 0, + groupingDiameter: 0, + precisionScore: 0, + consistencyScore: 0, + ); + } + + // Calculate distances from center + final distances = shots.map((shot) { + final dx = shot.x - centerX; + final dy = shot.y - centerY; + return math.sqrt(dx * dx + dy * dy); + }).toList(); + + final avgDistance = distances.reduce((a, b) => a + b) / distances.length; + + // Calculate grouping (spread between shots) + double maxSpread = 0; + for (int i = 0; i < shots.length; i++) { + for (int j = i + 1; j < shots.length; j++) { + final dx = shots[i].x - shots[j].x; + final dy = shots[i].y - shots[j].y; + final dist = math.sqrt(dx * dx + dy * dy); + if (dist > maxSpread) maxSpread = dist; + } + } + + // Calculate standard deviation of distances (consistency) + final meanDist = avgDistance; + double variance = 0; + for (final d in distances) { + variance += math.pow(d - meanDist, 2); + } + final stdDevDist = math.sqrt(variance / distances.length); + + // Precision score: based on average distance from center (0-100) + // 0 distance = 100 score, 0.5 distance = 0 score + final precisionScore = math.max(0, (1 - avgDistance * 2) * 100); + + // Consistency score: based on grouping tightness (0-100) + // Lower spread = higher consistency + final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100); + + return PrecisionStats( + avgDistanceFromCenter: avgDistance.toDouble(), + groupingDiameter: maxSpread.toDouble(), + precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(), + consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(), + ); + } + + /// Calculate standard deviation statistics + StdDevStats _calculateStdDev(List shots) { + if (shots.isEmpty) { + return const StdDevStats( + stdDevX: 0, + stdDevY: 0, + stdDevRadial: 0, + stdDevScore: 0, + meanX: 0.5, + meanY: 0.5, + meanScore: 0, + ); + } + + // Calculate means + double sumX = 0, sumY = 0, sumScore = 0; + for (final shot in shots) { + sumX += shot.x; + sumY += shot.y; + sumScore += shot.score; + } + final meanX = sumX / shots.length; + final meanY = sumY / shots.length; + final meanScore = sumScore / shots.length; + + // Calculate variances + double varianceX = 0, varianceY = 0, varianceScore = 0; + for (final shot in shots) { + varianceX += math.pow(shot.x - meanX, 2); + varianceY += math.pow(shot.y - meanY, 2); + varianceScore += math.pow(shot.score - meanScore, 2); + } + varianceX /= shots.length; + varianceY /= shots.length; + varianceScore /= shots.length; + + final stdDevX = math.sqrt(varianceX); + final stdDevY = math.sqrt(varianceY); + final stdDevScore = math.sqrt(varianceScore); + + // Radial standard deviation + final stdDevRadial = math.sqrt(varianceX + varianceY); + + return StdDevStats( + stdDevX: stdDevX, + stdDevY: stdDevY, + stdDevRadial: stdDevRadial, + stdDevScore: stdDevScore, + meanX: meanX, + meanY: meanY, + meanScore: meanScore, + ); + } + + /// Calculate regional distribution + RegionalStats _calculateRegional( + List shots, + double centerX, + double centerY, + ) { + if (shots.isEmpty) { + return const RegionalStats( + quadrantDistribution: {}, + sectorDistribution: {}, + dominantDirection: 'Centre', + biasX: 0, + biasY: 0, + ); + } + + // Quadrant distribution + final quadrants = { + 'Haut-Gauche': 0, + 'Haut-Droite': 0, + 'Bas-Gauche': 0, + 'Bas-Droite': 0, + }; + + // Sector distribution (8 sectors + center) + final sectors = { + 'N': 0, + 'NE': 0, + 'E': 0, + 'SE': 0, + 'S': 0, + 'SO': 0, + 'O': 0, + 'NO': 0, + 'Centre': 0, + }; + + double sumDx = 0, sumDy = 0; + + for (final shot in shots) { + final dx = shot.x - centerX; + final dy = shot.y - centerY; + sumDx += dx; + sumDy += dy; + + // Quadrant + if (dy < 0) { + quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = + quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1; + } else { + quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = + quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1; + } + + // Sector + final distance = math.sqrt(dx * dx + dy * dy); + if (distance < 0.1) { + sectors['Centre'] = sectors['Centre']! + 1; + } else { + final angle = math.atan2(dy, dx) * 180 / math.pi; + final sector = _angleToSector(angle); + sectors[sector] = sectors[sector]! + 1; + } + } + + // Calculate bias + final biasX = sumDx / shots.length; + final biasY = sumDy / shots.length; + + // Find dominant direction + String dominant = 'Centre'; + int maxCount = 0; + sectors.forEach((key, value) { + if (value > maxCount) { + maxCount = value; + dominant = key; + } + }); + + return RegionalStats( + quadrantDistribution: quadrants, + sectorDistribution: sectors, + dominantDirection: dominant, + biasX: biasX, + biasY: biasY, + ); + } + + String _angleToSector(double angle) { + // Angle is in degrees, -180 to 180 + // 0 = East, 90 = South, -90 = North, 180/-180 = West + if (angle >= -22.5 && angle < 22.5) return 'E'; + if (angle >= 22.5 && angle < 67.5) return 'SE'; + if (angle >= 67.5 && angle < 112.5) return 'S'; + if (angle >= 112.5 && angle < 157.5) return 'SO'; + if (angle >= 157.5 || angle < -157.5) return 'O'; + if (angle >= -157.5 && angle < -112.5) return 'NO'; + if (angle >= -112.5 && angle < -67.5) return 'N'; + if (angle >= -67.5 && angle < -22.5) return 'NE'; + return 'Centre'; + } + + SessionStatistics _emptyStatistics(StatsPeriod period, List sessions) { + return SessionStatistics( + totalShots: 0, + totalScore: 0, + avgScore: 0, + maxScore: 0, + minScore: 0, + heatMap: const HeatMap( + gridSize: 5, + zones: [], + maxShotsInZone: 0, + totalShots: 0, + ), + precision: const PrecisionStats( + avgDistanceFromCenter: 0, + groupingDiameter: 0, + precisionScore: 0, + consistencyScore: 0, + ), + stdDev: const StdDevStats( + stdDevX: 0, + stdDevY: 0, + stdDevRadial: 0, + stdDevScore: 0, + meanX: 0.5, + meanY: 0.5, + meanScore: 0, + ), + regional: const RegionalStats( + quadrantDistribution: {}, + sectorDistribution: {}, + dominantDirection: 'Centre', + biasX: 0, + biasY: 0, + ), + sessions: sessions, + period: period, + ); + } +} diff --git a/lib/services/target_detection_service.dart b/lib/services/target_detection_service.dart new file mode 100644 index 0000000..bc502d2 --- /dev/null +++ b/lib/services/target_detection_service.dart @@ -0,0 +1,257 @@ +import 'dart:math' as math; +import '../data/models/target_type.dart'; +import 'image_processing_service.dart'; + +export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics; + +class TargetDetectionResult { + final double centerX; // Relative (0-1) + final double centerY; // Relative (0-1) + final double radius; // Relative (0-1) + final List impacts; + final bool success; + final String? errorMessage; + + TargetDetectionResult({ + required this.centerX, + required this.centerY, + required this.radius, + required this.impacts, + this.success = true, + this.errorMessage, + }); + + factory TargetDetectionResult.error(String message) { + return TargetDetectionResult( + centerX: 0.5, + centerY: 0.5, + radius: 0.4, + impacts: [], + success: false, + errorMessage: message, + ); + } +} + +class DetectedImpactResult { + final double x; // Relative (0-1) + final double y; // Relative (0-1) + final double radius; // Absolute pixels + final int suggestedScore; + + DetectedImpactResult({ + required this.x, + required this.y, + required this.radius, + required this.suggestedScore, + }); +} + +class TargetDetectionService { + final ImageProcessingService _imageProcessingService; + + TargetDetectionService({ + ImageProcessingService? imageProcessingService, + }) : _imageProcessingService = imageProcessingService ?? ImageProcessingService(); + + /// Detect target and impacts from an image file + TargetDetectionResult detectTarget( + String imagePath, + TargetType targetType, + ) { + try { + // Detect main target + final mainTarget = _imageProcessingService.detectMainTarget(imagePath); + + double centerX = 0.5; + double centerY = 0.5; + double radius = 0.4; + + if (mainTarget != null) { + centerX = mainTarget.centerX; + centerY = mainTarget.centerY; + radius = mainTarget.radius; + } + + // Detect impacts + final impacts = _imageProcessingService.detectImpacts(imagePath); + + // Convert impacts to relative coordinates and calculate scores + final detectedImpacts = impacts.map((impact) { + final score = targetType == TargetType.concentric + ? _calculateConcentricScore(impact.x, impact.y, centerX, centerY, radius) + : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); + + return DetectedImpactResult( + x: impact.x, + y: impact.y, + radius: impact.radius, + suggestedScore: score, + ); + }).toList(); + + return TargetDetectionResult( + centerX: centerX, + centerY: centerY, + radius: radius, + impacts: detectedImpacts, + ); + } catch (e) { + return TargetDetectionResult.error('Erreur de detection: $e'); + } + } + + int _calculateConcentricScore( + double impactX, + double impactY, + double centerX, + double centerY, + double targetRadius, + ) { + // Calculate distance from center (normalized to target radius) + final dx = impactX - centerX; + final dy = impactY - centerY; + final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; + + // Score zones (10 zones) + if (distance <= 0.1) return 10; + if (distance <= 0.2) return 9; + if (distance <= 0.3) return 8; + if (distance <= 0.4) return 7; + if (distance <= 0.5) return 6; + if (distance <= 0.6) return 5; + if (distance <= 0.7) return 4; + if (distance <= 0.8) return 3; + if (distance <= 0.9) return 2; + if (distance <= 1.0) return 1; + return 0; // Outside target + } + + int _calculateSilhouetteScore( + double impactX, + double impactY, + double centerX, + double centerY, + ) { + // For silhouettes, scoring is typically based on zones + // Head and center mass = 5, body = 4, lower = 3 + + final dx = (impactX - centerX).abs(); + final dy = impactY - centerY; + + // Check if within silhouette bounds (approximate) + if (dx > 0.15) return 0; // Too far left/right + + // Vertical zones + if (dy < -0.25) return 5; // Head zone (top) + if (dy < 0.0) return 5; // Center mass (upper body) + if (dy < 0.15) return 4; // Body + if (dy < 0.35) return 3; // Lower body + + return 0; // Outside target + } + + /// Detect only impacts with custom settings (doesn't affect target position) + List detectImpactsOnly( + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, + ImpactDetectionSettings settings, + ) { + try { + // Detect impacts with custom settings + final impacts = _imageProcessingService.detectImpactsWithSettings( + imagePath, + settings, + ); + + // Convert impacts to relative coordinates and calculate scores + return impacts.map((impact) { + final score = targetType == TargetType.concentric + ? _calculateConcentricScoreWithRings( + impact.x, impact.y, centerX, centerY, radius, ringCount) + : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); + + return DetectedImpactResult( + x: impact.x, + y: impact.y, + radius: impact.radius, + suggestedScore: score, + ); + }).toList(); + } catch (e) { + return []; + } + } + + int _calculateConcentricScoreWithRings( + double impactX, + double impactY, + double centerX, + double centerY, + double targetRadius, + int ringCount, + ) { + // Calculate distance from center (normalized to target radius) + final dx = impactX - centerX; + final dy = impactY - centerY; + final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; + + // Score zones based on ringCount + for (int i = 0; i < ringCount; i++) { + final zoneRadius = (i + 1) / ringCount; + if (distance <= zoneRadius) { + return 10 - i; + } + } + + return 0; // Outside target + } + + /// Analyze reference impacts to learn their characteristics + ImpactCharacteristics? analyzeReferenceImpacts( + String imagePath, + List references, + ) { + return _imageProcessingService.analyzeReferenceImpacts(imagePath, references); + } + + /// Detect impacts based on reference characteristics (calibrated detection) + List detectImpactsFromReferences( + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, + ImpactCharacteristics characteristics, { + double tolerance = 2.0, + }) { + try { + final impacts = _imageProcessingService.detectImpactsFromReferences( + imagePath, + characteristics, + tolerance: tolerance, + ); + + return impacts.map((impact) { + final score = targetType == TargetType.concentric + ? _calculateConcentricScoreWithRings( + impact.x, impact.y, centerX, centerY, radius, ringCount) + : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); + + return DetectedImpactResult( + x: impact.x, + y: impact.y, + radius: impact.radius, + suggestedScore: score, + ); + }).toList(); + } catch (e) { + return []; + } + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..9ff4bf8 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "bully") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.bully") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..64a0ece --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2db3c22 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..748490e --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "bully"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "bully"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..0c9779f --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import path_provider_foundation +import sqflite_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d170557 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* bully.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "bully.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* bully.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* bully.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bully.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bully"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bully.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bully"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bully.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bully.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bully"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..f41f15e --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..4066f5d --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = bully + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.bully + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..62ca582 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,682 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+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: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + 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: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" + url: "https://pub.dev" + source: hosted + version: "0.66.2" + 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_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + 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" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.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: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.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: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + 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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "00e5e65f8e9b556ed3d999ad310881c956ffb656ed96bea487a4c50ffdff6d14" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + 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: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0-35.0.dev <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..cbd5573 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,118 @@ +name: bully +description: "A new Flutter project." +# 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.12.0-35.0.dev + +# 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 + + # Image processing with OpenCV (disabled for now due to build issues) + # opencv_dart: ^2.1.0 + + # Image capture from camera/gallery + image_picker: ^1.0.7 + + # Local database for history + sqflite: ^2.3.2 + sqflite_common_ffi: ^2.3.3 + + # File path utilities + path_provider: ^2.1.2 + path: ^1.8.3 + + # State management + provider: ^6.1.1 + + # UUID generation + uuid: ^4.3.3 + + # Date formatting + intl: ^0.19.0 + + # Charts for history visualization + fl_chart: ^0.66.2 + + # Image processing for impact detection + image: ^4.1.7 + +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/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..5e6a1db --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,20 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/app.dart'; + +void main() { + testWidgets('App loads correctly', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const BullyApp()); + + // Verify that the app title is displayed. + expect(find.text('Bully'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6350b17 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + bully + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..1a18826 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "bully", + "short_name": "bully", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..a39507a --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(bully LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "bully") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..77ab7a0 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a423a02 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..7c43142 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "bully" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "bully" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "bully.exe" "\0" + VALUE "ProductName", "bully" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..bb03ab6 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"bully", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_