Compare commits
15 Commits
a182f1b922
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecf4a5cede | ||
|
|
6737ad80e4 | ||
|
|
afa2c35c90 | ||
|
|
36b420639d | ||
|
|
9bd4b29967 | ||
|
|
bc93f3fa9a | ||
|
|
6d320bedc9 | ||
|
|
cc7abba373 | ||
|
|
890449d5e3 | ||
|
|
506225ac62 | ||
|
|
bc6d7d4542 | ||
|
|
5b9ca568f8 | ||
|
|
7cbb48e679 | ||
|
|
8cd4854924 | ||
|
|
a7e5f91a21 |
@@ -1,3 +1,4 @@
|
|||||||
|
test_audio_tts.js,1772996026925,be4d2d713c256578bc16646116e3e81fc2627a1d89e45b211318b51e3612f259
|
||||||
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
||||||
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
||||||
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
||||||
@@ -18,11 +19,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,1772996026461,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
|
||||||
|
assets/assets/sounds/error.mp3,1772996026458,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 +34,16 @@ 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,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64
|
version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6
|
||||||
index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721
|
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc
|
||||||
flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9
|
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303
|
||||||
assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
|
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
|
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
|
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac
|
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
|
||||||
assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c
|
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
|
||||||
main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a
|
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848
|
||||||
|
|||||||
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.
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
# Changelog - EM2RP
|
# Changelog - EM2RP
|
||||||
|
|
||||||
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.
|
||||||
|
## 12/03/2026bis
|
||||||
|
Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.
|
||||||
|
|
||||||
|
## 12/03/2026
|
||||||
|
Ajout d'une page de statistiques détaillées pour les équipements et les événements.
|
||||||
|
|
||||||
|
## 10/03/2026
|
||||||
|
Migration vers Google Cloud TTS exclusif pour une compatibilité maximale sur tous les navigateurs. Suppression du TTS local (Web Speech API) qui causait des problèmes de compatibilité sur certaines configurations (notamment Chromium/Linux).
|
||||||
|
|
||||||
|
Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS. Résolution bug d'affichage des événements pour les membres CREW
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
# Système de Gestion des Mises à Jour - EM2RP
|
|
||||||
|
|
||||||
## 📋 Vue d'ensemble
|
|
||||||
|
|
||||||
Ce système permet de gérer automatiquement les mises à jour de l'application web Flutter, en notifiant les utilisateurs et en forçant le rechargement du cache si nécessaire.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Architecture
|
|
||||||
|
|
||||||
### Fichiers impliqués
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
- **`lib/config/app_version.dart`** : Fichier source de vérité pour la version
|
|
||||||
- **`web/version.json`** : Fichier déployé avec l'app pour vérification côté serveur
|
|
||||||
|
|
||||||
#### Services
|
|
||||||
- **`lib/services/update_service.dart`** : Service de vérification des mises à jour
|
|
||||||
- **`lib/views/widgets/common/update_dialog.dart`** : Widget d'affichage du dialog de mise à jour
|
|
||||||
|
|
||||||
#### Scripts
|
|
||||||
- **`scripts/increment_version.js`** : Incrémente automatiquement la version
|
|
||||||
- **`scripts/update_version_json.js`** : Génère version.json depuis app_version.dart
|
|
||||||
- **`deploy.bat`** : Script de déploiement complet
|
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
- **`CHANGELOG.md`** : Notes de version (utilisées dans le dialog)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Workflow de déploiement
|
|
||||||
|
|
||||||
### 1. Développement normal
|
|
||||||
Travaillez normalement sur votre code en mode développement.
|
|
||||||
|
|
||||||
### 2. Déploiement d'une nouvelle version
|
|
||||||
```bash
|
|
||||||
deploy.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
Ce script exécute automatiquement :
|
|
||||||
1. ✅ Bascule en mode PRODUCTION
|
|
||||||
2. ✅ **Incrémente la version** (0.3.8 → 0.3.9)
|
|
||||||
3. ✅ **Incrémente le buildNumber** (1 → 2)
|
|
||||||
4. ✅ **Génère version.json** depuis app_version.dart
|
|
||||||
5. ✅ Build Flutter Web
|
|
||||||
6. ✅ Déploie sur Firebase Hosting
|
|
||||||
7. ✅ Retour en mode DÉVELOPPEMENT
|
|
||||||
|
|
||||||
### 3. Mise à jour côté utilisateur
|
|
||||||
Au prochain chargement de l'app (ou après 2 secondes) :
|
|
||||||
- L'app vérifie `https://em2rp.web.app/version.json`
|
|
||||||
- Compare avec la version locale dans `app_version.dart`
|
|
||||||
- Si `buildNumber serveur > buildNumber local` → Affiche le dialog
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Format de version
|
|
||||||
|
|
||||||
### app_version.dart
|
|
||||||
```dart
|
|
||||||
class AppVersion {
|
|
||||||
static const String version = '0.3.8'; // Version sémantique
|
|
||||||
static const int buildNumber = 1; // Numéro de build (incrémenté automatiquement)
|
|
||||||
|
|
||||||
static String get fullVersion => 'v$version';
|
|
||||||
static String get fullVersionWithBuild => 'v$version+$buildNumber';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### version.json (déployé)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "0.3.8",
|
|
||||||
"buildNumber": 1,
|
|
||||||
"updateUrl": "https://em2rp.web.app",
|
|
||||||
"forceUpdate": false,
|
|
||||||
"releaseNotes": "• Scanner QR Code\n• Génération QR conteneurs\n• Performance améliorée"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Comparaison des versions
|
|
||||||
|
|
||||||
Le système compare uniquement le **buildNumber** :
|
|
||||||
- `buildNumber serveur > buildNumber local` → Mise à jour disponible
|
|
||||||
- Ignore les versions identiques même si la version sémantique change
|
|
||||||
|
|
||||||
**Exemple** :
|
|
||||||
- Local : `0.3.8+1`
|
|
||||||
- Serveur : `0.3.9+2`
|
|
||||||
- Résultat : Mise à jour proposée (2 > 1) ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Expérience utilisateur
|
|
||||||
|
|
||||||
### Mise à jour normale (forceUpdate: false)
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ 🔄 Mise à jour disponible │
|
|
||||||
├────────────────────────────────────┤
|
|
||||||
│ Version actuelle : 0.3.8 (1) │
|
|
||||||
│ Nouvelle version : 0.3.9 (2) │
|
|
||||||
│ │
|
|
||||||
│ Nouveautés : │
|
|
||||||
│ • Scanner QR Code │
|
|
||||||
│ • Performance améliorée │
|
|
||||||
│ │
|
|
||||||
│ [Plus tard] [Mettre à jour] 🔄 │
|
|
||||||
└────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mise à jour forcée (forceUpdate: true)
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ ⚠️ Mise à jour requise │
|
|
||||||
├────────────────────────────────────┤
|
|
||||||
│ Version actuelle : 0.3.8 (1) │
|
|
||||||
│ Nouvelle version : 0.3.9 (2) │
|
|
||||||
│ │
|
|
||||||
│ ⚠️ Cette mise à jour est │
|
|
||||||
│ obligatoire pour continuer │
|
|
||||||
│ │
|
|
||||||
│ [Mettre à jour] 🔄 │
|
|
||||||
└────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Utilisation avancée
|
|
||||||
|
|
||||||
### Forcer une mise à jour critique
|
|
||||||
Si vous déployez un correctif critique :
|
|
||||||
|
|
||||||
1. Modifiez `web/version.json` **après le déploiement** :
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "0.3.9",
|
|
||||||
"buildNumber": 2,
|
|
||||||
"forceUpdate": true, // ← Changer à true
|
|
||||||
"releaseNotes": "🔴 Correctif de sécurité important"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Les utilisateurs ne pourront plus fermer le dialog jusqu'à la mise à jour
|
|
||||||
|
|
||||||
### Personnaliser les notes de version
|
|
||||||
Éditez `CHANGELOG.md` avant le déploiement :
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [0.3.9] - 2026-01-16
|
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- Scanner QR Code pour équipements
|
|
||||||
- Génération QR pour conteneurs
|
|
||||||
|
|
||||||
### Amélioré
|
|
||||||
- Performance du dialog de sélection
|
|
||||||
- Gestion du cache
|
|
||||||
|
|
||||||
### Corrigé
|
|
||||||
- Bug de cache des équipements
|
|
||||||
```
|
|
||||||
|
|
||||||
Les 5 premières lignes de la section seront utilisées dans le dialog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Tests
|
|
||||||
|
|
||||||
### Test 1 : Vérification de version locale
|
|
||||||
```dart
|
|
||||||
// Dans n'importe quel fichier
|
|
||||||
import 'package:em2rp/config/app_version.dart';
|
|
||||||
|
|
||||||
print('Version: ${AppVersion.version}');
|
|
||||||
print('Build: ${AppVersion.buildNumber}');
|
|
||||||
print('Full: ${AppVersion.fullVersionWithBuild}');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 2 : Forcer l'affichage du dialog
|
|
||||||
Modifiez temporairement `web/version.json` :
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"buildNumber": 999 // Très grand nombre
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rechargez l'app → Le dialog s'affiche immédiatement
|
|
||||||
|
|
||||||
### Test 3 : Tester le rechargement
|
|
||||||
1. Cliquez sur "Mettre à jour"
|
|
||||||
2. Vérifiez que la page se recharge
|
|
||||||
3. Vérifiez que le cache est vidé (nouvelles ressources chargées)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Logs de debug
|
|
||||||
|
|
||||||
En mode debug, des logs sont affichés dans la console :
|
|
||||||
|
|
||||||
```
|
|
||||||
[UpdateService] Current version: 0.3.8+1
|
|
||||||
[UpdateService] Server version: 0.3.9+2
|
|
||||||
```
|
|
||||||
|
|
||||||
Si pas de mise à jour disponible, rien ne s'affiche.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Sécurité
|
|
||||||
|
|
||||||
### Headers HTTP pour forcer le non-cache
|
|
||||||
Le fichier `web/index.html` contient :
|
|
||||||
```html
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache-busting sur version.json
|
|
||||||
Chaque requête ajoute un timestamp :
|
|
||||||
```dart
|
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
Uri.parse('$versionUrl?t=$timestamp')
|
|
||||||
```
|
|
||||||
|
|
||||||
Garantit que la version la plus récente est toujours récupérée.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Résolution de problèmes
|
|
||||||
|
|
||||||
### Problème : Le dialog ne s'affiche pas
|
|
||||||
**Causes possibles :**
|
|
||||||
1. Le `buildNumber` serveur n'est pas supérieur au local
|
|
||||||
2. Erreur réseau (timeout 10s)
|
|
||||||
3. Le fichier `version.json` n'existe pas sur le serveur
|
|
||||||
|
|
||||||
**Solution :**
|
|
||||||
```bash
|
|
||||||
# Vérifier la version déployée
|
|
||||||
curl https://em2rp.web.app/version.json
|
|
||||||
|
|
||||||
# Forcer un nouveau déploiement
|
|
||||||
deploy.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problème : Le cache ne se vide pas
|
|
||||||
**Causes possibles :**
|
|
||||||
1. Service Worker actif (ancienne version)
|
|
||||||
2. Cache navigateur très persistant
|
|
||||||
|
|
||||||
**Solution :**
|
|
||||||
```javascript
|
|
||||||
// Dans les DevTools du navigateur
|
|
||||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
|
||||||
registrations.forEach(r => r.unregister());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Puis CTRL+SHIFT+R (rechargement forcé)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problème : Le script increment_version.js échoue
|
|
||||||
**Solution :**
|
|
||||||
```bash
|
|
||||||
# Vérifier la syntaxe du fichier app_version.dart
|
|
||||||
# Doit contenir exactement :
|
|
||||||
static const String version = '0.3.8';
|
|
||||||
static const int buildNumber = 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Évolution future
|
|
||||||
|
|
||||||
### Fonctionnalités possibles
|
|
||||||
- [ ] Afficher un changelog complet dans le dialog
|
|
||||||
- [ ] Permettre de sauter une version (skip this version)
|
|
||||||
- [ ] Notifications push pour les mises à jour critiques
|
|
||||||
- [ ] Analytics sur le taux d'adoption des mises à jour
|
|
||||||
- [ ] Support des mises à jour en arrière-plan
|
|
||||||
|
|
||||||
### Améliorations techniques
|
|
||||||
- [ ] Utiliser un CDN pour version.json
|
|
||||||
- [ ] Implémenter un rollback automatique si erreur
|
|
||||||
- [ ] Ajouter une vérification de santé post-déploiement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Commandes rapides
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Déployer une nouvelle version
|
|
||||||
deploy.bat
|
|
||||||
|
|
||||||
# Incrémenter manuellement la version
|
|
||||||
node scripts\increment_version.js
|
|
||||||
|
|
||||||
# Générer version.json manuellement
|
|
||||||
node scripts\update_version_json.js
|
|
||||||
|
|
||||||
# Vérifier la version actuelle
|
|
||||||
type lib\config\app_version.dart
|
|
||||||
|
|
||||||
# Vérifier la version déployée
|
|
||||||
curl https://em2rp.web.app/version.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist de déploiement
|
|
||||||
|
|
||||||
Avant chaque déploiement :
|
|
||||||
|
|
||||||
- [ ] Tester l'application en local
|
|
||||||
- [ ] Mettre à jour `CHANGELOG.md` avec les nouveautés
|
|
||||||
- [ ] Vérifier que tous les tests passent
|
|
||||||
- [ ] Exécuter `deploy.bat`
|
|
||||||
- [ ] Vérifier le déploiement sur https://em2rp.web.app
|
|
||||||
- [ ] Tester la mise à jour sur un navigateur propre
|
|
||||||
- [ ] Informer l'équipe de la nouvelle version
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
En cas de problème avec le système de mise à jour, vérifier :
|
|
||||||
1. Les logs dans la console du navigateur
|
|
||||||
2. Le fichier `version.json` déployé
|
|
||||||
3. Le fichier `app_version.dart` local
|
|
||||||
4. La connexion réseau de l'utilisateur
|
|
||||||
|
|
||||||
**Le système est conçu pour échouer silencieusement** : Si une erreur se produit, l'utilisateur peut continuer à utiliser l'app normalement sans être bloqué.
|
|
||||||
|
|
||||||
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.
103
em2rp/deploy_hosting.ps1
Normal file
103
em2rp/deploy_hosting.ps1
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Script de déploiement du hosting Firebase
|
||||||
|
# Ce script construit l'application et la déploie sur Firebase Hosting
|
||||||
|
|
||||||
|
Write-Host "=== Déploiement Firebase Hosting ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Vérifier que nous sommes dans le bon dossier
|
||||||
|
if (!(Test-Path "pubspec.yaml")) {
|
||||||
|
Write-Host "ERREUR: Ce script doit être exécuté depuis la racine du projet Flutter" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Construire l'application Flutter pour le web
|
||||||
|
Write-Host "Étape 1/3: Construction de l'application Flutter pour le web..." -ForegroundColor Yellow
|
||||||
|
flutter build web
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: La construction de l'application a échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Application construite avec succès" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 3. Vérifier que version.json existe
|
||||||
|
if (!(Test-Path "build/web/version.json")) {
|
||||||
|
Write-Host "AVERTISSEMENT: version.json n'a pas été copié dans build/web/" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Copier manuellement si nécessaire
|
||||||
|
if (Test-Path "web/version.json") {
|
||||||
|
Write-Host " → Copie de web/version.json vers build/web/..." -ForegroundColor Yellow
|
||||||
|
Copy-Item "web/version.json" "build/web/version.json"
|
||||||
|
Write-Host "✓ Fichier copié" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ERREUR: web/version.json n'existe pas" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 4. Afficher la version qui va être déployée
|
||||||
|
$versionContent = Get-Content "build/web/version.json" | ConvertFrom-Json
|
||||||
|
Write-Host "Version à déployer: $($versionContent.version)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Force update: $($versionContent.forceUpdate)" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 5. Demander confirmation
|
||||||
|
$confirm = Read-Host "Voulez-vous déployer sur Firebase Hosting ? (o/n)"
|
||||||
|
if ($confirm -ne "o" -and $confirm -ne "O") {
|
||||||
|
Write-Host "Déploiement annulé" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 6. Déployer sur Firebase Hosting
|
||||||
|
Write-Host "Étape 2/3: Déploiement sur Firebase Hosting..." -ForegroundColor Yellow
|
||||||
|
firebase deploy --only hosting
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Le déploiement a échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Déploiement réussi" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 7. Vérifier que version.json est accessible
|
||||||
|
Write-Host "Étape 3/3: Vérification de l'accès à version.json..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "https://app.em2events.fr/version.json" -Method GET -UseBasicParsing
|
||||||
|
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host "✓ version.json est accessible" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Vérifier les en-têtes CORS
|
||||||
|
if ($response.Headers["Access-Control-Allow-Origin"]) {
|
||||||
|
Write-Host "✓ En-têtes CORS configurés correctement" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ ATTENTION: En-têtes CORS non détectés" -ForegroundColor Yellow
|
||||||
|
Write-Host " Les en-têtes peuvent prendre quelques minutes pour se propager" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Afficher la version déployée
|
||||||
|
$deployedVersion = ($response.Content | ConvertFrom-Json).version
|
||||||
|
Write-Host "Version déployée: $deployedVersion" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ Code de statut: $($response.StatusCode)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Impossible de vérifier l'accès à version.json" -ForegroundColor Yellow
|
||||||
|
Write-Host " Erreur: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
Write-Host " Le fichier peut prendre quelques minutes pour être accessible" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Déploiement terminé ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Les utilisateurs recevront une notification de mise à jour au prochain chargement de l'application." -ForegroundColor Green
|
||||||
|
Write-Host "URL de l'application: https://app.em2events.fr" -ForegroundColor Cyan
|
||||||
@@ -42,6 +42,25 @@
|
|||||||
"**/.*",
|
"**/.*",
|
||||||
"**/node_modules/**"
|
"**/node_modules/**"
|
||||||
],
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "version.json",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Origin",
|
||||||
|
"value": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Methods",
|
||||||
|
"value": "GET, OPTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "no-cache, no-store, must-revalidate"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "**",
|
"source": "**",
|
||||||
|
|||||||
@@ -1,23 +1,97 @@
|
|||||||
{
|
{
|
||||||
"indexes": [
|
"indexes": [
|
||||||
{
|
{
|
||||||
"collectionGroup": "events",
|
"collectionGroup": "alerts",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "EndDateTime",
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isRead",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "StartDateTime",
|
"fieldPath": "createdAt",
|
||||||
"order": "ASCENDING"
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "alerts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "status",
|
"fieldPath": "status",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "__name__",
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "equipments",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "category",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -27,7 +101,7 @@
|
|||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "status",
|
"fieldPath": "EndDateTime",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,7 +109,7 @@
|
|||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "EndDateTime",
|
"fieldPath": "status",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -43,4 +117,3 @@
|
|||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
em2rp/functions/.env
Normal file
9
em2rp/functions/.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Configuration SMTP pour l'envoi d'emails
|
||||||
|
SMTP_HOST="mail.em2events.fr"
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER="notify@em2events.fr"
|
||||||
|
SMTP_PASS="aL8@Rx8xqFrNij$a"
|
||||||
|
|
||||||
|
# URL de l'application
|
||||||
|
APP_URL="https://app.em2events.fr"
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ const withCors = (handler) => {
|
|||||||
* Crée une alerte et envoie les notifications
|
* Crée une alerte et envoie les notifications
|
||||||
* Gère tout le processus côté backend de A à Z
|
* Gère tout le processus côté backend de A à Z
|
||||||
*/
|
*/
|
||||||
exports.createAlert = onRequest({cors: false, invoker: 'public'}, withCors(async (req, res) => {
|
exports.createAlert = onRequest({
|
||||||
|
cors: false,
|
||||||
|
invoker: 'public',
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, withCors(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|||||||
146
em2rp/functions/generateTTS.js
Normal file
146
em2rp/functions/generateTTS.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Cloud Function: generateTTS
|
||||||
|
* Génère de l'audio TTS avec Google Cloud Text-to-Speech
|
||||||
|
* Avec système de cache dans Firebase Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
const textToSpeech = require('@google-cloud/text-to-speech');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
|
||||||
|
* @param {string} text - Texte à hasher
|
||||||
|
* @return {string} Hash MD5
|
||||||
|
*/
|
||||||
|
function generateCacheKey(text, voiceConfig = {}) {
|
||||||
|
const cacheString = JSON.stringify({
|
||||||
|
text,
|
||||||
|
lang: voiceConfig.languageCode || 'fr-FR',
|
||||||
|
voice: voiceConfig.name || 'fr-FR-Standard-B',
|
||||||
|
});
|
||||||
|
return crypto.createHash('md5').update(cacheString).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère l'audio TTS et le stocke dans Firebase Storage
|
||||||
|
* @param {string} text - Texte à synthétiser
|
||||||
|
* @param {object} storage - Instance Firebase Storage
|
||||||
|
* @param {object} bucket - Bucket Firebase Storage
|
||||||
|
* @param {object} voiceConfig - Configuration de la voix (optionnel)
|
||||||
|
* @return {Promise<{audioUrl: string, cached: boolean}>}
|
||||||
|
*/
|
||||||
|
async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||||
|
try {
|
||||||
|
// Validation du texte
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
throw new Error('Text cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
throw new Error('Text too long (max 5000 characters)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration par défaut de la voix
|
||||||
|
const defaultVoiceConfig = {
|
||||||
|
languageCode: 'fr-FR',
|
||||||
|
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
|
||||||
|
ssmlGender: 'MALE',
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
|
||||||
|
|
||||||
|
// Générer la clé de cache
|
||||||
|
const cacheKey = generateCacheKey(text, finalVoiceConfig);
|
||||||
|
const fileName = `tts-cache/${cacheKey}.mp3`;
|
||||||
|
const file = bucket.file(fileName);
|
||||||
|
|
||||||
|
// Vérifier si le fichier existe déjà dans le cache
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
|
||||||
|
|
||||||
|
// Générer une URL signée valide 7 jours
|
||||||
|
const [url] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl: url,
|
||||||
|
cached: true,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
|
||||||
|
cacheKey,
|
||||||
|
text: text.substring(0, 50),
|
||||||
|
voice: finalVoiceConfig.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le client Text-to-Speech
|
||||||
|
const client = new textToSpeech.TextToSpeechClient();
|
||||||
|
|
||||||
|
// Configuration de la requête
|
||||||
|
const request = {
|
||||||
|
input: { text: text },
|
||||||
|
voice: finalVoiceConfig,
|
||||||
|
audioConfig: {
|
||||||
|
audioEncoding: 'MP3',
|
||||||
|
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
|
||||||
|
pitch: -2.0, // Voix un peu plus grave
|
||||||
|
volumeGainDb: 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appeler l'API Google Cloud TTS
|
||||||
|
const [response] = await client.synthesizeSpeech(request);
|
||||||
|
|
||||||
|
if (!response.audioContent) {
|
||||||
|
throw new Error('No audio content returned from TTS API');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ✓ Audio generated', {
|
||||||
|
size: response.audioContent.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sauvegarder dans Firebase Storage
|
||||||
|
await file.save(response.audioContent, {
|
||||||
|
metadata: {
|
||||||
|
contentType: 'audio/mpeg',
|
||||||
|
metadata: {
|
||||||
|
text: text.substring(0, 100), // Premier 100 caractères pour debug
|
||||||
|
voice: finalVoiceConfig.name,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ✓ Audio cached', { fileName });
|
||||||
|
|
||||||
|
// Générer une URL signée
|
||||||
|
const [url] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl: url,
|
||||||
|
cached: false,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[generateTTS] ✗ Error', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
text: text?.substring(0, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateTTS, generateCacheKey };
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ const { Storage } = require('@google-cloud/storage');
|
|||||||
// Utilitaires
|
// Utilitaires
|
||||||
const auth = require('./utils/auth');
|
const auth = require('./utils/auth');
|
||||||
const helpers = require('./utils/helpers');
|
const helpers = require('./utils/helpers');
|
||||||
|
const { generateTTS } = require('./generateTTS');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -28,6 +29,7 @@ const db = admin.firestore();
|
|||||||
const httpOptions = {
|
const httpOptions = {
|
||||||
cors: false,
|
cors: false,
|
||||||
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
|
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
|
||||||
|
region: 'europe-west9', // Région européenne (Paris)
|
||||||
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1662,19 +1664,25 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Récupérer tous les utilisateurs en une seule fois
|
// Récupérer tous les utilisateurs en PARALLÈLE (optimisé)
|
||||||
const usersMap = {};
|
const usersMap = {};
|
||||||
if (userIdsSet.size > 0) {
|
if (userIdsSet.size > 0) {
|
||||||
const userIds = Array.from(userIdsSet);
|
const userIds = Array.from(userIdsSet);
|
||||||
|
const batchSize = 30; // Augmenté de 10 à 30 pour réduire le nombre de requêtes
|
||||||
|
|
||||||
// Récupérer par batch (Firestore limite à 10 par requête 'in')
|
// Exécuter les requêtes en PARALLÈLE au lieu de séquentiel
|
||||||
const batchSize = 10;
|
const batchPromises = [];
|
||||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||||
const batch = userIds.slice(i, i + batchSize);
|
const batch = userIds.slice(i, i + batchSize);
|
||||||
const usersSnapshot = await db.collection('users')
|
batchPromises.push(
|
||||||
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
db.collection('users')
|
||||||
.get();
|
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(batchPromises);
|
||||||
|
results.forEach(usersSnapshot => {
|
||||||
usersSnapshot.docs.forEach(userDoc => {
|
usersSnapshot.docs.forEach(userDoc => {
|
||||||
const userData = userDoc.data();
|
const userData = userDoc.data();
|
||||||
// Stocker uniquement les données publiques
|
// Stocker uniquement les données publiques
|
||||||
@@ -1687,7 +1695,7 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
profilePhotoUrl: userData.profilePhotoUrl || '',
|
profilePhotoUrl: userData.profilePhotoUrl || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sérialiser les événements avec workforce comme liste d'UIDs
|
// Sérialiser les événements avec workforce comme liste d'UIDs
|
||||||
@@ -1725,6 +1733,291 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENTS - Get by month (optimized lazy loading)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
|
* Réduit drastiquement le temps de chargement en ne chargeant que le mois demandé
|
||||||
|
*/
|
||||||
|
exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const { userId, year, month } = req.body.data || {};
|
||||||
|
|
||||||
|
if (!year || !month) {
|
||||||
|
res.status(400).json({ error: 'year and month are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetching events for ${year}-${month}`);
|
||||||
|
|
||||||
|
// Calculer le début et la fin du mois
|
||||||
|
const startOfMonth = admin.firestore.Timestamp.fromDate(
|
||||||
|
new Date(year, month - 1, 1, 0, 0, 0)
|
||||||
|
);
|
||||||
|
const endOfMonth = admin.firestore.Timestamp.fromDate(
|
||||||
|
new Date(year, month, 0, 23, 59, 59)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur peut voir tous les événements
|
||||||
|
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
|
||||||
|
|
||||||
|
let eventsQuery = db.collection('events')
|
||||||
|
.where('StartDateTime', '>=', startOfMonth)
|
||||||
|
.where('StartDateTime', '<=', endOfMonth);
|
||||||
|
|
||||||
|
if (!canViewAll) {
|
||||||
|
// Utilisateur normal : seulement ses événements assignés
|
||||||
|
const userRef = db.collection('users').doc(userId || decodedToken.uid);
|
||||||
|
eventsQuery = eventsQuery.where('workforce', 'array-contains', userRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsSnapshot = await eventsQuery.get();
|
||||||
|
|
||||||
|
logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`);
|
||||||
|
|
||||||
|
// Collecter tous les UIDs utilisateurs uniques
|
||||||
|
const userIdsSet = new Set();
|
||||||
|
|
||||||
|
eventsSnapshot.docs.forEach(doc => {
|
||||||
|
const data = doc.data();
|
||||||
|
if (data.workforce && Array.isArray(data.workforce)) {
|
||||||
|
data.workforce.forEach(userRef => {
|
||||||
|
if (userRef && userRef.id) {
|
||||||
|
userIdsSet.add(userRef.id);
|
||||||
|
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||||
|
userIdsSet.add(userRef.split('/')[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer tous les utilisateurs en PARALLÈLE (optimisé)
|
||||||
|
const usersMap = {};
|
||||||
|
if (userIdsSet.size > 0) {
|
||||||
|
const userIds = Array.from(userIdsSet);
|
||||||
|
const batchSize = 30; // Limite Firestore augmentée de 10 à 30
|
||||||
|
|
||||||
|
// Exécuter les requêtes en parallèle au lieu de séquentiel
|
||||||
|
const batchPromises = [];
|
||||||
|
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||||
|
const batch = userIds.slice(i, i + batchSize);
|
||||||
|
batchPromises.push(
|
||||||
|
db.collection('users')
|
||||||
|
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(batchPromises);
|
||||||
|
results.forEach(usersSnapshot => {
|
||||||
|
usersSnapshot.docs.forEach(userDoc => {
|
||||||
|
const userData = userDoc.data();
|
||||||
|
usersMap[userDoc.id] = {
|
||||||
|
uid: userDoc.id,
|
||||||
|
firstName: userData.firstName || '',
|
||||||
|
lastName: userData.lastName || '',
|
||||||
|
email: userData.email || '',
|
||||||
|
phoneNumber: userData.phoneNumber || '',
|
||||||
|
profilePhotoUrl: userData.profilePhotoUrl || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sérialiser les événements avec workforce comme liste d'UIDs
|
||||||
|
const events = eventsSnapshot.docs.map(doc => {
|
||||||
|
const data = doc.data();
|
||||||
|
|
||||||
|
// Convertir workforce en liste d'UIDs
|
||||||
|
let workforceUids = [];
|
||||||
|
if (data.workforce && Array.isArray(data.workforce)) {
|
||||||
|
workforceUids = data.workforce.map(userRef => {
|
||||||
|
if (userRef && userRef.id) {
|
||||||
|
return userRef.id;
|
||||||
|
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||||
|
return userRef.split('/')[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(uid => uid !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data),
|
||||||
|
workforce: workforceUids,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
events,
|
||||||
|
users: usersMap,
|
||||||
|
month: { year, month }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching events by month:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||||
|
* Optimisé pour la page de préparation et l'affichage détaillé
|
||||||
|
*/
|
||||||
|
exports.getEventWithDetails = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const { eventId } = req.body.data || {};
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
res.status(400).json({ error: 'eventId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'événement
|
||||||
|
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||||
|
|
||||||
|
if (!eventDoc.exists) {
|
||||||
|
res.status(404).json({ error: 'Event not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = eventDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
|
||||||
|
if (!canViewAll) {
|
||||||
|
// Vérifier si l'utilisateur est dans la workforce
|
||||||
|
const userRef = db.collection('users').doc(decodedToken.uid);
|
||||||
|
const isInWorkforce = eventData.workforce && eventData.workforce.some(ref =>
|
||||||
|
(ref.id && ref.id === decodedToken.uid) ||
|
||||||
|
(typeof ref === 'string' && ref === `users/${decodedToken.uid}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isInWorkforce) {
|
||||||
|
res.status(403).json({ error: 'Forbidden: Not assigned to this event' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[getEventWithDetails] Loading details for event ${eventId}`);
|
||||||
|
|
||||||
|
// Collecter tous les IDs d'équipements et de containers
|
||||||
|
const equipmentIds = new Set();
|
||||||
|
const containerIds = new Set();
|
||||||
|
|
||||||
|
if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) {
|
||||||
|
eventData.assignedEquipment.forEach(eq => {
|
||||||
|
if (eq.equipmentId) {
|
||||||
|
equipmentIds.add(eq.equipmentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) {
|
||||||
|
eventData.assignedContainers.forEach(id => containerIds.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`);
|
||||||
|
|
||||||
|
// Charger tous les équipements en parallèle
|
||||||
|
const equipmentPromises = Array.from(equipmentIds).map(id =>
|
||||||
|
db.collection('equipments').doc(id).get()
|
||||||
|
);
|
||||||
|
const equipmentDocs = await Promise.all(equipmentPromises);
|
||||||
|
|
||||||
|
const equipmentMap = {};
|
||||||
|
for (const doc of equipmentDocs) {
|
||||||
|
if (doc.exists) {
|
||||||
|
let data = { id: doc.id, ...doc.data() };
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
equipmentMap[doc.id] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger tous les containers en parallèle
|
||||||
|
const containerPromises = Array.from(containerIds).map(id =>
|
||||||
|
db.collection('containers').doc(id).get()
|
||||||
|
);
|
||||||
|
const containerDocs = await Promise.all(containerPromises);
|
||||||
|
|
||||||
|
// Collecter les IDs des équipements enfants des containers
|
||||||
|
const childEquipmentIds = new Set();
|
||||||
|
for (const doc of containerDocs) {
|
||||||
|
if (doc.exists) {
|
||||||
|
const containerData = doc.data();
|
||||||
|
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
|
||||||
|
containerData.equipmentIds.forEach(id => childEquipmentIds.add(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`);
|
||||||
|
|
||||||
|
// Charger les équipements enfants des containers
|
||||||
|
const childEquipmentPromises = Array.from(childEquipmentIds).map(id =>
|
||||||
|
db.collection('equipments').doc(id).get()
|
||||||
|
);
|
||||||
|
const childEquipmentDocs = await Promise.all(childEquipmentPromises);
|
||||||
|
|
||||||
|
// Ajouter les enfants au map d'équipements
|
||||||
|
for (const doc of childEquipmentDocs) {
|
||||||
|
if (doc.exists && !equipmentMap[doc.id]) {
|
||||||
|
let data = { id: doc.id, ...doc.data() };
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
equipmentMap[doc.id] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire les containers avec leurs enfants complets
|
||||||
|
const containerMap = {};
|
||||||
|
for (const doc of containerDocs) {
|
||||||
|
if (doc.exists) {
|
||||||
|
let containerData = { id: doc.id, ...doc.data() };
|
||||||
|
containerData = helpers.serializeTimestamps(containerData);
|
||||||
|
containerData = helpers.serializeReferences(containerData);
|
||||||
|
|
||||||
|
// Ajouter les équipements enfants complets
|
||||||
|
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
|
||||||
|
containerData.children = containerData.equipmentIds
|
||||||
|
.map(id => equipmentMap[id])
|
||||||
|
.filter(eq => eq !== undefined);
|
||||||
|
} else {
|
||||||
|
containerData.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
containerMap[doc.id] = containerData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la réponse finale
|
||||||
|
const event = {
|
||||||
|
id: eventDoc.id,
|
||||||
|
...helpers.serializeTimestamps(eventData),
|
||||||
|
workforce: eventData.workforce ? eventData.workforce.map(ref =>
|
||||||
|
(ref.id || (typeof ref === 'string' ? ref.split('/')[1] : null))
|
||||||
|
).filter(uid => uid !== null) : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
event,
|
||||||
|
equipments: equipmentMap,
|
||||||
|
containers: containerMap,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting event with details:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAINTENANCES - Read with permissions
|
// MAINTENANCES - Read with permissions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1895,19 +2188,20 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
* Récupère un utilisateur spécifique par son ID
|
* Récupère un utilisateur spécifique par son ID
|
||||||
* Tout utilisateur authentifié peut accéder aux données publiques
|
* Tout utilisateur authentifié peut accéder aux données publiques
|
||||||
*/
|
*/
|
||||||
exports.getUser = onCall(async (request) => {
|
exports.getUser = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await authenticateUser(request);
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
const db = getFirestore();
|
|
||||||
|
|
||||||
const { userId } = request.data;
|
const { userId } = req.body.data || req.body || {};
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("userId is required");
|
res.status(400).json({ error: 'userId is required' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDoc = await db.collection("users").doc(userId).get();
|
const userDoc = await db.collection('users').doc(userId).get();
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
throw new Error("User not found");
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userDoc.data();
|
const user = userDoc.data();
|
||||||
@@ -1916,11 +2210,11 @@ exports.getUser = onCall(async (request) => {
|
|||||||
const userData = {
|
const userData = {
|
||||||
id: userDoc.id,
|
id: userDoc.id,
|
||||||
uid: user.uid || userDoc.id,
|
uid: user.uid || userDoc.id,
|
||||||
email: user.email || "",
|
email: user.email || '',
|
||||||
firstName: user.firstName || "",
|
firstName: user.firstName || '',
|
||||||
lastName: user.lastName || "",
|
lastName: user.lastName || '',
|
||||||
phoneNumber: user.phoneNumber || "",
|
phoneNumber: user.phoneNumber || '',
|
||||||
profilePhotoUrl: user.profilePhotoUrl || "",
|
profilePhotoUrl: user.profilePhotoUrl || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inclure le rôle si disponible
|
// Inclure le rôle si disponible
|
||||||
@@ -1934,12 +2228,12 @@ exports.getUser = onCall(async (request) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user: userData };
|
res.status(200).json({ user: userData });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error fetching user:", error);
|
logger.error('Error fetching user:', error);
|
||||||
throw new Error(error.message || "Failed to fetch user");
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -3334,6 +3628,7 @@ const {sendDailyDigest} = require('./sendDailyDigest');
|
|||||||
exports.sendDailyDigest = onSchedule({
|
exports.sendDailyDigest = onSchedule({
|
||||||
schedule: '0 8 * * *',
|
schedule: '0 8 * * *',
|
||||||
timeZone: 'Europe/Paris',
|
timeZone: 'Europe/Paris',
|
||||||
|
region: 'europe-west9',
|
||||||
retryCount: 2,
|
retryCount: 2,
|
||||||
memory: '512MiB'
|
memory: '512MiB'
|
||||||
}, async (context) => {
|
}, async (context) => {
|
||||||
@@ -3353,7 +3648,10 @@ exports.sendDailyDigest = onSchedule({
|
|||||||
* Trigger : Nouvel événement créé
|
* Trigger : Nouvel événement créé
|
||||||
* Envoie une notification à tous les membres de la workforce
|
* Envoie une notification à tous les membres de la workforce
|
||||||
*/
|
*/
|
||||||
exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) => {
|
exports.onEventCreated = onDocumentCreated({
|
||||||
|
document: 'events/{eventId}',
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, async (event) => {
|
||||||
logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`);
|
logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3393,7 +3691,10 @@ exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) =>
|
|||||||
* Trigger : Événement modifié (workforce changée)
|
* Trigger : Événement modifié (workforce changée)
|
||||||
* Envoie une notification aux nouveaux membres ajoutés à la workforce
|
* Envoie une notification aux nouveaux membres ajoutés à la workforce
|
||||||
*/
|
*/
|
||||||
exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) => {
|
exports.onEventUpdated = onDocumentUpdated({
|
||||||
|
document: 'events/{eventId}',
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, async (event) => {
|
||||||
const before = event.data.before.data();
|
const before = event.data.before.data();
|
||||||
const after = event.data.after.data();
|
const after = event.data.after.data();
|
||||||
const eventId = event.params.eventId;
|
const eventId = event.params.eventId;
|
||||||
@@ -3444,7 +3745,10 @@ exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) =>
|
|||||||
* Trigger : Nouvelle alerte créée
|
* Trigger : Nouvelle alerte créée
|
||||||
* Envoie un email immédiat si l'alerte est critique
|
* Envoie un email immédiat si l'alerte est critique
|
||||||
*/
|
*/
|
||||||
exports.onAlertCreated = onDocumentCreated('alerts/{alertId}', async (event) => {
|
exports.onAlertCreated = onDocumentCreated({
|
||||||
|
document: 'alerts/{alertId}',
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, async (event) => {
|
||||||
const alertId = event.params.alertId;
|
const alertId = event.params.alertId;
|
||||||
const alertData = event.data.data();
|
const alertData = event.data.data();
|
||||||
|
|
||||||
@@ -3890,3 +4194,72 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TEXT-TO-SPEECH - Generate TTS Audio
|
||||||
|
// ============================================================================
|
||||||
|
// Options HTTP spécifiques pour TTS avec CORS activé
|
||||||
|
const ttsHttpOptions = {
|
||||||
|
cors: true, // Activer CORS automatique
|
||||||
|
invoker: 'public',
|
||||||
|
region: 'europe-west9',
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Authentification utilisateur
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
logger.info('[generateTTSV2] Request from user:', {
|
||||||
|
uid: decodedToken.uid,
|
||||||
|
email: decodedToken.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupération des paramètres
|
||||||
|
const { text, voiceConfig } = req.body.data || {};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!text) {
|
||||||
|
res.status(400).json({ error: 'Text parameter is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
res.status(400).json({ error: 'Text too long (max 5000 characters)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Génération de l'audio avec cache
|
||||||
|
const bucketName = admin.storage().bucket().name;
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
|
||||||
|
const result = await generateTTS(text, storage, bucket, voiceConfig);
|
||||||
|
|
||||||
|
logger.info('[generateTTSV2] ✓ Success', {
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
audioUrl: result.audioUrl,
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[generateTTSV2] ✗ Error:', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestion des erreurs spécifiques
|
||||||
|
if (error.code === 'PERMISSION_DENIED') {
|
||||||
|
res.status(403).json({ error: 'Permission denied. Check Google Cloud TTS API is enabled.' });
|
||||||
|
} else if (error.code === 'QUOTA_EXCEEDED') {
|
||||||
|
res.status(429).json({ error: 'TTS quota exceeded. Try again later.' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
42
em2rp/functions/package-lock.json
generated
42
em2rp/functions/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "functions",
|
"name": "functions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
@@ -772,12 +773,23 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google-cloud/text-to-speech": {
|
||||||
|
"version": "5.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-5.8.1.tgz",
|
||||||
|
"integrity": "sha512-HXyZBtfQq+ETSLwWV/k3zFRWSzt+KEfiC5/OqXNNUed+lU/LEyN0CsqqEmkFfkL8BPsVIMAK2xiYCaDsKENukg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-gax": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
"@js-sdsl/ordered-map": "^4.4.2"
|
"@js-sdsl/ordered-map": "^4.4.2"
|
||||||
@@ -791,7 +803,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash.camelcase": "^4.3.0",
|
"lodash.camelcase": "^4.3.0",
|
||||||
"long": "^5.0.0",
|
"long": "^5.0.0",
|
||||||
@@ -1310,7 +1321,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
@@ -1631,8 +1641,7 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
@@ -1845,7 +1854,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1855,7 +1863,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2379,7 +2386,6 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -2412,7 +2418,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2425,7 +2430,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
@@ -2727,7 +2731,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -2831,7 +2834,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3576,7 +3578,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -3715,7 +3716,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
||||||
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.10.9",
|
"@grpc/grpc-js": "^1.10.9",
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
@@ -3743,7 +3743,6 @@
|
|||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
@@ -4093,7 +4092,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5110,8 +5108,7 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@@ -5490,7 +5487,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@@ -5843,7 +5839,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
||||||
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protobufjs": "^7.2.5"
|
"protobufjs": "^7.2.5"
|
||||||
},
|
},
|
||||||
@@ -6035,7 +6030,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6517,7 +6511,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6532,7 +6525,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -6997,7 +6989,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -7035,7 +7026,6 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -7052,7 +7042,6 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@@ -7071,7 +7060,6 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
|||||||
* Appelée par le client lors du chargement/déchargement
|
* Appelée par le client lors du chargement/déchargement
|
||||||
* Crée automatiquement les alertes nécessaires
|
* Crée automatiquement les alertes nécessaires
|
||||||
*/
|
*/
|
||||||
exports.processEquipmentValidation = onCall({cors: true}, async (request) => {
|
exports.processEquipmentValidation = onCall({
|
||||||
|
cors: true,
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, async (request) => {
|
||||||
try {
|
try {
|
||||||
// L'authentification est automatique avec onCall
|
// L'authentification est automatique avec onCall
|
||||||
const {auth, data} = request;
|
const {auth, data} = request;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const functions = require('firebase-functions');
|
const {onCall} = require('firebase-functions/v2/https');
|
||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const handlebars = require('handlebars');
|
const handlebars = require('handlebars');
|
||||||
@@ -10,22 +10,19 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
|||||||
* Envoie un email d'alerte à un utilisateur
|
* Envoie un email d'alerte à un utilisateur
|
||||||
* Appelé par le client Dart via callable function
|
* Appelé par le client Dart via callable function
|
||||||
*/
|
*/
|
||||||
exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
exports.sendAlertEmail = onCall({
|
||||||
|
region: 'europe-west9',
|
||||||
|
cors: true
|
||||||
|
}, async (request) => {
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
if (!context.auth) {
|
if (!request.auth) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('L\'utilisateur doit être authentifié');
|
||||||
'unauthenticated',
|
|
||||||
'L\'utilisateur doit être authentifié',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {alertId, userId, templateType} = data;
|
const {alertId, userId, templateType} = request.data;
|
||||||
|
|
||||||
if (!alertId || !userId) {
|
if (!alertId || !userId) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('alertId et userId sont requis');
|
||||||
'invalid-argument',
|
|
||||||
'alertId et userId sont requis',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,10 +33,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!alertDoc.exists) {
|
if (!alertDoc.exists) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('Alerte introuvable');
|
||||||
'not-found',
|
|
||||||
'Alerte introuvable',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const alert = alertDoc.data();
|
const alert = alertDoc.data();
|
||||||
@@ -51,10 +45,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('Utilisateur introuvable');
|
||||||
'not-found',
|
|
||||||
'Utilisateur introuvable',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userDoc.data();
|
const user = userDoc.data();
|
||||||
@@ -112,10 +103,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur envoi email:', error);
|
console.error('Erreur envoi email:', error);
|
||||||
throw new functions.https.HttpsError(
|
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
||||||
'internal',
|
|
||||||
`Erreur lors de l'envoi de l'email: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const EMAIL_CONFIG = {
|
|||||||
},
|
},
|
||||||
replyTo: 'contact@em2events.fr',
|
replyTo: 'contact@em2events.fr',
|
||||||
// URL de l'application pour les liens
|
// URL de l'application pour les liens
|
||||||
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
|
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ class ApiConfig {
|
|||||||
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
||||||
|
|
||||||
// URL de base pour les Cloud Functions
|
// URL de base pour les Cloud Functions
|
||||||
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
|
static const String productionUrl = 'https://europe-west9-em2rp-951dc.cloudfunctions.net';
|
||||||
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
|
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/europe-west9';
|
||||||
|
|
||||||
/// Retourne l'URL de base selon l'environnement
|
/// Retourne l'URL de base selon l'environnement
|
||||||
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
||||||
|
|||||||
@@ -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.0.4';
|
static const String version = '1.1.18';
|
||||||
|
|
||||||
/// 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';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Env {
|
class Env {
|
||||||
static const bool isDevelopment = true;
|
static const bool isDevelopment = false;
|
||||||
|
|
||||||
// Configuration de l'auto-login en développement
|
// Configuration de l'auto-login en développement
|
||||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
static const String devAdminEmail = '';
|
||||||
static const String devAdminPassword = 'Pastis51!';
|
static const String devAdminPassword = '';
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -14,4 +14,3 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
|
||||||
|
|
||||||
class EventFormController extends ChangeNotifier {
|
class EventFormController extends ChangeNotifier {
|
||||||
// Controllers
|
// Controllers
|
||||||
@@ -91,7 +90,20 @@ class EventFormController extends ChangeNotifier {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (existingEvent != null) {
|
if (existingEvent != null) {
|
||||||
_populateFromEvent(existingEvent);
|
// 🔧 FIX: Recharger l'événement avec tous les détails (équipements + containers avec enfants)
|
||||||
|
try {
|
||||||
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
final result = await dataService.getEventWithDetails(existingEvent.id);
|
||||||
|
final eventData = result['event'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Reconstruire l'événement avec les données complètes
|
||||||
|
final completeEvent = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
_populateFromEvent(completeEvent);
|
||||||
|
} catch (e) {
|
||||||
|
// Si erreur, utiliser l'événement existant (fallback)
|
||||||
|
print('[EventFormController] Error loading event with details, using existing: $e');
|
||||||
|
_populateFromEvent(existingEvent);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_selectedStatus = EventStatus.waitingForApproval;
|
_selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
|
||||||
@@ -352,15 +364,9 @@ class EventFormController extends ChangeNotifier {
|
|||||||
|
|
||||||
await EventFormService.updateEvent(updatedEvent);
|
await EventFormService.updateEvent(updatedEvent);
|
||||||
|
|
||||||
// Recharger les événements après modification
|
// Mettre à jour l'événement dans le cache (au lieu de tout recharger)
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localUserProvider.uid;
|
await eventProvider.updateEvent(updatedEvent);
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
_success = "Événement modifié avec succès !";
|
_success = "Événement modifié avec succès !";
|
||||||
} else {
|
} else {
|
||||||
@@ -394,23 +400,22 @@ class EventFormController extends ChangeNotifier {
|
|||||||
|
|
||||||
final eventId = await EventFormService.createEvent(newEvent);
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
|
|
||||||
|
// Créer l'événement avec l'ID retourné
|
||||||
|
EventModel createdEvent = newEvent.copyWith(id: eventId);
|
||||||
|
|
||||||
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
||||||
if (_uploadedFiles.isNotEmpty) {
|
if (_uploadedFiles.isNotEmpty) {
|
||||||
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
||||||
if (newFiles.isNotEmpty) {
|
if (newFiles.isNotEmpty) {
|
||||||
await EventFormService.updateEventDocuments(eventId, newFiles);
|
await EventFormService.updateEventDocuments(eventId, newFiles);
|
||||||
|
// Mettre à jour l'événement avec les nouvelles URLs
|
||||||
|
createdEvent = createdEvent.copyWith(documents: newFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload events
|
// Ajouter l'événement au cache
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localUserProvider.uid;
|
await eventProvider.addEvent(createdEvent);
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
_success = "Événement créé avec succès !";
|
_success = "Événement créé avec succès !";
|
||||||
}
|
}
|
||||||
@@ -435,19 +440,9 @@ class EventFormController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Supprimer l'événement via l'API
|
// Supprimer l'événement via le provider (qui appelle l'API et met à jour le cache)
|
||||||
final dataService = DataService(FirebaseFunctionsApiService());
|
|
||||||
await dataService.deleteEvent(eventId);
|
|
||||||
|
|
||||||
// Recharger la liste des événements
|
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localUserProvider.uid;
|
await eventProvider.deleteEvent(eventId);
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
_success = "Événement supprimé avec succès !";
|
_success = "Événement supprimé avec succès !";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
import 'package:em2rp/providers/alert_provider.dart';
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
import 'package:em2rp/views/alerts_page.dart';
|
import 'package:em2rp/views/alerts_page.dart';
|
||||||
import 'package:em2rp/views/calendar_page.dart';
|
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';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
@@ -27,9 +30,10 @@ import 'package:provider/provider.dart';
|
|||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'views/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
|
import 'services/update_service.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
import 'config/api_config.dart';
|
import 'config/api_config.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'views/widgets/common/update_dialog.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -97,26 +101,25 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return UpdateChecker(
|
return MaterialApp(
|
||||||
child: MaterialApp(
|
title: 'EM2 Hub',
|
||||||
title: 'EM2 Hub',
|
theme: ThemeData(
|
||||||
theme: ThemeData(
|
primarySwatch: Colors.red,
|
||||||
primarySwatch: Colors.red,
|
primaryColor: AppColors.noir,
|
||||||
primaryColor: AppColors.noir,
|
colorScheme:
|
||||||
colorScheme:
|
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
||||||
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
textTheme: const TextTheme(
|
||||||
textTheme: const TextTheme(
|
bodyMedium: TextStyle(color: AppColors.noir),
|
||||||
bodyMedium: TextStyle(color: AppColors.noir),
|
),
|
||||||
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: AppColors.noir),
|
||||||
),
|
),
|
||||||
inputDecorationTheme: const InputDecorationTheme(
|
enabledBorder: OutlineInputBorder(
|
||||||
focusedBorder: OutlineInputBorder(
|
borderSide: BorderSide(color: AppColors.gris),
|
||||||
borderSide: BorderSide(color: AppColors.noir),
|
),
|
||||||
),
|
labelStyle: TextStyle(color: AppColors.noir),
|
||||||
enabledBorder: OutlineInputBorder(
|
hintStyle: TextStyle(color: AppColors.gris),
|
||||||
borderSide: BorderSide(color: AppColors.gris),
|
|
||||||
),
|
|
||||||
labelStyle: TextStyle(color: AppColors.noir),
|
|
||||||
hintStyle: TextStyle(color: AppColors.gris),
|
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -157,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(
|
||||||
@@ -182,8 +188,9 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/event_statistics': (context) => const AuthGuard(
|
||||||
|
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,26 +206,58 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_autoLogin();
|
// Attendre la fin du premier build avant de naviguer
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_autoLogin();
|
||||||
|
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
|
||||||
|
_checkForUpdateDelayed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie les mises à jour après un délai
|
||||||
|
Future<void> _checkForUpdateDelayed() async {
|
||||||
|
try {
|
||||||
|
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateService.checkForUpdate();
|
||||||
|
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
// Attendre encore un peu pour être sûr que le bon contexte est disponible
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !updateInfo.forceUpdate,
|
||||||
|
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[AutoLoginWrapper] Error checking for update: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _autoLogin() async {
|
Future<void> _autoLogin() async {
|
||||||
|
PerformanceMonitor.start('App.autoLogin');
|
||||||
try {
|
try {
|
||||||
final localAuthProvider =
|
final localAuthProvider =
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
|
||||||
// Vérifier si l'utilisateur est déjà connecté
|
// Vérifier si l'utilisateur est déjà connecté
|
||||||
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
||||||
|
PerformanceMonitor.start('App.signIn');
|
||||||
// Connexion automatique en mode développement
|
// Connexion automatique en mode développement
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
Env.devAdminEmail,
|
Env.devAdminEmail,
|
||||||
Env.devAdminPassword,
|
Env.devAdminPassword,
|
||||||
);
|
);
|
||||||
|
PerformanceMonitor.end('App.signIn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les données utilisateur
|
|
||||||
await localAuthProvider.loadUserData();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||||
// En Flutter Web, on peut vérifier window.location.hash
|
// En Flutter Web, on peut vérifier window.location.hash
|
||||||
@@ -227,7 +266,7 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
|
|
||||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||||
|
|
||||||
// Si une route spécifique est demandée (autre que / ou vide)
|
// Navigation immédiate sans attendre le chargement des données
|
||||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||||
Navigator.of(context).pushReplacementNamed(fragment);
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
@@ -236,9 +275,18 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PerformanceMonitor.end('App.autoLogin');
|
||||||
|
PerformanceMonitor.printSummary();
|
||||||
|
|
||||||
|
// Charger les données utilisateur en arrière-plan
|
||||||
|
localAuthProvider.loadUserData().catchError((e) {
|
||||||
|
print('Error loading user data: $e');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Auto login failed: $e');
|
print('Auto login failed: $e');
|
||||||
|
PerformanceMonitor.end('App.autoLogin');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacementNamed('/login');
|
Navigator.of(context).pushReplacementNamed('/login');
|
||||||
}
|
}
|
||||||
@@ -247,9 +295,41 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: CircularProgressIndicator(),
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Logo de l'application
|
||||||
|
Image.asset(
|
||||||
|
'assets/logos/RectangleLogoBlack.png',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 80,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Chargement...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class AlertModel {
|
|||||||
|
|
||||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime _parseDate(dynamic value) {
|
DateTime parseDate(dynamic value) {
|
||||||
if (value == null) return DateTime.now();
|
if (value == null) return DateTime.now();
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
@@ -174,13 +174,13 @@ class AlertModel {
|
|||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
createdAt: _parseDate(map['createdAt']),
|
createdAt: parseDate(map['createdAt']),
|
||||||
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
dueDate: map['dueDate'] != null ? parseDate(map['dueDate']) : null,
|
||||||
actionUrl: map['actionUrl'],
|
actionUrl: map['actionUrl'],
|
||||||
isRead: map['isRead'] ?? false,
|
isRead: map['isRead'] ?? false,
|
||||||
isResolved: map['isResolved'] ?? false,
|
isResolved: map['isResolved'] ?? false,
|
||||||
resolution: map['resolution'],
|
resolution: map['resolution'],
|
||||||
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
resolvedAt: map['resolvedAt'] != null ? parseDate(map['resolvedAt']) : null,
|
||||||
resolvedByUserId: map['resolvedByUserId'],
|
resolvedByUserId: map['resolvedByUserId'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ class ContainerModel {
|
|||||||
/// Factory depuis Firestore
|
/// Factory depuis Firestore
|
||||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -270,8 +270,8 @@ class ContainerModel {
|
|||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
history: history,
|
history: history,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@ class ContainerHistoryEntry {
|
|||||||
|
|
||||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||||
// Helper pour parser la date
|
// Helper pour parser la date
|
||||||
DateTime _parseDate(dynamic value) {
|
DateTime parseDate(dynamic value) {
|
||||||
if (value == null) return DateTime.now();
|
if (value == null) return DateTime.now();
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
@@ -359,7 +359,7 @@ class ContainerHistoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ContainerHistoryEntry(
|
return ContainerHistoryEntry(
|
||||||
timestamp: _parseDate(map['timestamp']),
|
timestamp: parseDate(map['timestamp']),
|
||||||
action: map['action'] ?? '',
|
action: map['action'] ?? '',
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
previousValue: map['previousValue'],
|
previousValue: map['previousValue'],
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class EquipmentModel {
|
|||||||
|
|
||||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -416,13 +416,13 @@ class EquipmentModel {
|
|||||||
length: map['length']?.toDouble(),
|
length: map['length']?.toDouble(),
|
||||||
width: map['width']?.toDouble(),
|
width: map['width']?.toDouble(),
|
||||||
height: map['height']?.toDouble(),
|
height: map['height']?.toDouble(),
|
||||||
purchaseDate: _parseDate(map['purchaseDate']),
|
purchaseDate: parseDate(map['purchaseDate']),
|
||||||
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
|
||||||
maintenanceIds: maintenanceIds,
|
maintenanceIds: maintenanceIds,
|
||||||
imageUrl: map['imageUrl'],
|
imageUrl: map['imageUrl'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ class EventModel {
|
|||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
try {
|
try {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
DateTime parseDate(dynamic value, DateTime defaultValue) {
|
||||||
if (value == null) return defaultValue;
|
if (value == null) return defaultValue;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||||
@@ -370,8 +370,8 @@ class EventModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des timestamps avec support ISO string
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
final DateTime startDate = parseDate(map['StartDateTime'], DateTime.now());
|
||||||
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
final DateTime endDate = parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
// Gestion sécurisée des documents
|
// Gestion sécurisée des documents
|
||||||
final docsRaw = map['documents'] ?? [];
|
final docsRaw = map['documents'] ?? [];
|
||||||
|
|||||||
132
em2rp/lib/models/event_statistics_models.dart
Normal file
132
em2rp/lib/models/event_statistics_models.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsFilter {
|
||||||
|
final DateTimeRange period;
|
||||||
|
final Set<String> eventTypeIds;
|
||||||
|
final bool includeCanceled;
|
||||||
|
final Set<EventStatus> selectedStatuses;
|
||||||
|
|
||||||
|
const EventStatisticsFilter({
|
||||||
|
required this.period,
|
||||||
|
this.eventTypeIds = const {},
|
||||||
|
this.includeCanceled = false,
|
||||||
|
this.selectedStatuses = const {
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
EventStatisticsFilter copyWith({
|
||||||
|
DateTimeRange? period,
|
||||||
|
Set<String>? eventTypeIds,
|
||||||
|
bool? includeCanceled,
|
||||||
|
Set<EventStatus>? selectedStatuses,
|
||||||
|
}) {
|
||||||
|
return EventStatisticsFilter(
|
||||||
|
period: period ?? this.period,
|
||||||
|
eventTypeIds: eventTypeIds ?? this.eventTypeIds,
|
||||||
|
includeCanceled: includeCanceled ?? this.includeCanceled,
|
||||||
|
selectedStatuses: selectedStatuses ?? this.selectedStatuses,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventTypeStatistics {
|
||||||
|
final String eventTypeId;
|
||||||
|
final String eventTypeName;
|
||||||
|
final int totalEvents;
|
||||||
|
final double totalAmount;
|
||||||
|
final double validatedAmount;
|
||||||
|
final double pendingAmount;
|
||||||
|
final double canceledAmount;
|
||||||
|
|
||||||
|
const EventTypeStatistics({
|
||||||
|
required this.eventTypeId,
|
||||||
|
required this.eventTypeName,
|
||||||
|
required this.totalEvents,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.validatedAmount,
|
||||||
|
required this.pendingAmount,
|
||||||
|
required this.canceledAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class OptionStatistics {
|
||||||
|
final String optionKey;
|
||||||
|
final String optionLabel;
|
||||||
|
final int usageCount;
|
||||||
|
final int validatedUsageCount;
|
||||||
|
final int quantity;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const OptionStatistics({
|
||||||
|
required this.optionKey,
|
||||||
|
required this.optionLabel,
|
||||||
|
required this.usageCount,
|
||||||
|
required this.validatedUsageCount,
|
||||||
|
required this.quantity,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventStatisticsSummary {
|
||||||
|
final int totalEvents;
|
||||||
|
final int validatedEvents;
|
||||||
|
final int pendingEvents;
|
||||||
|
final int canceledEvents;
|
||||||
|
|
||||||
|
final double totalAmount;
|
||||||
|
final double validatedAmount;
|
||||||
|
final double pendingAmount;
|
||||||
|
final double canceledAmount;
|
||||||
|
|
||||||
|
final double baseAmount;
|
||||||
|
final double optionsAmount;
|
||||||
|
final double medianAmount;
|
||||||
|
final List<EventTypeStatistics> byEventType;
|
||||||
|
final List<OptionStatistics> topOptions;
|
||||||
|
|
||||||
|
const EventStatisticsSummary({
|
||||||
|
required this.totalEvents,
|
||||||
|
required this.validatedEvents,
|
||||||
|
required this.pendingEvents,
|
||||||
|
required this.canceledEvents,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.validatedAmount,
|
||||||
|
required this.pendingAmount,
|
||||||
|
required this.canceledAmount,
|
||||||
|
required this.baseAmount,
|
||||||
|
required this.optionsAmount,
|
||||||
|
required this.medianAmount,
|
||||||
|
required this.byEventType,
|
||||||
|
required this.topOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const empty = EventStatisticsSummary(
|
||||||
|
totalEvents: 0,
|
||||||
|
validatedEvents: 0,
|
||||||
|
pendingEvents: 0,
|
||||||
|
canceledEvents: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
validatedAmount: 0,
|
||||||
|
pendingAmount: 0,
|
||||||
|
canceledAmount: 0,
|
||||||
|
baseAmount: 0,
|
||||||
|
optionsAmount: 0,
|
||||||
|
medianAmount: 0,
|
||||||
|
byEventType: [],
|
||||||
|
topOptions: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
double get averageAmount => totalEvents == 0 ? 0 : totalAmount / totalEvents;
|
||||||
|
|
||||||
|
double get validationRate =>
|
||||||
|
totalEvents == 0 ? 0 : validatedEvents / totalEvents;
|
||||||
|
|
||||||
|
double get baseContributionRate =>
|
||||||
|
totalAmount == 0 ? 0 : baseAmount / totalAmount;
|
||||||
|
|
||||||
|
double get optionsContributionRate =>
|
||||||
|
totalAmount == 0 ? 0 : optionsAmount / totalAmount;
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ class MaintenanceModel {
|
|||||||
|
|
||||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -76,15 +76,15 @@ class MaintenanceModel {
|
|||||||
id: id,
|
id: id,
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
type: maintenanceTypeFromString(map['type']),
|
type: maintenanceTypeFromString(map['type']),
|
||||||
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
completedDate: _parseDate(map['completedDate']),
|
completedDate: parseDate(map['completedDate']),
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
description: map['description'] ?? '',
|
description: map['description'] ?? '',
|
||||||
performedBy: map['performedBy'],
|
performedBy: map['performedBy'],
|
||||||
cost: map['cost']?.toDouble(),
|
cost: map['cost']?.toDouble(),
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
em2rp/lib/models/qr_code_process_result.dart
Normal file
63
em2rp/lib/models/qr_code_process_result.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/// Résultat du traitement d'un code QR ou saisi manuellement
|
||||||
|
class QRCodeProcessResult {
|
||||||
|
/// Indique si le traitement a réussi
|
||||||
|
final bool success;
|
||||||
|
|
||||||
|
/// Message descriptif du résultat
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
/// Liste des IDs d'équipements affectés par le traitement
|
||||||
|
final List<String> affectedEquipmentIds;
|
||||||
|
|
||||||
|
/// Mises à jour des états de validation (équipements cochés)
|
||||||
|
final Map<String, bool>? updatedValidationState;
|
||||||
|
|
||||||
|
/// Mises à jour des quantités actuelles
|
||||||
|
final Map<String, int>? updatedQuantities;
|
||||||
|
|
||||||
|
/// Indique si le code n'a pas été trouvé dans l'événement actuel
|
||||||
|
/// (utilisé pour proposer de l'ajouter depuis la BDD)
|
||||||
|
final bool codeNotFoundInEvent;
|
||||||
|
|
||||||
|
const QRCodeProcessResult({
|
||||||
|
required this.success,
|
||||||
|
this.message,
|
||||||
|
this.affectedEquipmentIds = const [],
|
||||||
|
this.updatedValidationState,
|
||||||
|
this.updatedQuantities,
|
||||||
|
this.codeNotFoundInEvent = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Crée un résultat de succès
|
||||||
|
factory QRCodeProcessResult.success({
|
||||||
|
required String message,
|
||||||
|
required List<String> affectedEquipmentIds,
|
||||||
|
Map<String, bool>? updatedValidationState,
|
||||||
|
Map<String, int>? updatedQuantities,
|
||||||
|
}) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: true,
|
||||||
|
message: message,
|
||||||
|
affectedEquipmentIds: affectedEquipmentIds,
|
||||||
|
updatedValidationState: updatedValidationState,
|
||||||
|
updatedQuantities: updatedQuantities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un résultat d'erreur
|
||||||
|
factory QRCodeProcessResult.error(String message) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: false,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un résultat indiquant que le code n'est pas dans l'événement
|
||||||
|
factory QRCodeProcessResult.notFoundInEvent(String code) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: false,
|
||||||
|
message: 'Code $code non trouvé dans cet événement',
|
||||||
|
codeNotFoundInEvent: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,13 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
Timer? _searchDebounceTimer;
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
// Liste paginée pour la page de gestion
|
// Liste paginée pour la page de gestion
|
||||||
List<ContainerModel> _paginatedContainers = [];
|
final List<ContainerModel> _paginatedContainers = [];
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
String? _lastVisible;
|
String? _lastVisible;
|
||||||
|
|
||||||
// Cache complet pour compatibilité
|
// Cache complet pour compatibilité
|
||||||
List<ContainerModel> _containers = [];
|
final List<ContainerModel> _containers = [];
|
||||||
|
|
||||||
// Filtres et recherche
|
// Filtres et recherche
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Timer? _searchDebounceTimer;
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
// Liste paginée pour la page de gestion
|
// Liste paginée pour la page de gestion
|
||||||
List<EquipmentModel> _paginatedEquipment = [];
|
final List<EquipmentModel> _paginatedEquipment = [];
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
String? _lastVisible;
|
String? _lastVisible;
|
||||||
|
|
||||||
// Cache complet pour getEquipmentsByIds et compatibilité
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||||
List<EquipmentModel> _equipment = [];
|
final List<EquipmentModel> _equipment = [];
|
||||||
List<String> _models = [];
|
List<String> _models = [];
|
||||||
List<String> _brands = [];
|
List<String> _brands = [];
|
||||||
|
|
||||||
|
|||||||
@@ -3,31 +3,60 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
|
|
||||||
class EventProvider with ChangeNotifier {
|
class EventProvider with ChangeNotifier {
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
List<EventModel> _events = [];
|
List<EventModel> _events = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
List<EventModel> get events => _events;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
|
|
||||||
// Cache des utilisateurs chargés depuis getEvents
|
// Cache des utilisateurs chargés depuis getEvents
|
||||||
Map<String, Map<String, dynamic>> _usersCache = {};
|
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||||
|
|
||||||
|
// Cache pour éviter les rechargements inutiles (ancien système)
|
||||||
|
DateTime? _lastLoadTime;
|
||||||
|
String? _lastUserId;
|
||||||
|
bool _lastCanViewAll = false;
|
||||||
|
|
||||||
|
// Nouveau: Cache par mois pour le lazy loading
|
||||||
|
final Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
|
||||||
|
String? _currentMonth; // Mois actuellement affiché
|
||||||
|
|
||||||
|
List<EventModel> get events => _events;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
||||||
|
bool _shouldReload(String userId, bool canViewAllEvents) {
|
||||||
|
if (_lastLoadTime == null) return true;
|
||||||
|
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(_lastLoadTime!);
|
||||||
|
return difference.inSeconds > 30;
|
||||||
|
}
|
||||||
|
|
||||||
/// Charger les événements d'un utilisateur via l'API
|
/// Charger les événements d'un utilisateur via l'API
|
||||||
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
|
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
|
||||||
|
PerformanceMonitor.start('EventProvider.loadUserEvents');
|
||||||
|
|
||||||
|
// Éviter les rechargements inutiles
|
||||||
|
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
|
||||||
|
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Sauvegarder les paramètres
|
|
||||||
_saveLastLoadParams(userId, canViewAllEvents);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.getEvents_API');
|
||||||
// Charger via l'API - les permissions sont vérifiées côté serveur
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
final result = await _dataService.getEvents(userId: userId);
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
|
PerformanceMonitor.end('EventProvider.getEvents_API');
|
||||||
|
|
||||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
final usersData = result['users'] as Map<String, dynamic>;
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
@@ -38,6 +67,7 @@ class EventProvider with ChangeNotifier {
|
|||||||
|
|
||||||
print('Found ${eventsData.length} events from API');
|
print('Found ${eventsData.length} events from API');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.parseEvents');
|
||||||
List<EventModel> allEvents = [];
|
List<EventModel> allEvents = [];
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
|
||||||
@@ -51,23 +81,157 @@ class EventProvider with ChangeNotifier {
|
|||||||
failedCount++;
|
failedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseEvents');
|
||||||
|
|
||||||
_events = allEvents;
|
_events = allEvents;
|
||||||
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
_lastLoadTime = DateTime.now();
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
|
print('Successfully loaded ${_events.length} events ($failedCount failed)');
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading events: $e');
|
print('Error loading events: $e');
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
|
Future<void> loadMonthEvents(String userId, int year, int month,
|
||||||
|
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
||||||
|
|
||||||
|
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// Vérifier le cache
|
||||||
|
if (!forceReload && _eventsByMonth.containsKey(monthKey)) {
|
||||||
|
print('[EventProvider] Using cached events for $monthKey');
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_currentMonth = monthKey;
|
||||||
|
_events = _eventsByMonth[monthKey]!;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
print('[EventProvider] Loading events for month: $monthKey');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||||
|
final result = await _dataService.getEventsByMonth(
|
||||||
|
userId: userId,
|
||||||
|
year: year,
|
||||||
|
month: month
|
||||||
|
);
|
||||||
|
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
||||||
|
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
||||||
|
_usersCache.addAll(
|
||||||
|
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
|
||||||
|
);
|
||||||
|
|
||||||
|
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.parseMonthEvents');
|
||||||
|
List<EventModel> monthEvents = [];
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
// Parser les événements
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
monthEvents.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseMonthEvents');
|
||||||
|
|
||||||
|
// Stocker dans le cache par mois
|
||||||
|
_eventsByMonth[monthKey] = monthEvents;
|
||||||
|
|
||||||
|
// Mettre à jour _events et _currentMonth seulement si ce n'est pas un préchargement silencieux
|
||||||
|
if (!silent) {
|
||||||
|
_currentMonth = monthKey;
|
||||||
|
_events = monthEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les infos de cache global
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
|
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventProvider] Error loading month events: $e');
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Précharger les mois adjacents en arrière-plan
|
||||||
|
void preloadAdjacentMonths(String userId, int year, int month,
|
||||||
|
{bool canViewAllEvents = false}) {
|
||||||
|
|
||||||
|
// Mois précédent
|
||||||
|
final prevMonth = month == 1 ? 12 : month - 1;
|
||||||
|
final prevYear = month == 1 ? year - 1 : year;
|
||||||
|
|
||||||
|
// Mois suivant
|
||||||
|
final nextMonth = month == 12 ? 1 : month + 1;
|
||||||
|
final nextYear = month == 12 ? year + 1 : year;
|
||||||
|
|
||||||
|
print('[EventProvider] Preloading adjacent months...');
|
||||||
|
|
||||||
|
// Charger en arrière-plan (sans bloquer l'UI ni notifier)
|
||||||
|
Future.microtask(() async {
|
||||||
|
try {
|
||||||
|
await loadMonthEvents(userId, prevYear, prevMonth,
|
||||||
|
canViewAllEvents: canViewAllEvents, silent: true);
|
||||||
|
await loadMonthEvents(userId, nextYear, nextMonth,
|
||||||
|
canViewAllEvents: canViewAllEvents, silent: true);
|
||||||
|
print('[EventProvider] Adjacent months preloaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventProvider] Error preloading adjacent months: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vide entièrement le cache (mois + métadonnées) pour forcer un rechargement complet
|
||||||
|
void clearAllCache() {
|
||||||
|
_eventsByMonth.clear();
|
||||||
|
_lastLoadTime = null;
|
||||||
|
_lastUserId = null;
|
||||||
|
_currentMonth = null;
|
||||||
|
print('[EventProvider] Cache entièrement vidé');
|
||||||
|
}
|
||||||
|
|
||||||
/// Recharger les événements (utilise le dernier userId)
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer un événement spécifique par ID
|
/// Récupérer un événement spécifique par ID
|
||||||
@@ -82,8 +246,16 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Ajouter un nouvel événement
|
/// Ajouter un nouvel événement
|
||||||
Future<void> addEvent(EventModel event) async {
|
Future<void> addEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
// L'événement est créé via l'API dans le service
|
// Ajouter l'événement localement dans _events
|
||||||
await refreshEvents(_lastUserId ?? '', canViewAllEvents: _lastCanViewAll);
|
_events.add(event);
|
||||||
|
|
||||||
|
// Ajouter dans le cache par mois
|
||||||
|
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
if (_eventsByMonth.containsKey(monthKey)) {
|
||||||
|
_eventsByMonth[monthKey]!.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding event: $e');
|
print('Error adding event: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -93,10 +265,34 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Mettre à jour un événement
|
/// Mettre à jour un événement
|
||||||
Future<void> updateEvent(EventModel event) async {
|
Future<void> updateEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
// Mise à jour locale immédiate
|
// Mise à jour dans _events
|
||||||
final index = _events.indexWhere((e) => e.id == event.id);
|
final index = _events.indexWhere((e) => e.id == event.id);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final oldEvent = _events[index];
|
||||||
_events[index] = event;
|
_events[index] = event;
|
||||||
|
|
||||||
|
// Mettre à jour dans le cache par mois
|
||||||
|
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
|
||||||
|
if (oldMonthKey != newMonthKey) {
|
||||||
|
if (_eventsByMonth.containsKey(oldMonthKey)) {
|
||||||
|
_eventsByMonth[oldMonthKey]!.removeWhere((e) => e.id == event.id);
|
||||||
|
}
|
||||||
|
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||||
|
_eventsByMonth[newMonthKey]!.add(event);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Même mois, juste mettre à jour
|
||||||
|
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||||
|
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
|
||||||
|
if (monthIndex != -1) {
|
||||||
|
_eventsByMonth[newMonthKey]![monthIndex] = event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -109,7 +305,19 @@ class EventProvider with ChangeNotifier {
|
|||||||
Future<void> deleteEvent(String eventId) async {
|
Future<void> deleteEvent(String eventId) async {
|
||||||
try {
|
try {
|
||||||
await _dataService.deleteEvent(eventId);
|
await _dataService.deleteEvent(eventId);
|
||||||
|
|
||||||
|
// Trouver l'événement pour obtenir sa date avant de le supprimer
|
||||||
|
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
|
||||||
|
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// Supprimer de _events
|
||||||
_events.removeWhere((event) => event.id == eventId);
|
_events.removeWhere((event) => event.id == eventId);
|
||||||
|
|
||||||
|
// Supprimer du cache par mois
|
||||||
|
if (_eventsByMonth.containsKey(monthKey)) {
|
||||||
|
_eventsByMonth[monthKey]!.removeWhere((event) => event.id == eventId);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting event: $e');
|
print('Error deleting event: $e');
|
||||||
@@ -157,16 +365,9 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Vider la liste des événements
|
/// Vider la liste des événements
|
||||||
void clearEvents() {
|
void clearEvents() {
|
||||||
_events = [];
|
_events = [];
|
||||||
|
_lastLoadTime = null;
|
||||||
|
_lastUserId = null;
|
||||||
|
_lastCanViewAll = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variables pour stocker le dernier appel
|
|
||||||
String? _lastUserId;
|
|
||||||
bool _lastCanViewAll = false;
|
|
||||||
|
|
||||||
/// Sauvegarder les paramètres du dernier chargement
|
|
||||||
void _saveLastLoadParams(String userId, bool canViewAllEvents) {
|
|
||||||
_lastUserId = userId;
|
|
||||||
_lastCanViewAll = canViewAllEvents;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../models/notification_preferences_model.dart';
|
|||||||
import '../utils/firebase_storage_manager.dart';
|
import '../utils/firebase_storage_manager.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../services/data_service.dart';
|
import '../services/data_service.dart';
|
||||||
|
import '../utils/performance_monitor.dart';
|
||||||
|
|
||||||
class LocalUserProvider with ChangeNotifier {
|
class LocalUserProvider with ChangeNotifier {
|
||||||
UserModel? _currentUser;
|
UserModel? _currentUser;
|
||||||
@@ -15,6 +16,9 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||||
final DataService _dataService = DataService(apiService);
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
|
bool _isLoadingUserData = false;
|
||||||
|
DateTime? _lastUserDataLoad;
|
||||||
|
|
||||||
UserModel? get currentUser => _currentUser;
|
UserModel? get currentUser => _currentUser;
|
||||||
String? get uid => _currentUser?.uid;
|
String? get uid => _currentUser?.uid;
|
||||||
String? get firstName => _currentUser?.firstName;
|
String? get firstName => _currentUser?.firstName;
|
||||||
@@ -25,18 +29,46 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
String? get phoneNumber => _currentUser?.phoneNumber;
|
String? get phoneNumber => _currentUser?.phoneNumber;
|
||||||
RoleModel? get currentRole => _currentRole;
|
RoleModel? get currentRole => _currentRole;
|
||||||
List<String> get permissions => _currentRole?.permissions ?? [];
|
List<String> get permissions => _currentRole?.permissions ?? [];
|
||||||
|
bool get isLoadingUserData => _isLoadingUserData;
|
||||||
|
|
||||||
|
/// Vérifie si les données utilisateur doivent être rechargées
|
||||||
|
bool _shouldReloadUserData() {
|
||||||
|
if (_currentUser == null) return true;
|
||||||
|
if (_lastUserDataLoad == null) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(_lastUserDataLoad!);
|
||||||
|
return difference.inMinutes > 5; // Cache de 5 minutes pour les données utilisateur
|
||||||
|
}
|
||||||
|
|
||||||
/// Charge les données de l'utilisateur actuel via Cloud Function
|
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||||
Future<void> loadUserData() async {
|
Future<void> loadUserData({bool forceReload = false}) async {
|
||||||
if (_auth.currentUser == null) {
|
if (_auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Éviter les rechargements inutiles
|
||||||
|
if (!forceReload && !_shouldReloadUserData()) {
|
||||||
|
print('Using cached user data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éviter les appels simultanés
|
||||||
|
if (_isLoadingUserData) {
|
||||||
|
print('User data already loading, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoadingUserData = true;
|
||||||
|
PerformanceMonitor.start('LocalUserProvider.loadUserData');
|
||||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
// Utiliser la Cloud Function getCurrentUser
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
|
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
||||||
final result = await apiService.call('getCurrentUser', {});
|
final result = await apiService.call('getCurrentUser', {});
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.getCurrentUser_API');
|
||||||
|
|
||||||
final userData = result['user'] as Map<String, dynamic>;
|
final userData = result['user'] as Map<String, dynamic>;
|
||||||
|
|
||||||
print('User data loaded from API: ${userData['uid']}');
|
print('User data loaded from API: ${userData['uid']}');
|
||||||
@@ -59,9 +91,14 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
print('User data loaded successfully');
|
print('User data loaded successfully');
|
||||||
|
_lastUserDataLoad = DateTime.now();
|
||||||
|
_isLoadingUserData = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading user data: $e');
|
print('Error loading user data: $e');
|
||||||
|
_isLoadingUserData = false;
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +113,8 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
void clearUser() {
|
void clearUser() {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
_currentRole = null;
|
_currentRole = null;
|
||||||
|
_lastUserDataLoad = null;
|
||||||
|
_isLoadingUserData = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +196,8 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
||||||
email: email, password: password);
|
email: email, password: password);
|
||||||
await loadUserData();
|
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
||||||
|
// pour ne pas bloquer la navigation
|
||||||
return userCredential;
|
return userCredential;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw FirebaseAuthException(code: 'login-failed', message: e.toString());
|
throw FirebaseAuthException(code: 'login-failed', message: e.toString());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
144
em2rp/lib/services/audio_feedback_service.dart
Normal file
144
em2rp/lib/services/audio_feedback_service.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service pour émettre des feedbacks sonores lors des interactions (Web)
|
||||||
|
class AudioFeedbackService {
|
||||||
|
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 {
|
||||||
|
await _playSound('assets/assets/sounds/ok.mp3', 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un son d'erreur
|
||||||
|
static Future<void> playErrorBeep() async {
|
||||||
|
await _playSound('assets/assets/sounds/error.mp3', 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un feedback complet (son uniquement, sans vibration)
|
||||||
|
static Future<void> playFullFeedback({bool isSuccess = true}) async {
|
||||||
|
if (isSuccess) {
|
||||||
|
await playSuccessBeep();
|
||||||
|
} else {
|
||||||
|
await playErrorBeep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
_isInitialized = false;
|
||||||
|
_audioUnlocked = false;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error disposing', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/config/api_config.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Service de Text-to-Speech utilisant Google Cloud TTS via Cloud Function
|
||||||
|
/// Avec système de cache pour optimiser les performances et réduire les coûts
|
||||||
|
class CloudTextToSpeechService {
|
||||||
|
static final Map<String, String> _audioCache = {};
|
||||||
|
static final Map<String, web.HTMLAudioElement> _audioPlayers = {};
|
||||||
|
|
||||||
|
/// Générer l'audio TTS via Cloud Function
|
||||||
|
/// Retourne l'URL de l'audio (mise en cache automatiquement côté serveur)
|
||||||
|
static Future<String?> generateAudio(String text) async {
|
||||||
|
try {
|
||||||
|
// Vérifier le cache local d'abord
|
||||||
|
if (_audioCache.containsKey(text)) {
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Local cache HIT: "${text.substring(0, 30)}..."');
|
||||||
|
return _audioCache[text];
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Generating audio for: "$text"');
|
||||||
|
|
||||||
|
// Récupérer le token d'authentification
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
DebugLog.error('[CloudTTS] User not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = await user.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
DebugLog.error('[CloudTTS] Failed to get auth token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer la requête
|
||||||
|
final url = '${ApiConfig.baseUrl}/generateTTSV2';
|
||||||
|
final headers = {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
final body = json.encode({
|
||||||
|
'data': {
|
||||||
|
'text': text,
|
||||||
|
'voiceConfig': {
|
||||||
|
'languageCode': 'fr-FR',
|
||||||
|
'name': 'fr-FR-Standard-B', // Voix masculine gratuite
|
||||||
|
'ssmlGender': 'MALE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Calling Cloud Function...');
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
// Appeler la Cloud Function
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
final duration = DateTime.now().difference(startTime).inMilliseconds;
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final audioUrl = data['audioUrl'] as String?;
|
||||||
|
final cached = data['cached'] as bool? ?? false;
|
||||||
|
|
||||||
|
if (audioUrl != null) {
|
||||||
|
// Mettre en cache localement
|
||||||
|
_audioCache[text] = audioUrl;
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Audio generated - cached: $cached, duration: ${duration}ms');
|
||||||
|
|
||||||
|
return audioUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.error('[CloudTTS] Failed to generate audio', {
|
||||||
|
'status': response.statusCode,
|
||||||
|
'body': response.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Exception:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un audio depuis une URL
|
||||||
|
static void playAudio(String audioUrl) {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[CloudTTS] Playing audio...');
|
||||||
|
|
||||||
|
// Créer ou réutiliser un HTMLAudioElement
|
||||||
|
final player = _audioPlayers[audioUrl] ?? web.HTMLAudioElement();
|
||||||
|
if (!_audioPlayers.containsKey(audioUrl)) {
|
||||||
|
player.src = audioUrl;
|
||||||
|
_audioPlayers[audioUrl] = player;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurer le volume
|
||||||
|
player.volume = 1.0;
|
||||||
|
|
||||||
|
// Écouter les événements
|
||||||
|
player.onEnded.listen((_) {
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Playback finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.onError.listen((event) {
|
||||||
|
DebugLog.error('[CloudTTS] ✗ Playback error:', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lire l'audio (pas de await avec package:web)
|
||||||
|
player.play();
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Playback started');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Error playing audio:', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer et lire l'audio en une seule opération
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
try {
|
||||||
|
final audioUrl = await generateAudio(text);
|
||||||
|
|
||||||
|
if (audioUrl != null) {
|
||||||
|
playAudio(audioUrl);
|
||||||
|
} else {
|
||||||
|
DebugLog.error('[CloudTTS] Failed to generate audio for speech');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Error in speak:', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter tous les audios en cours
|
||||||
|
static void stopAll() {
|
||||||
|
for (final player in _audioPlayers.values) {
|
||||||
|
try {
|
||||||
|
player.pause();
|
||||||
|
player.currentTime = 0;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorer les erreurs de pause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DebugLog.info('[CloudTTS] All players stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer le cache
|
||||||
|
static void clearCache() {
|
||||||
|
_audioCache.clear();
|
||||||
|
_audioPlayers.clear();
|
||||||
|
DebugLog.info('[CloudTTS] Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pré-charger des audios fréquemment utilisés
|
||||||
|
static Future<void> preloadCommonPhrases() async {
|
||||||
|
final phrases = [
|
||||||
|
'Équipement scanné',
|
||||||
|
'Flight case',
|
||||||
|
'Conteneur',
|
||||||
|
'Validé',
|
||||||
|
'Erreur',
|
||||||
|
];
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Preloading ${phrases.length} common phrases...');
|
||||||
|
|
||||||
|
for (final phrase in phrases) {
|
||||||
|
try {
|
||||||
|
await generateAudio(phrase);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.warning('[CloudTTS] Failed to preload: $phrase - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Preload complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,8 @@ class DataService {
|
|||||||
/// Met à jour un événement
|
/// Met à jour un événement
|
||||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'eventId': eventId, 'data': data};
|
// Correction : fusionner eventId et les champs de data à la racine
|
||||||
|
final requestData = {'eventId': eventId, ...data};
|
||||||
await _apiService.call('updateEvent', requestData);
|
await _apiService.call('updateEvent', requestData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||||
@@ -195,7 +196,11 @@ class DataService {
|
|||||||
/// Crée une option
|
/// Crée une option
|
||||||
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'code': code, ...data};
|
final requestData = {
|
||||||
|
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
|
||||||
|
'code': code,
|
||||||
|
...data
|
||||||
|
};
|
||||||
final result = await _apiService.call('createOption', requestData);
|
final result = await _apiService.call('createOption', requestData);
|
||||||
return result['id'] as String? ?? code;
|
return result['id'] as String? ?? code;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -206,7 +211,7 @@ class DataService {
|
|||||||
/// Met à jour une option
|
/// Met à jour une option
|
||||||
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'optionId': optionId, ...data};
|
final requestData = {'optionId': optionId, 'data': data};
|
||||||
await _apiService.call('updateOption', requestData);
|
await _apiService.call('updateOption', requestData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||||
@@ -248,6 +253,65 @@ class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
|
Future<Map<String, dynamic>> getEventsByMonth({
|
||||||
|
required String userId,
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getEventsByMonth for $year-$month');
|
||||||
|
final result = await _apiService.call('getEventsByMonth', {
|
||||||
|
'userId': userId,
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting events by month: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||||
|
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Getting event with details: $eventId');
|
||||||
|
final result = await _apiService.call('getEventWithDetails', {
|
||||||
|
'eventId': eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final event = result['event'] as Map<String, dynamic>?;
|
||||||
|
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||||
|
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
if (event == null) {
|
||||||
|
throw Exception('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'event': event,
|
||||||
|
'equipments': equipments,
|
||||||
|
'containers': containers,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting event with details: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||||
Future<List<Map<String, dynamic>>> getEquipments() async {
|
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import 'package:cloud_functions/cloud_functions.dart';
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
/// Service d'envoi d'emails via Cloud Functions
|
/// Service d'envoi d'emails via Cloud Functions
|
||||||
class EmailService {
|
class EmailService {
|
||||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
|
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||||
|
|
||||||
/// Envoie un email d'alerte à un utilisateur
|
/// Envoie un email d'alerte à un utilisateur
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class EventFormService {
|
|||||||
required String sourcePath,
|
required String sourcePath,
|
||||||
required String destinationPath,
|
required String destinationPath,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
final url = Uri.parse('https://europe-west9-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
final idToken = await user?.getIdToken();
|
final idToken = await user?.getIdToken();
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
|
||||||
import 'package:em2rp/services/api_service.dart';
|
|
||||||
|
|
||||||
/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour
|
|
||||||
class EventPreparationServiceExtended {
|
|
||||||
final ApiService _apiService = apiService;
|
|
||||||
|
|
||||||
|
|
||||||
// === CHARGEMENT (LOADING) ===
|
|
||||||
|
|
||||||
/// Valider un équipement individuel pour le chargement
|
|
||||||
Future<void> validateEquipmentLoading(String eventId, String equipmentId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateEquipmentLoading', {
|
|
||||||
'eventId': eventId,
|
|
||||||
'equipmentId': equipmentId,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating equipment loading: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Valider tous les équipements pour le chargement
|
|
||||||
Future<void> validateAllLoading(String eventId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateAllLoading', {
|
|
||||||
'eventId': eventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all loading: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DÉCHARGEMENT (UNLOADING) ===
|
|
||||||
|
|
||||||
/// Valider un équipement individuel pour le déchargement
|
|
||||||
Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateEquipmentUnloading', {
|
|
||||||
'eventId': eventId,
|
|
||||||
'equipmentId': equipmentId,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating equipment unloading: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Valider tous les équipements pour le déchargement
|
|
||||||
Future<void> validateAllUnloading(String eventId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateAllUnloading', {
|
|
||||||
'eventId': eventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all unloading: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === PRÉPARATION + CHARGEMENT ===
|
|
||||||
|
|
||||||
/// Valider préparation ET chargement en même temps
|
|
||||||
Future<void> validateAllPreparationAndLoading(String eventId) async {
|
|
||||||
try {
|
|
||||||
// Note: On pourrait créer une fonction cloud dédiée pour ça,
|
|
||||||
// mais pour l'instant on appelle les deux séquentiellement
|
|
||||||
await _apiService.call('validateAllPreparation', {'eventId': eventId});
|
|
||||||
await _apiService.call('validateAllLoading', {'eventId': eventId});
|
|
||||||
|
|
||||||
// Invalider le cache
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all preparation and loading: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DÉCHARGEMENT + RETOUR ===
|
|
||||||
|
|
||||||
/// Valider déchargement ET retour en même temps
|
|
||||||
Future<void> validateAllUnloadingAndReturn(
|
|
||||||
String eventId,
|
|
||||||
Map<String, int>? returnedQuantities,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
// Note: On pourrait créer une fonction cloud dédiée pour ça,
|
|
||||||
// mais pour l'instant on appelle les deux séquentiellement
|
|
||||||
await _apiService.call('validateAllUnloading', {'eventId': eventId});
|
|
||||||
await _apiService.call('validateAllReturn', {
|
|
||||||
'eventId': eventId,
|
|
||||||
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalider le cache
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all unloading and return: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
280
em2rp/lib/services/event_statistics_service.dart
Normal file
280
em2rp/lib/services/event_statistics_service.dart
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_statistics_models.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsService {
|
||||||
|
const EventStatisticsService();
|
||||||
|
|
||||||
|
static const double _taxRatio = 1.2;
|
||||||
|
|
||||||
|
EventStatisticsSummary buildSummary({
|
||||||
|
required List<EventModel> events,
|
||||||
|
required EventStatisticsFilter filter,
|
||||||
|
required Map<String, String> eventTypeNames,
|
||||||
|
}) {
|
||||||
|
final filteredEvents =
|
||||||
|
events.where((event) => _matchesFilter(event, filter)).toList();
|
||||||
|
|
||||||
|
if (filteredEvents.isEmpty) {
|
||||||
|
return EventStatisticsSummary.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedEvents = 0;
|
||||||
|
var pendingEvents = 0;
|
||||||
|
var canceledEvents = 0;
|
||||||
|
|
||||||
|
var validatedAmount = 0.0;
|
||||||
|
var pendingAmount = 0.0;
|
||||||
|
var canceledAmount = 0.0;
|
||||||
|
|
||||||
|
var baseAmount = 0.0;
|
||||||
|
var optionsAmount = 0.0;
|
||||||
|
|
||||||
|
final eventAmounts = <double>[];
|
||||||
|
final byType = <String, _EventTypeAccumulator>{};
|
||||||
|
final optionStats = <String, _OptionAccumulator>{};
|
||||||
|
|
||||||
|
for (final event in filteredEvents) {
|
||||||
|
final base = _toHtAmount(event.basePrice);
|
||||||
|
final optionTotal = _computeOptionsTotal(event);
|
||||||
|
final amount = base + optionTotal;
|
||||||
|
final isValidated = event.status == EventStatus.confirmed;
|
||||||
|
|
||||||
|
eventAmounts.add(amount);
|
||||||
|
baseAmount += base;
|
||||||
|
optionsAmount += optionTotal;
|
||||||
|
|
||||||
|
switch (event.status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
validatedEvents += 1;
|
||||||
|
validatedAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
pendingEvents += 1;
|
||||||
|
pendingAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
canceledEvents += 1;
|
||||||
|
canceledAmount += amount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eventTypeId = event.eventTypeId;
|
||||||
|
final eventTypeName = eventTypeNames[eventTypeId] ?? 'Type inconnu';
|
||||||
|
final typeAccumulator = byType.putIfAbsent(
|
||||||
|
eventTypeId,
|
||||||
|
() => _EventTypeAccumulator(
|
||||||
|
eventTypeId: eventTypeId, eventTypeName: eventTypeName),
|
||||||
|
);
|
||||||
|
typeAccumulator.totalEvents += 1;
|
||||||
|
typeAccumulator.totalAmount += amount;
|
||||||
|
switch (event.status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
typeAccumulator.validatedAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
typeAccumulator.pendingAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
typeAccumulator.canceledAmount += amount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final rawOption in event.options) {
|
||||||
|
final optionPrice = _toHtAmount(_toDouble(rawOption['price']));
|
||||||
|
final optionQuantity = _toInt(rawOption['quantity'], fallback: 1);
|
||||||
|
if (optionPrice == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final optionId = (rawOption['id'] ??
|
||||||
|
rawOption['code'] ??
|
||||||
|
rawOption['name'] ??
|
||||||
|
'option')
|
||||||
|
.toString();
|
||||||
|
final optionLabel = _buildOptionLabel(rawOption, optionId);
|
||||||
|
final optionAmount = optionPrice * optionQuantity;
|
||||||
|
|
||||||
|
final optionAccumulator = optionStats.putIfAbsent(
|
||||||
|
optionId,
|
||||||
|
() =>
|
||||||
|
_OptionAccumulator(optionKey: optionId, optionLabel: optionLabel),
|
||||||
|
);
|
||||||
|
optionAccumulator.usageCount += 1;
|
||||||
|
if (isValidated) {
|
||||||
|
optionAccumulator.validatedUsageCount += 1;
|
||||||
|
}
|
||||||
|
optionAccumulator.quantity += optionQuantity;
|
||||||
|
optionAccumulator.totalAmount += optionAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final byEventType = byType.values
|
||||||
|
.map((accumulator) => EventTypeStatistics(
|
||||||
|
eventTypeId: accumulator.eventTypeId,
|
||||||
|
eventTypeName: accumulator.eventTypeName,
|
||||||
|
totalEvents: accumulator.totalEvents,
|
||||||
|
totalAmount: accumulator.totalAmount,
|
||||||
|
validatedAmount: accumulator.validatedAmount,
|
||||||
|
pendingAmount: accumulator.pendingAmount,
|
||||||
|
canceledAmount: accumulator.canceledAmount,
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.totalAmount.compareTo(a.totalAmount));
|
||||||
|
|
||||||
|
final topOptions = optionStats.values
|
||||||
|
.map((accumulator) => OptionStatistics(
|
||||||
|
optionKey: accumulator.optionKey,
|
||||||
|
optionLabel: accumulator.optionLabel,
|
||||||
|
usageCount: accumulator.usageCount,
|
||||||
|
validatedUsageCount: accumulator.validatedUsageCount,
|
||||||
|
quantity: accumulator.quantity,
|
||||||
|
totalAmount: accumulator.totalAmount,
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final validatedComparison =
|
||||||
|
b.validatedUsageCount.compareTo(a.validatedUsageCount);
|
||||||
|
if (validatedComparison != 0) {
|
||||||
|
return validatedComparison;
|
||||||
|
}
|
||||||
|
return b.totalAmount.compareTo(a.totalAmount);
|
||||||
|
});
|
||||||
|
|
||||||
|
return EventStatisticsSummary(
|
||||||
|
totalEvents: filteredEvents.length,
|
||||||
|
validatedEvents: validatedEvents,
|
||||||
|
pendingEvents: pendingEvents,
|
||||||
|
canceledEvents: canceledEvents,
|
||||||
|
totalAmount: validatedAmount + pendingAmount + canceledAmount,
|
||||||
|
validatedAmount: validatedAmount,
|
||||||
|
pendingAmount: pendingAmount,
|
||||||
|
canceledAmount: canceledAmount,
|
||||||
|
baseAmount: baseAmount,
|
||||||
|
optionsAmount: optionsAmount,
|
||||||
|
medianAmount: _computeMedian(eventAmounts),
|
||||||
|
byEventType: byEventType,
|
||||||
|
topOptions: topOptions.take(8).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesFilter(EventModel event, EventStatisticsFilter filter) {
|
||||||
|
if (!_overlapsRange(event, filter.period)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.selectedStatuses.contains(event.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.eventTypeIds.isNotEmpty &&
|
||||||
|
!filter.eventTypeIds.contains(event.eventTypeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsRange(EventModel event, DateTimeRange range) {
|
||||||
|
return !event.endDateTime.isBefore(range.start) &&
|
||||||
|
!event.startDateTime.isAfter(range.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _computeOptionsTotal(EventModel event) {
|
||||||
|
return event.options.fold<double>(0.0, (sum, option) {
|
||||||
|
final optionPrice = _toHtAmount(_toDouble(option['price']));
|
||||||
|
final optionQuantity = _toInt(option['quantity'], fallback: 1);
|
||||||
|
return sum + (optionPrice * optionQuantity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
double _toHtAmount(double storedAmount) {
|
||||||
|
return storedAmount / _taxRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _toDouble(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toDouble();
|
||||||
|
}
|
||||||
|
return double.tryParse(value.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _toInt(dynamic value, {int fallback = 0}) {
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toInt();
|
||||||
|
}
|
||||||
|
return int.tryParse(value.toString()) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildOptionLabel(Map<String, dynamic> option, String fallback) {
|
||||||
|
final code = (option['code'] ?? '').toString().trim();
|
||||||
|
final name = (option['name'] ?? '').toString().trim();
|
||||||
|
|
||||||
|
if (code.isNotEmpty && name.isNotEmpty) {
|
||||||
|
return '$code - $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.isNotEmpty) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _computeMedian(List<double> values) {
|
||||||
|
if (values.isEmpty) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sorted = [...values]..sort();
|
||||||
|
final middleIndex = sorted.length ~/ 2;
|
||||||
|
|
||||||
|
if (sorted.length.isOdd) {
|
||||||
|
return sorted[middleIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventTypeAccumulator {
|
||||||
|
final String eventTypeId;
|
||||||
|
final String eventTypeName;
|
||||||
|
int totalEvents = 0;
|
||||||
|
double totalAmount = 0.0;
|
||||||
|
double validatedAmount = 0.0;
|
||||||
|
double pendingAmount = 0.0;
|
||||||
|
double canceledAmount = 0.0;
|
||||||
|
|
||||||
|
_EventTypeAccumulator({
|
||||||
|
required this.eventTypeId,
|
||||||
|
required this.eventTypeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionAccumulator {
|
||||||
|
final String optionKey;
|
||||||
|
final String optionLabel;
|
||||||
|
int usageCount = 0;
|
||||||
|
int validatedUsageCount = 0;
|
||||||
|
int quantity = 0;
|
||||||
|
double totalAmount = 0.0;
|
||||||
|
|
||||||
|
_OptionAccumulator({
|
||||||
|
required this.optionKey,
|
||||||
|
required this.optionLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:em2rp/config/app_version.dart';
|
import 'package:em2rp/config/app_version.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class IcsExportService {
|
class IcsExportService {
|
||||||
@@ -17,7 +16,7 @@ class IcsExportService {
|
|||||||
Map<String, String>? optionNames,
|
Map<String, String>? optionNames,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
final timestamp = '${DateFormat('yyyyMMddTHHmmss').format(now)}Z';
|
||||||
|
|
||||||
// Récupérer les informations supplémentaires
|
// Récupérer les informations supplémentaires
|
||||||
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
||||||
@@ -239,7 +238,7 @@ END:VCALENDAR''';
|
|||||||
/// Formate une date au format ICS (yyyyMMddTHHmmssZ)
|
/// Formate une date au format ICS (yyyyMMddTHHmmssZ)
|
||||||
static String _formatDateForIcs(DateTime dateTime) {
|
static String _formatDateForIcs(DateTime dateTime) {
|
||||||
final utcDate = dateTime.toUtc();
|
final utcDate = dateTime.toUtc();
|
||||||
return DateFormat('yyyyMMddTHHmmss').format(utcDate) + 'Z';
|
return '${DateFormat('yyyyMMddTHHmmss').format(utcDate)}Z';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Échappe les caractères spéciaux pour le format ICS
|
/// Échappe les caractères spéciaux pour le format ICS
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
@@ -286,7 +285,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 +298,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,
|
||||||
|
|||||||
240
em2rp/lib/services/qr_code_processing_service.dart
Normal file
240
em2rp/lib/services/qr_code_processing_service.dart
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/qr_code_process_result.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service pour traiter les codes QR scannés ou saisis manuellement
|
||||||
|
/// pendant la préparation d'un événement
|
||||||
|
class QRCodeProcessingService {
|
||||||
|
/// Traiter un code (équipement ou container)
|
||||||
|
Future<QRCodeProcessResult> processCode({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step, // Changed to dynamic to accept any PreparationStep enum
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, ContainerModel> containerCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Processing code: $code');
|
||||||
|
|
||||||
|
// Identifier le type selon le préfixe
|
||||||
|
final isContainer = code.startsWith('BOX_');
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
return await _processContainer(
|
||||||
|
code: code,
|
||||||
|
event: event,
|
||||||
|
step: step,
|
||||||
|
equipmentCache: equipmentCache,
|
||||||
|
containerCache: containerCache,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return await _processEquipment(
|
||||||
|
code: code,
|
||||||
|
event: event,
|
||||||
|
step: step,
|
||||||
|
equipmentCache: equipmentCache,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[QRCodeProcessingService] Error processing code', e);
|
||||||
|
return QRCodeProcessResult.error('Erreur lors du traitement du code: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un code d'équipement
|
||||||
|
Future<QRCodeProcessResult> _processEquipment({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
// Chercher l'équipement dans les équipements assignés
|
||||||
|
final eventEquipment = event.assignedEquipment
|
||||||
|
.cast<EventEquipment?>()
|
||||||
|
.firstWhere(
|
||||||
|
(eq) => eq?.equipmentId == code,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventEquipment == null) {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Equipment $code not found in event');
|
||||||
|
return QRCodeProcessResult.notFoundInEvent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
final equipment = equipmentCache[code];
|
||||||
|
final equipmentName = equipment?.name ?? 'Équipement inconnu';
|
||||||
|
|
||||||
|
// Vérifier si l'équipement a des quantités
|
||||||
|
if (equipment?.hasQuantity ?? false) {
|
||||||
|
return _processQuantitativeEquipment(
|
||||||
|
code: code,
|
||||||
|
equipmentName: equipmentName,
|
||||||
|
eventEquipment: eventEquipment,
|
||||||
|
step: step,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _processNonQuantitativeEquipment(
|
||||||
|
code: code,
|
||||||
|
equipmentName: equipmentName,
|
||||||
|
validationState: validationState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un équipement quantitatif (incrémenter la quantité)
|
||||||
|
QRCodeProcessResult _processQuantitativeEquipment({
|
||||||
|
required String code,
|
||||||
|
required String equipmentName,
|
||||||
|
required EventEquipment eventEquipment,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) {
|
||||||
|
final currentQty = currentQuantities[code] ?? 0;
|
||||||
|
final targetQty = _getTargetQuantity(eventEquipment, step);
|
||||||
|
|
||||||
|
// Vérifier si on a déjà atteint la quantité cible
|
||||||
|
if (currentQty >= targetQty) {
|
||||||
|
return QRCodeProcessResult.error(
|
||||||
|
'Quantité cible déjà atteinte pour $equipmentName ($currentQty/$targetQty)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrémenter la quantité
|
||||||
|
final newQty = currentQty + 1;
|
||||||
|
final shouldCheck = newQty >= targetQty;
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: '$equipmentName : $newQty/$targetQty${shouldCheck ? " ✓" : ""}',
|
||||||
|
affectedEquipmentIds: [code],
|
||||||
|
updatedQuantities: {code: newQty},
|
||||||
|
updatedValidationState: shouldCheck ? {code: true} : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un équipement non quantitatif (cocher)
|
||||||
|
QRCodeProcessResult _processNonQuantitativeEquipment({
|
||||||
|
required String code,
|
||||||
|
required String equipmentName,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
}) {
|
||||||
|
// Vérifier si déjà coché
|
||||||
|
if (validationState[code] == true) {
|
||||||
|
return QRCodeProcessResult.error('$equipmentName est déjà coché');
|
||||||
|
}
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: '$equipmentName a été coché ✓',
|
||||||
|
affectedEquipmentIds: [code],
|
||||||
|
updatedValidationState: {code: true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un code de container (cocher tous les enfants)
|
||||||
|
Future<QRCodeProcessResult> _processContainer({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, ContainerModel> containerCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
// Vérifier que le container est assigné à l'événement
|
||||||
|
if (!event.assignedContainers.contains(code)) {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Container $code not found in event');
|
||||||
|
return QRCodeProcessResult.notFoundInEvent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerCache[code];
|
||||||
|
if (container == null) {
|
||||||
|
return QRCodeProcessResult.error('Container introuvable dans le cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter tous les équipements enfants
|
||||||
|
final updatedValidation = <String, bool>{};
|
||||||
|
final updatedQuantities = <String, int>{};
|
||||||
|
int processedCount = 0;
|
||||||
|
|
||||||
|
for (final childId in container.equipmentIds) {
|
||||||
|
final childEventEq = event.assignedEquipment
|
||||||
|
.cast<EventEquipment?>()
|
||||||
|
.firstWhere(
|
||||||
|
(eq) => eq?.equipmentId == childId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (childEventEq == null) continue;
|
||||||
|
|
||||||
|
final childEquipment = equipmentCache[childId];
|
||||||
|
|
||||||
|
// Si quantitatif, mettre la quantité actuelle = quantité cible
|
||||||
|
if (childEquipment?.hasQuantity ?? false) {
|
||||||
|
final targetQty = _getTargetQuantity(childEventEq, step);
|
||||||
|
updatedQuantities[childId] = targetQty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cocher l'enfant
|
||||||
|
updatedValidation[childId] = true;
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedCount == 0) {
|
||||||
|
return QRCodeProcessResult.error(
|
||||||
|
'Aucun équipement trouvé dans le container ${container.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: 'Container ${container.name} : $processedCount équipement(s) validé(s) ✓',
|
||||||
|
affectedEquipmentIds: updatedValidation.keys.toList(),
|
||||||
|
updatedValidationState: updatedValidation,
|
||||||
|
updatedQuantities: updatedQuantities.isNotEmpty ? updatedQuantities : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir la quantité requise selon l'étape
|
||||||
|
/// Logique: chaque étape utilise la quantité actuelle de l'étape N-1
|
||||||
|
int _getTargetQuantity(EventEquipment eventEquipment, dynamic step) {
|
||||||
|
// Convertir l'enum en string pour comparer
|
||||||
|
final stepString = step.toString().split('.').last;
|
||||||
|
|
||||||
|
switch (stepString) {
|
||||||
|
case 'preparation':
|
||||||
|
// Étape 1 : Quantité définie à la création de l'événement
|
||||||
|
return eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'loadingOutbound':
|
||||||
|
// Étape 2 : Quantité validée à l'étape 1 (préparation)
|
||||||
|
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'unloadingReturn':
|
||||||
|
// Étape 3 : Quantité validée à l'étape 2 (chargement)
|
||||||
|
return eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'return_':
|
||||||
|
// Étape 4 : Quantité validée à l'étape 3 (déchargement)
|
||||||
|
return eventEquipment.quantityAtUnloading ??
|
||||||
|
eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return eventEquipment.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|||||||
78
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
78
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:em2rp/services/cloud_text_to_speech_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service de synthèse vocale utilisant exclusivement Google Cloud TTS
|
||||||
|
/// Garantit une qualité et une compatibilité maximales sur tous les navigateurs
|
||||||
|
class SmartTextToSpeechService {
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initialiser le service
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[SmartTTS] Initializing Cloud TTS only...');
|
||||||
|
|
||||||
|
// Pré-charger les phrases courantes pour Cloud TTS
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
CloudTextToSpeechService.preloadCommonPhrases();
|
||||||
|
});
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
DebugLog.info('[SmartTTS] ✓ Initialized (Cloud TTS only)');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Initialization error', e);
|
||||||
|
_initialized = true; // Continuer quand même
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un texte à haute voix avec Google Cloud TTS
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[SmartTTS] → Using Cloud TTS');
|
||||||
|
await CloudTextToSpeechService.speak(text);
|
||||||
|
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter toute lecture en cours
|
||||||
|
static Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
CloudTextToSpeechService.stopAll();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Error stopping', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si une lecture est en cours
|
||||||
|
static Future<bool> isSpeaking() async {
|
||||||
|
// Cloud TTS n'a pas de méthode native pour vérifier le statut
|
||||||
|
// Retourner false par défaut (peut être amélioré si nécessaire)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir le statut actuel
|
||||||
|
static Map<String, dynamic> getStatus() {
|
||||||
|
return {
|
||||||
|
'initialized': _initialized,
|
||||||
|
'currentStrategy': 'Cloud TTS (exclusive)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
CloudTextToSpeechService.clearCache();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Error disposing', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -80,9 +80,23 @@ class UpdateService {
|
|||||||
/// Force le rechargement de l'application (vide le cache)
|
/// Force le rechargement de l'application (vide le cache)
|
||||||
static Future<void> reloadApp() async {
|
static Future<void> reloadApp() async {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
// Pour le web, recharger la page en utilisant JavaScript
|
// Pour le web, recharger la page en vidant le cache
|
||||||
|
// Utiliser window.location.reload(true) force un rechargement depuis le serveur
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Reloading app...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// On utilise launchUrl avec le mode _self pour recharger dans la même fenêtre
|
||||||
|
// Le paramètre de cache-busting garantit un nouveau chargement
|
||||||
final url = Uri.base;
|
final url = Uri.base;
|
||||||
await launchUrl(url, webOnlyWindowName: '_self');
|
final reloadUrl = url.replace(
|
||||||
|
queryParameters: {
|
||||||
|
...url.queryParameters,
|
||||||
|
'_reload': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await launchUrl(reloadUrl, webOnlyWindowName: '_self');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ class CalendarUtils {
|
|||||||
|
|
||||||
static List<EventModel> getEventsForDay(
|
static List<EventModel> getEventsForDay(
|
||||||
DateTime day, List<EventModel> events) {
|
DateTime day, List<EventModel> events) {
|
||||||
final dayStart = DateTime(day.year, day.month, day.day, 0, 0);
|
final nextDay = day.add(const Duration(days: 1));
|
||||||
final dayEnd = DateTime(day.year, day.month, day.day, 23, 59, 59);
|
final dayStart = DateTime(day.year, day.month, day.day, 2, 0);
|
||||||
|
final dayEnd = DateTime(nextDay.year, nextDay.month, nextDay.day, 2, 59, 59);
|
||||||
|
|
||||||
return events.where((event) {
|
return events.where((event) {
|
||||||
return !(event.endDateTime.isBefore(dayStart) ||
|
return !(event.endDateTime.isBefore(dayStart) ||
|
||||||
|
|||||||
129
em2rp/lib/utils/performance_monitor.dart
Normal file
129
em2rp/lib/utils/performance_monitor.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Service de monitoring des performances de l'application
|
||||||
|
/// Permet de mesurer les temps de chargement et d'identifier les goulots d'étranglement
|
||||||
|
class PerformanceMonitor {
|
||||||
|
static final Map<String, DateTime> _timings = {};
|
||||||
|
static final Map<String, Duration> _results = {};
|
||||||
|
static bool _enabled = kDebugMode; // Actif uniquement en mode debug par défaut
|
||||||
|
|
||||||
|
/// Active ou désactive le monitoring
|
||||||
|
static void setEnabled(bool enabled) {
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre le chronomètre pour une opération
|
||||||
|
static void start(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
_timings[key] = DateTime.now();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] START: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrête le chronomètre et affiche le résultat
|
||||||
|
static void end(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
if (_timings.containsKey(key)) {
|
||||||
|
final duration = DateTime.now().difference(_timings[key]!);
|
||||||
|
_results[key] = duration;
|
||||||
|
_timings.remove(key);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
final color = _getColorForDuration(duration);
|
||||||
|
print('[PerformanceMonitor] $color END: $key - ${duration.inMilliseconds}ms');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] ⚠️ No start time found for: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque un point dans le temps (pour mesurer des étapes)
|
||||||
|
static void mark(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] 📍 MARK: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les résultats de toutes les mesures
|
||||||
|
static Map<String, Duration> getResults() {
|
||||||
|
return Map.unmodifiable(_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche un résumé des performances
|
||||||
|
static void printSummary() {
|
||||||
|
if (!_enabled || _results.isEmpty) return;
|
||||||
|
|
||||||
|
print('\n${'=' * 60}');
|
||||||
|
print('PERFORMANCE SUMMARY');
|
||||||
|
print('=' * 60);
|
||||||
|
|
||||||
|
// Trier par durée décroissante
|
||||||
|
final sortedResults = _results.entries.toList()
|
||||||
|
..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
|
||||||
|
for (var entry in sortedResults) {
|
||||||
|
final color = _getColorForDuration(entry.value);
|
||||||
|
final ms = entry.value.inMilliseconds;
|
||||||
|
print('$color ${entry.key.padRight(40)} : ${ms.toString().padLeft(6)}ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = _results.values.fold<Duration>(
|
||||||
|
Duration.zero,
|
||||||
|
(sum, duration) => sum + duration,
|
||||||
|
);
|
||||||
|
print('=' * 60);
|
||||||
|
print('TOTAL: ${total.inMilliseconds}ms');
|
||||||
|
print('=' * 60 + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialise toutes les mesures
|
||||||
|
static void reset() {
|
||||||
|
_timings.clear();
|
||||||
|
_results.clear();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] 🔄 Reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une couleur basée sur la durée (pour les logs)
|
||||||
|
static String _getColorForDuration(Duration duration) {
|
||||||
|
final ms = duration.inMilliseconds;
|
||||||
|
if (ms < 100) return '🟢'; // Rapide
|
||||||
|
if (ms < 500) return '🟡'; // Moyen
|
||||||
|
if (ms < 1000) return '🟠'; // Lent
|
||||||
|
return '🔴'; // Très lent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesure une opération asynchrone
|
||||||
|
static Future<T> measure<T>(String key, Future<T> Function() operation) async {
|
||||||
|
start(key);
|
||||||
|
try {
|
||||||
|
final result = await operation();
|
||||||
|
end(key);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
end(key);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesure une opération synchrone
|
||||||
|
static T measureSync<T>(String key, T Function() operation) {
|
||||||
|
start(key);
|
||||||
|
try {
|
||||||
|
final result = operation();
|
||||||
|
end(key);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
end(key);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
em2rp/lib/utils/web_download.dart
Normal file
7
em2rp/lib/utils/web_download.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Fichier d'export conditionnel pour le téléchargement web
|
||||||
|
/// Utilise l'implémentation web sur le web, et le stub sur les autres plateformes
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'web_download_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'web_download_web.dart';
|
||||||
|
|
||||||
6
em2rp/lib/utils/web_download_stub.dart
Normal file
6
em2rp/lib/utils/web_download_stub.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// Stub pour le téléchargement web
|
||||||
|
/// Utilisé sur les plateformes non-web (mobile, desktop)
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
throw UnsupportedError('Le téléchargement web n\'est pas supporté sur cette plateforme');
|
||||||
|
}
|
||||||
|
|
||||||
29
em2rp/lib/utils/web_download_web.dart
Normal file
29
em2rp/lib/utils/web_download_web.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Implémentation web du téléchargement de fichier
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
final bytes = Uint8List.fromList(utf8.encode(content));
|
||||||
|
|
||||||
|
// Créer un Blob avec les données
|
||||||
|
final blob = web.Blob(
|
||||||
|
[bytes.toJS].toJS,
|
||||||
|
web.BlobPropertyBag(type: 'text/csv;charset=utf-8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer une URL pour le blob
|
||||||
|
final url = web.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Créer un lien de téléchargement et le cliquer
|
||||||
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
|
||||||
|
// Nettoyer l'URL
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +28,14 @@ class LoginViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
||||||
// Appelle la méthode du provider qui gère la connexion Auth ET le chargement des données utilisateur
|
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
emailController.text,
|
emailController.text,
|
||||||
passwordController.text,
|
passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
||||||
|
await localAuthProvider.loadUserData();
|
||||||
|
|
||||||
// Vérifier si le contexte est toujours valide
|
// Vérifier si le contexte est toujours valide
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
@@ -23,66 +26,155 @@ class CalendarPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CalendarPageState extends State<CalendarPage> {
|
class _CalendarPageState extends State<CalendarPage> {
|
||||||
|
static const double _minDetailsPaneFraction = 0.25;
|
||||||
|
static const double _maxDetailsPaneFraction = 0.5;
|
||||||
|
static const double _desktopResizeHandleWidth = 12;
|
||||||
|
static const double _minCalendarPaneWidth = 480;
|
||||||
|
static const double _minDetailsPaneWidth = 320;
|
||||||
|
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||||
DateTime _focusedDay = DateTime.now();
|
DateTime _focusedDay = DateTime.now();
|
||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
bool _calendarCollapsed = false;
|
bool _calendarCollapsed = false;
|
||||||
int _selectedEventIndex = 0;
|
int _selectedEventIndex = 0;
|
||||||
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
String?
|
||||||
|
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
double _detailsPaneFraction = 0.35;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initializeDateFormatting('fr_FR', null);
|
initializeDateFormatting('fr_FR', null);
|
||||||
Future.microtask(() => _loadEvents());
|
// Charger les événements du mois courant après le premier build
|
||||||
// Sélection automatique de l'événement le plus proche de maintenant
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
_loadCurrentMonthEvents();
|
||||||
final events = eventProvider.events;
|
|
||||||
if (events.isNotEmpty) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
|
|
||||||
final todayEvents = events
|
|
||||||
.where((e) =>
|
|
||||||
e.startDateTime.year == now.year &&
|
|
||||||
e.startDateTime.month == now.month &&
|
|
||||||
e.startDateTime.day == now.day)
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
EventModel? selected;
|
|
||||||
DateTime? selectedDay;
|
|
||||||
if (todayEvents.isNotEmpty) {
|
|
||||||
selected = todayEvents[0];
|
|
||||||
selectedDay = DateTime(now.year, now.month, now.day);
|
|
||||||
} else {
|
|
||||||
// Chercher le prochain événement à venir
|
|
||||||
final futureEvents = events
|
|
||||||
.where((e) => e.startDateTime.isAfter(now))
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
if (futureEvents.isNotEmpty) {
|
|
||||||
selected = futureEvents[0];
|
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
|
||||||
} else {
|
|
||||||
// Aucun événement à venir, prendre le plus proche dans le passé
|
|
||||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
selected = events.last;
|
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_selectedDay = selectedDay;
|
|
||||||
_focusedDay = selectedDay!;
|
|
||||||
_selectedEventIndex = 0;
|
|
||||||
_selectedEvent = selected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charge les événements du mois courant avec lazy loading
|
||||||
|
Future<void> _loadCurrentMonthEvents() async {
|
||||||
|
PerformanceMonitor.start('CalendarPage.loadCurrentMonthEvents');
|
||||||
|
|
||||||
|
final localAuthProvider =
|
||||||
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userId = localAuthProvider.uid;
|
||||||
|
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Loading events for ${_focusedDay.year}-${_focusedDay.month}');
|
||||||
|
|
||||||
|
await eventProvider.loadMonthEvents(
|
||||||
|
userId,
|
||||||
|
_focusedDay.year,
|
||||||
|
_focusedDay.month,
|
||||||
|
canViewAllEvents: canViewAllEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Précharger les mois adjacents en arrière-plan
|
||||||
|
eventProvider.preloadAdjacentMonths(
|
||||||
|
userId,
|
||||||
|
_focusedDay.year,
|
||||||
|
_focusedDay.month,
|
||||||
|
canViewAllEvents: canViewAllEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||||
|
_selectDefaultEvent();
|
||||||
|
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMonitor.end('CalendarPage.loadCurrentMonthEvents');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vide le cache et recharge les événements du mois courant
|
||||||
|
Future<void> _refreshEvents() async {
|
||||||
|
if (_isRefreshing) return;
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
try {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
eventProvider.clearAllCache();
|
||||||
|
await _loadCurrentMonthEvents();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||||
|
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||||
|
Future<void> _loadEventsAsync() async {
|
||||||
|
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||||
|
await _loadEvents();
|
||||||
|
|
||||||
|
// Sélectionner l'événement approprié après le chargement
|
||||||
|
if (mounted) {
|
||||||
|
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||||
|
_selectDefaultEvent();
|
||||||
|
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||||
|
}
|
||||||
|
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne automatiquement l'événement le plus proche de maintenant
|
||||||
|
void _selectDefaultEvent() {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final events = eventProvider.events;
|
||||||
|
|
||||||
|
if (events.isEmpty) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Trouver les événements d'aujourd'hui
|
||||||
|
final todayEvents = events.where((e) {
|
||||||
|
final start = e.startDateTime;
|
||||||
|
return start.year == now.year &&
|
||||||
|
start.month == now.month &&
|
||||||
|
start.day == now.day;
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
|
EventModel? selected;
|
||||||
|
DateTime? selectedDay;
|
||||||
|
|
||||||
|
if (todayEvents.isNotEmpty) {
|
||||||
|
selected = todayEvents[0];
|
||||||
|
selectedDay = DateTime(now.year, now.month, now.day);
|
||||||
|
} else {
|
||||||
|
// Chercher le prochain événement à venir
|
||||||
|
final futureEvents = events
|
||||||
|
.where((e) => e.startDateTime.isAfter(now))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
|
if (futureEvents.isNotEmpty) {
|
||||||
|
selected = futureEvents[0];
|
||||||
|
final start = selected.startDateTime;
|
||||||
|
selectedDay = DateTime(start.year, start.month, start.day);
|
||||||
|
} else {
|
||||||
|
// Aucun événement à venir, prendre le plus récent
|
||||||
|
final sortedEvents = events.toList()
|
||||||
|
..sort((a, b) => b.startDateTime.compareTo(a.startDateTime));
|
||||||
|
selected = sortedEvents.first;
|
||||||
|
final start = selected.startDateTime;
|
||||||
|
selectedDay = DateTime(start.year, start.month, start.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDay = selectedDay;
|
||||||
|
_focusedDay = selectedDay!;
|
||||||
|
_selectedEventIndex = 0;
|
||||||
|
_selectedEvent = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEvents() async {
|
Future<void> _loadEvents() async {
|
||||||
final localAuthProvider =
|
final localAuthProvider =
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
@@ -122,17 +214,100 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _clampDetailsPaneFraction(double fraction, double totalWidth) {
|
||||||
|
if (totalWidth <= 0) {
|
||||||
|
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
final minFractionFromPixels = _minDetailsPaneWidth / totalWidth;
|
||||||
|
final maxFractionFromPixels =
|
||||||
|
(totalWidth - _desktopResizeHandleWidth - _minCalendarPaneWidth) /
|
||||||
|
totalWidth;
|
||||||
|
|
||||||
|
final minFraction =
|
||||||
|
math.max(_minDetailsPaneFraction, minFractionFromPixels);
|
||||||
|
final maxFraction =
|
||||||
|
math.min(_maxDetailsPaneFraction, maxFractionFromPixels);
|
||||||
|
|
||||||
|
if (maxFraction < minFraction) {
|
||||||
|
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fraction.clamp(minFraction, maxFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
|
||||||
|
if (_selectedEvent != null) {
|
||||||
|
return EventDetails(
|
||||||
|
event: _selectedEvent!,
|
||||||
|
selectedDate: _selectedDay,
|
||||||
|
events: filteredEvents,
|
||||||
|
onSelectEvent: (event, date) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEvent = event;
|
||||||
|
_selectedDay = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: _selectedDay != null
|
||||||
|
? const Text('Aucun événement ne démarre à cette date')
|
||||||
|
: const Text('Sélectionnez un événement pour voir les détails'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopResizeHandle(double totalWidth) {
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeLeftRight,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
setState(() {
|
||||||
|
_detailsPaneFraction = _clampDetailsPaneFraction(
|
||||||
|
_detailsPaneFraction - (details.delta.dx / totalWidth),
|
||||||
|
totalWidth,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: SizedBox(
|
||||||
|
width: _desktopResizeHandleWidth,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 4,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||||
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
|
final canViewAllUserEvents =
|
||||||
|
localUserProvider.hasPermission('view_all_user_events');
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
// Appliquer le filtre utilisateur si actif
|
// Appliquer le filtre utilisateur si actif
|
||||||
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||||
|
|
||||||
|
// Debug logs
|
||||||
|
print(
|
||||||
|
'[CalendarPage.build] Total events: ${eventProvider.events.length}, Filtered: ${filteredEvents.length}');
|
||||||
|
if (eventProvider.events.isNotEmpty) {
|
||||||
|
print(
|
||||||
|
'[CalendarPage.build] First event: ${eventProvider.events.first.name} at ${eventProvider.events.first.startDateTime}');
|
||||||
|
}
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -144,6 +319,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Calendrier",
|
title: "Calendrier",
|
||||||
|
actions: [
|
||||||
|
if (_isRefreshing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||||
|
tooltip: 'Mettre à jour les événements',
|
||||||
|
onPressed: _refreshEvents,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -177,7 +372,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
),
|
),
|
||||||
// Corps du calendrier
|
// Corps du calendrier
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
|
child: isMobile
|
||||||
|
? _buildMobileLayout(filteredEvents)
|
||||||
|
: _buildDesktopLayout(filteredEvents),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -201,36 +398,30 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
// Calendrier (65% de la largeur)
|
final totalWidth = constraints.maxWidth;
|
||||||
Expanded(
|
final detailsPaneFraction =
|
||||||
flex: 65,
|
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
|
||||||
child: _buildCalendar(filteredEvents),
|
final detailsWidth = totalWidth * detailsPaneFraction;
|
||||||
),
|
final calendarWidth =
|
||||||
// Détails de l'événement (35% de la largeur)
|
totalWidth - _desktopResizeHandleWidth - detailsWidth;
|
||||||
Expanded(
|
|
||||||
flex: 35,
|
return Row(
|
||||||
child: _selectedEvent != null
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
? EventDetails(
|
children: [
|
||||||
event: _selectedEvent!,
|
SizedBox(
|
||||||
selectedDate: _selectedDay,
|
width: calendarWidth,
|
||||||
events: filteredEvents,
|
child: _buildCalendar(filteredEvents),
|
||||||
onSelectEvent: (event, date) {
|
),
|
||||||
setState(() {
|
_buildDesktopResizeHandle(totalWidth),
|
||||||
_selectedEvent = event;
|
SizedBox(
|
||||||
_selectedDay = date;
|
width: detailsWidth,
|
||||||
});
|
child: _buildDesktopDetailsPane(filteredEvents),
|
||||||
},
|
),
|
||||||
)
|
],
|
||||||
: Center(
|
);
|
||||||
child: _selectedDay != null
|
},
|
||||||
? Text('Aucun événement ne démarre à cette date')
|
|
||||||
: const Text(
|
|
||||||
'Sélectionnez un événement pour voir les détails'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,16 +462,24 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -306,16 +505,24 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
|
final newMonth = DateTime(
|
||||||
|
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = DateTime(
|
_focusedDay = newMonth;
|
||||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
|
final newMonth = DateTime(
|
||||||
|
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = DateTime(
|
_focusedDay = newMonth;
|
||||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -477,10 +684,14 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_left,
|
icon: const Icon(Icons.chevron_left,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -518,10 +729,14 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_right,
|
icon: const Icon(Icons.chevron_right,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -635,9 +850,20 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPageChanged: (focusedDay) {
|
onPageChanged: (focusedDay) {
|
||||||
|
// Détecter si on a changé de mois
|
||||||
|
final monthChanged = focusedDay.year != _focusedDay.year ||
|
||||||
|
focusedDay.month != _focusedDay.month;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = focusedDay;
|
_focusedDay = focusedDay;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Charger les événements du nouveau mois si nécessaire
|
||||||
|
if (monthChanged) {
|
||||||
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${focusedDay.year}-${focusedDay.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onEventSelected: (event) {
|
onEventSelected: (event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
|
|
||||||
// Type
|
// Type
|
||||||
DropdownButtonFormField<ContainerType>(
|
DropdownButtonFormField<ContainerType>(
|
||||||
value: _selectedType,
|
initialValue: _selectedType,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Type de container *',
|
labelText: 'Type de container *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -194,7 +194,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
|
|
||||||
// Statut
|
// Statut
|
||||||
DropdownButtonFormField<EquipmentStatus>(
|
DropdownButtonFormField<EquipmentStatus>(
|
||||||
value: _selectedStatus,
|
initialValue: _selectedStatus,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Statut *',
|
labelText: 'Statut *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
|||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_card.dart';
|
import 'package:em2rp/views/widgets/management/management_card.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
@@ -26,6 +28,28 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
widget: const OptionsManagement(),
|
widget: const OptionsManagement(),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Exporter les événements',
|
||||||
|
icon: Icons.file_download,
|
||||||
|
widget: const EventsExport(),
|
||||||
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Statistiques evenements',
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
widget: const PermissionGate(
|
||||||
|
requiredPermissions: ['generate_reports'],
|
||||||
|
fallback: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions necessaires pour voir les statistiques.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: EventStatisticsTab(),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -72,7 +96,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Menu horizontal en mobile
|
// Menu horizontal en mobile
|
||||||
Container(
|
SizedBox(
|
||||||
height: 60,
|
height: 60,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -137,7 +161,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -171,7 +195,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
selectedTileColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
onTap: () => setState(() => _selectedIndex = index),
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<EquipmentCategory>(
|
child: DropdownButtonFormField<EquipmentCategory>(
|
||||||
value: _selectedCategory,
|
initialValue: _selectedCategory,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Catégorie *',
|
labelText: 'Catégorie *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -299,7 +299,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<EquipmentStatus>(
|
child: DropdownButtonFormField<EquipmentStatus>(
|
||||||
value: _selectedStatus,
|
initialValue: _selectedStatus,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Statut *',
|
labelText: 'Statut *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
|||||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
|
||||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
// Éviter les appels multiples
|
// Éviter les appels multiples avec un flag simple (sans setState)
|
||||||
if (_isLoadingMore) return;
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
final provider = context.read<EquipmentProvider>();
|
final provider = context.read<EquipmentProvider>();
|
||||||
@@ -70,16 +69,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
|
|
||||||
// Vérifier qu'on peut charger plus
|
// Vérifier qu'on peut charger plus
|
||||||
if (provider.hasMore && !provider.isLoadingMore) {
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
setState(() => _isLoadingMore = true);
|
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||||
|
_isLoadingMore = true;
|
||||||
|
|
||||||
provider.loadNextPage().then((_) {
|
provider.loadNextPage().then((_) {
|
||||||
if (mounted) {
|
_isLoadingMore = false;
|
||||||
setState(() => _isLoadingMore = false);
|
|
||||||
}
|
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
if (mounted) {
|
_isLoadingMore = false;
|
||||||
setState(() => _isLoadingMore = false);
|
|
||||||
}
|
|
||||||
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -502,15 +498,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
|
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
|
||||||
|
// Note : À ajuster selon la hauteur réelle de vos cartes
|
||||||
|
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
|
||||||
|
// ✅ Augmenter le cache pour un scroll plus fluide
|
||||||
|
cacheExtent: 500, // Précharger 500px en plus
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// Dernier élément = indicateur de chargement
|
// Dernier élément = indicateur de chargement
|
||||||
if (index == equipments.length) {
|
if (index == equipments.length) {
|
||||||
return Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: provider.isLoadingMore
|
child: CircularProgressIndicator(),
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -525,78 +524,81 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
||||||
final isSelected = isItemSelected(equipment.id);
|
final isSelected = isItemSelected(equipment.id);
|
||||||
|
|
||||||
return Card(
|
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
return RepaintBoundary(
|
||||||
color: isSelectionMode && isSelected
|
key: ValueKey(equipment.id),
|
||||||
? AppColors.rouge.withValues(alpha: 0.1)
|
child: Card(
|
||||||
: null,
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
color: isSelectionMode && isSelected
|
||||||
leading: isSelectionMode
|
? AppColors.rouge.withValues(alpha: 0.1)
|
||||||
? Checkbox(
|
: null,
|
||||||
value: isSelected,
|
child: ListTile(
|
||||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
leading: isSelectionMode
|
||||||
activeColor: AppColors.rouge,
|
? Checkbox(
|
||||||
)
|
value: isSelected,
|
||||||
: CircleAvatar(
|
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
activeColor: AppColors.rouge,
|
||||||
child: equipment.category.getIcon(
|
)
|
||||||
size: 20,
|
: CircleAvatar(
|
||||||
color: equipment.category.color,
|
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||||
),
|
child: equipment.category.getIcon(
|
||||||
),
|
size: 20,
|
||||||
title: Row(
|
color: equipment.category.color,
|
||||||
children: [
|
),
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
title: Row(
|
||||||
equipment.id,
|
children: [
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
),
|
equipment.id,
|
||||||
// Afficher le badge de statut calculé dynamiquement
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
if (equipment.category != EquipmentCategory.consumable &&
|
|
||||||
equipment.category != EquipmentCategory.cable)
|
|
||||||
EquipmentStatusBadge(equipment: equipment),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
|
||||||
.trim()
|
|
||||||
.isNotEmpty
|
|
||||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
|
||||||
: 'Marque/Modèle non défini',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
|
||||||
),
|
|
||||||
// Afficher la sous-catégorie si elle existe
|
|
||||||
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'📁 ${equipment.subCategory}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[500],
|
|
||||||
fontSize: 12,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Afficher le badge de statut calculé dynamiquement
|
||||||
|
if (equipment.category != EquipmentCategory.consumable &&
|
||||||
|
equipment.category != EquipmentCategory.cable)
|
||||||
|
EquipmentStatusBadge(equipment: equipment),
|
||||||
],
|
],
|
||||||
// Afficher la quantité disponible pour les consommables/câbles
|
),
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
subtitle: Column(
|
||||||
equipment.category == EquipmentCategory.cable) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildQuantityDisplay(equipment),
|
Text(
|
||||||
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
|
.trim()
|
||||||
|
.isNotEmpty
|
||||||
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||||
|
: 'Marque/Modèle non défini',
|
||||||
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
|
),
|
||||||
|
// Afficher la sous-catégorie si elle existe
|
||||||
|
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'📁 ${equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Afficher la quantité disponible pour les consommables/câbles
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildQuantityDisplay(equipment),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
trailing: isSelectionMode
|
||||||
trailing: isSelectionMode
|
? null
|
||||||
? null
|
: Row(
|
||||||
: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
|
||||||
equipment.category == EquipmentCategory.cable)
|
equipment.category == EquipmentCategory.cable)
|
||||||
PermissionGate(
|
PermissionGate(
|
||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
@@ -640,6 +642,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
? () => toggleItemSelection(equipment.id)
|
? () => toggleItemSelection(equipment.id)
|
||||||
: () => _viewEquipmentDetails(equipment),
|
: () => _viewEquipmentDetails(equipment),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import 'package:em2rp/models/container_model.dart';
|
|||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
|
||||||
import 'package:em2rp/services/data_service.dart';
|
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/audio_feedback_service.dart';
|
||||||
|
import 'package:em2rp/services/smart_text_to_speech_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';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event_preparation/code_not_found_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event_preparation/add_equipment_to_event_dialog.dart';
|
||||||
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 {
|
||||||
@@ -40,20 +47,20 @@ class EventPreparationPage extends StatefulWidget {
|
|||||||
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
late final DataService _dataService;
|
late final DataService _dataService;
|
||||||
|
late final QRCodeProcessingService _qrCodeService;
|
||||||
|
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
Map<String, int> _returnedQuantities = {};
|
final Map<String, int> _returnedQuantities = {};
|
||||||
|
|
||||||
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
||||||
Map<String, bool> _localValidationState = {};
|
final Map<String, bool> _localValidationState = {};
|
||||||
|
|
||||||
|
// Gestion des quantités par étape
|
||||||
// NOUVEAU : Gestion des quantités par étape
|
final Map<String, int> _quantitiesAtPreparation = {};
|
||||||
Map<String, int> _quantitiesAtPreparation = {};
|
final Map<String, int> _quantitiesAtLoading = {};
|
||||||
Map<String, int> _quantitiesAtLoading = {};
|
final Map<String, int> _quantitiesAtUnloading = {};
|
||||||
Map<String, int> _quantitiesAtUnloading = {};
|
final Map<String, int> _quantitiesAtReturn = {};
|
||||||
Map<String, int> _quantitiesAtReturn = {};
|
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isValidating = false;
|
bool _isValidating = false;
|
||||||
@@ -63,6 +70,14 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
// Stockage de l'événement actuel
|
// Stockage de l'événement actuel
|
||||||
late EventModel _currentEvent;
|
late EventModel _currentEvent;
|
||||||
|
|
||||||
|
// 🆕 Pour la saisie manuelle de codes
|
||||||
|
final TextEditingController _manualCodeController = TextEditingController();
|
||||||
|
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;
|
||||||
@@ -100,11 +115,18 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
super.initState();
|
super.initState();
|
||||||
_currentEvent = widget.initialEvent;
|
_currentEvent = widget.initialEvent;
|
||||||
_dataService = DataService(FirebaseFunctionsApiService());
|
_dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
_qrCodeService = QRCodeProcessingService();
|
||||||
_animationController = AnimationController(
|
_animationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialiser le service de synthèse vocale hybride
|
||||||
|
SmartTextToSpeechService.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()) {
|
||||||
@@ -140,6 +162,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_manualCodeController.dispose();
|
||||||
|
_manualCodeFocusNode.dispose();
|
||||||
|
SmartTextToSpeechService.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,20 +172,46 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
// 🔧 FIX: Utiliser getEventWithDetails pour charger toutes les données d'un coup
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
DebugLog.info('[EventPreparationPage] Loading event with details: ${_currentEvent.id}');
|
||||||
|
|
||||||
// S'assurer que les équipements sont chargés
|
final result = await _dataService.getEventWithDetails(_currentEvent.id);
|
||||||
await equipmentProvider.ensureLoaded();
|
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
|
||||||
await containerProvider.ensureLoaded();
|
final containersMap = result['containers'] as Map<String, dynamic>;
|
||||||
|
|
||||||
final equipment = await equipmentProvider.equipmentStream.first;
|
DebugLog.info('[EventPreparationPage] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
|
||||||
final containers = await containerProvider.containersStream.first;
|
|
||||||
|
|
||||||
|
// Remplir les caches
|
||||||
|
_equipmentCache.clear();
|
||||||
|
_containerCache.clear();
|
||||||
|
|
||||||
|
// Remplir le cache d'équipements
|
||||||
|
equipmentsMap.forEach((id, data) {
|
||||||
|
try {
|
||||||
|
final equipment = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
|
_equipmentCache[id] = equipment;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EventPreparationPage] Error parsing equipment $id', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remplir le cache de containers
|
||||||
|
containersMap.forEach((id, data) {
|
||||||
|
try {
|
||||||
|
final container = ContainerModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
|
_containerCache[id] = container;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EventPreparationPage] Error parsing container $id', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialiser les états de validation et quantités pour chaque équipement assigné
|
||||||
for (var eq in _currentEvent.assignedEquipment) {
|
for (var eq in _currentEvent.assignedEquipment) {
|
||||||
final equipmentItem = equipment.firstWhere(
|
final equipmentItem = _equipmentCache[eq.equipmentId];
|
||||||
(e) => e.id == eq.equipmentId,
|
|
||||||
orElse: () => EquipmentModel(
|
// S'assurer que l'équipement est dans le cache (même si inconnu)
|
||||||
|
if (equipmentItem == null) {
|
||||||
|
_equipmentCache[eq.equipmentId] = EquipmentModel(
|
||||||
id: eq.equipmentId,
|
id: eq.equipmentId,
|
||||||
name: 'Équipement inconnu',
|
name: 'Équipement inconnu',
|
||||||
category: EquipmentCategory.other,
|
category: EquipmentCategory.other,
|
||||||
@@ -168,9 +219,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
maintenanceIds: [],
|
maintenanceIds: [],
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
_equipmentCache[eq.equipmentId] = equipmentItem;
|
|
||||||
|
|
||||||
// Initialiser l'état local de validation depuis l'événement
|
// Initialiser l'état local de validation depuis l'événement
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
@@ -190,15 +240,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
if ((_currentStep == PreparationStep.return_ ||
|
if ((_currentStep == PreparationStep.return_ ||
|
||||||
_currentStep == PreparationStep.unloadingReturn) &&
|
_currentStep == PreparationStep.unloadingReturn) &&
|
||||||
equipmentItem.hasQuantity) {
|
(equipmentItem?.hasQuantity ?? false)) {
|
||||||
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S'assurer que les containers assignés sont dans le cache (même si inconnus)
|
||||||
for (var containerId in _currentEvent.assignedContainers) {
|
for (var containerId in _currentEvent.assignedContainers) {
|
||||||
final container = containers.firstWhere(
|
if (!_containerCache.containsKey(containerId)) {
|
||||||
(c) => c.id == containerId,
|
_containerCache[containerId] = ContainerModel(
|
||||||
orElse: () => ContainerModel(
|
|
||||||
id: containerId,
|
id: containerId,
|
||||||
name: 'Conteneur inconnu',
|
name: 'Conteneur inconnu',
|
||||||
type: ContainerType.flightCase,
|
type: ContainerType.flightCase,
|
||||||
@@ -206,9 +256,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
equipmentIds: [],
|
equipmentIds: [],
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
_containerCache[containerId] = container;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EventPreparationPage] Error', e);
|
DebugLog.error('[EventPreparationPage] Error', e);
|
||||||
@@ -392,7 +441,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
|
final result = await FirebaseFunctions.instanceFor(region: 'europe-west9')
|
||||||
.httpsCallable('processEquipmentValidation')
|
.httpsCallable('processEquipmentValidation')
|
||||||
.call({
|
.call({
|
||||||
'eventId': _currentEvent.id,
|
'eventId': _currentEvent.id,
|
||||||
@@ -411,10 +460,17 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
// Recharger l'événement depuis le provider
|
// Recharger l'événement depuis le provider
|
||||||
final eventProvider = context.read<EventProvider>();
|
final eventProvider = context.read<EventProvider>();
|
||||||
// Recharger la liste des événements pour rafraîchir les données
|
|
||||||
final userId = context.read<LocalUserProvider>().uid;
|
// Recharger uniquement cet événement depuis l'API pour obtenir les dernières données
|
||||||
if (userId != null) {
|
try {
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: true);
|
final result = await _dataService.getEventWithDetails(_currentEvent.id);
|
||||||
|
final eventData = result['event'] as Map<String, dynamic>;
|
||||||
|
final updatedEvent = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
|
||||||
|
// Mettre à jour dans le cache
|
||||||
|
await eventProvider.updateEvent(updatedEvent);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EventPreparationPage] Erreur lors du rechargement de l\'événement', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _showSuccessAnimation = true);
|
setState(() => _showSuccessAnimation = true);
|
||||||
@@ -564,6 +620,341 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 🆕 NOUVELLES MÉTHODES POUR LE SCAN QR ET LA SAISIE MANUELLE
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// Ouvrir le scanner QR en mode multi-scan
|
||||||
|
Future<void> _openQRScanner() async {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeScannerDialog(
|
||||||
|
multiScanMode: true,
|
||||||
|
onCodeScanned: _handleScannedCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un code (scanné ou saisi manuellement)
|
||||||
|
Future<void> _handleScannedCode(String code) async {
|
||||||
|
final result = await _qrCodeService.processCode(
|
||||||
|
code: code.trim(),
|
||||||
|
event: _currentEvent,
|
||||||
|
step: _currentStep,
|
||||||
|
equipmentCache: _equipmentCache,
|
||||||
|
containerCache: _containerCache,
|
||||||
|
validationState: _localValidationState,
|
||||||
|
currentQuantities: _getCurrentQuantitiesMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// ✅ Succès : mettre à jour l'état
|
||||||
|
setState(() {
|
||||||
|
if (result.updatedValidationState != null) {
|
||||||
|
_localValidationState.addAll(result.updatedValidationState!);
|
||||||
|
}
|
||||||
|
if (result.updatedQuantities != null) {
|
||||||
|
_updateQuantitiesMap(result.updatedQuantities!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔊 Jouer le feedback sonore et haptique
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: true);
|
||||||
|
|
||||||
|
// Feedback visuel
|
||||||
|
_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) {
|
||||||
|
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
||||||
|
// 🔊 Son d'erreur
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
||||||
|
|
||||||
|
await _handleCodeNotFoundInEvent(code.trim());
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ❌ Erreur (ex: quantité déjà atteinte, déjà coché)
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
||||||
|
_showErrorFeedback(result.message ?? 'Erreur lors du traitement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gérer un code non trouvé dans l'événement
|
||||||
|
Future<void> _handleCodeNotFoundInEvent(String code) async {
|
||||||
|
// Afficher le dialog de confirmation
|
||||||
|
final shouldSearch = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => CodeNotFoundDialog(scannedCode: code),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldSearch != true) return;
|
||||||
|
|
||||||
|
// Afficher le dialog de chargement
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const AddEquipmentToEventDialog(
|
||||||
|
state: AddEquipmentState.loading,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Identifier le type selon le préfixe
|
||||||
|
final isContainer = code.startsWith('BOX_');
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
await _addContainerToEvent(code);
|
||||||
|
} else {
|
||||||
|
await _addEquipmentToEvent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔊 Bip de succès
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: true);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EventPreparationPage] Error adding item to event', e);
|
||||||
|
|
||||||
|
// Fermer le dialog de chargement et afficher l'erreur
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AddEquipmentToEventDialog(
|
||||||
|
state: AddEquipmentState.error,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔊 Bip d'erreur
|
||||||
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un équipement à l'événement
|
||||||
|
Future<void> _addEquipmentToEvent(String equipmentId) async {
|
||||||
|
// Rechercher l'équipement dans la base de données
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
|
||||||
|
// Chercher d'abord dans le cache
|
||||||
|
EquipmentModel? equipment = equipmentProvider.allEquipment
|
||||||
|
.cast<EquipmentModel?>()
|
||||||
|
.firstWhere(
|
||||||
|
(eq) => eq?.id == equipmentId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si pas dans le cache, charger depuis Firestore
|
||||||
|
if (equipment == null) {
|
||||||
|
final equipmentService = EquipmentService();
|
||||||
|
equipment = await equipmentService.getEquipmentById(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment == null) {
|
||||||
|
throw Exception('Équipement non trouvé dans la base de données');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter l'équipement à l'événement
|
||||||
|
final newEventEquipment = EventEquipment(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedEquipment = List<EventEquipment>.from(_currentEvent.assignedEquipment)
|
||||||
|
..add(newEventEquipment);
|
||||||
|
|
||||||
|
await _dataService.updateEvent(_currentEvent.id, {
|
||||||
|
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mettre à jour l'état local
|
||||||
|
setState(() {
|
||||||
|
_currentEvent = _currentEvent.copyWith(
|
||||||
|
assignedEquipment: updatedEquipment,
|
||||||
|
);
|
||||||
|
_equipmentCache[equipmentId] = equipment!;
|
||||||
|
_localValidationState[equipmentId] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fermer le dialog de chargement et afficher le succès
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AddEquipmentToEventDialog(
|
||||||
|
state: AddEquipmentState.success,
|
||||||
|
itemName: equipment!.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un container à l'événement
|
||||||
|
Future<void> _addContainerToEvent(String containerId) async {
|
||||||
|
// Rechercher le container dans la base de données
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
await containerProvider.ensureLoaded();
|
||||||
|
|
||||||
|
final container = await containerProvider.getContainerById(containerId);
|
||||||
|
|
||||||
|
if (container == null) {
|
||||||
|
throw Exception('Container non trouvé dans la base de données');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter le container à l'événement
|
||||||
|
final updatedContainers = List<String>.from(_currentEvent.assignedContainers)
|
||||||
|
..add(containerId);
|
||||||
|
|
||||||
|
await _dataService.updateEvent(_currentEvent.id, {
|
||||||
|
'assignedContainers': updatedContainers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mettre à jour l'état local
|
||||||
|
setState(() {
|
||||||
|
_currentEvent = _currentEvent.copyWith(
|
||||||
|
assignedContainers: updatedContainers,
|
||||||
|
);
|
||||||
|
_containerCache[containerId] = container;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fermer le dialog de chargement et afficher le succès
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AddEquipmentToEventDialog(
|
||||||
|
state: AddEquipmentState.success,
|
||||||
|
itemName: 'Container ${container.name}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter la saisie manuelle d'un code
|
||||||
|
Future<void> _handleManualCodeEntry(String code) async {
|
||||||
|
if (code.trim().isEmpty) return;
|
||||||
|
|
||||||
|
// Ajouter le code à la file d'attente
|
||||||
|
_scanQueue.add(code.trim());
|
||||||
|
|
||||||
|
// Effacer le champ immédiatement pour permettre le prochain scan
|
||||||
|
_manualCodeController.clear();
|
||||||
|
|
||||||
|
// Maintenir le focus sur le champ pour permettre une saisie continue
|
||||||
|
_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
|
||||||
|
Map<String, int> _getCurrentQuantitiesMap() {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return _quantitiesAtPreparation;
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return _quantitiesAtLoading;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return _quantitiesAtUnloading;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return _quantitiesAtReturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour les quantités selon l'étape
|
||||||
|
void _updateQuantitiesMap(Map<String, int> quantities) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
_quantitiesAtPreparation.addAll(quantities);
|
||||||
|
break;
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
_quantitiesAtLoading.addAll(quantities);
|
||||||
|
break;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
_quantitiesAtUnloading.addAll(quantities);
|
||||||
|
break;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
_quantitiesAtReturn.addAll(quantities);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
|
||||||
|
int _getTargetQuantity(EventEquipment eventEquipment) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return eventEquipment.quantity; // Quantité initiale
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return eventEquipment.quantityAtUnloading ??
|
||||||
|
eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Afficher un message de succès
|
||||||
|
void _showSuccessFeedback(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Afficher un message d'erreur
|
||||||
|
void _showErrorFeedback(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// FIN DES NOUVELLES MÉTHODES
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
Future<void> _confirm() async {
|
Future<void> _confirm() async {
|
||||||
// Vérifier s'il y a des équipements manquants (non cochés localement)
|
// Vérifier s'il y a des équipements manquants (non cochés localement)
|
||||||
final missingEquipmentIds = _currentEvent.assignedEquipment
|
final missingEquipmentIds = _currentEvent.assignedEquipment
|
||||||
@@ -765,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 SmartTextToSpeechService.speak('Prochain item: $nextItem');
|
||||||
|
} else {
|
||||||
|
await SmartTextToSpeechService.speak('Tous les items sont validés');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -775,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: [
|
||||||
@@ -788,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -842,6 +1308,58 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Champ de saisie manuelle avec bouton scanner
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _manualCodeController,
|
||||||
|
focusNode: _manualCodeFocusNode,
|
||||||
|
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),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: allValidated ? null : _validateAllAndConfirm,
|
onPressed: allValidated ? null : _validateAllAndConfirm,
|
||||||
@@ -860,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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
31
em2rp/lib/views/event_statistics_page.dart
Normal file
31
em2rp/lib/views/event_statistics_page.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsPage extends StatelessWidget {
|
||||||
|
const EventStatisticsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PermissionGate(
|
||||||
|
requiredPermissions: const ['generate_reports'],
|
||||||
|
fallback: const Scaffold(
|
||||||
|
appBar: CustomAppBar(title: 'Acces refuse'),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions necessaires pour acceder aux statistiques.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Scaffold(
|
||||||
|
appBar: CustomAppBar(title: 'Statistiques evenements'),
|
||||||
|
drawer: MainDrawer(currentPage: '/event_statistics'),
|
||||||
|
body: EventStatisticsTab(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -58,41 +58,45 @@ class LoginPage extends StatelessWidget {
|
|||||||
Widget _buildLoginForm(BuildContext context) {
|
Widget _buildLoginForm(BuildContext context) {
|
||||||
return Consumer<LoginViewModel>(
|
return Consumer<LoginViewModel>(
|
||||||
builder: (context, loginViewModel, child) {
|
builder: (context, loginViewModel, child) {
|
||||||
return Column(
|
return AutofillGroup(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const LogoWidget(),
|
children: <Widget>[
|
||||||
const SizedBox(height: 30),
|
const LogoWidget(),
|
||||||
const WelcomeTextWidget(),
|
const SizedBox(height: 30),
|
||||||
const SizedBox(height: 40),
|
const WelcomeTextWidget(),
|
||||||
EmailTextFieldWidget(
|
const SizedBox(height: 40),
|
||||||
emailController: loginViewModel.emailController,
|
EmailTextFieldWidget(
|
||||||
highlightEmailField: loginViewModel.highlightEmailField,
|
emailController: loginViewModel.emailController,
|
||||||
),
|
highlightEmailField: loginViewModel.highlightEmailField,
|
||||||
const SizedBox(height: 20),
|
onSubmitted: () => loginViewModel.signIn(context),
|
||||||
PasswordTextFieldWidget(
|
|
||||||
passwordController: loginViewModel.passwordController,
|
|
||||||
obscurePassword: loginViewModel.obscurePassword,
|
|
||||||
highlightPasswordField: loginViewModel.highlightPasswordField,
|
|
||||||
onTogglePasswordVisibility:
|
|
||||||
loginViewModel.togglePasswordVisibility,
|
|
||||||
),
|
|
||||||
ForgotPasswordButtonWidget(
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) =>
|
|
||||||
const ForgotPasswordDialogWidget(),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 30),
|
PasswordTextFieldWidget(
|
||||||
LoginButtonWidget(
|
passwordController: loginViewModel.passwordController,
|
||||||
isLoading: loginViewModel.isLoading,
|
obscurePassword: loginViewModel.obscurePassword,
|
||||||
onPressed: () => loginViewModel.signIn(context),
|
highlightPasswordField: loginViewModel.highlightPasswordField,
|
||||||
),
|
onTogglePasswordVisibility:
|
||||||
const SizedBox(height: 20),
|
loginViewModel.togglePasswordVisibility,
|
||||||
ErrorMessageWidget(errorMessage: loginViewModel.errorMessage),
|
onSubmitted: () => loginViewModel.signIn(context),
|
||||||
],
|
),
|
||||||
|
ForgotPasswordButtonWidget(
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) =>
|
||||||
|
const ForgotPasswordDialogWidget(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
LoginButtonWidget(
|
||||||
|
isLoading: loginViewModel.isLoading,
|
||||||
|
onPressed: () => loginViewModel.signIn(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ErrorMessageWidget(errorMessage: loginViewModel.errorMessage),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:em2rp/utils/debug_log.dart';
|
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
|
|||||||
class EmailTextFieldWidget extends StatelessWidget {
|
class EmailTextFieldWidget extends StatelessWidget {
|
||||||
final TextEditingController emailController;
|
final TextEditingController emailController;
|
||||||
final bool highlightEmailField;
|
final bool highlightEmailField;
|
||||||
|
final VoidCallback? onSubmitted;
|
||||||
|
|
||||||
const EmailTextFieldWidget({
|
const EmailTextFieldWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.emailController,
|
required this.emailController,
|
||||||
required this.highlightEmailField,
|
required this.highlightEmailField,
|
||||||
|
this.onSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -16,6 +18,9 @@ class EmailTextFieldWidget extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [AutofillHints.email, AutofillHints.username],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => onSubmitted?.call(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
final bool obscurePassword;
|
final bool obscurePassword;
|
||||||
final bool highlightPasswordField;
|
final bool highlightPasswordField;
|
||||||
final VoidCallback onTogglePasswordVisibility;
|
final VoidCallback onTogglePasswordVisibility;
|
||||||
|
final VoidCallback? onSubmitted;
|
||||||
|
|
||||||
const PasswordTextFieldWidget({
|
const PasswordTextFieldWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -14,6 +15,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
required this.obscurePassword,
|
required this.obscurePassword,
|
||||||
required this.highlightPasswordField,
|
required this.highlightPasswordField,
|
||||||
required this.onTogglePasswordVisibility,
|
required this.onTogglePasswordVisibility,
|
||||||
|
this.onSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,6 +23,9 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => onSubmitted?.call(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Mot de passe',
|
labelText: 'Mot de passe',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class EventDetailsDocuments extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
height: MediaQuery.of(context).size.height * 0.8,
|
height: MediaQuery.of(context).size.height * 0.8,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import 'package:em2rp/views/event_add_page.dart';
|
|||||||
import 'package:em2rp/services/ics_export_service.dart';
|
import 'package:em2rp/services/ics_export_service.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'dart:html' as html;
|
import 'package:web/web.dart' as web;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
class EventDetailsHeader extends StatefulWidget {
|
class EventDetailsHeader extends StatefulWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@@ -180,12 +181,13 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
|||||||
|
|
||||||
// Créer un blob et télécharger le fichier
|
// Créer un blob et télécharger le fichier
|
||||||
final bytes = utf8.encode(icsContent);
|
final bytes = utf8.encode(icsContent);
|
||||||
final blob = html.Blob([bytes], 'text/calendar');
|
final blob = web.Blob([bytes.toJS].toJS, web.BlobPropertyBag(type: 'text/calendar'));
|
||||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
final url = web.URL.createObjectURL(blob);
|
||||||
html.AnchorElement(href: url)
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
..setAttribute('download', fileName)
|
anchor.href = url;
|
||||||
..click();
|
anchor.download = fileName;
|
||||||
html.Url.revokeObjectUrl(url);
|
anchor.click();
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -23,39 +23,50 @@ class EventStatusButton extends StatefulWidget {
|
|||||||
|
|
||||||
class _EventStatusButtonState extends State<EventStatusButton> {
|
class _EventStatusButtonState extends State<EventStatusButton> {
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
EventStatus? _optimisticStatus;
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
Future<void> _changeStatus(EventStatus newStatus) async {
|
@override
|
||||||
if (widget.event.status == newStatus) return;
|
void didUpdateWidget(EventStatusButton oldWidget) {
|
||||||
setState(() => _loading = true);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// Réinitialiser le statut optimiste si on affiche un nouvel événement
|
||||||
|
if (oldWidget.event.id != widget.event.id) {
|
||||||
|
_optimisticStatus = null;
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeStatus(EventStatus newStatus) async {
|
||||||
|
if ((widget.event.status == newStatus) || _loading) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_optimisticStatus = newStatus;
|
||||||
|
});
|
||||||
|
final oldStatus = widget.event.status;
|
||||||
try {
|
try {
|
||||||
// Mettre à jour via l'API
|
|
||||||
await _dataService.updateEvent(widget.event.id, {
|
await _dataService.updateEvent(widget.event.id, {
|
||||||
'status': eventStatusToString(newStatus),
|
'status': eventStatusToString(newStatus),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Récupérer l'événement mis à jour via l'API
|
|
||||||
final result = await _dataService.getEvents();
|
final result = await _dataService.getEvents();
|
||||||
final eventsList = result['events'] as List<dynamic>;
|
final eventsList = result['events'] as List<dynamic>;
|
||||||
final eventData = eventsList.firstWhere(
|
final eventData = eventsList.firstWhere(
|
||||||
(e) => e['id'] == widget.event.id,
|
(e) => e['id'] == widget.event.id,
|
||||||
orElse: () => <String, dynamic>{},
|
orElse: () => <String, dynamic>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (eventData.isNotEmpty) {
|
if (eventData.isNotEmpty) {
|
||||||
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
|
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
|
||||||
|
|
||||||
widget.onSelectEvent(
|
widget.onSelectEvent(
|
||||||
updatedEvent,
|
updatedEvent,
|
||||||
widget.selectedDate ?? updatedEvent.startDateTime,
|
widget.selectedDate ?? updatedEvent.startDateTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Provider.of<EventProvider>(context, listen: false)
|
await Provider.of<EventProvider>(context, listen: false)
|
||||||
.updateEvent(updatedEvent);
|
.updateEvent(updatedEvent);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_optimisticStatus = oldStatus;
|
||||||
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
|
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
|
||||||
);
|
);
|
||||||
@@ -69,11 +80,22 @@ class _EventStatusButtonState extends State<EventStatusButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = widget.event.status;
|
final status = _optimisticStatus ?? widget.event.status;
|
||||||
String texte;
|
String texte;
|
||||||
Color couleurFond;
|
Color couleurFond;
|
||||||
List<Widget> enfants = [];
|
List<Widget> enfants = [];
|
||||||
|
|
||||||
|
if (_loading) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case EventStatus.waitingForApproval:
|
case EventStatus.waitingForApproval:
|
||||||
texte = "En Attente";
|
texte = "En Attente";
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import 'package:em2rp/models/event_model.dart';
|
|||||||
import 'package:em2rp/utils/calendar_utils.dart';
|
import 'package:em2rp/utils/calendar_utils.dart';
|
||||||
|
|
||||||
class MonthView extends StatelessWidget {
|
class MonthView extends StatelessWidget {
|
||||||
|
static const double _calendarPadding = 8.0;
|
||||||
|
static const double _headerHeight = 52.0;
|
||||||
|
static const double _headerVerticalPadding = 16.0;
|
||||||
|
static const double _daysOfWeekHeight = 16.0;
|
||||||
|
|
||||||
final DateTime focusedDay;
|
final DateTime focusedDay;
|
||||||
final DateTime? selectedDay;
|
final DateTime? selectedDay;
|
||||||
final CalendarFormat calendarFormat;
|
final CalendarFormat calendarFormat;
|
||||||
@@ -30,11 +35,17 @@ class MonthView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final rowHeight = (constraints.maxHeight - 100) / 6;
|
final rowCount = _computeRowCount(focusedDay);
|
||||||
|
final availableHeight = constraints.maxHeight -
|
||||||
|
(_calendarPadding * 2) -
|
||||||
|
_headerHeight -
|
||||||
|
_headerVerticalPadding -
|
||||||
|
_daysOfWeekHeight;
|
||||||
|
final rowHeight = availableHeight / rowCount;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: constraints.maxHeight,
|
height: constraints.maxHeight,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(_calendarPadding),
|
||||||
child: TableCalendar(
|
child: TableCalendar(
|
||||||
firstDay: DateTime.utc(2020, 1, 1),
|
firstDay: DateTime.utc(2020, 1, 1),
|
||||||
lastDay: DateTime.utc(2030, 12, 31),
|
lastDay: DateTime.utc(2030, 12, 31),
|
||||||
@@ -42,6 +53,7 @@ class MonthView extends StatelessWidget {
|
|||||||
calendarFormat: calendarFormat,
|
calendarFormat: calendarFormat,
|
||||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||||
locale: 'fr_FR',
|
locale: 'fr_FR',
|
||||||
|
daysOfWeekHeight: _daysOfWeekHeight,
|
||||||
availableCalendarFormats: const {
|
availableCalendarFormats: const {
|
||||||
CalendarFormat.month: 'Mois',
|
CalendarFormat.month: 'Mois',
|
||||||
CalendarFormat.week: 'Semaine',
|
CalendarFormat.week: 'Semaine',
|
||||||
@@ -132,10 +144,9 @@ class MonthView extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) {
|
Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) {
|
||||||
final dayEvents = CalendarUtils.getEventsForDay(day, events);
|
final dayEvents = CalendarUtils.getEventsForDay(day, events);
|
||||||
|
final statusCounts = _getStatusCounts(dayEvents);
|
||||||
final textColor =
|
final textColor =
|
||||||
isSelected ? Colors.white : (isToday ? AppColors.rouge : null);
|
isSelected ? Colors.white : (isToday ? AppColors.rouge : null);
|
||||||
final badgeColor = isSelected ? Colors.white : AppColors.rouge;
|
|
||||||
final badgeTextColor = isSelected ? AppColors.rouge : Colors.white;
|
|
||||||
|
|
||||||
BoxDecoration decoration;
|
BoxDecoration decoration;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -161,56 +172,125 @@ class MonthView extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(4),
|
margin: const EdgeInsets.all(4),
|
||||||
decoration: decoration,
|
decoration: decoration,
|
||||||
child: Stack(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.all(4),
|
||||||
Positioned(
|
child: Column(
|
||||||
top: 4,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
left: 4,
|
children: [
|
||||||
child: Text(
|
Row(
|
||||||
day.day.toString(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: textColor),
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
day.day.toString(),
|
||||||
if (dayEvents.isNotEmpty)
|
style: TextStyle(color: textColor),
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: badgeColor,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
const SizedBox(width: 4),
|
||||||
dayEvents.length.toString(),
|
Expanded(
|
||||||
style: TextStyle(
|
child: Align(
|
||||||
color: badgeTextColor,
|
alignment: Alignment.topRight,
|
||||||
fontSize: 12,
|
child: Wrap(
|
||||||
fontWeight: FontWeight.bold,
|
spacing: 4,
|
||||||
|
runSpacing: 2,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: _buildStatusBadges(statusCounts),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (dayEvents.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: dayEvents
|
||||||
|
.map((event) => _buildEventItem(event, isSelected, day))
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
if (dayEvents.isNotEmpty)
|
],
|
||||||
Positioned(
|
),
|
||||||
bottom: 2,
|
|
||||||
left: 2,
|
|
||||||
right: 2,
|
|
||||||
top: 28,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: dayEvents
|
|
||||||
.map((event) => _buildEventItem(event, isSelected, day))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<EventStatus, int> _getStatusCounts(List<EventModel> dayEvents) {
|
||||||
|
final counts = <EventStatus, int>{
|
||||||
|
EventStatus.confirmed: 0,
|
||||||
|
EventStatus.waitingForApproval: 0,
|
||||||
|
EventStatus.canceled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final event in dayEvents) {
|
||||||
|
counts[event.status] = (counts[event.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildStatusBadges(Map<EventStatus, int> statusCounts) {
|
||||||
|
final badges = <Widget>[];
|
||||||
|
|
||||||
|
void addBadge({
|
||||||
|
required EventStatus status,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
required String tooltipLabel,
|
||||||
|
}) {
|
||||||
|
final count = statusCounts[status] ?? 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
badges.add(
|
||||||
|
Tooltip(
|
||||||
|
message: '$count $tooltipLabel',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
count.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.confirmed,
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
textColor: Colors.white,
|
||||||
|
tooltipLabel:
|
||||||
|
'validé${(statusCounts[EventStatus.confirmed] ?? 0) > 1 ? 's' : ''}',
|
||||||
|
);
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.waitingForApproval,
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
textColor: Colors.black,
|
||||||
|
tooltipLabel: 'en attente',
|
||||||
|
);
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.canceled,
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
textColor: Colors.white,
|
||||||
|
tooltipLabel:
|
||||||
|
'annulé${(statusCounts[EventStatus.canceled] ?? 0) > 1 ? 's' : ''}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEventItem(
|
Widget _buildEventItem(
|
||||||
EventModel event, bool isSelected, DateTime currentDay) {
|
EventModel event, bool isSelected, DateTime currentDay) {
|
||||||
Color color;
|
Color color;
|
||||||
@@ -228,7 +308,6 @@ class MonthView extends StatelessWidget {
|
|||||||
icon = Icons.close;
|
icon = Icons.close;
|
||||||
break;
|
break;
|
||||||
case EventStatus.waitingForApproval:
|
case EventStatus.waitingForApproval:
|
||||||
default:
|
|
||||||
color = Colors.amber;
|
color = Colors.amber;
|
||||||
textColor = Colors.black;
|
textColor = Colors.black;
|
||||||
icon = Icons.hourglass_empty;
|
icon = Icons.hourglass_empty;
|
||||||
@@ -243,7 +322,8 @@ class MonthView extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 2),
|
margin: const EdgeInsets.only(bottom: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? color.withAlpha(220) : color.withOpacity(0.18),
|
color:
|
||||||
|
isSelected ? color.withAlpha(220) : color.withValues(alpha: 0.18),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -282,4 +362,13 @@ class MonthView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calcule le nombre de rangées affichées pour le mois de [focusedDay]
|
||||||
|
/// (calendrier commençant le lundi : offset = weekday - 1)
|
||||||
|
int _computeRowCount(DateTime focusedDay) {
|
||||||
|
final firstOfMonth = DateTime(focusedDay.year, focusedDay.month, 1);
|
||||||
|
final daysInMonth = DateTime(focusedDay.year, focusedDay.month + 1, 0).day;
|
||||||
|
final offset = (firstOfMonth.weekday - 1) % 7; // 0 = lundi, 6 = dimanche
|
||||||
|
return ((daysInMonth + offset) / 7).ceil();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class _UserFilterDropdownState extends State<UserFilterDropdown> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
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/smart_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 SmartTextToSpeechService.initialize();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
|
||||||
|
await SmartTextToSpeechService.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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,17 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
|
|
||||||
/// Dialog pour scanner un QR code et récupérer l'ID
|
/// Dialog pour scanner un QR code et récupérer l'ID
|
||||||
class QRCodeScannerDialog extends StatefulWidget {
|
class QRCodeScannerDialog extends StatefulWidget {
|
||||||
const QRCodeScannerDialog({super.key});
|
/// Callback appelé quand un code est scanné (mode multi-scan)
|
||||||
|
final Function(String code)? onCodeScanned;
|
||||||
|
|
||||||
|
/// Active le mode scan continu (ne ferme pas automatiquement)
|
||||||
|
final bool multiScanMode;
|
||||||
|
|
||||||
|
const QRCodeScannerDialog({
|
||||||
|
super.key,
|
||||||
|
this.onCodeScanned,
|
||||||
|
this.multiScanMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
|
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
|
||||||
@@ -45,12 +55,27 @@ class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
|
|||||||
_scannedCode = code;
|
_scannedCode = code;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retourner le code après un court délai pour montrer le feedback visuel
|
if (widget.multiScanMode && widget.onCodeScanned != null) {
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
// Mode multi-scan : appeler le callback et rester ouvert
|
||||||
if (mounted) {
|
widget.onCodeScanned!(code);
|
||||||
Navigator.of(context).pop(code);
|
|
||||||
}
|
// Réinitialiser après un délai pour permettre un nouveau scan
|
||||||
});
|
Future.delayed(const Duration(milliseconds: 800), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isProcessing = false;
|
||||||
|
_scannedCode = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Mode simple : retourner le code et fermer
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_statistics_models.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/event_statistics_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
enum _AmountDisplayMode { ht, ttc }
|
||||||
|
|
||||||
|
enum _DatePreset { currentMonth, previousMonth, currentYear, previousYear }
|
||||||
|
|
||||||
|
class EventStatisticsTab extends StatefulWidget {
|
||||||
|
const EventStatisticsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventStatisticsTab> createState() => _EventStatisticsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventStatisticsTabState extends State<EventStatisticsTab> {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
final EventStatisticsService _statisticsService =
|
||||||
|
const EventStatisticsService();
|
||||||
|
|
||||||
|
final NumberFormat _currencyFormat =
|
||||||
|
NumberFormat.currency(locale: 'fr_FR', symbol: 'EUR ');
|
||||||
|
final NumberFormat _percentFormat = NumberFormat.percentPattern('fr_FR');
|
||||||
|
|
||||||
|
DateTimeRange _selectedPeriod = _initialPeriod();
|
||||||
|
final Set<String> _selectedEventTypeIds = {};
|
||||||
|
final Set<EventStatus> _selectedStatuses = {
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
};
|
||||||
|
_AmountDisplayMode _amountDisplayMode = _AmountDisplayMode.ht;
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
List<EventModel> _events = [];
|
||||||
|
Map<String, String> _eventTypeNames = {};
|
||||||
|
EventStatisticsSummary _summary = EventStatisticsSummary.empty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTimeRange _initialPeriod() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(now.year, now.month, 1),
|
||||||
|
end: DateTime(now.year, now.month + 1, 0, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStatistics() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final localUserProvider =
|
||||||
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
_dataService.getEvents(userId: userId),
|
||||||
|
_dataService.getEventTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final eventsResult = results[0] as Map<String, dynamic>;
|
||||||
|
final eventTypesResult = results[1] as List<Map<String, dynamic>>;
|
||||||
|
final eventsData = eventsResult['events'] as List<Map<String, dynamic>>;
|
||||||
|
|
||||||
|
final parsedEvents = <EventModel>[];
|
||||||
|
for (final eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
parsedEvents
|
||||||
|
.add(EventModel.fromMap(eventData, eventData['id'] as String));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed rows and continue to keep the dashboard available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final eventTypeNames = <String, String>{};
|
||||||
|
for (final eventType in eventTypesResult) {
|
||||||
|
final id = (eventType['id'] ?? '').toString();
|
||||||
|
if (id.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventTypeNames[id] = (eventType['name'] ?? id).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_events = parsedEvents;
|
||||||
|
_eventTypeNames = eventTypeNames;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
_rebuildSummary();
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Erreur lors du chargement des statistiques: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildSummary() {
|
||||||
|
final filter = EventStatisticsFilter(
|
||||||
|
period: _selectedPeriod,
|
||||||
|
eventTypeIds: _selectedEventTypeIds,
|
||||||
|
includeCanceled: _selectedStatuses.contains(EventStatus.canceled),
|
||||||
|
selectedStatuses: _selectedStatuses,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_summary = _statisticsService.buildSummary(
|
||||||
|
events: _events,
|
||||||
|
filter: filter,
|
||||||
|
eventTypeNames: _eventTypeNames,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDateRange() async {
|
||||||
|
final selectedRange = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2035),
|
||||||
|
initialDateRange: _selectedPeriod,
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: AppColors.rouge,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedRange == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = DateTimeRange(
|
||||||
|
start: selectedRange.start,
|
||||||
|
end: DateTime(
|
||||||
|
selectedRange.end.year,
|
||||||
|
selectedRange.end.month,
|
||||||
|
selectedRange.end.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetFilters() {
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = _initialPeriod();
|
||||||
|
_selectedEventTypeIds.clear();
|
||||||
|
_selectedStatuses.clear();
|
||||||
|
_selectedStatuses.addAll({
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
});
|
||||||
|
_amountDisplayMode = _AmountDisplayMode.ht;
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCurrency(double value) => _currencyFormat.format(value);
|
||||||
|
|
||||||
|
String _formatPercent(double value) => _percentFormat.format(value);
|
||||||
|
|
||||||
|
String get _amountUnitLabel =>
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ht ? 'HT' : 'TTC';
|
||||||
|
|
||||||
|
double _toDisplayAmount(double htAmount) {
|
||||||
|
if (_amountDisplayMode == _AmountDisplayMode.ttc) {
|
||||||
|
return htAmount * 1.2;
|
||||||
|
}
|
||||||
|
return htAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAmount(double htAmount) =>
|
||||||
|
_formatCurrency(_toDisplayAmount(htAmount));
|
||||||
|
|
||||||
|
String _presetLabel(_DatePreset preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case _DatePreset.currentMonth:
|
||||||
|
return 'Ce mois-ci';
|
||||||
|
case _DatePreset.previousMonth:
|
||||||
|
return 'Mois dernier';
|
||||||
|
case _DatePreset.currentYear:
|
||||||
|
return 'Cette année';
|
||||||
|
case _DatePreset.previousYear:
|
||||||
|
return 'Année dernière';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForMonth(int year, int month) {
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(year, month, 1),
|
||||||
|
end: DateTime(year, month + 1, 0, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForYear(int year) {
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(year, 1, 1),
|
||||||
|
end: DateTime(year, 12, 31, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForPreset(_DatePreset preset) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case _DatePreset.currentMonth:
|
||||||
|
return _rangeForMonth(now.year, now.month);
|
||||||
|
case _DatePreset.previousMonth:
|
||||||
|
return _rangeForMonth(now.year, now.month - 1);
|
||||||
|
case _DatePreset.currentYear:
|
||||||
|
return _rangeForYear(now.year);
|
||||||
|
case _DatePreset.previousYear:
|
||||||
|
return _rangeForYear(now.year - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPresetSelected(_DatePreset preset) {
|
||||||
|
final presetRange = _rangeForPreset(preset);
|
||||||
|
return _selectedPeriod.start == presetRange.start &&
|
||||||
|
_selectedPeriod.end == presetRange.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyDatePreset(_DatePreset preset) {
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = _rangeForPreset(preset);
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _loadStatistics,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadStatistics,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildFiltersCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSummaryCards(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildByTypeSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTopOptionsSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFiltersCard() {
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_alt, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Filtres',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ToggleButtons(
|
||||||
|
isSelected: [
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ht,
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ttc,
|
||||||
|
],
|
||||||
|
onPressed: (index) {
|
||||||
|
setState(() {
|
||||||
|
_amountDisplayMode = index == 0
|
||||||
|
? _AmountDisplayMode.ht
|
||||||
|
: _AmountDisplayMode.ttc;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
children: const [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text('HT'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text('TTC'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _resetFilters,
|
||||||
|
icon: const Icon(Icons.restart_alt),
|
||||||
|
label: const Text('Réinitialiser'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _selectDateRange,
|
||||||
|
icon: const Icon(Icons.date_range),
|
||||||
|
label: Text(
|
||||||
|
'${dateFormat.format(_selectedPeriod.start)} - ${dateFormat.format(_selectedPeriod.end)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
..._DatePreset.values.map(
|
||||||
|
(preset) => ChoiceChip(
|
||||||
|
label: Text(_presetLabel(preset)),
|
||||||
|
selected: _isPresetSelected(preset),
|
||||||
|
onSelected: (_) => _applyDatePreset(preset),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Statuts d\'événements',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Validés'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.confirmed),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.confirmed);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.confirmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('En attente'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.waitingForApproval),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.waitingForApproval);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.waitingForApproval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Annulés'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.canceled),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.canceled);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.canceled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_eventTypeNames.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Types d\'événements',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _eventTypeNames.entries.map((entry) {
|
||||||
|
final selected = _selectedEventTypeIds.contains(entry.key);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(entry.value),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedEventTypeIds.add(entry.key);
|
||||||
|
} else {
|
||||||
|
_selectedEventTypeIds.remove(entry.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryCards() {
|
||||||
|
final metrics = <_MetricConfig>[
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Total événements',
|
||||||
|
value: _summary.totalEvents.toString(),
|
||||||
|
subtitle: 'Sur la période sélectionnée',
|
||||||
|
icon: Icons.event,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant total',
|
||||||
|
value: _formatAmount(_summary.totalAmount),
|
||||||
|
subtitle: 'Base + options ($_amountUnitLabel)',
|
||||||
|
icon: Icons.account_balance_wallet,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant validé',
|
||||||
|
value: _formatAmount(_summary.validatedAmount),
|
||||||
|
subtitle: '${_summary.validatedEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.verified,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant non validé',
|
||||||
|
value: _formatAmount(_summary.pendingAmount),
|
||||||
|
subtitle: '${_summary.pendingEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant annulé',
|
||||||
|
value: _formatAmount(_summary.canceledAmount),
|
||||||
|
subtitle: '${_summary.canceledEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.cancel,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Panier moyen',
|
||||||
|
value: _formatAmount(_summary.averageAmount),
|
||||||
|
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Panier médian',
|
||||||
|
value: _formatAmount(_summary.medianAmount),
|
||||||
|
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||||
|
icon: Icons.timeline,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Pourcentage de validation',
|
||||||
|
value: _formatPercent(_summary.validationRate),
|
||||||
|
subtitle:
|
||||||
|
'${_summary.validatedEvents} validés sur ${_summary.totalEvents}',
|
||||||
|
icon: Icons.pie_chart,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Base vs options',
|
||||||
|
value:
|
||||||
|
'${_formatPercent(_summary.baseContributionRate)} / ${_formatPercent(_summary.optionsContributionRate)}',
|
||||||
|
subtitle:
|
||||||
|
'Base: ${_formatAmount(_summary.baseAmount)} - Options: ${_formatAmount(_summary.optionsAmount)}',
|
||||||
|
icon: Icons.stacked_bar_chart,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'KPI période',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: metrics
|
||||||
|
.map(
|
||||||
|
(metric) => _buildMetricCard(
|
||||||
|
title: metric.title,
|
||||||
|
value: metric.value,
|
||||||
|
subtitle: metric.subtitle,
|
||||||
|
icon: metric.icon,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required String subtitle,
|
||||||
|
required IconData icon,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 280,
|
||||||
|
child: Card(
|
||||||
|
elevation: 1,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildByTypeSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Répartition par type d\'événement',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_summary.byEventType.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'Aucune donnée pour la période et les filtres sélectionnés.')
|
||||||
|
else
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: DataTable(
|
||||||
|
columns: [
|
||||||
|
const DataColumn(label: Text('Type')),
|
||||||
|
const DataColumn(label: Text('Nb')),
|
||||||
|
const DataColumn(label: Text('Validé')),
|
||||||
|
const DataColumn(label: Text('Non validé')),
|
||||||
|
const DataColumn(label: Text('Annulé')),
|
||||||
|
DataColumn(label: Text('Total $_amountUnitLabel')),
|
||||||
|
],
|
||||||
|
rows: _summary.byEventType
|
||||||
|
.map(
|
||||||
|
(row) => DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text(row.eventTypeName)),
|
||||||
|
DataCell(Text(row.totalEvents.toString())),
|
||||||
|
DataCell(Text(_formatAmount(row.validatedAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.pendingAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.canceledAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.totalAmount))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopOptionsSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Top options',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_summary.topOptions.isEmpty)
|
||||||
|
const Text('Aucune option valorisée sur la période sélectionnée.')
|
||||||
|
else
|
||||||
|
..._summary.topOptions.map(
|
||||||
|
(option) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.add_chart, color: AppColors.rouge),
|
||||||
|
title: Text(option.optionLabel),
|
||||||
|
subtitle: Text(
|
||||||
|
'Validées ${option.validatedUsageCount} fois',
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_formatAmount(option.totalAmount),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricConfig {
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _MetricConfig({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:csv/csv.dart';
|
||||||
|
import 'package:universal_io/io.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:em2rp/utils/web_download.dart' as web_download;
|
||||||
|
|
||||||
|
|
||||||
|
class EventsExport extends StatefulWidget {
|
||||||
|
const EventsExport({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventsExport> createState() => _EventsExportState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventsExportState extends State<EventsExport> {
|
||||||
|
late final DataService _dataService;
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
DateTime? _startDate;
|
||||||
|
DateTime? _endDate;
|
||||||
|
final List<String> _selectedEventTypeIds = [];
|
||||||
|
final List<String> _selectedStatuses = [];
|
||||||
|
|
||||||
|
// Options disponibles
|
||||||
|
List<Map<String, dynamic>> _eventTypes = [];
|
||||||
|
final List<String> _availableStatuses = [
|
||||||
|
'CONFIRMED',
|
||||||
|
'WAITING_FOR_APPROVAL',
|
||||||
|
'CANCELED',
|
||||||
|
];
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _loadingEventTypes = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
_loadEventTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEventTypes() async {
|
||||||
|
setState(() => _loadingEventTypes = true);
|
||||||
|
try {
|
||||||
|
final eventTypesData = await _dataService.getEventTypes();
|
||||||
|
setState(() {
|
||||||
|
_eventTypes = eventTypesData;
|
||||||
|
_loadingEventTypes = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors du chargement des types d\'événements: $e';
|
||||||
|
_loadingEventTypes = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return 'Validé';
|
||||||
|
case 'WAITING_FOR_APPROVAL':
|
||||||
|
return 'En attente';
|
||||||
|
case 'CANCELED':
|
||||||
|
return 'Annulé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportEvents() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer tous les événements via l'API
|
||||||
|
final result = await _dataService.getEvents();
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Parser les événements
|
||||||
|
List<EventModel> events = [];
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
events.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Erreur lors du parsing de l\'événement ${eventData['id']}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer les filtres
|
||||||
|
events = _applyFilters(events);
|
||||||
|
|
||||||
|
if (events.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Aucun événement ne correspond aux critères de filtrage.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le CSV
|
||||||
|
final csv = await _generateCSV(events, usersData);
|
||||||
|
|
||||||
|
// Sauvegarder et partager le fichier
|
||||||
|
await _saveAndShareCSV(csv);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Export réussi : ${events.length} événement(s) exporté(s)'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors de l\'export: $e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventModel> _applyFilters(List<EventModel> events) {
|
||||||
|
return events.where((event) {
|
||||||
|
// Filtre par date
|
||||||
|
if (_startDate != null && event.endDateTime.isBefore(_startDate!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_endDate != null && event.startDateTime.isAfter(_endDate!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par type d'événement
|
||||||
|
if (_selectedEventTypeIds.isNotEmpty &&
|
||||||
|
!_selectedEventTypeIds.contains(event.eventTypeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
if (_selectedStatuses.isNotEmpty &&
|
||||||
|
!_selectedStatuses.contains(eventStatusToString(event.status))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _generateCSV(
|
||||||
|
List<EventModel> events,
|
||||||
|
Map<String, dynamic> usersData,
|
||||||
|
) async {
|
||||||
|
final List<List<dynamic>> rows = [];
|
||||||
|
|
||||||
|
// En-têtes
|
||||||
|
rows.add([
|
||||||
|
'Titre',
|
||||||
|
'Type d\'événement',
|
||||||
|
'Statut',
|
||||||
|
'Description',
|
||||||
|
'Date début',
|
||||||
|
'Date fin',
|
||||||
|
'Durée montage (min)',
|
||||||
|
'Durée démontage (min)',
|
||||||
|
'Prix de base HT (€)',
|
||||||
|
'Prix de base TTC (€)',
|
||||||
|
'Options',
|
||||||
|
'Prix total HT (€)',
|
||||||
|
'Prix total TTC (€)',
|
||||||
|
'Workforce',
|
||||||
|
'Contact client',
|
||||||
|
'Jauge',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Récupérer les noms des types d'événements
|
||||||
|
final eventTypesMap = <String, String>{};
|
||||||
|
for (var eventType in _eventTypes) {
|
||||||
|
eventTypesMap[eventType['id']] = eventType['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||||
|
|
||||||
|
for (var event in events) {
|
||||||
|
// Calculer le prix total TTC (base + options)
|
||||||
|
final totalTTC = event.basePrice +
|
||||||
|
event.options.fold<num>(
|
||||||
|
0,
|
||||||
|
(sum, opt) {
|
||||||
|
final priceTTC = opt['price'] ?? 0.0;
|
||||||
|
final quantity = opt['quantity'] ?? 1;
|
||||||
|
return sum + (priceTTC * quantity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer les prix HT (TVA 20%)
|
||||||
|
final basePriceHT = event.basePrice / 1.20;
|
||||||
|
final totalHT = totalTTC / 1.20;
|
||||||
|
|
||||||
|
// Formatter les options
|
||||||
|
final optionsStr = event.options.isEmpty
|
||||||
|
? 'Aucune'
|
||||||
|
: event.options.map((opt) {
|
||||||
|
final name = opt['name'] ?? '';
|
||||||
|
final code = opt['code'] ?? '';
|
||||||
|
final details = opt['details'] ?? '';
|
||||||
|
final price = opt['price'] ?? 0.0;
|
||||||
|
final quantity = opt['quantity'] ?? 1;
|
||||||
|
|
||||||
|
final optName = code.isNotEmpty ? '$code - $name' : name;
|
||||||
|
final optDetails = details.isNotEmpty ? ' ($details)' : '';
|
||||||
|
final optPrice = (price * quantity).toStringAsFixed(2);
|
||||||
|
|
||||||
|
return '$optName$optDetails: $optPrice €';
|
||||||
|
}).join(' | ');
|
||||||
|
|
||||||
|
// Formatter la workforce
|
||||||
|
final workforceStr = event.workforce.isEmpty
|
||||||
|
? 'Aucun'
|
||||||
|
: event.workforce.map((worker) {
|
||||||
|
if (worker is String) {
|
||||||
|
// UID - récupérer le nom depuis usersData
|
||||||
|
final userData = usersData[worker] as Map<String, dynamic>?;
|
||||||
|
if (userData != null) {
|
||||||
|
final firstName = userData['firstName'] ?? '';
|
||||||
|
final lastName = userData['lastName'] ?? '';
|
||||||
|
return '$firstName $lastName'.trim();
|
||||||
|
}
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
return worker.toString();
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
// Formatter le contact
|
||||||
|
final contactParts = <String>[];
|
||||||
|
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) {
|
||||||
|
contactParts.add(event.contactEmail!);
|
||||||
|
}
|
||||||
|
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) {
|
||||||
|
contactParts.add(event.contactPhone!);
|
||||||
|
}
|
||||||
|
final contactStr = contactParts.isEmpty ? 'N/A' : contactParts.join(' | ');
|
||||||
|
|
||||||
|
rows.add([
|
||||||
|
event.name,
|
||||||
|
eventTypesMap[event.eventTypeId] ?? event.eventTypeId,
|
||||||
|
_getStatusLabel(eventStatusToString(event.status)),
|
||||||
|
event.description,
|
||||||
|
dateFormat.format(event.startDateTime),
|
||||||
|
dateFormat.format(event.endDateTime),
|
||||||
|
event.installationTime,
|
||||||
|
event.disassemblyTime,
|
||||||
|
basePriceHT.toStringAsFixed(2),
|
||||||
|
event.basePrice.toStringAsFixed(2),
|
||||||
|
optionsStr,
|
||||||
|
totalHT.toStringAsFixed(2),
|
||||||
|
totalTTC.toStringAsFixed(2),
|
||||||
|
workforceStr,
|
||||||
|
contactStr,
|
||||||
|
event.jauge?.toString() ?? 'N/A',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const ListToCsvConverter().convert(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndShareCSV(String csvContent) async {
|
||||||
|
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||||
|
final fileName = 'evenements_export_$timestamp.csv';
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Sur le web, télécharger directement avec la fonction conditionnelle
|
||||||
|
web_download.downloadFile(csvContent, fileName);
|
||||||
|
} else {
|
||||||
|
// Sur mobile/desktop, utiliser share_plus
|
||||||
|
final directory = await getTemporaryDirectory();
|
||||||
|
final filePath = '${directory.path}/$fileName';
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsString(csvContent);
|
||||||
|
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(filePath)],
|
||||||
|
subject: 'Export des événements',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectDateRange() async {
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2030),
|
||||||
|
initialDateRange: _startDate != null && _endDate != null
|
||||||
|
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||||
|
: null,
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: AppColors.rouge,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_startDate = picked.start;
|
||||||
|
_endDate = picked.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearDateRange() {
|
||||||
|
setState(() {
|
||||||
|
_startDate = null;
|
||||||
|
_endDate = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loadingEventTypes) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_download, color: AppColors.rouge, size: 32),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Exporter les événements',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Exportez les événements au format CSV avec filtres personnalisables',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Section des filtres
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildFiltersSection(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_error != null) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bouton d'export
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _exportEvents,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text(
|
||||||
|
_isLoading ? 'Export en cours...' : 'Exporter les événements',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFiltersSection() {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Filtres',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
|
||||||
|
// Filtre par période
|
||||||
|
_buildDateRangeFilter(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Filtre par type d'événement
|
||||||
|
_buildEventTypeFilter(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
_buildStatusFilter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateRangeFilter() {
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Période',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _selectDateRange,
|
||||||
|
icon: const Icon(Icons.calendar_today),
|
||||||
|
label: Text(
|
||||||
|
_startDate != null && _endDate != null
|
||||||
|
? 'Du ${dateFormat.format(_startDate!)} au ${dateFormat.format(_endDate!)}'
|
||||||
|
: 'Toutes les périodes',
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.rouge,
|
||||||
|
side: BorderSide(color: AppColors.rouge),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_startDate != null && _endDate != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _clearDateRange,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
color: Colors.grey,
|
||||||
|
tooltip: 'Effacer la période',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEventTypeFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Types d\'événement',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
// Bouton "Tous"
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Tous'),
|
||||||
|
selected: _selectedEventTypeIds.isEmpty,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedEventTypeIds.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: _selectedEventTypeIds.isEmpty
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Chips pour chaque type
|
||||||
|
..._eventTypes.map((eventType) {
|
||||||
|
final isSelected =
|
||||||
|
_selectedEventTypeIds.contains(eventType['id']);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(eventType['name']),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedEventTypeIds.add(eventType['id']);
|
||||||
|
} else {
|
||||||
|
_selectedEventTypeIds.remove(eventType['id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Statut de l\'événement',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
// Bouton "Tous"
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Tous'),
|
||||||
|
selected: _selectedStatuses.isEmpty,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedStatuses.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: _selectedStatuses.isEmpty ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Chips pour chaque statut
|
||||||
|
..._availableStatuses.map((status) {
|
||||||
|
final isSelected = _selectedStatuses.contains(status);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(_getStatusLabel(status)),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedStatuses.add(status);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
|
|
||||||
// Boutons d'action par équipement
|
// Boutons d'action par équipement
|
||||||
if (!isRemoved)
|
if (!isRemoved)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ContainerConflictInfo {
|
|||||||
if (status == ContainerConflictStatus.complete) {
|
if (status == ContainerConflictStatus.complete) {
|
||||||
return 'Tous les équipements sont déjà utilisés';
|
return 'Tous les équipements sont déjà utilisés';
|
||||||
}
|
}
|
||||||
return '${conflictingEquipmentIds.length}/${totalChildren} équipement(s) déjà utilisé(s)';
|
return '${conflictingEquipmentIds.length}/$totalChildren équipement(s) déjà utilisé(s)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +94,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
Map<String, SelectedItem> _selectedItems = {};
|
Map<String, SelectedItem> _selectedItems = {};
|
||||||
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
||||||
Map<String, int> _availableQuantities = {}; // Pour consommables
|
final Map<String, int> _availableQuantities = {}; // Pour consommables
|
||||||
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
final Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
||||||
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
final Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
||||||
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
|
final Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
|
||||||
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
|
final Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
|
||||||
|
|
||||||
// NOUVEAU : IDs en conflit récupérés en batch
|
// NOUVEAU : IDs en conflit récupérés en batch
|
||||||
Set<String> _conflictingEquipmentIds = {};
|
Set<String> _conflictingEquipmentIds = {};
|
||||||
@@ -119,12 +119,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
bool _hasMoreContainers = true;
|
bool _hasMoreContainers = true;
|
||||||
String? _lastEquipmentId;
|
String? _lastEquipmentId;
|
||||||
String? _lastContainerId;
|
String? _lastContainerId;
|
||||||
List<EquipmentModel> _paginatedEquipments = [];
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
List<ContainerModel> _paginatedContainers = [];
|
final List<ContainerModel> _paginatedContainers = [];
|
||||||
|
|
||||||
// Cache pour éviter les rebuilds inutiles
|
// Cache pour éviter les rebuilds inutiles
|
||||||
List<ContainerModel> _cachedContainers = [];
|
final List<ContainerModel> _cachedContainers = [];
|
||||||
List<EquipmentModel> _cachedEquipment = [];
|
final List<EquipmentModel> _cachedEquipment = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -1047,7 +1047,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: dialogWidth.clamp(600.0, 1200.0),
|
width: dialogWidth.clamp(600.0, 1200.0),
|
||||||
height: dialogHeight.clamp(500.0, 900.0),
|
height: dialogHeight.clamp(500.0, 900.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1458,66 +1458,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header de section repliable
|
|
||||||
Widget _buildCollapsibleSectionHeader(
|
|
||||||
String title,
|
|
||||||
IconData icon,
|
|
||||||
int count,
|
|
||||||
bool isExpanded,
|
|
||||||
Function(bool) onToggle,
|
|
||||||
) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onToggle(!isExpanded),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.rouge.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.rouge.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(icon, color: AppColors.rouge, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.rouge,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$count',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
|
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
|
||||||
final isSelected = _selectedItems.containsKey(equipment.id);
|
final isSelected = _selectedItems.containsKey(equipment.id);
|
||||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||||
@@ -1809,7 +1749,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}) {
|
}) {
|
||||||
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
||||||
|
|
||||||
return Container(
|
return SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -2369,7 +2309,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache local pour les équipements des conteneurs
|
// Cache local pour les équipements des conteneurs
|
||||||
Map<String, List<String>> _containerEquipmentCache = {};
|
final Map<String, List<String>> _containerEquipmentCache = {};
|
||||||
|
|
||||||
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
|
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
|
||||||
final isExpanded = _expandedContainers.contains(id);
|
final isExpanded = _expandedContainers.contains(id);
|
||||||
@@ -2425,7 +2365,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
|
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
|
||||||
|
|
||||||
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
||||||
class OptimizedEquipmentCard extends StatefulWidget {
|
class OptimizedEquipmentCard extends StatefulWidget {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import 'package:em2rp/providers/equipment_provider.dart';
|
|||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart';
|
|
||||||
import 'package:em2rp/services/event_availability_service.dart';
|
|
||||||
|
|
||||||
/// Section pour afficher et gérer le matériel assigné à un événement
|
/// Section pour afficher et gérer le matériel assigné à un événement
|
||||||
class EventAssignedEquipmentSection extends StatefulWidget {
|
class EventAssignedEquipmentSection extends StatefulWidget {
|
||||||
@@ -36,9 +34,8 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
|
|
||||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
||||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
||||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -64,19 +61,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
// Extraire les IDs des équipements assignés
|
DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
||||||
final equipmentIds = widget.assignedEquipment
|
|
||||||
.map((eq) => eq.equipmentId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Charger UNIQUEMENT les équipements nécessaires (optimisé)
|
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(equipmentIds);
|
final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||||
|
|
||||||
// Charger UNIQUEMENT les conteneurs nécessaires (optimisé)
|
|
||||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||||
|
|
||||||
// Créer le cache des équipements
|
final childEquipmentIds = <String>[];
|
||||||
for (var eq in widget.assignedEquipment) {
|
for (final container in containers) {
|
||||||
|
childEquipmentIds.addAll(container.equipmentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||||
|
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||||
|
|
||||||
|
_equipmentCache.clear();
|
||||||
|
_containerCache.clear();
|
||||||
|
|
||||||
|
for (final eq in widget.assignedEquipment) {
|
||||||
final equipmentItem = equipment.firstWhere(
|
final equipmentItem = equipment.firstWhere(
|
||||||
(e) => e.id == eq.equipmentId,
|
(e) => e.id == eq.equipmentId,
|
||||||
orElse: () => EquipmentModel(
|
orElse: () => EquipmentModel(
|
||||||
@@ -92,8 +94,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_equipmentCache[eq.equipmentId] = equipmentItem;
|
_equipmentCache[eq.equipmentId] = equipmentItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le cache des conteneurs
|
for (final containerId in widget.assignedContainers) {
|
||||||
for (var containerId in widget.assignedContainers) {
|
|
||||||
final container = containers.firstWhere(
|
final container = containers.firstWhere(
|
||||||
(c) => c.id == containerId,
|
(c) => c.id == containerId,
|
||||||
orElse: () => ContainerModel(
|
orElse: () => ContainerModel(
|
||||||
@@ -109,7 +110,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_containerCache[containerId] = container;
|
_containerCache[containerId] = container;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Erreur silencieuse - le cache restera vide
|
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,26 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||||
|
|
||||||
|
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
|
||||||
|
if (newContainers.isNotEmpty) {
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
final containers = await containerProvider.getContainersByIds(newContainers);
|
||||||
|
|
||||||
|
for (var container in containers) {
|
||||||
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
|
||||||
|
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
|
if (!existsInNew) {
|
||||||
|
newEquipment.add(EventEquipment(
|
||||||
|
equipmentId: childEquipmentId,
|
||||||
|
quantity: 1,
|
||||||
|
));
|
||||||
|
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
|
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
|
||||||
// On enregistre directement la sélection
|
// On enregistre directement la sélection
|
||||||
|
|
||||||
@@ -192,9 +213,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Notifier le changement
|
// Notifier le changement
|
||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
|
||||||
// Recharger le cache
|
|
||||||
await _loadEquipmentAndContainers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeEquipment(String equipmentId) {
|
void _removeEquipment(String equipmentId) {
|
||||||
@@ -217,25 +235,47 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
.where((id) => id != containerId)
|
.where((id) => id != containerId)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Retirer les équipements enfants de la liste des équipements assignés
|
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
||||||
final updatedEquipment = widget.assignedEquipment.where((eq) {
|
final updatedEquipment = <EventEquipment>[];
|
||||||
if (container != null) {
|
|
||||||
// Garder uniquement les équipements qui ne sont PAS dans ce conteneur
|
|
||||||
return !container.equipmentIds.contains(eq.equipmentId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
|
if (container != null) {
|
||||||
|
// Collecter les IDs d'équipements dans les autres containers
|
||||||
|
final Set<String> equipmentIdsInOtherContainers = {};
|
||||||
|
for (var otherContainerId in updatedContainers) {
|
||||||
|
final otherContainer = _containerCache[otherContainerId];
|
||||||
|
if (otherContainer != null) {
|
||||||
|
equipmentIdsInOtherContainers.addAll(otherContainer.equipmentIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garder les équipements qui :
|
||||||
|
// 1. Ne sont PAS dans le container supprimé OU
|
||||||
|
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
||||||
|
for (var eq in widget.assignedEquipment) {
|
||||||
|
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
|
||||||
|
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
||||||
|
|
||||||
|
if (!isInRemovedContainer || isInOtherContainer) {
|
||||||
|
updatedEquipment.add(eq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Si le container n'est pas dans le cache, garder tous les équipements
|
||||||
|
updatedEquipment.addAll(widget.assignedEquipment);
|
||||||
|
}
|
||||||
|
|
||||||
// Notifier le changement avec les deux listes mises à jour
|
// Notifier le changement avec les deux listes mises à jour
|
||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_containerCache.remove(containerId);
|
_containerCache.remove(containerId);
|
||||||
// Retirer aussi les équipements enfants du cache
|
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
||||||
if (container != null) {
|
if (container != null) {
|
||||||
|
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
||||||
for (var equipmentId in container.equipmentIds) {
|
for (var equipmentId in container.equipmentIds) {
|
||||||
_equipmentCache.remove(equipmentId);
|
if (!remainingEquipmentIds.contains(equipmentId)) {
|
||||||
|
_equipmentCache.remove(equipmentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -398,7 +438,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
...widget.assignedContainers.map((containerId) {
|
...widget.assignedContainers.map((containerId) {
|
||||||
final container = _containerCache[containerId];
|
final container = _containerCache[containerId];
|
||||||
return _buildContainerItem(container);
|
return _buildContainerItem(container);
|
||||||
}).toList(),
|
}),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -415,7 +455,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
..._getStandaloneEquipment().map((eq) {
|
..._getStandaloneEquipment().map((eq) {
|
||||||
final equipment = _equipmentCache[eq.equipmentId];
|
final equipment = _equipmentCache[eq.equipmentId];
|
||||||
return _buildEquipmentItem(equipment, eq);
|
return _buildEquipmentItem(equipment, eq);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -427,7 +467,14 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
Widget _buildContainerItem(ContainerModel? container) {
|
Widget _buildContainerItem(ContainerModel? container) {
|
||||||
if (container == null) {
|
if (container == null) {
|
||||||
return const SizedBox.shrink();
|
return const Card(
|
||||||
|
margin: EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.inventory_2, color: Colors.grey),
|
||||||
|
title: Text('Conteneur inconnu'),
|
||||||
|
subtitle: Text('Données du conteneur indisponibles'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
@@ -444,79 +491,69 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
onPressed: () => _removeContainer(container.id),
|
onPressed: () => _removeContainer(container.id),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
// Afficher les équipements enfants (par composition)
|
// 🔧 FIX: Utiliser directement le cache local au lieu du provider stream
|
||||||
Consumer<EquipmentProvider>(
|
Builder(
|
||||||
builder: (context, provider, child) {
|
builder: (context) {
|
||||||
return StreamBuilder<List<EquipmentModel>>(
|
// Récupérer les équipements enfants depuis le cache local
|
||||||
stream: provider.equipmentStream,
|
final childEquipments = container.equipmentIds
|
||||||
builder: (context, snapshot) {
|
.map((id) => _equipmentCache[id])
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
.where((eq) => eq != null)
|
||||||
return const Padding(
|
.cast<EquipmentModel>()
|
||||||
padding: EdgeInsets.all(16),
|
.toList();
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final allEquipment = snapshot.data ?? [];
|
if (childEquipments.isEmpty) {
|
||||||
final childEquipments = allEquipment
|
return Padding(
|
||||||
.where((eq) => container.equipmentIds.contains(eq.id))
|
padding: const EdgeInsets.all(16),
|
||||||
.toList();
|
child: Text(
|
||||||
|
'Aucun équipement dans ce conteneur (${container.equipmentIds.length} attendu(s))',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (childEquipments.isEmpty) {
|
return Padding(
|
||||||
return const Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
padding: EdgeInsets.all(16),
|
child: Column(
|
||||||
child: Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Aucun équipement dans ce conteneur',
|
children: [
|
||||||
style: TextStyle(color: Colors.grey),
|
Text(
|
||||||
|
'Contenu (${childEquipments.length} équipement(s))',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Contenu (${childEquipments.length} équipement(s))',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...childEquipments.map((eq) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Icon(
|
|
||||||
Icons.subdirectory_arrow_right,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
eq.category.getIcon(size: 16, color: eq.category.color),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
eq.id,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 8),
|
||||||
},
|
...childEquipments.map((eq) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Icon(
|
||||||
|
Icons.subdirectory_arrow_right,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
eq.category.getIcon(size: 16, color: eq.category.color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
eq.id,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -527,7 +564,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
||||||
if (equipment == null) {
|
if (equipment == null) {
|
||||||
return const SizedBox.shrink();
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Color(0xFFE0E0E0),
|
||||||
|
child: Icon(Icons.inventory_2, color: Colors.grey),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
eventEq.equipmentId,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: const Text('Équipement indisponible dans le cache local'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _removeEquipment(eventEq.equipmentId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
|
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// États possibles lors de l'ajout d'un équipement
|
||||||
|
enum AddEquipmentState {
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog pour afficher le résultat de l'ajout d'un équipement/container
|
||||||
|
class AddEquipmentToEventDialog extends StatelessWidget {
|
||||||
|
final AddEquipmentState state;
|
||||||
|
final String? itemName;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const AddEquipmentToEventDialog({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
this.itemName,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildIcon(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildMessage(),
|
||||||
|
if (state != AddEquipmentState.loading) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon() {
|
||||||
|
switch (state) {
|
||||||
|
case AddEquipmentState.loading:
|
||||||
|
return const SizedBox(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: AppColors.rouge,
|
||||||
|
strokeWidth: 4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case AddEquipmentState.success:
|
||||||
|
return const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.green,
|
||||||
|
);
|
||||||
|
case AddEquipmentState.error:
|
||||||
|
return const Icon(
|
||||||
|
Icons.error,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessage() {
|
||||||
|
switch (state) {
|
||||||
|
case AddEquipmentState.loading:
|
||||||
|
return const Text(
|
||||||
|
'Recherche en cours...',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
);
|
||||||
|
case AddEquipmentState.success:
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Ajouté avec succès !',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (itemName != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
itemName!,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case AddEquipmentState.error:
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Non trouvé',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Dialog affiché quand un code scanné n'est pas trouvé dans l'événement
|
||||||
|
class CodeNotFoundDialog extends StatelessWidget {
|
||||||
|
final String scannedCode;
|
||||||
|
|
||||||
|
const CodeNotFoundDialog({
|
||||||
|
super.key,
|
||||||
|
required this.scannedCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
title: const Text('Code non reconnu'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Le code scanné n\'est pas assigné à cet événement :',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
scannedCode,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Voulez-vous le rechercher dans la base de données et l\'ajouter à l\'événement ?',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Non'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.rouge),
|
||||||
|
child: const Text('Oui, rechercher'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
|||||||
final bool isMobile;
|
final bool isMobile;
|
||||||
final String? eventType;
|
final String? eventType;
|
||||||
|
|
||||||
const OptionSelectorWidget({
|
const OptionSelectorWidget({super.key,
|
||||||
Key? key,
|
|
||||||
this.eventType,
|
this.eventType,
|
||||||
required this.selectedOptions,
|
required this.selectedOptions,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ 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';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
|
|
||||||
class MainDrawer extends StatelessWidget {
|
class MainDrawer extends StatelessWidget {
|
||||||
final String currentPage;
|
final String currentPage;
|
||||||
@@ -113,6 +115,42 @@ 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()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PermissionGate(
|
||||||
|
requiredPermissions: const ['generate_reports'],
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.bar_chart),
|
||||||
|
title: const Text('Statistiques evenements'),
|
||||||
|
selected: currentPage == '/event_statistics',
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const EventStatisticsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ExpansionTileTheme(
|
ExpansionTileTheme(
|
||||||
data: const ExpansionTileThemeData(
|
data: const ExpansionTileThemeData(
|
||||||
iconColor: AppColors.noir,
|
iconColor: AppColors.noir,
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class _NotificationPreferencesWidgetState extends State<NotificationPreferencesW
|
|||||||
),
|
),
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
|
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
|
||||||
activeColor: Theme.of(context).primaryColor,
|
activeThumbColor: Theme.of(context).primaryColor,
|
||||||
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
|
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
|
||||||
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
|
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: em2rp
|
name: em2rp
|
||||||
description: "A new Flutter project."
|
description: "L'app de gestion d'événements et matériel par EM2 Events"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
@@ -10,56 +10,66 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
# Firebase & Authentication
|
||||||
firebase_core: ^4.2.0
|
firebase_core: ^4.2.0
|
||||||
firebase_auth: ^6.1.1
|
firebase_auth: ^6.1.1
|
||||||
cloud_firestore: ^6.0.3
|
cloud_firestore: ^6.0.3
|
||||||
cloud_functions: ^6.0.4
|
cloud_functions: ^6.0.4
|
||||||
google_sign_in: ^7.2.0
|
google_sign_in: ^7.2.0
|
||||||
provider: ^6.1.2
|
|
||||||
firebase_storage: ^13.0.3
|
firebase_storage: ^13.0.3
|
||||||
image_picker: ^1.1.2
|
|
||||||
universal_io: ^2.2.2
|
# State Management
|
||||||
|
provider: ^6.1.2
|
||||||
|
|
||||||
|
# UI Core
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
|
google_fonts: ^8.0.2
|
||||||
|
flutter_svg: ^2.2.1
|
||||||
|
cached_network_image: ^3.3.1
|
||||||
|
flutter_slidable: ^4.0.0
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# Calendar & Dates
|
||||||
table_calendar: ^3.0.9
|
table_calendar: ^3.0.9
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
google_maps_flutter: ^2.5.0
|
timezone: ^0.10.1
|
||||||
permission_handler: ^12.0.0+1
|
|
||||||
geolocator: ^14.0.1
|
# Storage & Files
|
||||||
flutter_map: ^8.1.1
|
|
||||||
latlong2: ^0.9.0
|
|
||||||
flutter_launcher_icons: ^0.14.3
|
|
||||||
flutter_native_splash: ^2.3.9
|
|
||||||
url_launcher: ^6.2.2
|
|
||||||
share_plus: ^12.0.1
|
|
||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
|
flutter_secure_storage: ^10.0.0
|
||||||
|
file_picker: ^10.1.9
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
flutter_dropzone: ^4.2.1
|
||||||
|
path: any
|
||||||
|
|
||||||
|
# PDF & Documents
|
||||||
pdf: ^3.10.7
|
pdf: ^3.10.7
|
||||||
printing: ^5.11.1
|
printing: ^5.11.1
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
mobile_scanner: ^5.2.3
|
mobile_scanner: ^7.2.0
|
||||||
flutter_local_notifications: ^19.2.1
|
|
||||||
timezone: ^0.10.1
|
|
||||||
flutter_secure_storage: ^9.0.0
|
|
||||||
http: ^1.1.2
|
|
||||||
flutter_dotenv: ^6.0.0
|
|
||||||
google_fonts: ^6.1.0
|
|
||||||
flutter_svg: ^2.2.1
|
|
||||||
cached_network_image: ^3.3.1
|
|
||||||
flutter_staggered_grid_view: ^0.7.0
|
|
||||||
shimmer: ^3.0.0
|
|
||||||
flutter_slidable: ^4.0.0
|
|
||||||
flutter_datetime_picker: ^1.5.1
|
|
||||||
flutter_colorpicker: ^1.0.3
|
|
||||||
flutter_rating_bar: ^4.0.1
|
|
||||||
flutter_chat_ui: ^2.3.1
|
|
||||||
flutter_chat_types: ^3.6.2
|
|
||||||
uuid: ^4.2.2
|
|
||||||
file_picker: ^10.1.9
|
|
||||||
flutter_dropzone: ^4.2.1
|
|
||||||
flutter_localizations:
|
|
||||||
sdk: flutter
|
|
||||||
timeago: ^3.6.1
|
|
||||||
|
|
||||||
path: any
|
# Network & API
|
||||||
|
http: ^1.1.2
|
||||||
|
universal_io: ^2.2.2
|
||||||
|
flutter_dotenv: ^6.0.0
|
||||||
|
|
||||||
|
# Sharing & Launch
|
||||||
|
url_launcher: ^6.2.2
|
||||||
|
share_plus: ^12.0.1
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
flutter_local_notifications: ^20.1.0
|
||||||
|
|
||||||
|
|
||||||
|
# Export/Import
|
||||||
|
csv: ^6.0.0
|
||||||
|
web: ^1.1.1
|
||||||
|
uuid: ^4.2.2
|
||||||
|
|
||||||
|
# Build-time tools (dev)
|
||||||
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
flutter_native_splash: ^2.3.9
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -72,3 +82,4 @@ flutter:
|
|||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/logos/
|
- assets/logos/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
- assets/sounds/
|
||||||
|
|||||||
@@ -5,18 +5,23 @@
|
|||||||
* - Bascule en mode PRODUCTION
|
* - Bascule en mode PRODUCTION
|
||||||
* - Incrémente la version
|
* - Incrémente la version
|
||||||
* - Build l'application Flutter pour le web
|
* - Build l'application Flutter pour le web
|
||||||
* - Déploie sur Firebase Hosting
|
* - Vérifie que version.json est bien présent
|
||||||
|
* - Déploie sur Firebase Hosting (avec en-têtes CORS pour version.json)
|
||||||
|
* - Vérifie que version.json est accessible avec CORS
|
||||||
* - Rebascule en mode DÉVELOPPEMENT
|
* - Rebascule en mode DÉVELOPPEMENT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { incrementVersion } = require('./increment_version');
|
const { incrementVersion } = require('./increment_version');
|
||||||
const { setProductionMode, setDevelopmentMode } = require('./toggle_env');
|
const { setProductionMode, setDevelopmentMode } = require('./toggle_env');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
console.log('🚀 Démarrage du déploiement Firebase Hosting...\n');
|
console.log('🚀 Démarrage du déploiement Firebase Hosting...\n');
|
||||||
|
|
||||||
// Étape 0: Basculer en mode production
|
// Étape 0: Basculer en mode production
|
||||||
console.log('🔒 Étape 0/4: Basculement en mode PRODUCTION');
|
console.log('🔒 Étape 0/5: Basculement en mode PRODUCTION');
|
||||||
if (!setProductionMode()) {
|
if (!setProductionMode()) {
|
||||||
console.error('❌ Impossible de basculer en mode production');
|
console.error('❌ Impossible de basculer en mode production');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -24,12 +29,12 @@ if (!setProductionMode()) {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Étape 1: Incrémenter la version
|
// Étape 1: Incrémenter la version
|
||||||
console.log('📝 Étape 1/4: Incrémentation de la version');
|
console.log('📝 Étape 1/5: Incrémentation de la version');
|
||||||
const newVersion = incrementVersion();
|
const newVersion = incrementVersion();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Étape 2: Build Flutter pour le web
|
// Étape 2: Build Flutter pour le web
|
||||||
console.log('🔨 Étape 2/4: Build Flutter Web');
|
console.log('🔨 Étape 2/5: Build Flutter Web');
|
||||||
try {
|
try {
|
||||||
execSync('flutter build web --release', {
|
execSync('flutter build web --release', {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -43,9 +48,42 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 3: Déploiement Firebase
|
// Étape 2.5: Vérifier que version.json est bien présent dans build/web
|
||||||
console.log('🌐 Étape 3/4: Déploiement sur Firebase Hosting');
|
console.log('🔍 Étape 2.5/5: Vérification de version.json');
|
||||||
|
const versionJsonPath = path.join(process.cwd(), 'build', 'web', 'version.json');
|
||||||
|
if (!fs.existsSync(versionJsonPath)) {
|
||||||
|
console.warn('⚠️ version.json n\'a pas été copié dans build/web/');
|
||||||
|
|
||||||
|
// Copier manuellement depuis web/version.json
|
||||||
|
const sourceVersionJsonPath = path.join(process.cwd(), 'web', 'version.json');
|
||||||
|
if (fs.existsSync(sourceVersionJsonPath)) {
|
||||||
|
console.log(' → Copie de web/version.json vers build/web/...');
|
||||||
|
fs.copyFileSync(sourceVersionJsonPath, versionJsonPath);
|
||||||
|
console.log('✅ Fichier version.json copié avec succès');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Impossible de trouver web/version.json');
|
||||||
|
setDevelopmentMode();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✅ version.json est présent dans build/web/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher la version qui va être déployée
|
||||||
try {
|
try {
|
||||||
|
const versionContent = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8'));
|
||||||
|
console.log(` 📦 Version: ${versionContent.version}`);
|
||||||
|
console.log(` 🔒 Force update: ${versionContent.forceUpdate}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Impossible de lire version.json');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Étape 3: Déploiement Firebase
|
||||||
|
console.log('🌐 Étape 3/5: Déploiement sur Firebase Hosting');
|
||||||
|
console.log(' ℹ️ Les en-têtes CORS pour version.json seront appliqués automatiquement');
|
||||||
|
try {
|
||||||
|
|
||||||
execSync('firebase deploy --only hosting', {
|
execSync('firebase deploy --only hosting', {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
cwd: process.cwd()
|
cwd: process.cwd()
|
||||||
@@ -59,8 +97,48 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 4: Rebascule en mode développement
|
// Étape 4: Vérifier que version.json est accessible avec CORS
|
||||||
console.log('\n🔓 Étape 4/4: Retour en mode DÉVELOPPEMENT');
|
console.log('\n🔍 Étape 4/5: Vérification de l\'accès à version.json');
|
||||||
|
setTimeout(() => {
|
||||||
|
https.get('https://app.em2events.fr/version.json', {
|
||||||
|
headers: {
|
||||||
|
'Origin': 'http://localhost'
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
console.log('✅ version.json est accessible (statut 200)');
|
||||||
|
|
||||||
|
// Vérifier les en-têtes CORS
|
||||||
|
const corsHeader = res.headers['access-control-allow-origin'];
|
||||||
|
if (corsHeader) {
|
||||||
|
console.log(`✅ En-têtes CORS configurés: ${corsHeader}`);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ En-têtes CORS non détectés (peuvent prendre quelques minutes pour se propager)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire et afficher la version déployée
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const deployed = JSON.parse(body);
|
||||||
|
console.log(`📦 Version déployée: ${deployed.version}`);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Statut HTTP: ${res.statusCode}`);
|
||||||
|
}
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.warn('⚠️ Impossible de vérifier l\'accès à version.json');
|
||||||
|
console.warn(` ${err.message}`);
|
||||||
|
console.warn(' Le fichier peut prendre quelques minutes pour être accessible');
|
||||||
|
});
|
||||||
|
}, 2000); // Attendre 2 secondes pour que le déploiement se propage
|
||||||
|
|
||||||
|
// Étape 5: Rebascule en mode développement
|
||||||
|
console.log('\n🔓 Étape 5/5: Retour en mode DÉVELOPPEMENT');
|
||||||
if (!setDevelopmentMode()) {
|
if (!setDevelopmentMode()) {
|
||||||
console.warn('⚠️ Impossible de rebascule en mode développement');
|
console.warn('⚠️ Impossible de rebascule en mode développement');
|
||||||
console.warn('⚠️ Exécutez manuellement: npm run env:dev');
|
console.warn('⚠️ Exécutez manuellement: npm run env:dev');
|
||||||
@@ -69,3 +147,4 @@ if (!setDevelopmentMode()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n✨ Processus de déploiement terminé!');
|
console.log('\n✨ Processus de déploiement terminé!');
|
||||||
|
console.log('📝 Les utilisateurs recevront une notification de mise à jour au prochain chargement.');
|
||||||
|
|||||||
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.0.4",
|
"version": "1.1.18",
|
||||||
"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": "Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.",
|
||||||
"timestamp": "2026-01-16T17:56:48.878Z"
|
"timestamp": "2026-03-12T20:11:54.548Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user