premier app version beta
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(flutter clean:*)",
|
||||
"Bash(flutter pub get:*)",
|
||||
"Bash(flutter run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
45
.gitignore
vendored
Normal file
@@ -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
|
||||
45
.metadata
Normal file
@@ -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'
|
||||
15
.vscode/launch.json
vendored
Normal file
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
99
CLAUDE.md
Normal file
@@ -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 <device_id>
|
||||
|
||||
# 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`).
|
||||
17
README.md
Normal file
@@ -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.
|
||||
28
analysis_options.yaml
Normal file
@@ -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
|
||||
14
android/.gitignore
vendored
Normal file
@@ -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
|
||||
44
android/app/build.gradle.kts
Normal file
@@ -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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
45
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="bully"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.bully
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
android/build.gradle.kts
Normal file
@@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
26
android/settings.gradle.kts
Normal file
@@ -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")
|
||||
34
ios/.gitignore
vendored
Normal file
@@ -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
|
||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
620
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
16
ios/Runner/AppDelegate.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
70
ios/Runner/Info.plist
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bully</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>bully</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
19
lib/app.dart
Normal file
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/core/constants/app_constants.dart
Normal file
@@ -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<double> 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<String, double> 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;
|
||||
}
|
||||
91
lib/core/theme/app_theme.dart
Normal file
@@ -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<Color> 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
254
lib/data/database/database_helper.dart
Normal file
@@ -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<Database> get database async {
|
||||
_database ??= await _initDatabase();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final databasesPath = await getDatabasesPath();
|
||||
final path = join(databasesPath, AppConstants.databaseName);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: AppConstants.databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
// Handle future database migrations here
|
||||
}
|
||||
|
||||
// Session operations
|
||||
Future<int> 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<Session?> 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<List<Session>> getAllSessions({
|
||||
String? targetType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
String? whereClause;
|
||||
List<dynamic>? 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 = <Session>[];
|
||||
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<int> 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<int> deleteSession(String id) async {
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
AppConstants.sessionsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
112
lib/data/models/session.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'shot.dart';
|
||||
import 'target_type.dart';
|
||||
|
||||
class Session {
|
||||
final String id;
|
||||
final TargetType targetType;
|
||||
final String imagePath;
|
||||
final List<Shot> 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<Shot>? 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<String, dynamic> 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<String, dynamic> map, List<Shot> 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)';
|
||||
}
|
||||
}
|
||||
72
lib/data/models/shot.dart
Normal file
@@ -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<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'score': score,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
factory Shot.fromMap(Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
84
lib/data/models/target.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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)';
|
||||
}
|
||||
}
|
||||
16
lib/data/models/target_type.dart
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/data/repositories/session_repository.dart
Normal file
@@ -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<Session> createSession({
|
||||
required TargetType targetType,
|
||||
required String imagePath,
|
||||
required List<Shot> 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<String> _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<Session?> getSession(String id) async {
|
||||
return await _databaseHelper.getSession(id);
|
||||
}
|
||||
|
||||
Future<List<Session>> getAllSessions({
|
||||
TargetType? targetType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
return await _databaseHelper.getAllSessions(
|
||||
targetType: targetType?.name,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSession(Session session) async {
|
||||
await _databaseHelper.updateSession(session);
|
||||
}
|
||||
|
||||
Future<void> 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<Map<String, dynamic>> getStatistics() async {
|
||||
return await _databaseHelper.getStatistics();
|
||||
}
|
||||
|
||||
String generateShotId() {
|
||||
return _uuid.v4();
|
||||
}
|
||||
}
|
||||
431
lib/features/analysis/analysis_provider.dart
Normal file
@@ -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<double>? _ringRadii; // Individual ring radii multipliers
|
||||
double _imageAspectRatio = 1.0; // width / height
|
||||
|
||||
// Shots
|
||||
List<Shot> _shots = [];
|
||||
|
||||
// Score results
|
||||
ScoreResult? _scoreResult;
|
||||
|
||||
// Grouping results
|
||||
GroupingResult? _groupingResult;
|
||||
|
||||
// Reference-based detection
|
||||
List<Shot> _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<double>? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
|
||||
double get imageAspectRatio => _imageAspectRatio;
|
||||
List<Shot> 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<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
|
||||
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
|
||||
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
|
||||
|
||||
/// Analyze an image
|
||||
Future<void> 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<int> 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<int> 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<double>? 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<Session> 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();
|
||||
}
|
||||
}
|
||||
1066
lib/features/analysis/analysis_screen.dart
Normal file
273
lib/features/analysis/widgets/grouping_stats.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
167
lib/features/analysis/widgets/score_card.dart
Normal file
@@ -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]!;
|
||||
}
|
||||
}
|
||||
561
lib/features/analysis/widgets/target_calibration.dart
Normal file
@@ -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<double>? initialRingRadii; // Individual ring radii multipliers
|
||||
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? 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<TargetCalibration> createState() => _TargetCalibrationState();
|
||||
}
|
||||
|
||||
class _TargetCalibrationState extends State<TargetCalibration> {
|
||||
late double _centerX;
|
||||
late double _centerY;
|
||||
late double _radius;
|
||||
late int _ringCount;
|
||||
late List<double> _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<double> 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<Color> 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;
|
||||
}
|
||||
}
|
||||
343
lib/features/analysis/widgets/target_overlay.dart
Normal file
@@ -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<Shot> shots;
|
||||
final double targetCenterX;
|
||||
final double targetCenterY;
|
||||
final double targetRadius;
|
||||
final TargetType targetType;
|
||||
final int ringCount;
|
||||
final List<double>? 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<Shot>? 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<Shot> shots;
|
||||
final double targetCenterX;
|
||||
final double targetCenterY;
|
||||
final double targetRadius;
|
||||
final TargetType targetType;
|
||||
final int ringCount;
|
||||
final List<double>? ringRadii;
|
||||
final double? groupingCenterX;
|
||||
final double? groupingCenterY;
|
||||
final double? groupingDiameter;
|
||||
final List<Shot>? 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;
|
||||
}
|
||||
}
|
||||
249
lib/features/capture/capture_screen.dart
Normal file
@@ -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<CaptureScreen> createState() => _CaptureScreenState();
|
||||
}
|
||||
|
||||
class _CaptureScreenState extends State<CaptureScreen> {
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/features/capture/widgets/image_source_button.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
lib/features/capture/widgets/target_type_selector.dart
Normal file
@@ -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<TargetType> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
228
lib/features/history/history_screen.dart
Normal file
@@ -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<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
List<Session> _sessions = [];
|
||||
bool _isLoading = true;
|
||||
TargetType? _filterType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final repository = context.read<SessionRepository>();
|
||||
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<TargetType?>(
|
||||
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<void> _deleteSession(Session session) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<SessionRepository>();
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
lib/features/history/session_detail_screen.dart
Normal file
@@ -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<ScoreCalculatorService>();
|
||||
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
||||
|
||||
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<void> _confirmDelete(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<SessionRepository>();
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
lib/features/history/widgets/history_chart.dart
Normal file
@@ -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<Session> 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<Session>.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<Session> 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<Session> displaySessions) {
|
||||
final avgScore = displaySessions.fold<int>(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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
148
lib/features/history/widgets/session_list_item.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
222
lib/features/home/home_screen.dart
Normal file
@@ -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<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
Map<String, dynamic>? _stats;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
final repository = context.read<SessionRepository>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
46
lib/features/home/widgets/stats_card.dart
Normal file
@@ -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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
717
lib/features/statistics/statistics_screen.dart
Normal file
@@ -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<StatisticsScreen> createState() => _StatisticsScreenState();
|
||||
}
|
||||
|
||||
class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
final StatisticsService _statisticsService = StatisticsService();
|
||||
StatsPeriod _selectedPeriod = StatsPeriod.all;
|
||||
SessionStatistics? _statistics;
|
||||
bool _isLoading = true;
|
||||
List<Session> _allSessions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use addPostFrameCallback to ensure context is available
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadStatistics();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<SessionRepository>();
|
||||
_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<int>(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<StatsPeriod>(
|
||||
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<String, int> 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 = <String>[];
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
232
lib/features/statistics/widgets/heat_map_widget.dart
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
54
lib/main.dart
Normal file
@@ -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<ImageProcessingService>(
|
||||
create: (_) => ImageProcessingService(),
|
||||
),
|
||||
Provider<TargetDetectionService>(
|
||||
create: (context) => TargetDetectionService(
|
||||
imageProcessingService: context.read<ImageProcessingService>(),
|
||||
),
|
||||
),
|
||||
Provider<ScoreCalculatorService>(
|
||||
create: (_) => ScoreCalculatorService(),
|
||||
),
|
||||
Provider<GroupingAnalyzerService>(
|
||||
create: (_) => GroupingAnalyzerService(),
|
||||
),
|
||||
Provider<SessionRepository>(
|
||||
create: (_) => SessionRepository(),
|
||||
),
|
||||
],
|
||||
child: const BullyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
230
lib/services/grouping_analyzer_service.dart
Normal file
@@ -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<Shot> 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 = <double>[];
|
||||
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<GroupingResult> 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,
|
||||
});
|
||||
}
|
||||
775
lib/services/image_processing_service.dart
Normal file
@@ -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<DetectedImpact> detectImpacts(String imagePath) {
|
||||
return detectImpactsWithSettings(
|
||||
imagePath,
|
||||
const ImpactDetectionSettings(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Detect impacts with custom settings
|
||||
List<DetectedImpact> 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<ReferenceImpact> 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 = <double>[];
|
||||
final sizes = <double>[];
|
||||
final circularities = <double>[];
|
||||
final fillRatios = <double>[];
|
||||
final thresholds = <double>[];
|
||||
|
||||
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<DetectedImpact> 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<List<bool>> mask,
|
||||
List<List<bool>> 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,
|
||||
});
|
||||
}
|
||||
220
lib/services/score_calculator_service.dart
Normal file
@@ -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<int, int> 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<double>? 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<Shot> 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<double>? ringRadii, // For concentric with individual ring radii
|
||||
}) {
|
||||
final scoreDistribution = <int, int>{};
|
||||
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<double>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
531
lib/services/statistics_service.dart
Normal file
@@ -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<List<HeatZone>> 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<String, int> quadrantDistribution;
|
||||
|
||||
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
|
||||
final Map<String, int> 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<Session> 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<Session> 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 = <Shot>[];
|
||||
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<int>(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<Session> _filterByPeriod(List<Session> 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<Shot> shots, {int gridSize = 5}) {
|
||||
// Initialize grid
|
||||
final grid = List.generate(
|
||||
gridSize,
|
||||
(_) => List.generate(gridSize, (_) => <Shot>[]),
|
||||
);
|
||||
|
||||
// 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 = <List<HeatZone>>[];
|
||||
for (int row = 0; row < gridSize; row++) {
|
||||
final rowZones = <HeatZone>[];
|
||||
for (int col = 0; col < gridSize; col++) {
|
||||
final cellShots = grid[row][col];
|
||||
final avgScore = cellShots.isEmpty
|
||||
? 0.0
|
||||
: cellShots.fold<int>(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<Shot> 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<Shot> 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<Shot> shots,
|
||||
double centerX,
|
||||
double centerY,
|
||||
) {
|
||||
if (shots.isEmpty) {
|
||||
return const RegionalStats(
|
||||
quadrantDistribution: {},
|
||||
sectorDistribution: {},
|
||||
dominantDirection: 'Centre',
|
||||
biasX: 0,
|
||||
biasY: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Quadrant distribution
|
||||
final quadrants = <String, int>{
|
||||
'Haut-Gauche': 0,
|
||||
'Haut-Droite': 0,
|
||||
'Bas-Gauche': 0,
|
||||
'Bas-Droite': 0,
|
||||
};
|
||||
|
||||
// Sector distribution (8 sectors + center)
|
||||
final sectors = <String, int>{
|
||||
'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<Session> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
257
lib/services/target_detection_service.dart
Normal file
@@ -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<DetectedImpactResult> 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<DetectedImpactResult> 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<ReferenceImpact> references,
|
||||
) {
|
||||
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references);
|
||||
}
|
||||
|
||||
/// Detect impacts based on reference characteristics (calibrated detection)
|
||||
List<DetectedImpactResult> 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
1
linux/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
128
linux/CMakeLists.txt
Normal file
@@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()
|
||||