Compare commits
4 Commits
506225ac62
...
mise-en-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc93f3fa9a | ||
|
|
6d320bedc9 | ||
|
|
cc7abba373 | ||
|
|
890449d5e3 |
@@ -18,11 +18,12 @@ canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074
|
|||||||
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
|
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
|
||||||
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
|
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
|
||||||
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
|
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
|
||||||
assets/packages/flutter_map/lib/assets/flutter_map_logo.png,1759916249804,26fe50c9203ccf93512b80d4ee1a7578184a910457b36a6a5b7d41b799efb966
|
|
||||||
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
|
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
|
||||||
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
||||||
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
||||||
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||||
|
assets/assets/sounds/ok.mp3,1771938119844,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
|
||||||
|
assets/assets/sounds/error.mp3,1771938125144,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
|
||||||
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
||||||
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
||||||
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
||||||
@@ -32,16 +33,17 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
|||||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||||
version.json,1770478530807,2cbfdf7f34574c2f9d4f1af02acb86d8d230af93790c97a3c7e1674c4db42ef4
|
version.json,1772532792027,2b3f91e827bc27a1901342a048b1bd81d0aabc50935717f9851e1a3ad6cb7411
|
||||||
index.html,1770478536326,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae
|
||||||
flutter_service_worker.js,1770478628965,cb72807cfcb05b0a2e7b3f4f0cf618a0284a3d2476c93672bd86ea99670b0f5d
|
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
assets/FontManifest.json,1770478624084,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9
|
||||||
assets/AssetManifest.json,1770478624084,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
|
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad
|
||||||
flutter_bootstrap.js,1770478536318,bf4a3b4bf79eaed1ce24892f20cfb270bcc22fb392bc9f6a1d17aeed42ed4ed8
|
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/AssetManifest.bin.json,1770478624084,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
|
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.bin,1770478624084,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
|
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770478628013,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/shaders/ink_sparkle.frag,1770478624492,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1770478628013,50e06fd231edee237d875cddbae1e22b682d32bb1284e3c32ca409fa489f9c21
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/NOTICES,1770478624086,d02d64a466e62fdaeee2534a3f65541362ccf29beb495e2af0fdce41f4ae28d9
|
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093
|
||||||
main.dart.js,1770478620736,03d43aeaa96cfdbe5b7491f9610223ec95c29d47095570dd61cd6cddac863496
|
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
|
||||||
|
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f
|
||||||
|
|||||||
450
em2rp/.github/agents/Dart and flutter.agent.md
vendored
Normal file
450
em2rp/.github/agents/Dart and flutter.agent.md
vendored
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
---
|
||||||
|
description: 'Instructions for writing Dart and Flutter code following the official recommendations.'
|
||||||
|
applyTo: '**/*.dart'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dart and Flutter
|
||||||
|
|
||||||
|
Best practices recommended by the Dart and Flutter teams. These instructions were taken from [Effective Dart](https://dart.dev/effective-dart) and [Architecture Recommendations](https://docs.flutter.dev/app-architecture/recommendations).
|
||||||
|
|
||||||
|
## Effective Dart
|
||||||
|
|
||||||
|
Over the past several years, we've written a ton of Dart code and learned a lot about what works well and what doesn't. We're sharing this with you so you can write consistent, robust, fast code too. There are two overarching themes:
|
||||||
|
|
||||||
|
1. **Be consistent.** When it comes to things like formatting, and casing, arguments about which is better are subjective and impossible to resolve. What we do know is that being *consistent* is objectively helpful.
|
||||||
|
|
||||||
|
If two pieces of code look different it should be because they *are* different in some meaningful way. When a bit of code stands out and catches your eye, it should do so for a useful reason.
|
||||||
|
|
||||||
|
2. **Be brief.** Dart was designed to be familiar, so it inherits many of the same statements and expressions as C, Java, JavaScript and other languages. But we created Dart because there is a lot of room to improve on what those languages offer. We added a bunch of features, from string interpolation to initializing formals, to help you express your intent more simply and easily.
|
||||||
|
|
||||||
|
If there are multiple ways to say something, you should generally pick the most concise one. This is not to say you should `code golf` yourself into cramming a whole program into a single line. The goal is code that is *economical*, not *dense*.
|
||||||
|
|
||||||
|
### The topics
|
||||||
|
|
||||||
|
We split the guidelines into a few separate topics for easy digestion:
|
||||||
|
|
||||||
|
* **Style** – This defines the rules for laying out and organizing code, or at least the parts that `dart format` doesn't handle for you. The style topic also specifies how identifiers are formatted: `camelCase`, `using_underscores`, etc.
|
||||||
|
|
||||||
|
* **Documentation** – This tells you everything you need to know about what goes inside comments. Both doc comments and regular, run-of-the-mill code comments.
|
||||||
|
|
||||||
|
* **Usage** – This teaches you how to make the best use of language features to implement behavior. If it's in a statement or expression, it's covered here.
|
||||||
|
|
||||||
|
* **Design** – This is the softest topic, but the one with the widest scope. It covers what we've learned about designing consistent, usable APIs for libraries. If it's in a type signature or declaration, this goes over it.
|
||||||
|
|
||||||
|
### How to read the topics
|
||||||
|
|
||||||
|
Each topic is broken into a few sections. Sections contain a list of guidelines. Each guideline starts with one of these words:
|
||||||
|
|
||||||
|
* **DO** guidelines describe practices that should always be followed. There will almost never be a valid reason to stray from them.
|
||||||
|
|
||||||
|
* **DON'T** guidelines are the converse: things that are almost never a good idea. Hopefully, we don't have as many of these as other languages do because we have less historical baggage.
|
||||||
|
|
||||||
|
* **PREFER** guidelines are practices that you *should* follow. However, there may be circumstances where it makes sense to do otherwise. Just make sure you understand the full implications of ignoring the guideline when you do.
|
||||||
|
|
||||||
|
* **AVOID** guidelines are the dual to "prefer": stuff you shouldn't do but where there may be good reasons to on rare occasions.
|
||||||
|
|
||||||
|
* **CONSIDER** guidelines are practices that you might or might not want to follow, depending on circumstances, precedents, and your own preference.
|
||||||
|
|
||||||
|
Some guidelines describe an **exception** where the rule does *not* apply. When listed, the exceptions may not be exhaustive—you might still need to use your judgement on other cases.
|
||||||
|
|
||||||
|
This sounds like the police are going to beat down your door if you don't have your laces tied correctly. Things aren't that bad. Most of the guidelines here are common sense and we're all reasonable people. The goal, as always, is nice, readable and maintainable code.
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
#### Style
|
||||||
|
|
||||||
|
##### Identifiers
|
||||||
|
|
||||||
|
* DO name types using `UpperCamelCase`.
|
||||||
|
* DO name extensions using `UpperCamelCase`.
|
||||||
|
* DO name packages, directories, and source files using `lowercase_with_underscores`.
|
||||||
|
* DO name import prefixes using `lowercase_with_underscores`.
|
||||||
|
* DO name other identifiers using `lowerCamelCase`.
|
||||||
|
* PREFER using `lowerCamelCase` for constant names.
|
||||||
|
* DO capitalize acronyms and abbreviations longer than two letters like words.
|
||||||
|
* PREFER using wildcards for unused callback parameters.
|
||||||
|
* DON'T use a leading underscore for identifiers that aren't private.
|
||||||
|
* DON'T use prefix letters.
|
||||||
|
* DON'T explicitly name libraries.
|
||||||
|
|
||||||
|
##### Ordering
|
||||||
|
|
||||||
|
* DO place `dart:` imports before other imports.
|
||||||
|
* DO place `package:` imports before relative imports.
|
||||||
|
* DO specify exports in a separate section after all imports.
|
||||||
|
* DO sort sections alphabetically.
|
||||||
|
|
||||||
|
##### Formatting
|
||||||
|
|
||||||
|
* DO format your code using `dart format`.
|
||||||
|
* CONSIDER changing your code to make it more formatter-friendly.
|
||||||
|
* PREFER lines 80 characters or fewer.
|
||||||
|
* DO use curly braces for all flow control statements.
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
##### Comments
|
||||||
|
|
||||||
|
* DO format comments like sentences.
|
||||||
|
* DON'T use block comments for documentation.
|
||||||
|
|
||||||
|
##### Doc comments
|
||||||
|
|
||||||
|
* DO use `///` doc comments to document members and types.
|
||||||
|
* PREFER writing doc comments for public APIs.
|
||||||
|
* CONSIDER writing a library-level doc comment.
|
||||||
|
* CONSIDER writing doc comments for private APIs.
|
||||||
|
* DO start doc comments with a single-sentence summary.
|
||||||
|
* DO separate the first sentence of a doc comment into its own paragraph.
|
||||||
|
* AVOID redundancy with the surrounding context.
|
||||||
|
* PREFER starting comments of a function or method with third-person verbs if its main purpose is a side effect.
|
||||||
|
* PREFER starting a non-boolean variable or property comment with a noun phrase.
|
||||||
|
* PREFER starting a boolean variable or property comment with "Whether" followed by a noun or gerund phrase.
|
||||||
|
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
|
||||||
|
* DON'T write documentation for both the getter and setter of a property.
|
||||||
|
* PREFER starting library or type comments with noun phrases.
|
||||||
|
* CONSIDER including code samples in doc comments.
|
||||||
|
* DO use square brackets in doc comments to refer to in-scope identifiers.
|
||||||
|
* DO use prose to explain parameters, return values, and exceptions.
|
||||||
|
* DO put doc comments before metadata annotations.
|
||||||
|
|
||||||
|
##### Markdown
|
||||||
|
|
||||||
|
* AVOID using markdown excessively.
|
||||||
|
* AVOID using HTML for formatting.
|
||||||
|
* PREFER backtick fences for code blocks.
|
||||||
|
|
||||||
|
##### Writing
|
||||||
|
|
||||||
|
* PREFER brevity.
|
||||||
|
* AVOID abbreviations and acronyms unless they are obvious.
|
||||||
|
* PREFER using "this" instead of "the" to refer to a member's instance.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
##### Libraries
|
||||||
|
|
||||||
|
* DO use strings in `part of` directives.
|
||||||
|
* DON'T import libraries that are inside the `src` directory of another package.
|
||||||
|
* DON'T allow an import path to reach into or out of `lib`.
|
||||||
|
* PREFER relative import paths.
|
||||||
|
|
||||||
|
##### Null
|
||||||
|
|
||||||
|
* DON'T explicitly initialize variables to `null`.
|
||||||
|
* DON'T use an explicit default value of `null`.
|
||||||
|
* DON'T use `true` or `false` in equality operations.
|
||||||
|
* AVOID `late` variables if you need to check whether they are initialized.
|
||||||
|
* CONSIDER type promotion or null-check patterns for using nullable types.
|
||||||
|
|
||||||
|
##### Strings
|
||||||
|
|
||||||
|
* DO use adjacent strings to concatenate string literals.
|
||||||
|
* PREFER using interpolation to compose strings and values.
|
||||||
|
* AVOID using curly braces in interpolation when not needed.
|
||||||
|
|
||||||
|
##### Collections
|
||||||
|
|
||||||
|
* DO use collection literals when possible.
|
||||||
|
* DON'T use `.length` to see if a collection is empty.
|
||||||
|
* AVOID using `Iterable.forEach()` with a function literal.
|
||||||
|
* DON'T use `List.from()` unless you intend to change the type of the result.
|
||||||
|
* DO use `whereType()` to filter a collection by type.
|
||||||
|
* DON'T use `cast()` when a nearby operation will do.
|
||||||
|
* AVOID using `cast()`.
|
||||||
|
|
||||||
|
##### Functions
|
||||||
|
|
||||||
|
* DO use a function declaration to bind a function to a name.
|
||||||
|
* DON'T create a lambda when a tear-off will do.
|
||||||
|
|
||||||
|
##### Variables
|
||||||
|
|
||||||
|
* DO follow a consistent rule for `var` and `final` on local variables.
|
||||||
|
* AVOID storing what you can calculate.
|
||||||
|
|
||||||
|
##### Members
|
||||||
|
|
||||||
|
* DON'T wrap a field in a getter and setter unnecessarily.
|
||||||
|
* PREFER using a `final` field to make a read-only property.
|
||||||
|
* CONSIDER using `=>` for simple members.
|
||||||
|
* DON'T use `this.` except to redirect to a named constructor or to avoid shadowing.
|
||||||
|
* DO initialize fields at their declaration when possible.
|
||||||
|
|
||||||
|
##### Constructors
|
||||||
|
|
||||||
|
* DO use initializing formals when possible.
|
||||||
|
* DON'T use `late` when a constructor initializer list will do.
|
||||||
|
* DO use `;` instead of `{}` for empty constructor bodies.
|
||||||
|
* DON'T use `new`.
|
||||||
|
* DON'T use `const` redundantly.
|
||||||
|
|
||||||
|
##### Error handling
|
||||||
|
|
||||||
|
* AVOID catches without `on` clauses.
|
||||||
|
* DON'T discard errors from catches without `on` clauses.
|
||||||
|
* DO throw objects that implement `Error` only for programmatic errors.
|
||||||
|
* DON'T explicitly catch `Error` or types that implement it.
|
||||||
|
* DO use `rethrow` to rethrow a caught exception.
|
||||||
|
|
||||||
|
##### Asynchrony
|
||||||
|
|
||||||
|
* PREFER async/await over using raw futures.
|
||||||
|
* DON'T use `async` when it has no useful effect.
|
||||||
|
* CONSIDER using higher-order methods to transform a stream.
|
||||||
|
* AVOID using Completer directly.
|
||||||
|
* DO test for `Future<T>` when disambiguating a `FutureOr<T>` whose type argument could be `Object`.
|
||||||
|
|
||||||
|
#### Design
|
||||||
|
|
||||||
|
##### Names
|
||||||
|
|
||||||
|
* DO use terms consistently.
|
||||||
|
* AVOID abbreviations.
|
||||||
|
* PREFER putting the most descriptive noun last.
|
||||||
|
* CONSIDER making the code read like a sentence.
|
||||||
|
* PREFER a noun phrase for a non-boolean property or variable.
|
||||||
|
* PREFER a non-imperative verb phrase for a boolean property or variable.
|
||||||
|
* CONSIDER omitting the verb for a named boolean parameter.
|
||||||
|
* PREFER the "positive" name for a boolean property or variable.
|
||||||
|
* PREFER an imperative verb phrase for a function or method whose main purpose is a side effect.
|
||||||
|
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
|
||||||
|
* CONSIDER an imperative verb phrase for a function or method if you want to draw attention to the work it performs.
|
||||||
|
* AVOID starting a method name with `get`.
|
||||||
|
* PREFER naming a method `to...()` if it copies the object's state to a new object.
|
||||||
|
* PREFER naming a method `as...()` if it returns a different representation backed by the original object.
|
||||||
|
* AVOID describing the parameters in the function's or method's name.
|
||||||
|
* DO follow existing mnemonic conventions when naming type parameters.
|
||||||
|
|
||||||
|
##### Libraries
|
||||||
|
|
||||||
|
* PREFER making declarations private.
|
||||||
|
* CONSIDER declaring multiple classes in the same library.
|
||||||
|
|
||||||
|
##### Classes and mixins
|
||||||
|
|
||||||
|
* AVOID defining a one-member abstract class when a simple function will do.
|
||||||
|
* AVOID defining a class that contains only static members.
|
||||||
|
* AVOID extending a class that isn't intended to be subclassed.
|
||||||
|
* DO use class modifiers to control if your class can be extended.
|
||||||
|
* AVOID implementing a class that isn't intended to be an interface.
|
||||||
|
* DO use class modifiers to control if your class can be an interface.
|
||||||
|
* PREFER defining a pure `mixin` or pure `class` to a `mixin class`.
|
||||||
|
|
||||||
|
##### Constructors
|
||||||
|
|
||||||
|
* CONSIDER making your constructor `const` if the class supports it.
|
||||||
|
|
||||||
|
##### Members
|
||||||
|
|
||||||
|
* PREFER making fields and top-level variables `final`.
|
||||||
|
* DO use getters for operations that conceptually access properties.
|
||||||
|
* DO use setters for operations that conceptually change properties.
|
||||||
|
* DON'T define a setter without a corresponding getter.
|
||||||
|
* AVOID using runtime type tests to fake overloading.
|
||||||
|
* AVOID public `late final` fields without initializers.
|
||||||
|
* AVOID returning nullable `Future`, `Stream`, and collection types.
|
||||||
|
* AVOID returning `this` from methods just to enable a fluent interface.
|
||||||
|
|
||||||
|
##### Types
|
||||||
|
|
||||||
|
* DO type annotate variables without initializers.
|
||||||
|
* DO type annotate fields and top-level variables if the type isn't obvious.
|
||||||
|
* DON'T redundantly type annotate initialized local variables.
|
||||||
|
* DO annotate return types on function declarations.
|
||||||
|
* DO annotate parameter types on function declarations.
|
||||||
|
* DON'T annotate inferred parameter types on function expressions.
|
||||||
|
* DON'T type annotate initializing formals.
|
||||||
|
* DO write type arguments on generic invocations that aren't inferred.
|
||||||
|
* DON'T write type arguments on generic invocations that are inferred.
|
||||||
|
* AVOID writing incomplete generic types.
|
||||||
|
* DO annotate with `dynamic` instead of letting inference fail.
|
||||||
|
* PREFER signatures in function type annotations.
|
||||||
|
* DON'T specify a return type for a setter.
|
||||||
|
* DON'T use the legacy typedef syntax.
|
||||||
|
* PREFER inline function types over typedefs.
|
||||||
|
* PREFER using function type syntax for parameters.
|
||||||
|
* AVOID using `dynamic` unless you want to disable static checking.
|
||||||
|
* DO use `Future<void>` as the return type of asynchronous members that do not produce values.
|
||||||
|
* AVOID using `FutureOr<T>` as a return type.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
* AVOID positional boolean parameters.
|
||||||
|
* AVOID optional positional parameters if the user may want to omit earlier parameters.
|
||||||
|
* AVOID mandatory parameters that accept a special "no argument" value.
|
||||||
|
* DO use inclusive start and exclusive end parameters to accept a range.
|
||||||
|
|
||||||
|
##### Equality
|
||||||
|
|
||||||
|
* DO override `hashCode` if you override `==`.
|
||||||
|
* DO make your `==` operator obey the mathematical rules of equality.
|
||||||
|
* AVOID defining custom equality for mutable classes.
|
||||||
|
* DON'T make the parameter to `==` nullable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flutter Architecture Recommendations
|
||||||
|
|
||||||
|
This page presents architecture best practices, why they matter, and
|
||||||
|
whether we recommend them for your Flutter application.
|
||||||
|
You should treat these recommendations as recommendations,
|
||||||
|
and not steadfast rules, and you should
|
||||||
|
adapt them to your app's unique requirements.
|
||||||
|
|
||||||
|
The best practices on this page have a priority,
|
||||||
|
which reflects how strongly the Flutter team recommends it.
|
||||||
|
|
||||||
|
* **Strongly recommend:** You should always implement this recommendation if
|
||||||
|
you're starting to build a new application. You should strongly consider
|
||||||
|
refactoring an existing app to implement this practice unless doing so would
|
||||||
|
fundamentally clash with your current approach.
|
||||||
|
* **Recommend**: This practice will likely improve your app.
|
||||||
|
* **Conditional**: This practice can improve your app in certain circumstances.
|
||||||
|
|
||||||
|
### Separation of concerns
|
||||||
|
|
||||||
|
You should separate your app into a UI layer and a data layer. Within those layers, you should further separate logic into classes by responsibility.
|
||||||
|
|
||||||
|
#### Use clearly defined data and UI layers.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Separation of concerns is the most important architectural principle.
|
||||||
|
The data layer exposes application data to the rest of the app, and contains most of the business logic in your application.
|
||||||
|
The UI layer displays application data and listens for user events from users. The UI layer contains separate classes for UI logic and widgets.
|
||||||
|
|
||||||
|
#### Use the repository pattern in the data layer.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
The repository pattern is a software design pattern that isolates the data access logic from the rest of the application.
|
||||||
|
It creates an abstraction layer between the application's business logic and the underlying data storage mechanisms (databases, APIs, file systems, etc.).
|
||||||
|
In practice, this means creating Repository classes and Service classes.
|
||||||
|
|
||||||
|
#### Use ViewModels and Views in the UI layer. (MVVM)
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Separation of concerns is the most important architectural principle.
|
||||||
|
This particular separation makes your code much less error prone because your widgets remain "dumb".
|
||||||
|
|
||||||
|
#### Use `ChangeNotifiers` and `Listenables` to handle widget updates.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> There are many options to handle state-management, and ultimately the decision comes down to personal preference.
|
||||||
|
|
||||||
|
The `ChangeNotifier` API is part of the Flutter SDK, and is a convenient way to have your widgets observe changes in your ViewModels.
|
||||||
|
|
||||||
|
#### Do not put logic in widgets.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Logic should be encapsulated in methods on the ViewModel. The only logic a view should contain is:
|
||||||
|
* Simple if-statements to show and hide widgets based on a flag or nullable field in the ViewModel
|
||||||
|
* Animation logic that relies on the widget to calculate
|
||||||
|
* Layout logic based on device information, like screen size or orientation.
|
||||||
|
* Simple routing logic
|
||||||
|
|
||||||
|
#### Use a domain layer.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> Use in apps with complex logic requirements.
|
||||||
|
|
||||||
|
A domain layer is only needed if your application has exceeding complex logic that crowds your ViewModels,
|
||||||
|
or if you find yourself repeating logic in ViewModels.
|
||||||
|
In very large apps, use-cases are useful, but in most apps they add unnecessary overhead.
|
||||||
|
|
||||||
|
### Handling data
|
||||||
|
|
||||||
|
Handling data with care makes your code easier to understand, less error prone, and
|
||||||
|
prevents malformed or unexpected data from being created.
|
||||||
|
|
||||||
|
#### Use unidirectional data flow.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Data updates should only flow from the data layer to the UI layer.
|
||||||
|
Interactions in the UI layer are sent to the data layer where they're processed.
|
||||||
|
|
||||||
|
#### Use `Commands` to handle events from user interaction.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
Commands prevent rendering errors in your app, and standardize how the UI layer sends events to the data layer.
|
||||||
|
|
||||||
|
#### Use immutable data models.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Immutable data is crucial in ensuring that any necessary changes occur only in the proper place, usually the data or domain layer.
|
||||||
|
Because immutable objects can't be modified after creation, you must create a new instance to reflect changes.
|
||||||
|
This process prevents accidental updates in the UI layer and supports a clear, unidirectional data flow.
|
||||||
|
|
||||||
|
#### Use freezed or built_value to generate immutable data models.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
You can use packages to help generate useful functionality in your data models, `freezed` or `built_value`.
|
||||||
|
These can generate common model methods like JSON ser/des, deep equality checking and copy methods.
|
||||||
|
These code generation packages can add significant build time to your applications if you have a lot of models.
|
||||||
|
|
||||||
|
#### Create separate API models and domain models.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> Use in large apps.
|
||||||
|
|
||||||
|
Using separate models adds verbosity, but prevents complexity in ViewModels and use-cases.
|
||||||
|
|
||||||
|
### App structure
|
||||||
|
|
||||||
|
Well organized code benefits both the health of the app itself, and the team working on the code.
|
||||||
|
|
||||||
|
#### Use dependency injection.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Dependency injection prevents your app from having globally accessible objects, which makes your code less error prone.
|
||||||
|
We recommend you use the `provider` package to handle dependency injection.
|
||||||
|
|
||||||
|
#### Use `go_router` for navigation.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
Go_router is the preferred way to write 90% of Flutter applications.
|
||||||
|
There are some specific use-cases that go_router doesn't solve,
|
||||||
|
in which case you can use the `Flutter Navigator API` directly or try other packages found on `pub.dev`.
|
||||||
|
|
||||||
|
#### Use standardized naming conventions for classes, files and directories.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
We recommend naming classes for the architectural component they represent.
|
||||||
|
For example, you may have the following classes:
|
||||||
|
|
||||||
|
* HomeViewModel
|
||||||
|
* HomeScreen
|
||||||
|
* UserRepository
|
||||||
|
* ClientApiService
|
||||||
|
|
||||||
|
For clarity, we do not recommend using names that can be confused with objects from the Flutter SDK.
|
||||||
|
For example, you should put your shared widgets in a directory called `ui/core/`,
|
||||||
|
rather than a directory called `/widgets`.
|
||||||
|
|
||||||
|
#### Use abstract repository classes
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Repository classes are the sources of truth for all data in your app,
|
||||||
|
and facilitate communication with external APIs.
|
||||||
|
Creating abstract repository classes allows you to create different implementations,
|
||||||
|
which can be used for different app environments, such as "development" and "staging".
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Good testing practices makes your app flexible.
|
||||||
|
It also makes it straightforward and low risk to add new logic and new UI.
|
||||||
|
|
||||||
|
#### Test architectural components separately, and together.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
* Write unit tests for every service, repository and ViewModel class. These tests should test the logic of every method individually.
|
||||||
|
* Write widget tests for views. Testing routing and dependency injection are particularly important.
|
||||||
|
|
||||||
|
#### Make fakes for testing (and write code that takes advantage of fakes.)
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Fakes aren't concerned with the inner workings of any given method as much
|
||||||
|
as they're concerned with inputs and outputs. If you have this in mind while writing application code,
|
||||||
|
you're forced to write modular, lightweight functions and classes with well defined inputs and outputs.
|
||||||
|
|
||||||
|
### Deploying Firebase
|
||||||
|
You should not use Firebase CLI. You have to ask the user for deploying or modifying something.
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 24/02/2026
|
||||||
|
Ajout de la gestion des maintenance et synthèse vocale
|
||||||
|
|
||||||
|
## 18/02/2026
|
||||||
|
Ajout de la fonctionnalité d'exportation des données au format CSV. Correction de bugs mineurs et amélioration des performances.
|
||||||
|
|
||||||
## 🚀 Nouveautés de la mise à jour
|
## 🚀 Nouveautés de la mise à jour
|
||||||
|
|
||||||
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
|
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
|
||||||
|
|||||||
BIN
em2rp/assets/sounds/error.mp3
Normal file
BIN
em2rp/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
em2rp/assets/sounds/ok.mp3
Normal file
BIN
em2rp/assets/sounds/ok.mp3
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '1.1.4';
|
static const String version = '1.1.14';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:em2rp/views/calendar_page.dart';
|
|||||||
import 'package:em2rp/views/login_page.dart';
|
import 'package:em2rp/views/login_page.dart';
|
||||||
import 'package:em2rp/views/equipment_management_page.dart';
|
import 'package:em2rp/views/equipment_management_page.dart';
|
||||||
import 'package:em2rp/views/container_management_page.dart';
|
import 'package:em2rp/views/container_management_page.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_management_page.dart';
|
||||||
import 'package:em2rp/views/container_form_page.dart';
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
import 'package:em2rp/views/container_detail_page.dart';
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
@@ -159,6 +160,9 @@ class MyApp extends StatelessWidget {
|
|||||||
'/container_management': (context) => const AuthGuard(
|
'/container_management': (context) => const AuthGuard(
|
||||||
requiredPermission: "view_equipment",
|
requiredPermission: "view_equipment",
|
||||||
child: ContainerManagementPage()),
|
child: ContainerManagementPage()),
|
||||||
|
'/maintenance_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "manage_maintenances",
|
||||||
|
child: MaintenanceManagementPage()),
|
||||||
'/container_form': (context) {
|
'/container_form': (context) {
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
return AuthGuard(
|
return AuthGuard(
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
import 'package:em2rp/services/maintenance_service.dart';
|
import 'package:em2rp/services/maintenance_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class MaintenanceProvider extends ChangeNotifier {
|
class MaintenanceProvider extends ChangeNotifier {
|
||||||
final MaintenanceService _service = MaintenanceService();
|
final MaintenanceService _service = MaintenanceService();
|
||||||
|
|
||||||
List<MaintenanceModel> _maintenances = [];
|
List<MaintenanceModel> _maintenances = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<MaintenanceModel> get maintenances => _maintenances;
|
List<MaintenanceModel> get maintenances => _maintenances;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Charger toutes les maintenances
|
||||||
|
Future<void> loadMaintenances({String? equipmentId}) async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (equipmentId != null) {
|
||||||
|
_maintenances = await _service.getMaintenancesByEquipment(equipmentId);
|
||||||
|
} else {
|
||||||
|
_maintenances = await _service.getAllMaintenances();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[MaintenanceProvider] Error loading maintenances', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupérer les maintenances pour un équipement spécifique
|
/// Récupérer les maintenances pour un équipement spécifique
|
||||||
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||||
@@ -24,9 +49,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||||
try {
|
try {
|
||||||
await _service.createMaintenance(maintenance);
|
await _service.createMaintenance(maintenance);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après création
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error creating maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,9 +60,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
await _service.updateMaintenance(id, data);
|
await _service.updateMaintenance(id, data);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après mise à jour
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error updating maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +71,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
Future<void> deleteMaintenance(String id) async {
|
Future<void> deleteMaintenance(String id) async {
|
||||||
try {
|
try {
|
||||||
await _service.deleteMaintenance(id);
|
await _service.deleteMaintenance(id);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après suppression
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error deleting maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +83,7 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
return await _service.getMaintenanceById(id);
|
return await _service.getMaintenanceById(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error getting maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,9 +96,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après complétion
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error completing maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error completing maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +108,13 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _service.checkUpcomingMaintenances();
|
await _service.checkUpcomingMaintenances();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking upcoming maintenances: $e');
|
DebugLog.error('[MaintenanceProvider] Error checking upcoming maintenances', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les maintenances en retard
|
/// Récupérer les maintenances en retard
|
||||||
List<MaintenanceModel> get overdueMaintances {
|
List<MaintenanceModel> get overdueMaintenances {
|
||||||
return _maintenances.where((m) => m.isOverdue).toList();
|
return _maintenances.where((m) => m.isOverdue).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +127,12 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
List<MaintenanceModel> get upcomingMaintenances {
|
List<MaintenanceModel> get upcomingMaintenances {
|
||||||
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Obtenir les maintenances pour un équipement spécifique (depuis le cache local)
|
||||||
|
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
||||||
|
return _maintenances.where((m) =>
|
||||||
|
m.equipmentIds.contains(equipmentId)
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,144 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
/// Service pour émettre des feedbacks sonores lors des interactions
|
/// Service pour émettre des feedbacks sonores lors des interactions (Web)
|
||||||
class AudioFeedbackService {
|
class AudioFeedbackService {
|
||||||
/// Jouer un son de succès (clic système)
|
static bool _isInitialized = false;
|
||||||
|
static bool _audioUnlocked = false;
|
||||||
|
|
||||||
|
/// Initialiser le service
|
||||||
|
static Future<void> _initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Initializing audio service for Web...');
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error initializing audio', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Débloquer l'audio (à appeler lors de la première interaction utilisateur)
|
||||||
|
static Future<void> unlockAudio() async {
|
||||||
|
if (_audioUnlocked) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio already unlocked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!_isInitialized) await _initialize();
|
||||||
|
|
||||||
|
DebugLog.info('[AudioFeedbackService] Attempting to unlock audio...');
|
||||||
|
|
||||||
|
// Créer un audio temporaire et le jouer avec volume 0
|
||||||
|
final tempAudio = web.HTMLAudioElement();
|
||||||
|
tempAudio.src = 'assets/assets/sounds/ok.mp3';
|
||||||
|
tempAudio.volume = 0.01; // Volume très faible mais pas 0
|
||||||
|
tempAudio.preload = 'auto';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tempAudio.play().toDart;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
tempAudio.pause();
|
||||||
|
_audioUnlocked = true;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Audio unlocked successfully');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.warning('[AudioFeedbackService] ⚠ Could not unlock audio: $e');
|
||||||
|
DebugLog.warning('[AudioFeedbackService] User interaction may be required');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error unlocking audio', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Créer et jouer un son
|
||||||
|
static Future<void> _playSound(String assetPath, double volume) async {
|
||||||
|
try {
|
||||||
|
if (!_isInitialized) await _initialize();
|
||||||
|
|
||||||
|
DebugLog.info('[AudioFeedbackService] Attempting to play: $assetPath (volume: $volume)');
|
||||||
|
|
||||||
|
// Créer un nouvel élément audio à chaque fois
|
||||||
|
final audio = web.HTMLAudioElement();
|
||||||
|
audio.src = assetPath;
|
||||||
|
audio.volume = volume;
|
||||||
|
audio.preload = 'auto';
|
||||||
|
|
||||||
|
// Ajouter des événements pour debug
|
||||||
|
audio.onloadeddata = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio data loaded: $assetPath');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onerror = ((web.Event event) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Audio error for $assetPath: ${audio.error}');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onplay = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio started playing');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onended = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio finished playing');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Essayer de jouer
|
||||||
|
await audio.play().toDart;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Sound played successfully');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Play failed: $e');
|
||||||
|
|
||||||
|
// Si c'est un problème d'autoplay, essayer de débloquer
|
||||||
|
if (!_audioUnlocked) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Trying to unlock audio on error...');
|
||||||
|
_audioUnlocked = false; // Forcer le déblocage
|
||||||
|
await unlockAudio();
|
||||||
|
|
||||||
|
// Réessayer une fois après déblocage
|
||||||
|
try {
|
||||||
|
final retryAudio = web.HTMLAudioElement();
|
||||||
|
retryAudio.src = assetPath;
|
||||||
|
retryAudio.volume = volume;
|
||||||
|
await retryAudio.play().toDart;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Sound played on retry');
|
||||||
|
} catch (retryError) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Retry also failed: $retryError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error in _playSound', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un son de succès
|
||||||
static Future<void> playSuccessBeep() async {
|
static Future<void> playSuccessBeep() async {
|
||||||
try {
|
await _playSound('assets/assets/sounds/ok.mp3', 1.0);
|
||||||
await SystemSound.play(SystemSoundType.click);
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[AudioFeedbackService] Error playing success beep', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jouer un son d'erreur (alerte système)
|
/// Jouer un son d'erreur
|
||||||
static Future<void> playErrorBeep() async {
|
static Future<void> playErrorBeep() async {
|
||||||
try {
|
await _playSound('assets/assets/sounds/error.mp3', 0.8);
|
||||||
// Note: SystemSoundType.alert n'existe pas sur toutes les plateformes
|
|
||||||
// On utilise click pour l'instant, peut être amélioré avec audioplayers
|
|
||||||
await SystemSound.play(SystemSoundType.click);
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
await SystemSound.play(SystemSoundType.click);
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[AudioFeedbackService] Error playing error beep', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jouer une vibration haptique (si disponible)
|
/// Jouer un feedback complet (son uniquement, sans vibration)
|
||||||
static Future<void> playHapticFeedback() async {
|
|
||||||
try {
|
|
||||||
await HapticFeedback.mediumImpact();
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[AudioFeedbackService] Error playing haptic feedback', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jouer un feedback complet (son + vibration)
|
|
||||||
static Future<void> playFullFeedback({bool isSuccess = true}) async {
|
static Future<void> playFullFeedback({bool isSuccess = true}) async {
|
||||||
await playHapticFeedback();
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
await playSuccessBeep();
|
await playSuccessBeep();
|
||||||
} else {
|
} else {
|
||||||
await playErrorBeep();
|
await playErrorBeep();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
_isInitialized = false;
|
||||||
|
_audioUnlocked = false;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error disposing', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ class PDFService {
|
|||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
|
|
||||||
pdf.addPage(
|
pdf.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
pageFormat: PdfPageFormat.a4,
|
pageFormat: PdfPageFormat.a4,
|
||||||
margin: pw.EdgeInsets.zero,
|
margin: pw.EdgeInsets.zero,
|
||||||
@@ -299,10 +299,20 @@ class PDFService {
|
|||||||
runSpacing: 0, // 0 espace entre les lignes
|
runSpacing: 0, // 0 espace entre les lignes
|
||||||
children: List.generate(pageItems.length, (i) {
|
children: List.generate(pageItems.length, (i) {
|
||||||
final item = pageItems[i];
|
final item = pageItems[i];
|
||||||
|
// Déterminer si c'est la première colonne (indices pairs)
|
||||||
|
final bool isFirstColumn = (i % 2) == 0;
|
||||||
|
// Décalage de 2mm pour la première colonne
|
||||||
|
final double leftPadding = isFirstColumn ? 8.0 : 6.0; // 6 + 2mm
|
||||||
|
|
||||||
return pw.Container(
|
return pw.Container(
|
||||||
width: labelWidth,
|
width: labelWidth,
|
||||||
height: labelHeight,
|
height: labelHeight,
|
||||||
padding: const pw.EdgeInsets.all(6),
|
padding: pw.EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
right: 6,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
// Suppression de la décoration (bordure)
|
// Suppression de la décoration (bordure)
|
||||||
child: pw.Row(
|
child: pw.Row(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||||
|
|||||||
244
em2rp/lib/services/text_to_speech_service.dart
Normal file
244
em2rp/lib/services/text_to_speech_service.dart
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service de synthèse vocale pour lire des textes à haute voix (Web)
|
||||||
|
class TextToSpeechService {
|
||||||
|
static bool _isInitialized = false;
|
||||||
|
static bool _voicesLoaded = false;
|
||||||
|
static List<web.SpeechSynthesisVoice> _cachedVoices = [];
|
||||||
|
|
||||||
|
/// Initialiser le service TTS
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
final synthesis = web.window.speechSynthesis;
|
||||||
|
|
||||||
|
// Essayer de charger les voix immédiatement
|
||||||
|
_cachedVoices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
if (_cachedVoices.isNotEmpty) {
|
||||||
|
_voicesLoaded = true;
|
||||||
|
DebugLog.info('[TextToSpeechService] Service initialized with ${_cachedVoices.length} voices');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sur certains navigateurs (Firefox notamment), les voix se chargent de manière asynchrone
|
||||||
|
DebugLog.info('[TextToSpeechService] Waiting for voices to load asynchronously...');
|
||||||
|
|
||||||
|
// Attendre l'événement voiceschanged (si supporté)
|
||||||
|
final voicesLoaded = await _waitForVoices(synthesis);
|
||||||
|
|
||||||
|
if (voicesLoaded) {
|
||||||
|
_cachedVoices = synthesis.getVoices().toDart;
|
||||||
|
_voicesLoaded = true;
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Voices loaded asynchronously: ${_cachedVoices.length}');
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ No voices found after initialization');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attendre le chargement des voix (avec timeout)
|
||||||
|
static Future<bool> _waitForVoices(web.SpeechSynthesis synthesis) async {
|
||||||
|
// Essayer plusieurs fois avec des délais croissants
|
||||||
|
for (int attempt = 0; attempt < 5; attempt++) {
|
||||||
|
await Future.delayed(Duration(milliseconds: 100 * (attempt + 1)));
|
||||||
|
|
||||||
|
final voices = synthesis.getVoices().toDart;
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Attempt ${attempt + 1}/5: No voices yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un texte à haute voix
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final synthesis = web.window.speechSynthesis;
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Speaking requested: "$text"');
|
||||||
|
|
||||||
|
// Arrêter toute lecture en cours
|
||||||
|
synthesis.cancel();
|
||||||
|
|
||||||
|
// Attendre un peu pour que le cancel soit effectif
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// Créer une nouvelle utterance
|
||||||
|
final utterance = web.SpeechSynthesisUtterance(text);
|
||||||
|
utterance.lang = 'fr-FR';
|
||||||
|
utterance.rate = 0.7;
|
||||||
|
utterance.pitch = 0.7;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
// Récupérer les voix (depuis le cache ou re-charger)
|
||||||
|
var voices = _cachedVoices;
|
||||||
|
|
||||||
|
// Si le cache est vide, essayer de recharger
|
||||||
|
if (voices.isEmpty) {
|
||||||
|
DebugLog.info('[TextToSpeechService] Cache empty, reloading voices...');
|
||||||
|
voices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
// Sur Firefox/Linux, les voix peuvent ne pas être disponibles immédiatement
|
||||||
|
if (voices.isEmpty && !_voicesLoaded) {
|
||||||
|
DebugLog.info('[TextToSpeechService] Waiting for voices with multiple attempts...');
|
||||||
|
|
||||||
|
// Essayer plusieurs fois avec des délais
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
await Future.delayed(Duration(milliseconds: 100 * (i + 1)));
|
||||||
|
voices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Voices loaded on attempt ${i + 1}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le cache
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
_cachedVoices = voices;
|
||||||
|
_voicesLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Available voices: ${voices.length}');
|
||||||
|
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
web.SpeechSynthesisVoice? selectedVoice;
|
||||||
|
|
||||||
|
// Lister TOUTES les voix françaises pour debug
|
||||||
|
final frenchVoices = <web.SpeechSynthesisVoice>[];
|
||||||
|
for (final voice in voices) {
|
||||||
|
final lang = voice.lang.toLowerCase();
|
||||||
|
if (lang.startsWith('fr')) {
|
||||||
|
frenchVoices.add(voice);
|
||||||
|
DebugLog.info('[TextToSpeechService] French: ${voice.name} (${voice.lang}) ${voice.localService ? 'LOCAL' : 'REMOTE'}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frenchVoices.isEmpty) {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ NO French voices found!');
|
||||||
|
DebugLog.info('[TextToSpeechService] Available languages:');
|
||||||
|
for (final voice in voices.take(5)) {
|
||||||
|
DebugLog.info('[TextToSpeechService] - ${voice.name} (${voice.lang})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stratégie de sélection: préférer les voix LOCALES (plus fiables sur Linux)
|
||||||
|
for (final voice in frenchVoices) {
|
||||||
|
if (voice.localService) {
|
||||||
|
selectedVoice = voice;
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Selected LOCAL French voice: ${voice.name}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas de voix locale, chercher une voix masculine
|
||||||
|
if (selectedVoice == null) {
|
||||||
|
for (final voice in frenchVoices) {
|
||||||
|
final name = voice.name.toLowerCase();
|
||||||
|
if (name.contains('male') ||
|
||||||
|
name.contains('homme') ||
|
||||||
|
name.contains('thomas') ||
|
||||||
|
name.contains('paul') ||
|
||||||
|
name.contains('bernard')) {
|
||||||
|
selectedVoice = voice;
|
||||||
|
DebugLog.info('[TextToSpeechService] Selected male voice: ${voice.name}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: première voix française
|
||||||
|
selectedVoice ??= frenchVoices.isNotEmpty ? frenchVoices.first : null;
|
||||||
|
|
||||||
|
if (selectedVoice != null) {
|
||||||
|
utterance.voice = selectedVoice;
|
||||||
|
utterance.lang = selectedVoice.lang; // Utiliser la langue de la voix
|
||||||
|
DebugLog.info('[TextToSpeechService] Final voice: ${selectedVoice.name} (${selectedVoice.lang})');
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] No French voice, using default with lang=fr-FR');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ NO voices available at all!');
|
||||||
|
DebugLog.warning('[TextToSpeechService] On Linux: install speech-dispatcher and espeak-ng');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter des événements pour le debug
|
||||||
|
utterance.onstart = (web.SpeechSynthesisEvent event) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Speech started');
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
utterance.onend = (web.SpeechSynthesisEvent event) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Speech ended');
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
utterance.onerror = (web.SpeechSynthesisErrorEvent event) {
|
||||||
|
DebugLog.error('[TextToSpeechService] ✗ Speech error: ${event.error}');
|
||||||
|
|
||||||
|
// Messages spécifiques pour aider au diagnostic
|
||||||
|
if (event.error == 'synthesis-failed') {
|
||||||
|
DebugLog.error('[TextToSpeechService] ⚠ SYNTHESIS FAILED - Common on Linux');
|
||||||
|
DebugLog.error('[TextToSpeechService] Possible causes:');
|
||||||
|
DebugLog.error('[TextToSpeechService] 1. speech-dispatcher not installed/running');
|
||||||
|
DebugLog.error('[TextToSpeechService] 2. espeak or espeak-ng not installed');
|
||||||
|
DebugLog.error('[TextToSpeechService] 3. No TTS engine configured');
|
||||||
|
DebugLog.error('[TextToSpeechService] Fix: sudo apt-get install speech-dispatcher espeak-ng');
|
||||||
|
DebugLog.error('[TextToSpeechService] Then restart browser');
|
||||||
|
} else if (event.error == 'network') {
|
||||||
|
DebugLog.error('[TextToSpeechService] Network error - online voice unavailable');
|
||||||
|
} else if (event.error == 'audio-busy') {
|
||||||
|
DebugLog.error('[TextToSpeechService] Audio system is busy');
|
||||||
|
}
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
// Lire le texte
|
||||||
|
synthesis.speak(utterance);
|
||||||
|
DebugLog.info('[TextToSpeechService] Speech command sent');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter la lecture en cours
|
||||||
|
static Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
web.window.speechSynthesis.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si le service est en train de lire
|
||||||
|
static Future<bool> isSpeaking() async {
|
||||||
|
try {
|
||||||
|
return web.window.speechSynthesis.speaking;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
web.window.speechSynthesis.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,10 @@ enum AppPermission {
|
|||||||
|
|
||||||
// ============= MAINTENANCE =============
|
// ============= MAINTENANCE =============
|
||||||
/// Permet de voir les maintenances
|
/// Permet de voir les maintenances
|
||||||
viewMaintenance('view_maintenance'),
|
viewMaintenance('view_maintenances'),
|
||||||
|
|
||||||
/// Permet de créer, modifier et supprimer des maintenances
|
/// Permet de créer, modifier et supprimer des maintenances
|
||||||
manageMaintenance('manage_maintenance'),
|
manageMaintenance('manage_maintenances'),
|
||||||
|
|
||||||
// ============= UTILISATEURS =============
|
// ============= UTILISATEURS =============
|
||||||
/// Permet de voir la liste de tous les utilisateurs
|
/// Permet de voir la liste de tous les utilisateurs
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'package:em2rp/views/widgets/equipment/equipment_current_events_section.d
|
|||||||
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_form_page.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
maintenances: _maintenances,
|
maintenances: _maintenances,
|
||||||
isLoading: _isLoadingMaintenances,
|
isLoading: _isLoadingMaintenances,
|
||||||
hasManagePermission: hasManagePermission,
|
hasManagePermission: hasManagePermission,
|
||||||
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -175,6 +177,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
maintenances: _maintenances,
|
maintenances: _maintenances,
|
||||||
isLoading: _isLoadingMaintenances,
|
isLoading: _isLoadingMaintenances,
|
||||||
hasManagePermission: hasManagePermission,
|
hasManagePermission: hasManagePermission,
|
||||||
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
EquipmentDatesSection(equipment: widget.equipment),
|
EquipmentDatesSection(equipment: widget.equipment),
|
||||||
@@ -378,6 +381,36 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Planifier une nouvelle maintenance pour cet équipment
|
||||||
|
Future<void> _planMaintenance() async {
|
||||||
|
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final hasPermission = userProvider.hasPermission('manage_maintenances');
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MaintenanceFormPage(
|
||||||
|
initialEquipmentIds: [widget.equipment.id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recharger les maintenances si une maintenance a été créée
|
||||||
|
if (result == true && mounted) {
|
||||||
|
await _loadMaintenances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _editEquipment() {
|
void _editEquipment() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/services/qr_code_processing_service.dart';
|
import 'package:em2rp/services/qr_code_processing_service.dart';
|
||||||
import 'package:em2rp/services/audio_feedback_service.dart';
|
import 'package:em2rp/services/audio_feedback_service.dart';
|
||||||
|
import 'package:em2rp/services/text_to_speech_service.dart';
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
import 'package:em2rp/services/equipment_service.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||||
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'package:em2rp/views/widgets/event_preparation/add_equipment_to_event_dia
|
|||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/audio_diagnostic_button.dart';
|
||||||
|
|
||||||
/// Type d'étape de préparation
|
/// Type d'étape de préparation
|
||||||
enum PreparationStep {
|
enum PreparationStep {
|
||||||
@@ -72,6 +74,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
final TextEditingController _manualCodeController = TextEditingController();
|
final TextEditingController _manualCodeController = TextEditingController();
|
||||||
final FocusNode _manualCodeFocusNode = FocusNode();
|
final FocusNode _manualCodeFocusNode = FocusNode();
|
||||||
|
|
||||||
|
// 🆕 File d'attente pour traiter les scans séquentiellement
|
||||||
|
final List<String> _scanQueue = [];
|
||||||
|
bool _isProcessingQueue = false;
|
||||||
|
|
||||||
// Détermine l'étape actuelle selon le statut de l'événement
|
// Détermine l'étape actuelle selon le statut de l'événement
|
||||||
PreparationStep get _currentStep {
|
PreparationStep get _currentStep {
|
||||||
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
||||||
@@ -115,6 +121,12 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialiser le service de synthèse vocale
|
||||||
|
TextToSpeechService.initialize();
|
||||||
|
|
||||||
|
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
||||||
|
AudioFeedbackService.unlockAudio();
|
||||||
|
|
||||||
// Vérification de sécurité et chargement après le premier frame
|
// Vérification de sécurité et chargement après le premier frame
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_isCurrentStepCompleted()) {
|
if (_isCurrentStepCompleted()) {
|
||||||
@@ -152,6 +164,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
_manualCodeController.dispose();
|
_manualCodeController.dispose();
|
||||||
_manualCodeFocusNode.dispose();
|
_manualCodeFocusNode.dispose();
|
||||||
|
TextToSpeechService.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +664,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
// Feedback visuel
|
// Feedback visuel
|
||||||
_showSuccessFeedback(result.message ?? 'Code traité avec succès');
|
_showSuccessFeedback(result.message ?? 'Code traité avec succès');
|
||||||
|
|
||||||
|
// 🗣️ Annoncer le prochain item après un court délai
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
await _announceNextItem();
|
||||||
|
|
||||||
} else if (result.codeNotFoundInEvent) {
|
} else if (result.codeNotFoundInEvent) {
|
||||||
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
||||||
|
// 🔊 Son d'erreur
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
||||||
|
|
||||||
await _handleCodeNotFoundInEvent(code.trim());
|
await _handleCodeNotFoundInEvent(code.trim());
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -815,13 +835,33 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
Future<void> _handleManualCodeEntry(String code) async {
|
Future<void> _handleManualCodeEntry(String code) async {
|
||||||
if (code.trim().isEmpty) return;
|
if (code.trim().isEmpty) return;
|
||||||
|
|
||||||
await _handleScannedCode(code.trim());
|
// Ajouter le code à la file d'attente
|
||||||
|
_scanQueue.add(code.trim());
|
||||||
|
|
||||||
// Effacer le champ après traitement
|
// Effacer le champ immédiatement pour permettre le prochain scan
|
||||||
_manualCodeController.clear();
|
_manualCodeController.clear();
|
||||||
|
|
||||||
// Maintenir le focus sur le champ pour permettre une saisie continue
|
// Maintenir le focus sur le champ pour permettre une saisie continue
|
||||||
_manualCodeFocusNode.requestFocus();
|
_manualCodeFocusNode.requestFocus();
|
||||||
|
|
||||||
|
// Démarrer le traitement de la file si pas déjà en cours
|
||||||
|
if (!_isProcessingQueue) {
|
||||||
|
_processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traite la file d'attente des scans un par un
|
||||||
|
Future<void> _processQueue() async {
|
||||||
|
if (_isProcessingQueue) return;
|
||||||
|
|
||||||
|
_isProcessingQueue = true;
|
||||||
|
|
||||||
|
while (_scanQueue.isNotEmpty) {
|
||||||
|
final code = _scanQueue.removeAt(0);
|
||||||
|
await _handleScannedCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isProcessingQueue = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtenir les quantités actuelles selon l'étape
|
/// Obtenir les quantités actuelles selon l'étape
|
||||||
@@ -1116,6 +1156,67 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trouve le prochain item non validé à scanner
|
||||||
|
String? _findNextItemToScan() {
|
||||||
|
// Parcourir les items dans l'ordre et trouver le premier non validé
|
||||||
|
|
||||||
|
// 1. Parcourir les containers et leurs équipements
|
||||||
|
for (final containerId in _currentEvent.assignedContainers) {
|
||||||
|
final container = _containerCache[containerId];
|
||||||
|
if (container == null) continue;
|
||||||
|
|
||||||
|
// Vérifier si le container a des équipements non validés
|
||||||
|
bool hasUnvalidatedChild = false;
|
||||||
|
for (final equipmentId in container.equipmentIds) {
|
||||||
|
|
||||||
|
if (_currentEvent.assignedEquipment.any((e) => e.equipmentId == equipmentId)) {
|
||||||
|
final isValidated = _localValidationState[equipmentId] ?? false;
|
||||||
|
if (!isValidated) {
|
||||||
|
hasUnvalidatedChild = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si le container a des items non validés, retourner le nom du container
|
||||||
|
if (hasUnvalidatedChild) {
|
||||||
|
return container.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parcourir les équipements standalone (pas dans un container)
|
||||||
|
final Set<String> equipmentIdsInContainers = {};
|
||||||
|
for (final containerId in _currentEvent.assignedContainers) {
|
||||||
|
final container = _containerCache[containerId];
|
||||||
|
if (container != null) {
|
||||||
|
equipmentIdsInContainers.addAll(container.equipmentIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final eventEquipment in _currentEvent.assignedEquipment) {
|
||||||
|
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isValidated = _localValidationState[eventEquipment.equipmentId] ?? false;
|
||||||
|
if (!isValidated) {
|
||||||
|
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
||||||
|
return equipment?.name ?? 'Équipement ${eventEquipment.equipmentId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Tout est validé
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Annonce vocalement le prochain item à scanner
|
||||||
|
Future<void> _announceNextItem() async {
|
||||||
|
final nextItem = _findNextItemToScan();
|
||||||
|
if (nextItem != null) {
|
||||||
|
await TextToSpeechService.speak('Prochain item: $nextItem');
|
||||||
|
} else {
|
||||||
|
await TextToSpeechService.speak('Tous les items sont validés');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -1126,6 +1227,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(stepTitle),
|
title: Text(stepTitle),
|
||||||
backgroundColor: AppColors.bleuFonce,
|
backgroundColor: AppColors.bleuFonce,
|
||||||
|
actions: const [
|
||||||
|
AudioDiagnosticButton(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -1139,31 +1243,42 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// Nom de l'événement et barre de progression sur la même ligne
|
||||||
_currentEvent.name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LinearProgressIndicator(
|
child: Text(
|
||||||
value: _getProgress(),
|
_currentEvent.name,
|
||||||
backgroundColor: Colors.grey.shade300,
|
style: const TextStyle(
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
fontSize: 18,
|
||||||
allValidated ? Colors.green : AppColors.bleuFonce,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Expanded(
|
||||||
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
child: Row(
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
Expanded(
|
||||||
fontSize: 16,
|
child: LinearProgressIndicator(
|
||||||
|
value: _getProgress(),
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
allValidated ? Colors.green : AppColors.bleuFonce,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1193,48 +1308,56 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 🆕 Champ de saisie manuelle de code
|
// Champ de saisie manuelle avec bouton scanner
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: _manualCodeController,
|
|
||||||
focusNode: _manualCodeFocusNode,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Saisie manuelle d\'un code',
|
|
||||||
hintText: 'Entrez un ID d\'équipement ou container',
|
|
||||||
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
|
|
||||||
suffixIcon: _manualCodeController.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
_manualCodeController.clear();
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
),
|
|
||||||
onSubmitted: _handleManualCodeEntry,
|
|
||||||
onChanged: (value) => setState(() {}),
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 🆕 Bouton Scanner QR Code
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ElevatedButton.icon(
|
Row(
|
||||||
onPressed: _openQRScanner,
|
children: [
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
Expanded(
|
||||||
label: const Text('Scanner QR Code'),
|
child: TextField(
|
||||||
style: ElevatedButton.styleFrom(
|
controller: _manualCodeController,
|
||||||
backgroundColor: Colors.blue[700],
|
focusNode: _manualCodeFocusNode,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
decoration: InputDecoration(
|
||||||
),
|
labelText: 'Saisie manuelle d\'un code',
|
||||||
|
hintText: 'ID d\'équipement ou container',
|
||||||
|
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
|
||||||
|
suffixIcon: _manualCodeController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_manualCodeController.clear();
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
),
|
||||||
|
onSubmitted: _handleManualCodeEntry,
|
||||||
|
onChanged: (value) => setState(() {}),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// IconButton pour scanner QR Code
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _openQRScanner,
|
||||||
|
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
|
||||||
|
iconSize: 28,
|
||||||
|
tooltip: 'Scanner QR Code',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -1255,9 +1378,44 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.all(16),
|
builder: (context, constraints) {
|
||||||
children: _buildChecklistItems(),
|
// Afficher 2 colonnes si la largeur le permet (> 600px)
|
||||||
|
final useColumns = constraints.maxWidth > 600;
|
||||||
|
final items = _buildChecklistItems();
|
||||||
|
|
||||||
|
if (useColumns && items.length > 1) {
|
||||||
|
// Diviser en 2 colonnes
|
||||||
|
final mid = (items.length / 2).ceil();
|
||||||
|
final leftItems = items.sublist(0, mid);
|
||||||
|
final rightItems = items.sublist(mid);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: leftItems,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: rightItems,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Une seule colonne
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
619
em2rp/lib/views/maintenance_form_page.dart
Normal file
619
em2rp/lib/views/maintenance_form_page.dart
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Page de formulaire pour créer ou modifier une maintenance
|
||||||
|
class MaintenanceFormPage extends StatefulWidget {
|
||||||
|
final MaintenanceModel? maintenance;
|
||||||
|
final List<String>? initialEquipmentIds;
|
||||||
|
|
||||||
|
const MaintenanceFormPage({
|
||||||
|
super.key,
|
||||||
|
this.maintenance,
|
||||||
|
this.initialEquipmentIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MaintenanceFormPage> createState() => _MaintenanceFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MaintenanceFormPageState extends State<MaintenanceFormPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _costController = TextEditingController();
|
||||||
|
final _notesController = TextEditingController();
|
||||||
|
|
||||||
|
MaintenanceType _selectedType = MaintenanceType.preventive;
|
||||||
|
DateTime _scheduledDate = DateTime.now();
|
||||||
|
final List<String> _selectedEquipmentIds = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
bool get _isEditing => widget.maintenance != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
if (_isEditing) {
|
||||||
|
_nameController.text = widget.maintenance!.name;
|
||||||
|
_descriptionController.text = widget.maintenance!.description;
|
||||||
|
_selectedType = widget.maintenance!.type;
|
||||||
|
_scheduledDate = widget.maintenance!.scheduledDate;
|
||||||
|
_selectedEquipmentIds.addAll(widget.maintenance!.equipmentIds);
|
||||||
|
|
||||||
|
if (widget.maintenance!.cost != null) {
|
||||||
|
_costController.text = widget.maintenance!.cost!.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
if (widget.maintenance!.notes != null) {
|
||||||
|
_notesController.text = widget.maintenance!.notes!;
|
||||||
|
}
|
||||||
|
} else if (widget.initialEquipmentIds != null) {
|
||||||
|
// Pré-remplir avec les équipements fournis
|
||||||
|
_selectedEquipmentIds.addAll(widget.initialEquipmentIds!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les équipements
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<EquipmentProvider>().ensureLoaded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_costController.dispose();
|
||||||
|
_notesController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_isEditing ? 'Modifier la maintenance' : 'Nouvelle maintenance'),
|
||||||
|
backgroundColor: AppColors.bleuFonce,
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Nom
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom de la maintenance *',
|
||||||
|
hintText: 'Ex: Révision annuelle',
|
||||||
|
prefixIcon: Icon(Icons.title),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Le nom est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Type
|
||||||
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
|
initialValue: _selectedType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Type de maintenance *',
|
||||||
|
prefixIcon: Icon(Icons.category),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: MaintenanceType.values.map((type) {
|
||||||
|
final info = _getMaintenanceTypeInfo(type);
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: type,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(info.$2, size: 20, color: info.$3),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(info.$1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Date planifiée
|
||||||
|
InkWell(
|
||||||
|
onTap: _selectDate,
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date planifiée *',
|
||||||
|
prefixIcon: Icon(Icons.event),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(DateFormat('dd/MM/yyyy').format(_scheduledDate)),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Équipements
|
||||||
|
_buildEquipmentSelector(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Description
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description *',
|
||||||
|
hintText: 'Détails de l\'opération à effectuer',
|
||||||
|
prefixIcon: Icon(Icons.description),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: 4,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'La description est requise';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Coût estimé
|
||||||
|
TextFormField(
|
||||||
|
controller: _costController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Coût estimé (€)',
|
||||||
|
hintText: 'Ex: 150.00',
|
||||||
|
prefixIcon: Icon(Icons.euro),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (value) {
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
if (double.tryParse(value) == null) {
|
||||||
|
return 'Coût invalide';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
TextFormField(
|
||||||
|
controller: _notesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes',
|
||||||
|
hintText: 'Informations complémentaires',
|
||||||
|
prefixIcon: Icon(Icons.notes),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Bouton sauvegarder
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _saveMaintenance,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: Text(_isEditing ? 'Mettre à jour' : 'Créer la maintenance'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.bleuFonce,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEquipmentSelector() {
|
||||||
|
return Consumer<EquipmentProvider>(
|
||||||
|
builder: (context, equipmentProvider, _) {
|
||||||
|
// Filtrer uniquement les équipements
|
||||||
|
final availableEquipment = equipmentProvider.allEquipment
|
||||||
|
.cast<EquipmentModel>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Équipements concernés *',
|
||||||
|
prefixIcon: const Icon(Icons.inventory),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _selectedEquipmentIds.isEmpty ? 'Sélectionnez au moins un équipement' : null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_selectedEquipmentIds.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'Aucun équipement sélectionné',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _selectedEquipmentIds.map((id) {
|
||||||
|
final equipment = availableEquipment.firstWhere(
|
||||||
|
(eq) => eq.id == id,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: id,
|
||||||
|
name: 'Inconnu',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Chip(
|
||||||
|
label: Text(equipment.name),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 18),
|
||||||
|
onDeleted: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedEquipmentIds.remove(id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _showEquipmentPicker(availableEquipment),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter des équipements'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEquipmentPicker(List<EquipmentModel> availableEquipment) async {
|
||||||
|
final selectedIds = await showDialog<List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _EquipmentPickerDialog(
|
||||||
|
availableEquipment: availableEquipment,
|
||||||
|
initialSelectedIds: _selectedEquipmentIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedIds != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEquipmentIds.clear();
|
||||||
|
_selectedEquipmentIds.addAll(selectedIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate() async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _scheduledDate,
|
||||||
|
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
setState(() {
|
||||||
|
_scheduledDate = date;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMaintenance() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedEquipmentIds.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Veuillez sélectionner au moins un équipement'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final cost = _costController.text.trim().isNotEmpty
|
||||||
|
? double.tryParse(_costController.text.trim())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final notes = _notesController.text.trim().isNotEmpty
|
||||||
|
? _notesController.text.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (_isEditing) {
|
||||||
|
// Mise à jour
|
||||||
|
await context.read<MaintenanceProvider>().updateMaintenance(
|
||||||
|
widget.maintenance!.id,
|
||||||
|
{
|
||||||
|
'name': _nameController.text.trim(),
|
||||||
|
'description': _descriptionController.text.trim(),
|
||||||
|
'type': maintenanceTypeToString(_selectedType),
|
||||||
|
'scheduledDate': _scheduledDate,
|
||||||
|
'equipmentIds': _selectedEquipmentIds,
|
||||||
|
'cost': cost,
|
||||||
|
'notes': notes,
|
||||||
|
'updatedAt': DateTime.now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Maintenance mise à jour avec succès'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Création
|
||||||
|
final maintenance = MaintenanceModel(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
equipmentIds: _selectedEquipmentIds,
|
||||||
|
type: _selectedType,
|
||||||
|
scheduledDate: _scheduledDate,
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
cost: cost,
|
||||||
|
notes: notes,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.read<MaintenanceProvider>().createMaintenance(maintenance);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Maintenance créée avec succès'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(String, IconData, Color) _getMaintenanceTypeInfo(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.preventive:
|
||||||
|
return ('Préventive', Icons.schedule, Colors.blue);
|
||||||
|
case MaintenanceType.corrective:
|
||||||
|
return ('Corrective', Icons.build, Colors.orange);
|
||||||
|
case MaintenanceType.inspection:
|
||||||
|
return ('Inspection', Icons.search, Colors.purple);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog pour sélectionner plusieurs équipements
|
||||||
|
class _EquipmentPickerDialog extends StatefulWidget {
|
||||||
|
final List<EquipmentModel> availableEquipment;
|
||||||
|
final List<String> initialSelectedIds;
|
||||||
|
|
||||||
|
const _EquipmentPickerDialog({
|
||||||
|
required this.availableEquipment,
|
||||||
|
required this.initialSelectedIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EquipmentPickerDialog> createState() => _EquipmentPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EquipmentPickerDialogState extends State<_EquipmentPickerDialog> {
|
||||||
|
late List<String> _selectedIds;
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedIds = List.from(widget.initialSelectedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final filteredEquipment = widget.availableEquipment.where((eq) {
|
||||||
|
if (_searchQuery.isEmpty) return true;
|
||||||
|
return eq.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||||
|
eq.id.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Sélectionner des équipements'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Barre de recherche
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rechercher',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Compteur
|
||||||
|
Text(
|
||||||
|
'${_selectedIds.length} équipement(s) sélectionné(s)',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Liste des équipements
|
||||||
|
Expanded(
|
||||||
|
child: filteredEquipment.isEmpty
|
||||||
|
? const Center(child: Text('Aucun équipement trouvé'))
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: filteredEquipment.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final equipment = filteredEquipment[index];
|
||||||
|
final isSelected = _selectedIds.contains(equipment.id);
|
||||||
|
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected == true) {
|
||||||
|
_selectedIds.add(equipment.id);
|
||||||
|
} else {
|
||||||
|
_selectedIds.remove(equipment.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(equipment.name),
|
||||||
|
subtitle: Text(
|
||||||
|
'${equipment.id} • ${_getCategoryLabel(equipment.category)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
_getCategoryIcon(equipment.category),
|
||||||
|
color: AppColors.bleuFonce,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _selectedIds),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.bleuFonce),
|
||||||
|
child: const Text('Valider'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getCategoryLabel(EquipmentCategory category) {
|
||||||
|
switch (category) {
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return 'Son';
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return 'Lumière';
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return 'Vidéo';
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'Structure';
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return 'Effets';
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return 'Câblage';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'Consommable';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Backline';
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return 'Autre';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getCategoryIcon(EquipmentCategory category) {
|
||||||
|
switch (category) {
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return Icons.volume_up;
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return Icons.lightbulb;
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return Icons.videocam;
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return Icons.construction;
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return Icons.auto_awesome;
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return Icons.inventory_2;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.queue_music;
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return Icons.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
627
em2rp/lib/views/maintenance_management_page.dart
Normal file
627
em2rp/lib/views/maintenance_management_page.dart
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_form_page.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Page de gestion des maintenances
|
||||||
|
class MaintenanceManagementPage extends StatefulWidget {
|
||||||
|
const MaintenanceManagementPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MaintenanceManagementPage> createState() => _MaintenanceManagementPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
||||||
|
String _filterType = 'all'; // all, upcoming, overdue, completed
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadMaintenances();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMaintenances() async {
|
||||||
|
final maintenanceProvider = context.read<MaintenanceProvider>();
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
maintenanceProvider.loadMaintenances(),
|
||||||
|
equipmentProvider.ensureLoaded(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MaintenanceModel> _getFilteredMaintenances(List<MaintenanceModel> maintenances) {
|
||||||
|
switch (_filterType) {
|
||||||
|
case 'upcoming':
|
||||||
|
return maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
||||||
|
case 'overdue':
|
||||||
|
return maintenances.where((m) => m.isOverdue).toList();
|
||||||
|
case 'completed':
|
||||||
|
return maintenances.where((m) => m.isCompleted).toList();
|
||||||
|
default:
|
||||||
|
return maintenances;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PermissionGate(
|
||||||
|
requiredPermissions: const ['manage_maintenances'],
|
||||||
|
fallback: Scaffold(
|
||||||
|
appBar: const CustomAppBar(title: 'Accès refusé'),
|
||||||
|
drawer: const MainDrawer(currentPage: '/maintenance_management'),
|
||||||
|
body: const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des maintenances.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: const CustomAppBar(
|
||||||
|
title: 'Gestion des maintenances',
|
||||||
|
),
|
||||||
|
drawer: const MainDrawer(currentPage: '/maintenance_management'),
|
||||||
|
body: Consumer<MaintenanceProvider>(
|
||||||
|
builder: (context, maintenanceProvider, _) {
|
||||||
|
if (maintenanceProvider.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredMaintenances = _getFilteredMaintenances(
|
||||||
|
maintenanceProvider.maintenances,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Filtres
|
||||||
|
_buildFilterChips(),
|
||||||
|
|
||||||
|
// Statistiques
|
||||||
|
_buildStatsCards(maintenanceProvider),
|
||||||
|
|
||||||
|
// Liste des maintenances
|
||||||
|
Expanded(
|
||||||
|
child: filteredMaintenances.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildMaintenanceList(filteredMaintenances),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () => _navigateToForm(null),
|
||||||
|
backgroundColor: AppColors.bleuFonce,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Nouvelle maintenance'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChips() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildFilterChip('Toutes', 'all'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildFilterChip('À venir', 'upcoming'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildFilterChip('En retard', 'overdue'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildFilterChip('Complétées', 'completed'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChip(String label, String filterValue) {
|
||||||
|
final isSelected = _filterType == filterValue;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
_filterType = filterValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.bleuFonce.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: AppColors.bleuFonce,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatsCards(MaintenanceProvider provider) {
|
||||||
|
final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
|
||||||
|
final overdue = provider.maintenances.where((m) => m.isOverdue).length;
|
||||||
|
final completed = provider.maintenances.where((m) => m.isCompleted).length;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'À venir',
|
||||||
|
upcoming.toString(),
|
||||||
|
Icons.schedule,
|
||||||
|
Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'En retard',
|
||||||
|
overdue.toString(),
|
||||||
|
Icons.warning,
|
||||||
|
Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'Complétées',
|
||||||
|
completed.toString(),
|
||||||
|
Icons.check_circle,
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 28),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.build_outlined, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucune maintenance',
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Créez votre première maintenance',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMaintenanceList(List<MaintenanceModel> maintenances) {
|
||||||
|
// Trier par date (les plus récentes/urgentes en premier)
|
||||||
|
final sortedMaintenances = List<MaintenanceModel>.from(maintenances)
|
||||||
|
..sort((a, b) {
|
||||||
|
if (a.isCompleted && !b.isCompleted) return 1;
|
||||||
|
if (!a.isCompleted && b.isCompleted) return -1;
|
||||||
|
return a.scheduledDate.compareTo(b.scheduledDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: sortedMaintenances.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildMaintenanceCard(sortedMaintenances[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMaintenanceCard(MaintenanceModel maintenance) {
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
final equipmentNames = maintenance.equipmentIds
|
||||||
|
.map((id) => equipmentProvider.allEquipment
|
||||||
|
.cast<dynamic>()
|
||||||
|
.firstWhere((e) => e.id == id, orElse: () => null)
|
||||||
|
?.name ?? 'Inconnu')
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final typeInfo = _getMaintenanceTypeInfo(maintenance.type);
|
||||||
|
final statusInfo = _getStatusInfo(maintenance);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _navigateToForm(maintenance),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: typeInfo.$3.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(typeInfo.$2, size: 16, color: typeInfo.$3),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
typeInfo.$1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: typeInfo.$3,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusInfo.$2.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
statusInfo.$1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusInfo.$2,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onPressed: () => _showMaintenanceMenu(maintenance),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Nom
|
||||||
|
Text(
|
||||||
|
maintenance.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (maintenance.description.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
maintenance.description,
|
||||||
|
style: TextStyle(color: Colors.grey[700]),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Équipements
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: equipmentNames.map((name) {
|
||||||
|
return Chip(
|
||||||
|
label: Text(name, style: const TextStyle(fontSize: 12)),
|
||||||
|
backgroundColor: Colors.grey[200],
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.event, size: 16, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
maintenance.isCompleted
|
||||||
|
? 'Complétée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}'
|
||||||
|
: 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Coût
|
||||||
|
if (maintenance.cost != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.euro, size: 16, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${maintenance.cost!.toStringAsFixed(2)} €',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(String, IconData, Color) _getMaintenanceTypeInfo(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.preventive:
|
||||||
|
return ('Préventive', Icons.schedule, Colors.blue);
|
||||||
|
case MaintenanceType.corrective:
|
||||||
|
return ('Corrective', Icons.build, Colors.orange);
|
||||||
|
case MaintenanceType.inspection:
|
||||||
|
return ('Inspection', Icons.search, Colors.purple);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(String, Color) _getStatusInfo(MaintenanceModel maintenance) {
|
||||||
|
if (maintenance.isCompleted) {
|
||||||
|
return ('Complétée', Colors.green);
|
||||||
|
} else if (maintenance.isOverdue) {
|
||||||
|
return ('En retard', Colors.red);
|
||||||
|
} else {
|
||||||
|
return ('À venir', Colors.blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMaintenanceMenu(MaintenanceModel maintenance) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!maintenance.isCompleted)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
title: const Text('Marquer comme complétée'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_completeMaintenance(maintenance);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit, color: AppColors.bleuFonce),
|
||||||
|
title: const Text('Modifier'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_navigateToForm(maintenance);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
title: const Text('Supprimer'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_deleteMaintenance(maintenance);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completeMaintenance(MaintenanceModel maintenance) async {
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _CompleteMaintenanceDialog(maintenance: maintenance),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && mounted) {
|
||||||
|
try {
|
||||||
|
await context.read<MaintenanceProvider>().completeMaintenance(
|
||||||
|
maintenance.id,
|
||||||
|
performedBy: result['performedBy'],
|
||||||
|
cost: result['cost'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Maintenance marquée comme complétée'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadMaintenances();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteMaintenance(MaintenanceModel maintenance) async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer la maintenance'),
|
||||||
|
content: Text('Êtes-vous sûr de vouloir supprimer "${maintenance.name}" ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirm == true && mounted) {
|
||||||
|
try {
|
||||||
|
await context.read<MaintenanceProvider>().deleteMaintenance(maintenance.id);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Maintenance supprimée'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadMaintenances();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _navigateToForm(MaintenanceModel? maintenance) async {
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MaintenanceFormPage(maintenance: maintenance),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true && mounted) {
|
||||||
|
_loadMaintenances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog pour compléter une maintenance
|
||||||
|
class _CompleteMaintenanceDialog extends StatefulWidget {
|
||||||
|
final MaintenanceModel maintenance;
|
||||||
|
|
||||||
|
const _CompleteMaintenanceDialog({required this.maintenance});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompleteMaintenanceDialog> createState() => _CompleteMaintenanceDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompleteMaintenanceDialogState extends State<_CompleteMaintenanceDialog> {
|
||||||
|
final _costController = TextEditingController();
|
||||||
|
final _notesController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_costController.dispose();
|
||||||
|
_notesController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Compléter la maintenance'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _costController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Coût (€)',
|
||||||
|
hintText: 'Ex: 150.00',
|
||||||
|
prefixIcon: Icon(Icons.euro),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _notesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes (optionnel)',
|
||||||
|
hintText: 'Commentaires sur l\'intervention',
|
||||||
|
prefixIcon: Icon(Icons.notes),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final cost = double.tryParse(_costController.text);
|
||||||
|
Navigator.pop(context, {
|
||||||
|
'cost': cost,
|
||||||
|
'notes': _notesController.text.trim(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||||
|
child: const Text('Valider'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
147
em2rp/lib/views/widgets/common/audio_diagnostic_button.dart
Normal file
147
em2rp/lib/views/widgets/common/audio_diagnostic_button.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/services/audio_feedback_service.dart';
|
||||||
|
import 'package:em2rp/services/text_to_speech_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Bouton de diagnostic pour tester l'audio et le TTS
|
||||||
|
class AudioDiagnosticButton extends StatelessWidget {
|
||||||
|
const AudioDiagnosticButton({super.key});
|
||||||
|
|
||||||
|
Future<void> _testAudio(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AudioDiagnostic] ========== AUDIO TEST START ==========');
|
||||||
|
DebugLog.info('[AudioDiagnostic] User Agent: ${web.window.navigator.userAgent}');
|
||||||
|
DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}');
|
||||||
|
|
||||||
|
// Débloquer l'audio
|
||||||
|
DebugLog.info('[AudioDiagnostic] Step 1: Unlocking audio...');
|
||||||
|
await AudioFeedbackService.unlockAudio();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Tester le son de succès
|
||||||
|
DebugLog.info('[AudioDiagnostic] Step 2: Playing success beep...');
|
||||||
|
await AudioFeedbackService.playSuccessBeep();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
|
|
||||||
|
// Tester le son d'erreur
|
||||||
|
DebugLog.info('[AudioDiagnostic] Step 3: Playing error beep...');
|
||||||
|
await AudioFeedbackService.playErrorBeep();
|
||||||
|
|
||||||
|
DebugLog.info('[AudioDiagnostic] ========== AUDIO TEST END ==========');
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Test audio terminé - Vérifiez la console (F12)'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioDiagnostic] Error during audio test', e);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur audio: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testTTS(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AudioDiagnostic] ========== TTS TEST START ==========');
|
||||||
|
DebugLog.info('[AudioDiagnostic] User Agent: ${web.window.navigator.userAgent}');
|
||||||
|
DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}');
|
||||||
|
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
|
||||||
|
|
||||||
|
await TextToSpeechService.initialize();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
|
||||||
|
await TextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.');
|
||||||
|
|
||||||
|
DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Test TTS terminé - Vérifiez la console (F12)'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioDiagnostic] Error during TTS test', e);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur TTS: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.bug_report, color: Colors.grey),
|
||||||
|
tooltip: 'Diagnostic Audio/TTS',
|
||||||
|
onSelected: (value) async {
|
||||||
|
switch (value) {
|
||||||
|
case 'audio':
|
||||||
|
await _testAudio(context);
|
||||||
|
break;
|
||||||
|
case 'tts':
|
||||||
|
await _testTTS(context);
|
||||||
|
break;
|
||||||
|
case 'both':
|
||||||
|
await _testAudio(context);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
|
await _testTTS(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'audio',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.volume_up, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Test Audio'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'tts',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.record_voice_over, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Test TTS'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'both',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_circle, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Test Audio + TTS'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,12 +8,14 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget {
|
|||||||
final List<MaintenanceModel> maintenances;
|
final List<MaintenanceModel> maintenances;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final bool hasManagePermission;
|
final bool hasManagePermission;
|
||||||
|
final VoidCallback? onAddMaintenance;
|
||||||
|
|
||||||
const EquipmentMaintenanceHistorySection({
|
const EquipmentMaintenanceHistorySection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.maintenances,
|
required this.maintenances,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.hasManagePermission,
|
required this.hasManagePermission,
|
||||||
|
this.onAddMaintenance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,19 +39,42 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (hasManagePermission && onAddMaintenance != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle, color: AppColors.bleuFonce),
|
||||||
|
tooltip: 'Planifier une maintenance',
|
||||||
|
onPressed: onAddMaintenance,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (maintenances.isEmpty)
|
else if (maintenances.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Center(
|
child: Column(
|
||||||
child: Text(
|
children: [
|
||||||
'Aucune maintenance enregistrée',
|
const Center(
|
||||||
style: TextStyle(color: Colors.grey),
|
child: Text(
|
||||||
),
|
'Aucune maintenance enregistrée',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasManagePermission && onAddMaintenance != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onAddMaintenance,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Planifier une maintenance'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.bleuFonce,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/views/my_account_page.dart';
|
|||||||
import 'package:em2rp/views/user_management_page.dart';
|
import 'package:em2rp/views/user_management_page.dart';
|
||||||
import 'package:em2rp/views/data_management_page.dart';
|
import 'package:em2rp/views/data_management_page.dart';
|
||||||
import 'package:em2rp/views/equipment_management_page.dart';
|
import 'package:em2rp/views/equipment_management_page.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_management_page.dart';
|
||||||
import 'package:em2rp/config/app_version.dart';
|
import 'package:em2rp/config/app_version.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
@@ -113,6 +114,24 @@ class MainDrawer extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PermissionGate(
|
||||||
|
requiredPermissions: const ['manage_maintenances'],
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.build_circle),
|
||||||
|
title: const Text('Maintenances'),
|
||||||
|
selected: currentPage == '/maintenance_management',
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const MaintenanceManagementPage()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ExpansionTileTheme(
|
ExpansionTileTheme(
|
||||||
data: const ExpansionTileThemeData(
|
data: const ExpansionTileThemeData(
|
||||||
iconColor: AppColors.noir,
|
iconColor: AppColors.noir,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ dependencies:
|
|||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.2.1
|
flutter_local_notifications: ^19.2.1
|
||||||
|
|
||||||
|
|
||||||
# Export/Import
|
# Export/Import
|
||||||
csv: ^6.0.0
|
csv: ^6.0.0
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
@@ -81,3 +82,4 @@ flutter:
|
|||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/logos/
|
- assets/logos/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
- assets/sounds/
|
||||||
|
|||||||
71
em2rp/web/test_audio_tts.js
Normal file
71
em2rp/web/test_audio_tts.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
*// Script de test pour valider l'audio et le TTS dans la console du navigateur
|
||||||
|
// À copier-coller dans la console pour diagnostic
|
||||||
|
|
||||||
|
console.log('========== TEST AUDIO ET TTS ==========');
|
||||||
|
|
||||||
|
// Test 1 : Vérifier les voix disponibles
|
||||||
|
console.log('\n1️⃣ Test des voix de synthèse :');
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
const voices = synth.getVoices();
|
||||||
|
|
||||||
|
console.log(` ✓ Nombre de voix : ${voices.length}`);
|
||||||
|
|
||||||
|
const frenchVoices = voices.filter(v => v.lang.startsWith('fr'));
|
||||||
|
console.log(` ✓ Voix françaises : ${frenchVoices.length}`);
|
||||||
|
|
||||||
|
if (frenchVoices.length > 0) {
|
||||||
|
console.log(' ✓ Voix françaises disponibles :');
|
||||||
|
frenchVoices.forEach(v => {
|
||||||
|
console.log(` - ${v.name} (${v.lang}) ${v.localService ? 'LOCAL' : 'REMOTE'}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(' ⚠ AUCUNE voix française trouvée !');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2 : Tester le TTS
|
||||||
|
console.log('\n2️⃣ Test du TTS (dans 1 seconde) :');
|
||||||
|
setTimeout(() => {
|
||||||
|
const utterance = new SpeechSynthesisUtterance('Test de synthèse vocale');
|
||||||
|
utterance.lang = 'fr-FR';
|
||||||
|
utterance.rate = 0.7;
|
||||||
|
utterance.pitch = 0.7;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
if (frenchVoices.length > 0) {
|
||||||
|
utterance.voice = frenchVoices[0];
|
||||||
|
console.log(` ✓ Utilisation de la voix : ${frenchVoices[0].name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onstart = () => console.log(' ✓ TTS démarré');
|
||||||
|
utterance.onend = () => console.log(' ✓ TTS terminé');
|
||||||
|
utterance.onerror = (e) => console.error(' ✗ Erreur TTS:', e);
|
||||||
|
|
||||||
|
synth.speak(utterance);
|
||||||
|
console.log(' → Lecture en cours...');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Test 3 : Tester l'audio (dans 3 secondes)
|
||||||
|
console.log('\n3️⃣ Test de l\'audio (dans 3 secondes) :');
|
||||||
|
setTimeout(() => {
|
||||||
|
const audio = new Audio('assets/assets/sounds/ok.mp3');
|
||||||
|
audio.volume = 1.0;
|
||||||
|
|
||||||
|
audio.onloadeddata = () => console.log(' ✓ Audio chargé');
|
||||||
|
audio.onplay = () => console.log(' ✓ Audio en lecture');
|
||||||
|
audio.onended = () => console.log(' ✓ Audio terminé');
|
||||||
|
audio.onerror = (e) => {
|
||||||
|
console.error(' ✗ Erreur audio:', audio.error);
|
||||||
|
console.error(' ✗ Code erreur:', audio.error?.code);
|
||||||
|
console.error(' ✗ Message:', audio.error?.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.play().then(() => {
|
||||||
|
console.log(' → Lecture audio démarrée');
|
||||||
|
}).catch(e => {
|
||||||
|
console.error(' ✗ Échec du play():', e);
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
console.log('\n⏳ Tests en cours... Attendez les résultats ci-dessus');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.5",
|
"version": "1.1.14",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.",
|
"releaseNotes": "Ajout de la gestion des maintenance et synthèse vocale",
|
||||||
"timestamp": "2026-02-18T12:43:19.791Z"
|
"timestamp": "2026-03-03T10:13:12.014Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user