Version initiale de Gestion Prix
App Flutter de gestion de stock/prix pour tablette Android (100% local) : scan code-barres (OpenFoodFacts), prix au kilo/litre, packs, inventaire, prix d'achat + marge, filtres de tri, étiquettes PDF, design noir & blanc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -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
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
||||||
|
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
||||||
|
- platform: android
|
||||||
|
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
||||||
|
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
||||||
|
|
||||||
|
# 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'
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Gestion Prix Produit
|
||||||
|
|
||||||
|
App Android (Flutter) de gestion de stock et de prix pour un magasin, conçue pour
|
||||||
|
une tablette Android 12 en mode portrait. **Stockage 100% local** (SQLite) : aucune
|
||||||
|
connexion serveur nécessaire, fonctionne hors-ligne. Une connexion internet sert
|
||||||
|
seulement à récupérer le nom + l'image d'un produit lors du scan.
|
||||||
|
|
||||||
|
## Fonctions
|
||||||
|
|
||||||
|
- **Produits** : liste de tous les produits (nom, prix HT / TTC), recherche, édition.
|
||||||
|
- **Scanner** : scan d'un code-barres puis recherche automatique du nom + image via
|
||||||
|
[OpenFoodFacts](https://world.openfoodfacts.org), saisie des prix, ajout à la liste.
|
||||||
|
- **Étiquettes** : sélection des produits (un par un ou « tout sélectionner ») puis
|
||||||
|
génération d'une planche A4 d'étiquettes (nom, prix, code-barres) imprimable sur une
|
||||||
|
imprimante Wi-Fi classique (ex : Canon TS4550) via le système d'impression Android.
|
||||||
|
|
||||||
|
## Techno
|
||||||
|
|
||||||
|
- Flutter (Material 3, thème noir & blanc + accent bleu)
|
||||||
|
- `mobile_scanner` (scan code-barres, open source / ML Kit)
|
||||||
|
- `sqflite` (base de données locale SQLite, persistée sur la tablette)
|
||||||
|
- `pdf` + `printing` (étiquettes)
|
||||||
|
|
||||||
|
## Lancer l'app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
Les produits sont enregistrés localement et conservés d'une session à l'autre.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
lib/
|
||||||
|
├── main.dart # init + thème + navigation
|
||||||
|
├── config.dart # TVA par défaut
|
||||||
|
├── theme.dart # thème noir & blanc + accent
|
||||||
|
├── models/produit.dart
|
||||||
|
├── services/
|
||||||
|
│ ├── local_db.dart # base SQLite locale
|
||||||
|
│ ├── produit_repository.dart # source de vérité (CRUD)
|
||||||
|
│ ├── openfoodfacts_service.dart # lookup code-barres
|
||||||
|
│ └── etiquette_pdf.dart # génération PDF des étiquettes
|
||||||
|
├── screens/
|
||||||
|
│ ├── home_shell.dart # 3 onglets + barre de navigation
|
||||||
|
│ ├── produits_screen.dart
|
||||||
|
│ ├── scan_screen.dart
|
||||||
|
│ ├── produit_edit_screen.dart
|
||||||
|
│ └── etiquettes_screen.dart
|
||||||
|
├── widgets/produit_image.dart
|
||||||
|
└── utils/format.dart
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.magasin.gestion_prix_produit"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.magasin.gestion_prix_produit"
|
||||||
|
// 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")
|
||||||
|
// R8/minification désactivé : il supprimait des classes de ML Kit
|
||||||
|
// (scanner de code-barres) → caméra HS en release. Off = comportement debug.
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<application
|
||||||
|
android:label="Gestion Prix"
|
||||||
|
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.magasin.gestion_prix_produit
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#14141A</color>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
# This newDsl flag was added by the Flutter template
|
||||||
|
android.newDsl=false
|
||||||
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||||
@@ -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 "9.0.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
/// Configuration de l'application (stockage 100% local).
|
||||||
|
class Config {
|
||||||
|
/// Taux de TVA par défaut utilisé pour calculer le prix TTC à partir du HT.
|
||||||
|
static const double tvaParDefaut = 0.20; // 20 %
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'screens/home_shell.dart';
|
||||||
|
import 'services/produit_repository.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// App verrouillée en portrait (tablette).
|
||||||
|
await SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Chargement initial des produits depuis la base locale.
|
||||||
|
await ProduitRepository.instance.charger();
|
||||||
|
|
||||||
|
runApp(const MonApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MonApp extends StatelessWidget {
|
||||||
|
const MonApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Gestion Prix',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.clair(),
|
||||||
|
darkTheme: AppTheme.sombre(),
|
||||||
|
themeMode: ThemeMode.light,
|
||||||
|
home: const HomeShell(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/// Unités de vente supportées (poids et volume).
|
||||||
|
class Unites {
|
||||||
|
static const g = 'g';
|
||||||
|
static const kg = 'kg';
|
||||||
|
static const ml = 'ml';
|
||||||
|
static const cl = 'cl';
|
||||||
|
static const l = 'L';
|
||||||
|
|
||||||
|
/// Ancienne valeur (produits d'avant) : vendu à l'unité. Plus proposée.
|
||||||
|
static const piece = 'piece';
|
||||||
|
|
||||||
|
static const tout = [g, kg, ml, cl, l];
|
||||||
|
|
||||||
|
/// Vrai si le code est une unité de mesure valide (donc pas 'piece'/null).
|
||||||
|
static bool estValide(String? code) => tout.contains(code);
|
||||||
|
|
||||||
|
static String libelle(String? code) => switch (code) {
|
||||||
|
g => 'g',
|
||||||
|
kg => 'kg',
|
||||||
|
ml => 'ml',
|
||||||
|
cl => 'cl',
|
||||||
|
l => 'L',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un produit du stock.
|
||||||
|
class Produit {
|
||||||
|
final String id;
|
||||||
|
final String? codeBarres;
|
||||||
|
final String nom;
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
/// Prix de vente (au client).
|
||||||
|
final double prix;
|
||||||
|
|
||||||
|
/// Prix d'achat (coût). 0 = non renseigné.
|
||||||
|
final double prixAchat;
|
||||||
|
|
||||||
|
/// Contenance d'UN contenant (ex : 330 pour 330 ml, 400 pour 400 g).
|
||||||
|
final double? quantite;
|
||||||
|
|
||||||
|
/// Unité de la quantité : voir [Unites].
|
||||||
|
final String? unite;
|
||||||
|
|
||||||
|
/// Nombre de contenants pour un pack (ex : 6 pour un pack de 6 bières).
|
||||||
|
/// Null ou 1 = produit à l'unité (pas un pack).
|
||||||
|
final int? nbContenants;
|
||||||
|
|
||||||
|
/// Horodatage de création (ms) — sert au tri par ordre d'ajout.
|
||||||
|
final int creeLe;
|
||||||
|
|
||||||
|
/// Quantité en stock (inventaire).
|
||||||
|
final int stock;
|
||||||
|
|
||||||
|
const Produit({
|
||||||
|
required this.id,
|
||||||
|
this.codeBarres,
|
||||||
|
required this.nom,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.prix,
|
||||||
|
this.prixAchat = 0,
|
||||||
|
this.quantite,
|
||||||
|
this.unite,
|
||||||
|
this.nbContenants,
|
||||||
|
this.creeLe = 0,
|
||||||
|
this.stock = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get estPack => (nbContenants ?? 1) > 1;
|
||||||
|
|
||||||
|
/// Gain en euros (vente − achat). Pertinent seulement si un prix d'achat est saisi.
|
||||||
|
double get gain => prix - prixAchat;
|
||||||
|
|
||||||
|
/// Coefficient multiplicateur (prix de vente ÷ prix d'achat), ex : 1,5.
|
||||||
|
/// Null si le prix d'achat n'est pas renseigné. Sert au tri.
|
||||||
|
double? get coefficient => prixAchat > 0 ? prix / prixAchat : null;
|
||||||
|
|
||||||
|
/// Marge affichée : le coefficient (1,5) suivi d'un « % », ex : « 1,5 % », « 2 % ».
|
||||||
|
/// Vide si le prix d'achat n'est pas renseigné.
|
||||||
|
String get margeLibelle {
|
||||||
|
final c = coefficient;
|
||||||
|
if (c == null) return '';
|
||||||
|
var s = c.toStringAsFixed(2).replaceAll('.', ',');
|
||||||
|
if (s.contains(',')) {
|
||||||
|
s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r',$'), '');
|
||||||
|
}
|
||||||
|
return '$s %';
|
||||||
|
}
|
||||||
|
|
||||||
|
Produit copyWith({
|
||||||
|
String? id,
|
||||||
|
String? codeBarres,
|
||||||
|
String? nom,
|
||||||
|
String? imageUrl,
|
||||||
|
double? prix,
|
||||||
|
double? prixAchat,
|
||||||
|
double? quantite,
|
||||||
|
String? unite,
|
||||||
|
int? nbContenants,
|
||||||
|
int? creeLe,
|
||||||
|
int? stock,
|
||||||
|
}) {
|
||||||
|
return Produit(
|
||||||
|
id: id ?? this.id,
|
||||||
|
codeBarres: codeBarres ?? this.codeBarres,
|
||||||
|
nom: nom ?? this.nom,
|
||||||
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
|
prix: prix ?? this.prix,
|
||||||
|
prixAchat: prixAchat ?? this.prixAchat,
|
||||||
|
quantite: quantite ?? this.quantite,
|
||||||
|
unite: unite ?? this.unite,
|
||||||
|
nbContenants: nbContenants ?? this.nbContenants,
|
||||||
|
creeLe: creeLe ?? this.creeLe,
|
||||||
|
stock: stock ?? this.stock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prix à l'unité de mesure (€/kg ou €/L) — obligation légale pour les
|
||||||
|
/// produits vendus au poids/volume. Null pour les produits à la pièce.
|
||||||
|
({double valeur, String suffixe})? get prixMesure {
|
||||||
|
final q = quantite;
|
||||||
|
if (q == null || q <= 0) return null;
|
||||||
|
// Pour un pack, on calcule sur le volume/poids TOTAL (contenance × nombre).
|
||||||
|
final total = q * (nbContenants ?? 1);
|
||||||
|
return switch (unite) {
|
||||||
|
Unites.g => (valeur: prix / (total / 1000), suffixe: '/kg'),
|
||||||
|
Unites.kg => (valeur: prix / total, suffixe: '/kg'),
|
||||||
|
Unites.ml => (valeur: prix / (total / 1000), suffixe: '/L'),
|
||||||
|
Unites.cl => (valeur: prix / (total / 100), suffixe: '/L'),
|
||||||
|
Unites.l => (valeur: prix / total, suffixe: '/L'),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Libellé compact de la quantité, ex : « 330ml », « 1kg », « 75cl »,
|
||||||
|
/// ou « 6x33cl » pour un pack. Vide si absent.
|
||||||
|
String get quantiteLibelle {
|
||||||
|
final q = quantite;
|
||||||
|
if (q == null || q <= 0 || !Unites.estValide(unite)) return '';
|
||||||
|
final n = q == q.roundToDouble()
|
||||||
|
? q.toInt().toString()
|
||||||
|
: q.toString().replaceAll('.', ',');
|
||||||
|
final base = '$n${Unites.libelle(unite)}';
|
||||||
|
return estPack ? '${nbContenants}X$base' : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titre affiché (liste + étiquette) : la quantité est intégrée devant le nom,
|
||||||
|
/// ex : « 1kg Farine », « 75cl Coca-Cola ». Sinon juste le nom.
|
||||||
|
String get titre {
|
||||||
|
final ql = quantiteLibelle;
|
||||||
|
return ql.isEmpty ? nom : '$ql $nom';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Produit.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Produit(
|
||||||
|
id: map['id'].toString(),
|
||||||
|
codeBarres: map['code_barres'] as String?,
|
||||||
|
nom: (map['nom'] as String?) ?? '',
|
||||||
|
imageUrl: map['image_url'] as String?,
|
||||||
|
prix: (map['prix'] as num?)?.toDouble() ?? 0,
|
||||||
|
prixAchat: (map['prix_achat'] as num?)?.toDouble() ?? 0,
|
||||||
|
quantite: (map['quantite'] as num?)?.toDouble(),
|
||||||
|
unite: map['unite'] as String?,
|
||||||
|
nbContenants: (map['nb_contenants'] as num?)?.toInt(),
|
||||||
|
creeLe: (map['cree_le'] as num?)?.toInt() ?? 0,
|
||||||
|
stock: (map['stock'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map pour insertion/mise à jour dans la base (sans l'id, généré par SQLite).
|
||||||
|
Map<String, dynamic> toInsertMap() {
|
||||||
|
return {
|
||||||
|
'code_barres': codeBarres,
|
||||||
|
'nom': nom,
|
||||||
|
'image_url': imageUrl,
|
||||||
|
'prix': prix,
|
||||||
|
'prix_achat': prixAchat,
|
||||||
|
'quantite': quantite,
|
||||||
|
'unite': unite,
|
||||||
|
'nb_contenants': nbContenants,
|
||||||
|
'stock': stock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
import '../services/etiquette_pdf.dart';
|
||||||
|
import '../services/produit_repository.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import '../utils/format.dart';
|
||||||
|
import '../utils/tri.dart';
|
||||||
|
import '../widgets/produit_image.dart';
|
||||||
|
|
||||||
|
/// Onglet 3 : sélection de produits puis génération/impression des étiquettes.
|
||||||
|
class EtiquettesScreen extends StatefulWidget {
|
||||||
|
const EtiquettesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EtiquettesScreen> createState() => _EtiquettesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EtiquettesScreenState extends State<EtiquettesScreen> {
|
||||||
|
final _repo = ProduitRepository.instance;
|
||||||
|
final Set<String> _selection = {};
|
||||||
|
Tri _tri = Tri.recent;
|
||||||
|
|
||||||
|
bool _tousSelectionnes(List<Produit> produits) =>
|
||||||
|
produits.isNotEmpty && _selection.length == produits.length;
|
||||||
|
|
||||||
|
void _basculerTout(List<Produit> produits) {
|
||||||
|
setState(() {
|
||||||
|
if (_tousSelectionnes(produits)) {
|
||||||
|
_selection.clear();
|
||||||
|
} else {
|
||||||
|
_selection
|
||||||
|
..clear()
|
||||||
|
..addAll(produits.map((p) => p.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _imprimer(List<Produit> produits) async {
|
||||||
|
final choisis =
|
||||||
|
produits.where((p) => _selection.contains(p.id)).toList();
|
||||||
|
if (choisis.isEmpty) return;
|
||||||
|
|
||||||
|
await Printing.layoutPdf(
|
||||||
|
name: 'etiquettes_prix',
|
||||||
|
onLayout: (format) => EtiquettePdf.construire(choisis),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _repo,
|
||||||
|
builder: (context, _) {
|
||||||
|
final produits = trierProduits(_repo.produits, _tri);
|
||||||
|
// Nettoie la sélection des produits supprimés.
|
||||||
|
_selection.retainWhere((id) => produits.any((p) => p.id == id));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_entete(produits),
|
||||||
|
Expanded(
|
||||||
|
child: produits.isEmpty
|
||||||
|
? _vide()
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 120),
|
||||||
|
itemCount: produits.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final p = produits[i];
|
||||||
|
final sel = _selection.contains(p.id);
|
||||||
|
return _LigneSelection(
|
||||||
|
produit: p,
|
||||||
|
selectionne: sel,
|
||||||
|
onTap: () => setState(() {
|
||||||
|
sel
|
||||||
|
? _selection.remove(p.id)
|
||||||
|
: _selection.add(p.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: ListenableBuilder(
|
||||||
|
listenable: _repo,
|
||||||
|
builder: (context, _) {
|
||||||
|
final n = _selection.length;
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: n == 0
|
||||||
|
? null
|
||||||
|
: () => _imprimer(trierProduits(_repo.produits, _tri)),
|
||||||
|
backgroundColor: n == 0 ? Colors.grey : AppTheme.accent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
icon: const Icon(Icons.print),
|
||||||
|
label: Text(n == 0 ? 'Imprimer' : 'Imprimer ($n)'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _entete(List<Produit> produits) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Étiquettes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -1)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('Sélectionne les produits à imprimer',
|
||||||
|
style: TextStyle(color: AppTheme.grisTexte, fontSize: 14)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('${_selection.length} sélectionné(s)',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed:
|
||||||
|
produits.isEmpty ? null : () => _basculerTout(produits),
|
||||||
|
icon: Icon(_tousSelectionnes(produits)
|
||||||
|
? Icons.remove_done
|
||||||
|
: Icons.done_all),
|
||||||
|
label: Text(_tousSelectionnes(produits)
|
||||||
|
? 'Tout désélectionner'
|
||||||
|
: 'Tout sélectionner'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppTheme.accent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [for (final t in Tri.values) _puceTri(t)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _puceTri(Tri t) {
|
||||||
|
final sel = _tri == t;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(t.libelle),
|
||||||
|
selected: sel,
|
||||||
|
onSelected: (_) => setState(() => _tri = t),
|
||||||
|
showCheckmark: false,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: sel ? Colors.white : AppTheme.grisTexte,
|
||||||
|
),
|
||||||
|
selectedColor: AppTheme.accent,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
side: BorderSide(
|
||||||
|
color: sel
|
||||||
|
? AppTheme.accent
|
||||||
|
: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _vide() {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Text(
|
||||||
|
'Ajoute d’abord des produits pour pouvoir imprimer leurs étiquettes.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppTheme.grisTexte, fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LigneSelection extends StatelessWidget {
|
||||||
|
final Produit produit;
|
||||||
|
final bool selectionne;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _LigneSelection({
|
||||||
|
required this.produit,
|
||||||
|
required this.selectionne,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: selectionne
|
||||||
|
? AppTheme.accent.withValues(alpha: 0.08)
|
||||||
|
: scheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: selectionne
|
||||||
|
? AppTheme.accent
|
||||||
|
: scheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: selectionne ? 1.6 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_Case(coche: selectionne),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ProduitImage(url: produit.imageUrl, taille: 44, radius: 10),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(produit.titre,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(euro(produit.prix),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w800)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Case extends StatelessWidget {
|
||||||
|
final bool coche;
|
||||||
|
const _Case({required this.coche});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: coche ? AppTheme.accent : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: coche ? AppTheme.accent : AppTheme.grisTexte,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: coche
|
||||||
|
? const Icon(Icons.check, size: 18, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'etiquettes_screen.dart';
|
||||||
|
import 'produits_screen.dart';
|
||||||
|
import 'scan_screen.dart';
|
||||||
|
|
||||||
|
/// Coquille principale : contient les 3 onglets et la barre de navigation.
|
||||||
|
class HomeShell extends StatefulWidget {
|
||||||
|
const HomeShell({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeShellState extends State<HomeShell> {
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pages = [
|
||||||
|
ProduitsScreen(onAllerScanner: () => setState(() => _index = 1)),
|
||||||
|
ScanScreen(active: _index == 1),
|
||||||
|
const EtiquettesScreen(),
|
||||||
|
];
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(index: _index, children: pages),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _index,
|
||||||
|
height: 68,
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
|
onDestinationSelected: (i) => setState(() => _index = i),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.inventory_2_outlined),
|
||||||
|
selectedIcon: Icon(Icons.inventory_2),
|
||||||
|
label: 'Produits',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.qr_code_scanner_outlined),
|
||||||
|
selectedIcon: Icon(Icons.qr_code_scanner),
|
||||||
|
label: 'Scanner',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.print_outlined),
|
||||||
|
selectedIcon: Icon(Icons.print),
|
||||||
|
label: 'Étiquettes',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
import '../services/photo_service.dart';
|
||||||
|
import '../services/produit_repository.dart';
|
||||||
|
import '../services/reglages.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import '../widgets/produit_image.dart';
|
||||||
|
|
||||||
|
/// Écran d'ajout / modification d'un produit.
|
||||||
|
///
|
||||||
|
/// - [produit] fourni → mode modification.
|
||||||
|
/// - sinon → mode création (les champs *Initial servent au scan).
|
||||||
|
class ProduitEditScreen extends StatefulWidget {
|
||||||
|
final Produit? produit;
|
||||||
|
final String? codeBarresInitial;
|
||||||
|
final String? nomInitial;
|
||||||
|
final String? imageUrlInitial;
|
||||||
|
final double? quantiteInitial;
|
||||||
|
final String? uniteInitial;
|
||||||
|
|
||||||
|
const ProduitEditScreen({
|
||||||
|
super.key,
|
||||||
|
this.produit,
|
||||||
|
this.codeBarresInitial,
|
||||||
|
this.nomInitial,
|
||||||
|
this.imageUrlInitial,
|
||||||
|
this.quantiteInitial,
|
||||||
|
this.uniteInitial,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProduitEditScreen> createState() => _ProduitEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProduitEditScreenState extends State<ProduitEditScreen> {
|
||||||
|
final _repo = ProduitRepository.instance;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late final TextEditingController _nom;
|
||||||
|
late final TextEditingController _prix;
|
||||||
|
late final TextEditingController _prixAchat;
|
||||||
|
late final TextEditingController _quantite;
|
||||||
|
late final TextEditingController _nbContenants;
|
||||||
|
late final TextEditingController _stock;
|
||||||
|
|
||||||
|
String? _codeBarres;
|
||||||
|
String? _imageUrl;
|
||||||
|
late String _unite;
|
||||||
|
bool _estPack = false;
|
||||||
|
bool _enregistre = false;
|
||||||
|
|
||||||
|
bool get _modification => widget.produit != null;
|
||||||
|
|
||||||
|
/// Produit scanné mais absent d'OpenFoodFacts (nom à saisir à la main).
|
||||||
|
bool get _scanNonTrouve =>
|
||||||
|
widget.produit == null &&
|
||||||
|
widget.codeBarresInitial != null &&
|
||||||
|
(widget.nomInitial == null || widget.nomInitial!.trim().isEmpty);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final p = widget.produit;
|
||||||
|
_nom = TextEditingController(text: p?.nom ?? widget.nomInitial ?? '');
|
||||||
|
_prix = TextEditingController(text: p != null ? _fmt(p.prix) : '');
|
||||||
|
_prixAchat = TextEditingController(
|
||||||
|
text: (p != null && p.prixAchat > 0) ? _fmt(p.prixAchat) : '');
|
||||||
|
final q = p?.quantite ?? widget.quantiteInitial;
|
||||||
|
_quantite = TextEditingController(
|
||||||
|
text: (q != null && q > 0) ? _fmtQuantite(q) : '');
|
||||||
|
_estPack = p?.estPack ?? false;
|
||||||
|
_nbContenants = TextEditingController(
|
||||||
|
text: (p != null && p.estPack) ? p.nbContenants.toString() : '');
|
||||||
|
_stock = TextEditingController(text: (p?.stock ?? 0).toString());
|
||||||
|
// Unité : celle du produit si valide, sinon celle du scan, sinon 'g' par défaut.
|
||||||
|
_unite = Unites.estValide(p?.unite)
|
||||||
|
? p!.unite!
|
||||||
|
: (Unites.estValide(widget.uniteInitial) ? widget.uniteInitial! : Unites.g);
|
||||||
|
_codeBarres = p?.codeBarres ?? widget.codeBarresInitial;
|
||||||
|
_imageUrl = p?.imageUrl ?? widget.imageUrlInitial;
|
||||||
|
|
||||||
|
// Nouveau produit sans unité connue → on re-propose la dernière unité utilisée.
|
||||||
|
if (p == null && !Unites.estValide(widget.uniteInitial)) {
|
||||||
|
Reglages.instance.derniereUnite().then((u) {
|
||||||
|
if (mounted && _quantite.text.isEmpty && Unites.estValide(u)) {
|
||||||
|
setState(() => _unite = u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nom.dispose();
|
||||||
|
_prix.dispose();
|
||||||
|
_prixAchat.dispose();
|
||||||
|
_quantite.dispose();
|
||||||
|
_nbContenants.dispose();
|
||||||
|
_stock.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmtQuantite(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
|
||||||
|
|
||||||
|
String _fmt(double v) =>
|
||||||
|
v.toStringAsFixed(2).replaceAll('.', ',');
|
||||||
|
|
||||||
|
double _parse(String s) =>
|
||||||
|
double.tryParse(s.trim().replaceAll(',', '.')) ?? 0;
|
||||||
|
|
||||||
|
Future<void> _enregistrer() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _enregistre = true);
|
||||||
|
|
||||||
|
final base = widget.produit;
|
||||||
|
final q = _parse(_quantite.text);
|
||||||
|
final nb = _estPack ? (int.tryParse(_nbContenants.text.trim()) ?? 0) : 1;
|
||||||
|
final produit = Produit(
|
||||||
|
id: base?.id ?? 'temp',
|
||||||
|
codeBarres: _codeBarres,
|
||||||
|
nom: _nom.text.trim(),
|
||||||
|
imageUrl: _imageUrl,
|
||||||
|
prix: _parse(_prix.text),
|
||||||
|
prixAchat: _parse(_prixAchat.text),
|
||||||
|
quantite: q > 0 ? q : null,
|
||||||
|
unite: _unite,
|
||||||
|
nbContenants: (_estPack && nb > 1) ? nb : null,
|
||||||
|
creeLe: base?.creeLe ?? 0,
|
||||||
|
stock: int.tryParse(_stock.text.trim()) ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_modification) {
|
||||||
|
await _repo.modifier(produit);
|
||||||
|
} else {
|
||||||
|
await _repo.ajouter(produit);
|
||||||
|
}
|
||||||
|
// Mémorise l'unité pour accélérer la saisie du produit suivant.
|
||||||
|
await Reglages.instance.memoriserUnite(produit.unite ?? Unites.piece);
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _enregistre = false);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Erreur : $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changerPhoto() async {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
final action = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_camera, color: AppTheme.accent),
|
||||||
|
title: const Text('Prendre une photo'),
|
||||||
|
onTap: () => Navigator.pop(ctx, 'camera'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library, color: AppTheme.accent),
|
||||||
|
title: const Text('Choisir dans la galerie'),
|
||||||
|
onTap: () => Navigator.pop(ctx, 'gallery'),
|
||||||
|
),
|
||||||
|
if (_imageUrl != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
title: const Text('Supprimer la photo'),
|
||||||
|
onTap: () => Navigator.pop(ctx, 'remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (action == null) return;
|
||||||
|
if (action == 'remove') {
|
||||||
|
setState(() => _imageUrl = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final source =
|
||||||
|
action == 'camera' ? ImageSource.camera : ImageSource.gallery;
|
||||||
|
try {
|
||||||
|
final chemin = await PhotoService.choisir(source);
|
||||||
|
if (chemin != null && mounted) setState(() => _imageUrl = chemin);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Photo impossible : $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _supprimer() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Supprimer ce produit ?'),
|
||||||
|
content: Text('« ${widget.produit!.nom} » sera retiré de la liste.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Annuler')),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
await _repo.supprimer(widget.produit!.id);
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_modification ? 'Modifier' : 'Nouveau produit'),
|
||||||
|
actions: [
|
||||||
|
if (_modification)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
color: Colors.red,
|
||||||
|
onPressed: _supprimer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _changerPhoto,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
ProduitImage(url: _imageUrl, taille: 120, radius: 20),
|
||||||
|
Positioned(
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.photo_camera,
|
||||||
|
color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
_imageUrl == null ? 'Ajouter une photo' : 'Changer la photo',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_codeBarres != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.qr_code, size: 16,
|
||||||
|
color: AppTheme.grisTexte),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(_codeBarres!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.grisTexte,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_scanNonTrouve) ...[
|
||||||
|
_banniereNonTrouve(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
_label('Nom du produit'),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nom,
|
||||||
|
autofocus: !_modification && _nom.text.isEmpty,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(hintText: 'Ex : Nutella 400g'),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_label('Prix d\'achat'),
|
||||||
|
_champPrix(_prixAchat, obligatoire: false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_label('Prix de vente'),
|
||||||
|
_champPrix(_prix,
|
||||||
|
autofocus: !_modification && _nom.text.isNotEmpty),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_apercuMarge(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_label('Conditionnement'),
|
||||||
|
_toggleUnitePack(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (!_estPack)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [_label('Quantité'), _champQuantite()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [_label('Unité'), _selecteurUnite()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [_label('Nombre'), _champNombre()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [_label('Contenance'), _champQuantite()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [_label('Unité'), _selecteurUnite()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_apercuPrixMesure(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_label('Inventaire (stock)'),
|
||||||
|
_sectionStock(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _enregistre ? null : _enregistrer,
|
||||||
|
icon: _enregistre
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Icon(Icons.check),
|
||||||
|
label: Text(_modification ? 'Enregistrer' : 'Ajouter à la liste'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _label(String t) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8, left: 4),
|
||||||
|
child: Text(t,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _banniereNonTrouve() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accent.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppTheme.accent.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: AppTheme.accent, size: 20),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Produit non trouvé en ligne. Saisis-le : il sera mémorisé et '
|
||||||
|
'reconnu automatiquement aux prochains scans.',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.noir),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _champPrix(TextEditingController c,
|
||||||
|
{bool autofocus = false, bool obligatoire = true}) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: c,
|
||||||
|
autofocus: autofocus,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
||||||
|
],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(hintText: '0,00', suffixText: '€'),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) return obligatoire ? 'Requis' : null;
|
||||||
|
if (_parse(v) <= 0) return 'Invalide';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _apercuMarge() {
|
||||||
|
final achat = _parse(_prixAchat.text);
|
||||||
|
final vente = _parse(_prix.text);
|
||||||
|
if (achat <= 0 || vente <= 0) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final gain = vente - achat;
|
||||||
|
final coef = vente / achat;
|
||||||
|
final positif = coef >= 1;
|
||||||
|
final couleur = positif ? const Color(0xFF1B873F) : Colors.red;
|
||||||
|
final signe = gain >= 0 ? '+' : '';
|
||||||
|
final gainTxt = '$signe${gain.toStringAsFixed(2).replaceAll('.', ',')} €';
|
||||||
|
final apercu = Produit(id: '', nom: '', prix: vente, prixAchat: achat);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, left: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(positif ? Icons.trending_up : Icons.trending_down,
|
||||||
|
color: couleur, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Marge ${apercu.margeLibelle} ($gainTxt)',
|
||||||
|
style: TextStyle(color: couleur, fontWeight: FontWeight.w700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _champQuantite() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _quantite,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
||||||
|
],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(hintText: '0'),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) return 'Requis';
|
||||||
|
if (_parse(v) <= 0) return 'Invalide';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _selecteurUnite() {
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: _unite,
|
||||||
|
isExpanded: true,
|
||||||
|
items: [
|
||||||
|
for (final u in Unites.tout)
|
||||||
|
DropdownMenuItem(value: u, child: Text(Unites.libelle(u))),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
final nouvelle = v ?? Unites.g;
|
||||||
|
_convertirQuantite(_unite, nouvelle);
|
||||||
|
_unite = nouvelle;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit la quantité saisie quand on change d'unité dans la même
|
||||||
|
/// dimension (ex : 1500 ml → L = 1,5 ; 1500 g → kg = 1,5).
|
||||||
|
/// Changement de dimension (poids ↔ volume) : on garde la valeur telle quelle.
|
||||||
|
void _convertirQuantite(String from, String to) {
|
||||||
|
if (from == to) return;
|
||||||
|
final q = _parse(_quantite.text);
|
||||||
|
if (q <= 0) return;
|
||||||
|
|
||||||
|
const poids = {Unites.g: 1.0, Unites.kg: 1000.0}; // base : grammes
|
||||||
|
const volume = {Unites.ml: 1.0, Unites.cl: 10.0, Unites.l: 1000.0}; // base : ml
|
||||||
|
|
||||||
|
double? conv;
|
||||||
|
if (poids.containsKey(from) && poids.containsKey(to)) {
|
||||||
|
conv = q * poids[from]! / poids[to]!;
|
||||||
|
} else if (volume.containsKey(from) && volume.containsKey(to)) {
|
||||||
|
conv = q * volume[from]! / volume[to]!;
|
||||||
|
}
|
||||||
|
if (conv != null) {
|
||||||
|
conv = (conv * 1000).round() / 1000; // évite le bruit de virgule flottante
|
||||||
|
_quantite.text = _fmtQuantite(conv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toggleUnitePack() {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: false,
|
||||||
|
label: Text('Unité'),
|
||||||
|
icon: Icon(Icons.water_drop_outlined)),
|
||||||
|
ButtonSegment(
|
||||||
|
value: true,
|
||||||
|
label: Text('Pack'),
|
||||||
|
icon: Icon(Icons.inventory_2_outlined)),
|
||||||
|
],
|
||||||
|
selected: {_estPack},
|
||||||
|
showSelectedIcon: false,
|
||||||
|
onSelectionChanged: (s) => setState(() {
|
||||||
|
_estPack = s.first;
|
||||||
|
// En passant en Pack, on pré-remplit le nombre de contenants à 6
|
||||||
|
// (valeur courante, modifiable) pour que l'étiquette affiche direct « 6X… ».
|
||||||
|
if (_estPack && _nbContenants.text.trim().isEmpty) {
|
||||||
|
_nbContenants.text = '6';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _champNombre() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _nbContenants,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(hintText: '6', suffixText: '×'),
|
||||||
|
validator: (v) {
|
||||||
|
if (!_estPack) return null;
|
||||||
|
if ((int.tryParse((v ?? '').trim()) ?? 0) < 2) return '≥ 2';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ajusterStock(int delta) {
|
||||||
|
final cur = int.tryParse(_stock.text.trim()) ?? 0;
|
||||||
|
final n = (cur + delta).clamp(0, 999999);
|
||||||
|
_stock.text = n.toString();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionStock() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_btnStock(Icons.remove, () => _ajusterStock(-1)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _stock,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||||
|
decoration: const InputDecoration(suffixText: 'en stock'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_btnStock(Icons.add, () => _ajusterStock(1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [for (final d in [1, 5, 10, 50]) _chipStock(d)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _btnStock(IconData icon, VoidCallback onTap) {
|
||||||
|
return Material(
|
||||||
|
color: AppTheme.accent.withValues(alpha: 0.12),
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Icon(icon, color: AppTheme.accent, size: 22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _chipStock(int d) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text('+$d',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700, color: AppTheme.accent)),
|
||||||
|
onPressed: () => _ajusterStock(d),
|
||||||
|
backgroundColor: AppTheme.accent.withValues(alpha: 0.08),
|
||||||
|
side: BorderSide(color: AppTheme.accent.withValues(alpha: 0.3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _apercuPrixMesure() {
|
||||||
|
final q = _parse(_quantite.text);
|
||||||
|
final nb = _estPack ? (int.tryParse(_nbContenants.text.trim()) ?? 0) : 1;
|
||||||
|
final apercu = Produit(
|
||||||
|
id: '',
|
||||||
|
nom: _nom.text.trim(),
|
||||||
|
prix: _parse(_prix.text),
|
||||||
|
quantite: q > 0 ? q : null,
|
||||||
|
unite: _unite,
|
||||||
|
nbContenants: (_estPack && nb > 1) ? nb : null,
|
||||||
|
);
|
||||||
|
final mesure = apercu.prixMesure;
|
||||||
|
if (mesure == null || apercu.nom.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final txt =
|
||||||
|
'${mesure.valeur.toStringAsFixed(2).replaceAll('.', ',')} €${mesure.suffixe}';
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: 14),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accent.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sell_outlined, size: 18, color: AppTheme.accent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Sur l’étiquette : ${apercu.titre}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.noir, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('soit $txt',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.accent, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
import '../services/produit_repository.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import '../utils/format.dart';
|
||||||
|
import '../utils/tri.dart';
|
||||||
|
import '../widgets/produit_image.dart';
|
||||||
|
import 'produit_edit_screen.dart';
|
||||||
|
|
||||||
|
/// Onglet 1 : liste de tous les produits avec recherche, tri et édition.
|
||||||
|
class ProduitsScreen extends StatefulWidget {
|
||||||
|
/// Appelé par le bouton « Ajouter » pour basculer sur l'onglet Scanner.
|
||||||
|
final VoidCallback onAllerScanner;
|
||||||
|
|
||||||
|
const ProduitsScreen({super.key, required this.onAllerScanner});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProduitsScreen> createState() => _ProduitsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProduitsScreenState extends State<ProduitsScreen> {
|
||||||
|
final _repo = ProduitRepository.instance;
|
||||||
|
String _recherche = '';
|
||||||
|
Tri _tri = Tri.recent;
|
||||||
|
|
||||||
|
List<Produit> _filtrerEtTrier(List<Produit> tous) {
|
||||||
|
final q = _recherche.trim().toLowerCase();
|
||||||
|
final list = q.isEmpty
|
||||||
|
? tous
|
||||||
|
: tous
|
||||||
|
.where((p) =>
|
||||||
|
p.nom.toLowerCase().contains(q) ||
|
||||||
|
(p.codeBarres?.contains(q) ?? false))
|
||||||
|
.toList();
|
||||||
|
return trierProduits(list, _tri);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ouvrir(Produit p) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => ProduitEditScreen(produit: p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: widget.onAllerScanner,
|
||||||
|
backgroundColor: AppTheme.accent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _repo,
|
||||||
|
builder: (context, _) {
|
||||||
|
final produits = _filtrerEtTrier(_repo.produits);
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(child: _entete(_repo.produits.length)),
|
||||||
|
if (_repo.enCours && _repo.produits.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (produits.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: _vide(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
||||||
|
sliver: SliverList.separated(
|
||||||
|
itemCount: produits.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, i) => _ProduitCard(
|
||||||
|
produit: produits[i],
|
||||||
|
onTap: () => _ouvrir(produits[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _entete(int total) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
const Text('Produits',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -1)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('$total',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.grisTexte)),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
onChanged: (v) => setState(() => _recherche = v),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Rechercher un produit…',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
for (final t in Tri.values) _puceTri(t),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _puceTri(Tri t) {
|
||||||
|
final sel = _tri == t;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(t.libelle),
|
||||||
|
selected: sel,
|
||||||
|
onSelected: (_) => setState(() => _tri = t),
|
||||||
|
showCheckmark: false,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: sel ? Colors.white : AppTheme.grisTexte,
|
||||||
|
),
|
||||||
|
selectedColor: AppTheme.accent,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
side: BorderSide(
|
||||||
|
color: sel
|
||||||
|
? AppTheme.accent
|
||||||
|
: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _vide() {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.inventory_2_outlined,
|
||||||
|
size: 64, color: AppTheme.grisTexte),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_recherche.isEmpty
|
||||||
|
? 'Aucun produit pour l’instant.\nScanne un code-barres pour commencer.'
|
||||||
|
: 'Aucun résultat pour « $_recherche ».',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.grisTexte, fontSize: 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProduitCard extends StatelessWidget {
|
||||||
|
final Produit produit;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ProduitCard({required this.produit, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ProduitImage(url: produit.imageUrl),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
produit.titre,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_PastilleStock(stock: produit.stock),
|
||||||
|
if (produit.codeBarres != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(produit.codeBarres!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5, color: AppTheme.grisTexte)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(euro(produit.prix),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w800)),
|
||||||
|
if (produit.prixMesure != null)
|
||||||
|
Text(
|
||||||
|
'${produit.prixMesure!.valeur.toStringAsFixed(2).replaceAll('.', ',')} €${produit.prixMesure!.suffixe}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5, color: AppTheme.grisTexte),
|
||||||
|
),
|
||||||
|
if (produit.coefficient != null)
|
||||||
|
Text(
|
||||||
|
produit.margeLibelle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: produit.coefficient! >= 1
|
||||||
|
? const Color(0xFF1B873F)
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: AppTheme.grisTexte),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pastille indiquant le stock d'un produit (rouge si épuisé).
|
||||||
|
class _PastilleStock extends StatelessWidget {
|
||||||
|
final int stock;
|
||||||
|
const _PastilleStock({required this.stock});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final epuise = stock <= 0;
|
||||||
|
final couleur = epuise ? Colors.red : AppTheme.accent;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: couleur.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inventory_2_outlined, size: 13, color: couleur),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
epuise ? 'Rupture' : '$stock en stock',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, fontWeight: FontWeight.w700, color: couleur),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
import '../services/openfoodfacts_service.dart';
|
||||||
|
import '../services/produit_repository.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'produit_edit_screen.dart';
|
||||||
|
|
||||||
|
/// Onglet 2 : scan d'un code-barres → recherche OpenFoodFacts → formulaire.
|
||||||
|
class ScanScreen extends StatefulWidget {
|
||||||
|
final bool active;
|
||||||
|
const ScanScreen({super.key, required this.active});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScanScreen> createState() => _ScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanScreenState extends State<ScanScreen> {
|
||||||
|
final _controller = MobileScannerController(
|
||||||
|
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||||
|
formats: const [
|
||||||
|
BarcodeFormat.ean13,
|
||||||
|
BarcodeFormat.ean8,
|
||||||
|
BarcodeFormat.upcA,
|
||||||
|
BarcodeFormat.upcE,
|
||||||
|
BarcodeFormat.code128,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final _off = OpenFoodFactsService();
|
||||||
|
final _repo = ProduitRepository.instance;
|
||||||
|
|
||||||
|
bool _traite = false;
|
||||||
|
|
||||||
|
// La caméra est démarrée/arrêtée automatiquement par le widget MobileScanner
|
||||||
|
// quand il est monté/démonté selon l'onglet actif (autoStart). On ne fait
|
||||||
|
// AUCUN start()/stop() manuel ici : ça provoquerait un double démarrage.
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ScanScreen old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
// En revenant sur l'onglet Scanner, on réautorise la détection.
|
||||||
|
if (widget.active && !old.active) _traite = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||||
|
if (_traite || !widget.active) return;
|
||||||
|
final code = capture.barcodes.firstOrNull?.rawValue;
|
||||||
|
if (code == null || code.isEmpty) return;
|
||||||
|
|
||||||
|
// On bloque les détections suivantes pendant le traitement (sans couper la caméra).
|
||||||
|
setState(() => _traite = true);
|
||||||
|
|
||||||
|
// Déjà connu ? → on ouvre directement la fiche.
|
||||||
|
final existant = _repo.parCodeBarres(code);
|
||||||
|
if (existant != null) {
|
||||||
|
await _ouvrirExistant(existant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon on interroge OpenFoodFacts (avec un petit loader).
|
||||||
|
final info = await _off.chercherParCodeBarres(code);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final ajoute = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ProduitEditScreen(
|
||||||
|
codeBarresInitial: code,
|
||||||
|
nomInitial: info?.nom,
|
||||||
|
imageUrlInitial: info?.imageUrl,
|
||||||
|
quantiteInitial: info?.quantite,
|
||||||
|
uniteInitial: info?.unite,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _traite = false);
|
||||||
|
if (ajoute == true) await _popupAjoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ouvrirExistant(Produit p) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => ProduitEditScreen(produit: p)),
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _traite = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajout manuel via le bouton « + » (sans scanner).
|
||||||
|
Future<void> _ajouterManuel() async {
|
||||||
|
setState(() => _traite = true); // bloque les détections pendant la saisie
|
||||||
|
final ajoute = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => const ProduitEditScreen()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _traite = false);
|
||||||
|
if (ajoute == true) await _popupAjoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop-up de confirmation « Produit ajouté », se ferme tout seul.
|
||||||
|
Future<void> _popupAjoute() async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
final nav = Navigator.of(ctx);
|
||||||
|
Future.delayed(const Duration(milliseconds: 1300), () {
|
||||||
|
if (nav.canPop()) nav.pop();
|
||||||
|
});
|
||||||
|
return Dialog(
|
||||||
|
shape:
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check_circle,
|
||||||
|
color: Colors.green, size: 44),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Produit ajouté',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// La caméra (et donc la demande d'autorisation) n'est construite que
|
||||||
|
// lorsque l'onglet Scanner est réellement affiché.
|
||||||
|
if (!widget.active) {
|
||||||
|
return const ColoredBox(color: Colors.black);
|
||||||
|
}
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _traite ? null : _ajouterManuel,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppTheme.noir,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajout manuel'),
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _controller,
|
||||||
|
onDetect: _onDetect,
|
||||||
|
errorBuilder: (context, error) => _erreurCamera(error),
|
||||||
|
),
|
||||||
|
_cadre(),
|
||||||
|
_bandeauHaut(),
|
||||||
|
if (_traite)
|
||||||
|
Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(color: Colors.white),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Recherche du produit…',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _bandeauHaut() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 24),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Vise le code-barres du produit',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cadre() {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 170,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppTheme.accent, width: 3),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _erreurCamera(MobileScannerException error) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.no_photography_outlined,
|
||||||
|
color: Colors.white70, size: 56),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Accès à la caméra impossible.\nAutorise la caméra dans les réglages.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => _controller.start(),
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
|
||||||
|
/// Génère une planche A4 d'étiquettes de prix (nom, prix TTC, prix HT, code-barres),
|
||||||
|
/// imprimable sur une imprimante classique (ex : Canon TS4550).
|
||||||
|
class EtiquettePdf {
|
||||||
|
/// Construit le document PDF pour les [produits] sélectionnés.
|
||||||
|
static Future<Uint8List> construire(List<Produit> produits) async {
|
||||||
|
final doc = pw.Document();
|
||||||
|
|
||||||
|
doc.addPage(
|
||||||
|
pw.MultiPage(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: const pw.EdgeInsets.all(12),
|
||||||
|
build: (context) {
|
||||||
|
return [
|
||||||
|
pw.Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: produits.map(_etiquette).toList(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
static pw.Widget _etiquette(Produit p) {
|
||||||
|
// 3 colonnes sur A4 (~186mm utiles / 3 ≈ 62mm).
|
||||||
|
const largeur = 185.0; // points (~65mm)
|
||||||
|
const hauteur = 110.0; // points (~39mm)
|
||||||
|
|
||||||
|
return pw.Container(
|
||||||
|
width: largeur,
|
||||||
|
height: hauteur,
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.grey400, width: 0.7),
|
||||||
|
borderRadius: pw.BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
p.titre,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: pw.TextOverflow.clip,
|
||||||
|
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
pw.Container(
|
||||||
|
width: double.infinity,
|
||||||
|
alignment: pw.Alignment.center,
|
||||||
|
child: pw.Column(
|
||||||
|
mainAxisSize: pw.MainAxisSize.min,
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
_prix(p.prix),
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 22, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (p.prixMesure != null)
|
||||||
|
pw.Text(
|
||||||
|
_prixMesure(p),
|
||||||
|
style: const pw.TextStyle(
|
||||||
|
fontSize: 9, color: PdfColors.grey700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_codeBarres(p.codeBarres),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static pw.Widget _codeBarres(String? code) {
|
||||||
|
if (code == null || code.isEmpty) {
|
||||||
|
return pw.SizedBox(height: 22);
|
||||||
|
}
|
||||||
|
final estEan13 = code.length == 13 && int.tryParse(code) != null;
|
||||||
|
return pw.BarcodeWidget(
|
||||||
|
barcode: estEan13 ? pw.Barcode.ean13() : pw.Barcode.code128(),
|
||||||
|
data: code,
|
||||||
|
width: 150,
|
||||||
|
height: 26,
|
||||||
|
drawText: true,
|
||||||
|
textStyle: const pw.TextStyle(fontSize: 6),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _prix(double v) => '${v.toStringAsFixed(2).replaceAll('.', ',')} EUR';
|
||||||
|
|
||||||
|
static String _prixMesure(Produit p) {
|
||||||
|
final m = p.prixMesure!;
|
||||||
|
final unite = m.suffixe == '/kg' ? 'kg' : 'L';
|
||||||
|
return 'soit ${m.valeur.toStringAsFixed(2).replaceAll('.', ',')} EUR / $unite';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
/// Accès à la base SQLite locale (persistée sur la tablette).
|
||||||
|
class LocalDb {
|
||||||
|
LocalDb._();
|
||||||
|
static final LocalDb instance = LocalDb._();
|
||||||
|
|
||||||
|
Database? _db;
|
||||||
|
|
||||||
|
Future<Database> get database async {
|
||||||
|
return _db ??= await _ouvrir();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _ouvrir() async {
|
||||||
|
final dir = await getDatabasesPath();
|
||||||
|
final chemin = '$dir/gestion_prix.db';
|
||||||
|
return openDatabase(
|
||||||
|
chemin,
|
||||||
|
version: 6,
|
||||||
|
onCreate: (db, version) async {
|
||||||
|
await _creerTable(db);
|
||||||
|
},
|
||||||
|
onUpgrade: (db, ancienne, nouvelle) async {
|
||||||
|
if (ancienne < 2) {
|
||||||
|
// Ancien schéma (prix HT/TTC) → refonte complète.
|
||||||
|
await db.execute('DROP TABLE IF EXISTS produits');
|
||||||
|
await _creerTable(db);
|
||||||
|
}
|
||||||
|
if (ancienne < 3) {
|
||||||
|
// Ajout quantité + unité pour le prix au kilo/litre.
|
||||||
|
await db.execute('ALTER TABLE produits ADD COLUMN quantite REAL');
|
||||||
|
await db.execute('ALTER TABLE produits ADD COLUMN unite TEXT');
|
||||||
|
}
|
||||||
|
if (ancienne < 4) {
|
||||||
|
// Ajout du nombre de contenants (packs).
|
||||||
|
await db.execute('ALTER TABLE produits ADD COLUMN nb_contenants INTEGER');
|
||||||
|
}
|
||||||
|
if (ancienne < 5) {
|
||||||
|
// Ajout du stock (inventaire).
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE produits ADD COLUMN stock INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
if (ancienne < 6) {
|
||||||
|
// Ajout du prix d'achat (marge).
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE produits ADD COLUMN prix_achat REAL NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _creerTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE produits(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code_barres TEXT UNIQUE,
|
||||||
|
nom TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
prix REAL NOT NULL DEFAULT 0,
|
||||||
|
prix_achat REAL NOT NULL DEFAULT 0,
|
||||||
|
quantite REAL,
|
||||||
|
unite TEXT,
|
||||||
|
nb_contenants INTEGER,
|
||||||
|
stock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cree_le INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
|
||||||
|
/// Résultat d'une recherche produit sur OpenFoodFacts.
|
||||||
|
class InfoProduit {
|
||||||
|
final String? nom;
|
||||||
|
final String? imageUrl;
|
||||||
|
final String? marque;
|
||||||
|
final double? quantite;
|
||||||
|
final String? unite;
|
||||||
|
|
||||||
|
const InfoProduit({
|
||||||
|
this.nom,
|
||||||
|
this.imageUrl,
|
||||||
|
this.marque,
|
||||||
|
this.quantite,
|
||||||
|
this.unite,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get estVide => (nom == null || nom!.isEmpty) && imageUrl == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interroge l'API publique OpenFoodFacts pour récupérer le nom, l'image et la
|
||||||
|
/// quantité (grammage / litrage) d'un produit à partir de son code-barres.
|
||||||
|
class OpenFoodFactsService {
|
||||||
|
static const String _base = 'https://world.openfoodfacts.org/api/v2/product';
|
||||||
|
|
||||||
|
Future<InfoProduit?> chercherParCodeBarres(String codeBarres) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$_base/$codeBarres.json?fields=product_name,product_name_fr,brands,'
|
||||||
|
'image_front_url,image_url,product_quantity,product_quantity_unit',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await http
|
||||||
|
.get(uri, headers: {'User-Agent': 'GestionPrixProduit/1.0'})
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
|
||||||
|
if (res.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
// status == 1 => produit trouvé
|
||||||
|
if (data['status'] != 1) return const InfoProduit();
|
||||||
|
|
||||||
|
final p = data['product'] as Map<String, dynamic>? ?? {};
|
||||||
|
final nom = (p['product_name_fr'] as String?)?.trim();
|
||||||
|
final nomEn = (p['product_name'] as String?)?.trim();
|
||||||
|
final image = (p['image_front_url'] as String?)?.trim() ??
|
||||||
|
(p['image_url'] as String?)?.trim();
|
||||||
|
|
||||||
|
final (quantite, unite) = _quantite(p);
|
||||||
|
|
||||||
|
return InfoProduit(
|
||||||
|
nom: (nom != null && nom.isNotEmpty) ? nom : nomEn,
|
||||||
|
imageUrl: (image != null && image.isNotEmpty) ? image : null,
|
||||||
|
marque: (p['brands'] as String?)?.split(',').first.trim(),
|
||||||
|
quantite: quantite,
|
||||||
|
unite: unite,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Pas de réseau ou timeout : on retourne null, l'utilisateur saisit à la main.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalise la quantité OpenFoodFacts (product_quantity + unité) vers nos unités.
|
||||||
|
(double?, String?) _quantite(Map<String, dynamic> p) {
|
||||||
|
final q = (p['product_quantity'] as num?)?.toDouble();
|
||||||
|
final u = (p['product_quantity_unit'] as String?)?.toLowerCase().trim();
|
||||||
|
if (q == null || q <= 0 || u == null) return (null, null);
|
||||||
|
|
||||||
|
return switch (u) {
|
||||||
|
'g' => (q, Unites.g),
|
||||||
|
'kg' => (q, Unites.kg),
|
||||||
|
'mg' => (q / 1000, Unites.g),
|
||||||
|
'ml' => (q, Unites.ml),
|
||||||
|
'cl' => (q, Unites.cl),
|
||||||
|
'l' => (q, Unites.l),
|
||||||
|
_ => (null, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Prise et stockage local des photos de produits.
|
||||||
|
class PhotoService {
|
||||||
|
static final _picker = ImagePicker();
|
||||||
|
|
||||||
|
/// Ouvre l'appareil photo ([ImageSource.camera]) ou la galerie, copie l'image
|
||||||
|
/// choisie dans le stockage de l'app et retourne son chemin local (ou null si annulé).
|
||||||
|
static Future<String?> choisir(ImageSource source) async {
|
||||||
|
final x = await _picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
maxWidth: 1200,
|
||||||
|
imageQuality: 82,
|
||||||
|
);
|
||||||
|
if (x == null) return null;
|
||||||
|
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final dossier = Directory('${dir.path}/photos');
|
||||||
|
if (!await dossier.exists()) await dossier.create(recursive: true);
|
||||||
|
|
||||||
|
final nom = 'produit_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final dest = '${dossier.path}/$nom';
|
||||||
|
await File(x.path).copy(dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vrai si l'url pointe vers un fichier local (photo prise), pas vers le web.
|
||||||
|
static bool estLocale(String? url) =>
|
||||||
|
url != null && url.isNotEmpty && !url.startsWith('http');
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
import 'local_db.dart';
|
||||||
|
|
||||||
|
/// Source de vérité des produits, partagée par tous les écrans.
|
||||||
|
/// Persistée localement dans une base SQLite (aucun serveur, fonctionne hors-ligne).
|
||||||
|
class ProduitRepository extends ChangeNotifier {
|
||||||
|
ProduitRepository._();
|
||||||
|
static final ProduitRepository instance = ProduitRepository._();
|
||||||
|
|
||||||
|
final List<Produit> _produits = [];
|
||||||
|
bool _charge = false;
|
||||||
|
bool _enCours = false;
|
||||||
|
String? _erreur;
|
||||||
|
|
||||||
|
List<Produit> get produits => List.unmodifiable(_produits);
|
||||||
|
bool get charge => _charge;
|
||||||
|
bool get enCours => _enCours;
|
||||||
|
String? get erreur => _erreur;
|
||||||
|
|
||||||
|
Future<Database> get _db async => LocalDb.instance.database;
|
||||||
|
|
||||||
|
Future<void> charger() async {
|
||||||
|
_enCours = true;
|
||||||
|
_erreur = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final db = await _db;
|
||||||
|
final rows = await db.query('produits', orderBy: 'nom COLLATE NOCASE ASC');
|
||||||
|
_produits
|
||||||
|
..clear()
|
||||||
|
..addAll(rows.map(Produit.fromMap));
|
||||||
|
_charge = true;
|
||||||
|
} catch (e) {
|
||||||
|
_erreur = e.toString();
|
||||||
|
} finally {
|
||||||
|
_enCours = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Produit? parCodeBarres(String code) {
|
||||||
|
for (final p in _produits) {
|
||||||
|
if (p.codeBarres == code) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Produit> ajouter(Produit p) async {
|
||||||
|
final db = await _db;
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final data = p.toInsertMap()..['cree_le'] = ts;
|
||||||
|
final id = await db.insert(
|
||||||
|
'produits',
|
||||||
|
data,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
final cree = p.copyWith(id: id.toString(), creeLe: ts);
|
||||||
|
_produits.add(cree);
|
||||||
|
_trier();
|
||||||
|
notifyListeners();
|
||||||
|
return cree;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> modifier(Produit p) async {
|
||||||
|
final db = await _db;
|
||||||
|
await db.update(
|
||||||
|
'produits',
|
||||||
|
p.toInsertMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [int.tryParse(p.id) ?? p.id],
|
||||||
|
);
|
||||||
|
final i = _produits.indexWhere((e) => e.id == p.id);
|
||||||
|
if (i != -1) _produits[i] = p;
|
||||||
|
_trier();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> supprimer(String id) async {
|
||||||
|
final db = await _db;
|
||||||
|
await db.delete('produits', where: 'id = ?', whereArgs: [int.tryParse(id) ?? id]);
|
||||||
|
_produits.removeWhere((e) => e.id == id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trier() => _produits
|
||||||
|
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/produit.dart';
|
||||||
|
|
||||||
|
/// Petits réglages persistés (préférences utilisateur) pour accélérer la saisie.
|
||||||
|
class Reglages {
|
||||||
|
Reglages._();
|
||||||
|
static final Reglages instance = Reglages._();
|
||||||
|
|
||||||
|
static const _cleDerniereUnite = 'derniere_unite';
|
||||||
|
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
Future<SharedPreferences> get _p async =>
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Dernière unité choisie lors d'un ajout (pour la re-proposer par défaut).
|
||||||
|
Future<String> derniereUnite() async {
|
||||||
|
final p = await _p;
|
||||||
|
return p.getString(_cleDerniereUnite) ?? Unites.g;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> memoriserUnite(String unite) async {
|
||||||
|
final p = await _p;
|
||||||
|
await p.setString(_cleDerniereUnite, unite);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Thème noir & blanc épuré, avec une seule couleur d'accent.
|
||||||
|
/// Change [accent] pour ajuster la touche de couleur de toute l'app.
|
||||||
|
class AppTheme {
|
||||||
|
static const Color accent = Color(0xFF2F6FED); // bleu moderne
|
||||||
|
static const Color noir = Color(0xFF111114);
|
||||||
|
static const Color grisTexte = Color(0xFF6B6B72);
|
||||||
|
|
||||||
|
static ThemeData clair() {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: accent,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
).copyWith(
|
||||||
|
primary: noir,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
secondary: accent,
|
||||||
|
surface: Colors.white,
|
||||||
|
onSurface: noir,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _base(scheme, const Color(0xFFF6F6F7));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ThemeData sombre() {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: accent,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
).copyWith(
|
||||||
|
primary: Colors.white,
|
||||||
|
onPrimary: noir,
|
||||||
|
secondary: accent,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _base(scheme, const Color(0xFF0E0E11));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ThemeData _base(ColorScheme scheme, Color fond) {
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: fond,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: fond,
|
||||||
|
foregroundColor: scheme.onSurface,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
color: scheme.onSurface,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: scheme.surface,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: accent, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
indicatorColor: accent.withValues(alpha: 0.14),
|
||||||
|
elevation: 0,
|
||||||
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
|
final selected = states.contains(WidgetState.selected);
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
color: selected ? scheme.onSurface : grisTexte,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
|
final selected = states.contains(WidgetState.selected);
|
||||||
|
return IconThemeData(
|
||||||
|
color: selected ? accent : grisTexte,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
final NumberFormat _euro = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
|
||||||
|
|
||||||
|
String euro(double v) => _euro.format(v);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '../models/produit.dart';
|
||||||
|
|
||||||
|
/// Critères de tri partagés (page Produits et page Étiquettes).
|
||||||
|
enum Tri {
|
||||||
|
recent('Récents'),
|
||||||
|
alpha('A → Z'),
|
||||||
|
prixAsc('Prix ↑'),
|
||||||
|
prixDesc('Prix ↓'),
|
||||||
|
margeDesc('Marge ↓'),
|
||||||
|
margeAsc('Marge ↑');
|
||||||
|
|
||||||
|
const Tri(this.libelle);
|
||||||
|
final String libelle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une copie triée de [source] selon [tri].
|
||||||
|
List<Produit> trierProduits(List<Produit> source, Tri tri) {
|
||||||
|
final list = List<Produit>.of(source);
|
||||||
|
switch (tri) {
|
||||||
|
case Tri.recent:
|
||||||
|
// Plus récemment ajouté en premier (date d'ajout, puis id).
|
||||||
|
list.sort((a, b) {
|
||||||
|
final c = b.creeLe.compareTo(a.creeLe);
|
||||||
|
if (c != 0) return c;
|
||||||
|
return (int.tryParse(b.id) ?? 0).compareTo(int.tryParse(a.id) ?? 0);
|
||||||
|
});
|
||||||
|
case Tri.alpha:
|
||||||
|
list.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
|
||||||
|
case Tri.prixAsc:
|
||||||
|
list.sort((a, b) => a.prix.compareTo(b.prix));
|
||||||
|
case Tri.prixDesc:
|
||||||
|
list.sort((a, b) => b.prix.compareTo(a.prix));
|
||||||
|
case Tri.margeDesc:
|
||||||
|
list.sort(
|
||||||
|
(a, b) => (b.coefficient ?? -1e18).compareTo(a.coefficient ?? -1e18));
|
||||||
|
case Tri.margeAsc:
|
||||||
|
list.sort(
|
||||||
|
(a, b) => (a.coefficient ?? 1e18).compareTo(b.coefficient ?? 1e18));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../services/photo_service.dart';
|
||||||
|
|
||||||
|
/// Vignette d'image produit : photo locale, image web (OpenFoodFacts) ou icône par défaut.
|
||||||
|
class ProduitImage extends StatelessWidget {
|
||||||
|
final String? url;
|
||||||
|
final double taille;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
const ProduitImage({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
this.taille = 52,
|
||||||
|
this.radius = 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
child: Container(
|
||||||
|
width: taille,
|
||||||
|
height: taille,
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: _contenu(scheme),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(ColorScheme scheme) {
|
||||||
|
if (url == null || url!.isEmpty) {
|
||||||
|
return Icon(Icons.image_outlined,
|
||||||
|
color: scheme.onSurfaceVariant, size: taille * 0.42);
|
||||||
|
}
|
||||||
|
if (PhotoService.estLocale(url)) {
|
||||||
|
return Image.file(
|
||||||
|
File(url!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Icon(Icons.broken_image_outlined,
|
||||||
|
color: scheme.onSurfaceVariant, size: taille * 0.42),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: url!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, _) => const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, _, _) => Icon(Icons.broken_image_outlined,
|
||||||
|
color: scheme.onSurfaceVariant, size: taille * 0.42),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
barcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: barcode
|
||||||
|
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.9"
|
||||||
|
bidi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bidi
|
||||||
|
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.13"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
cached_network_image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cached_network_image
|
||||||
|
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
cached_network_image_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_platform_interface
|
||||||
|
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
cached_network_image_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_web
|
||||||
|
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+4"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_cache_manager:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_cache_manager
|
||||||
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.1"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+19"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+6"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.3"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.12.0"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
mobile_scanner:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mobile_scanner
|
||||||
|
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.1"
|
||||||
|
octo_image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: octo_image
|
||||||
|
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.6"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
pdf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pdf
|
||||||
|
sha256: "517df47af468734a23c8b513c0c6a8a62357403ab1b8ab7fad1606576a9dbdd0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.13.0"
|
||||||
|
pdf_widget_wrapper:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pdf_widget_wrapper
|
||||||
|
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
|
printing:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: printing
|
||||||
|
sha256: f6cd14c768c1352dd37a958a3ee351aa9ce305b398218d3acd86389d5f7ecad1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
qr:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: qr
|
||||||
|
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
record_use:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: record_use
|
||||||
|
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.26"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.11"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3+1"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.11"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.12.2 <4.0.0"
|
||||||
|
flutter: ">=3.44.0"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: gestion_prix_produit
|
||||||
|
description: "A new Flutter project."
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
|
# The following defines the version and build number for your application.
|
||||||
|
# A version number is three numbers separated by dots, like 1.2.43
|
||||||
|
# followed by an optional build number separated by a +.
|
||||||
|
# Both the version and the builder number may be overridden in flutter
|
||||||
|
# build by specifying --build-name and --build-number, respectively.
|
||||||
|
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||||
|
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||||
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||||
|
# Read more about iOS versioning at
|
||||||
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.12.2
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
mobile_scanner: ^7.2.0
|
||||||
|
http: ^1.6.0
|
||||||
|
pdf: ^3.13.0
|
||||||
|
printing: ^5.15.0
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
|
intl: ^0.20.3
|
||||||
|
sqflite: ^2.4.3
|
||||||
|
shared_preferences: ^2.5.5
|
||||||
|
image_picker: ^1.2.3
|
||||||
|
path_provider: ^2.1.6
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: false
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
|
adaptive_icon_background: "#14141A"
|
||||||
|
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
|
||||||
|
min_sdk_android: 21
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/to/font-from-package
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Test de fumée : l'app démarre et affiche la barre de navigation.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:gestion_prix_produit/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('L\'app démarre avec les 3 onglets', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MonApp());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Produits'), findsWidgets);
|
||||||
|
expect(find.text('Scanner'), findsOneWidget);
|
||||||
|
expect(find.text('Étiquettes'), findsWidgets);
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||