From f90f014f3653a7de4e3e31f490b01046ac56d96e Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 25 Mar 2026 17:41:32 +0100 Subject: [PATCH] Add roles garde fou + update gradle + wip sectionAgenda (online and manual) + update EventConfig + missing fields in GuidedSteps update popup + updates in stats screen --- CLAUDE.md | 65 +++ android/build.gradle | 8 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- devtools_options.yaml | 3 + lib/Components/common_loader.dart | 53 +- lib/Components/loader_animated_pieces.dart | 130 +++++ lib/Models/managerContext.dart | 2 + lib/Screens/ApiKeys/api_keys_screen.dart | 157 ++++-- .../SubSection/Agenda/agenda_config.dart | 47 +- .../Agenda/showNewOrUpdateEventAgenda.dart | 112 +++++ .../SubSection/Event/event_config.dart | 209 +++++++- .../Event/showNewOrUpdateMapAnnotation.dart | 180 +++++++ .../Event/showNewOrUpdateProgrammeBlock.dart | 122 +++++ .../Section/SubSection/Map/map_config.dart | 11 + .../Parcours/showNewOrUpdateGuidedStep.dart | 73 +++ .../Section/section_detail_screen.dart | 5 +- .../configuration_detail_screen.dart | 5 +- .../Configurations/configurations_screen.dart | 2 +- lib/Screens/Main/main_screen.dart | 120 ++++- .../Notifications/notifications_screen.dart | 454 ++++++++++++++++++ lib/Screens/Resources/resource_body_grid.dart | 3 +- lib/Screens/Users/users_screen.dart | 120 +++-- lib/client.dart | 4 + lib/main.dart | 9 +- manager_api_new/lib/api.dart | 5 + manager_api_new/lib/api/notification_api.dart | 97 ++++ .../lib/api/section_agenda_api.dart | 48 ++ manager_api_new/lib/api/section_api.dart | 12 +- .../lib/api/section_event_api.dart | 87 ++++ .../model/ai_chat_response_navigation.dart | 9 + .../lib/model/create_api_key_request.dart | 17 +- .../lib/model/event_agenda_dto.dart | 59 ++- manager_api_new/lib/model/map_dto.dart | 11 + .../lib/model/push_notification_dto.dart | 91 ++++ .../model/schedule_notification_request.dart | 75 +++ .../lib/model/section_event_dto.dart | 22 + .../lib/model/send_notification_request.dart | 67 +++ .../lib/model/stats_summary_dto.dart | 91 +++- .../lib/model/visit_event_dto.dart | 1 + 39 files changed, 2400 insertions(+), 188 deletions(-) create mode 100644 CLAUDE.md create mode 100644 devtools_options.yaml create mode 100644 lib/Components/loader_animated_pieces.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateMapAnnotation.dart create mode 100644 lib/Screens/Notifications/notifications_screen.dart create mode 100644 manager_api_new/lib/api/notification_api.dart create mode 100644 manager_api_new/lib/model/push_notification_dto.dart create mode 100644 manager_api_new/lib/model/schedule_notification_request.dart create mode 100644 manager_api_new/lib/model/send_notification_request.dart diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..77124b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# manager-app + +Interface de gestion de contenu Flutter (web/desktop) pour configurer `tablet-app` et `mymuseum-visitapp`. + +## Stack +- Flutter web (cible principale), support desktop partiel +- State management : **Provider** + `ChangeNotifier` +- Navigation : **GoRouter** +- Firebase Storage (upload de ressources) + +## Structure +``` +lib/ +├── main.dart # Entry point, init Firebase, GoRouter, breakpoints responsive +├── app_context.dart # Provider root wrapping ManagerAppContext +├── client.dart # Wrapper exposant les 12+ facades API générées +├── constants.dart # Couleurs, types de sections, langues, types de ressources +├── Models/ # ManagerAppContext (état central), Session, Menu... +├── Helpers/ # FileHelper (localStorage session), PDFHelper +├── Components/ # 48+ widgets réutilisables (inputs, pickers, players...) +└── Screens/ # Écrans par domaine + ├── Main/ # Dashboard principal avec menu latéral + ├── Configurations/ # Builder de configuration par type de section + │ └── SubSection/ # Map, Menu, Slider, Quiz, Article, PDF, Video, Weather, Event, Parcours, Game, Agenda + ├── Resources/ # CRUD ressources (Image, Video, Audio, PDF, JSON...) + ├── Applications/ # Liaison config ↔ app device + ├── Kiosk_devices/ # Gestion des devices + ├── Users/ # Gestion des utilisateurs + ├── ApiKeys/ # Gestion des clés API + ├── Notifications/ # Push notifications + └── Statistics/ # Analytics visiteurs +``` + +## Client API généré (manager_api_new/) +**C'est ici que le client est généré.** Il est consommé via dépendance locale par `tablet-app` et `mymuseum-visitapp`. + +- **Ne pas modifier manuellement** les fichiers dans `manager_api_new/` +- Régénérer via OpenAPI generator quand le backend change : `openapi_generator_config.json` à la racine +- 16 classes API : AuthenticationApi, ConfigurationApi, SectionApi, SectionMapApi, SectionEventApi, ResourceApi, DeviceApi, UserApi, StatsApi, AIApi, etc. +- 130+ DTOs générés dans `manager_api_new/lib/model/` + +## État central (ManagerAppContext) +Contient : credentials, accessToken, instanceId, instanceDTO, référence au client API, configuration/section sélectionnée. +Accès via `context.read()` ou `context.watch()`. + +## Flow d'authentification +1. `FileHelper().readSessionWeb()` → localStorage +2. Si pas de session → `/login` +3. POST `/api/Authentication/AuthenticateWithJson` → TokenDTO +4. Init `Client` avec le host backend +5. Fetch `InstanceDTO` → détermine les features (isMobile, isTablet, isWeb, isVR, isStatistic) +6. Redirect vers `/main/:view` + +## Breakpoints responsive +- Mobile : 0–550px +- Tablet : 550–800px +- Desktop : 801–1920px +- 4K : 1921px+ + +## Commandes utiles +```bash +flutter run -d chrome # Lancer en web +flutter build web # Build web +flutter pub run build_runner build # Regénérer code généré +``` diff --git a/android/build.gradle b/android/build.gradle index cf143e9..68b15a8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.9.0' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.1' + classpath 'com.android.tools.build:gradle:8.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,7 +14,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index cfe88f6..9162f10 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/Components/common_loader.dart b/lib/Components/common_loader.dart index a8568a0..bf349f5 100644 --- a/lib/Components/common_loader.dart +++ b/lib/Components/common_loader.dart @@ -1,57 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:manager_app/constants.dart'; +import 'package:manager_app/Components/loader_animated_pieces.dart'; -class CommonLoader extends StatefulWidget { - double? iconSize; +class CommonLoader extends StatelessWidget { + final double? iconSize; - CommonLoader({Key? key, this.iconSize}) : super(key: key); - - @override - State createState() => _CommonLoaderState(); -} - -class _CommonLoaderState extends State with TickerProviderStateMixin { - AnimationController? _controller; - - @override - void initState() { - _controller = AnimationController( - duration: const Duration(milliseconds: 5000), - vsync: this, - )..repeat(); - - super.initState(); - } - - @override - void dispose() { - _controller!.dispose(); - super.dispose(); - } + const CommonLoader({Key? key, this.iconSize}) : super(key: key); @override Widget build(BuildContext context) { - Size size = MediaQuery.of(context).size; - _controller!.forward(from: 0.0); - _controller!.addListener(() { - if (_controller!.isCompleted) { - _controller!.reverse(); - } - if(_controller!.isDismissed){ - _controller!.forward(); - } - }); + final size = iconSize ?? 60; return Center( - child: RotationTransition( - turns: Tween(begin: 0.0, end: 3.0).animate(_controller!), - child: SizedBox( - height: 45, - child: SvgPicture.asset('assets/images/MyInfoMate_logo_only.svg') - )/*Icon(Icons.museum_outlined, color: kPrimaryColor, size: widget.iconSize == null ? size.height*0.1 : widget.iconSize!)*/, - ), + child: LoaderWaveFloat(size: size), ); } } - - diff --git a/lib/Components/loader_animated_pieces.dart b/lib/Components/loader_animated_pieces.dart new file mode 100644 index 0000000..8bddfeb --- /dev/null +++ b/lib/Components/loader_animated_pieces.dart @@ -0,0 +1,130 @@ +import 'dart:math' as dart_math; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +String _makeSvg(String d, String fill, String transform) => + '' + '' + ''; + +final List _svgStrings = [ + // path1 — pink/red + _makeSvg( + 'm 178.04447,135.21247 c 0.002,9.09742 0.17625,49.4843 0.17178,58.58172 -0.005,10.44131 -0.004,20.8826 0.002,31.32392 0.005,9.06176 0.005,18.12351 0.003,27.18527 -0.002,5.37036 -0.002,10.7407 0.002,16.11105 0.0204,38.14483 0.0204,38.14483 -0.54694,53.99548 l -0.10305,3.01772 c -0.80885,18.6575 -7.8096,35.94603 -20.4863,49.70518 l -2.15235,2.35938 c -12.39998,12.98838 -30.69723,23.86033 -49.02594,24.74586 -2.64076,0.0575 -5.28022,0.0839 -7.921566,0.10008 l -3.044189,0.03 c -3.328264,0.0316 -6.656542,0.0564 -9.984864,0.0811 -2.346596,0.0206 -4.693189,0.0416 -7.039779,0.063 -4.940416,0.044 -9.880843,0.0849 -14.821305,0.12331 -6.216775,0.0484 -12.433489,0.10212 -18.650202,0.158 -6.037887,0.054 -17.776846,0.19345 -40.2740759,0.28883 L 57.571753,137.10368 c 14.685204,-1.44088 35.90966,0.0974 45.537897,0.12284 41.79401,0.11064 74.93393,-6.04872 74.93482,-2.01405 z', + '#d62f60', + 'translate(249.91406,6.8671875)', + ), + // path2 — teal + _makeSvg( + 'm 64.688834,203.13197 c 0.0035,6.66536 0.0067,13.33073 0.0093,19.99609 l 9.86e-4,2.48865 c 0.005,13.72121 -0.0058,27.44236 -0.02756,41.16355 -0.01265,8.44313 -0.01133,16.88604 0.0085,25.32916 0.01266,5.78741 0.0111,11.57471 -0.0014,17.36211 -0.0067,3.33159 -0.0047,6.66256 0.01058,9.99416 0.111194,25.65838 0.111194,25.65838 -4.500365,37.04128 l -0.849251,2.18899 c -7.771476,18.55174 -21.788527,30.7335 -39.685905,38.97898 -3.37688,1.34444 -5.301227,2.48389 -8.978708,2.48389 l -3.2442484,1.47057 c -4.8987376,0.0599 -14.0045433,0.089 -15.4540455,0.10655 -25.7222011,0.30356 -49.7229121,-5.03616 -68.8916011,-23.19214 -3.286346,-3.24956 -6.197141,-6.66938 -8.810303,-10.47827 -1.170385,-1.6798 -3.844026,-5.57044 -5.590218,-8.45711 -0.851523,-1.40767 -3.595298,-4.4516 -7.983006,-16.21404 -0.853709,-1.50963 -2.403339,-12.94213 -2.403339,-12.94213 -0.0915,-1.48718 -0.23669,-2.92345 -0.23801,-4.41344 l -0.01,-2.78866 0.005,-3.06828 -0.007,-3.25582 c -0.006,-3.61823 -0.006,-7.23642 -0.005,-10.85466 -0.003,-2.58667 -0.006,-5.17335 -0.0102,-7.76002 -0.008,-6.30723 -0.0111,-12.61445 -0.0116,-18.92169 -4.7e-4,-5.12481 -0.003,-10.24962 -0.006,-15.37442 -0.009,-14.52085 -0.0134,-29.0417 -0.0127,-43.56255 l 1.3e-4,-2.37302 1.2e-4,-2.3759 c 4.1e-4,-12.71422 -0.009,-25.42841 -0.0233,-38.14262 -0.0143,-13.04611 -0.0213,-26.0922 -0.0204,-39.13831 3.3e-4,-7.32798 -0.002,-14.65593 -0.0132,-21.9839 -0.01,-6.88802 -0.01,-13.77597 -0.002,-20.663998 10e-4,-2.533683 1.124,-6.567371 1.118,-9.101047 -0.008,-3.44738 -1.128,-5.394528 82.246422,49.699935 83.374422,55.09446 83.377322,60.92629 83.380322,66.75811 z', + '#51cdc8', + 'translate(112.81113,8.4930303)', + ), + // path3 — yellow + _makeSvg( + 'm 0,0 c 2.3047995,-6.7559e-4 4.6095989,-0.00164773 6.9143982,-0.00289917 4.8125578,-0.00146675 9.6250968,6.4331e-4 14.4376528,0.00534058 6.108101,0.00567724 12.216159,0.00242443 18.324259,-0.00357247 4.756666,-0.00364996 9.513323,-0.00244655 14.269991,1.2779e-4 2.249005,6.5808e-4 4.498012,-1.1905e-4 6.747016,-0.00247955 10.980782,-0.00899429 21.945816,0.06768592 32.917523,0.54693985 l 2.536386,0.10305596 C 119.97909,1.8334817 140.8053,14.193522 157.08594,31.132812 c 14.55199,16.47268 20.55085,36.163532 20.75,57.8125 0.0272,1.535344 0.0557,3.070663 0.0855,4.605958 0.0684,3.811023 0.11277,7.62181 0.14563,11.43329 0.018,2.0392 0.0412,21.15431 0.0616,30.38266 0.0376,3.46864 -119.042723,1.76559 -119.042723,1.76559 l -20.85965,1.44008 -111.140349,85.55992 c -0.161213,-39.22828 -0.161213,-39.22828 -0.195313,-55.73828 -0.02368,-11.37426 -0.05144,-22.7484 -0.106445,-34.12256 -0.04005,-8.28583 -0.06572,-16.57157 -0.0746,-24.8575 -0.0052,-4.3814 -0.01724,-8.76252 -0.04657,-13.143825 -0.02746,-4.136862 -0.03546,-8.273336 -0.02951,-12.41028 -0.0011,-1.50588 -0.0089,-3.011774 -0.02439,-4.517574 -0.131739,-13.445652 1.806738,-26.659985 8.289322,-38.647479 l 1.049072,-2.008056 c 2.851223,-5.317287 6.068313,-10.084275 10.138428,-14.554444 L -51.898437,21.875 C -43.655062,13.127533 -37.90534,5.8098811 -26.601562,1.8203125 l -12.3125,0.3125 v -1 C -25.942811,-0.00501656 -13.012554,-0.01357216 0,0 Z', + '#fec728', + 'translate(249.91406,6.8671875)', + ), + // path4 — dark blue + _makeSvg( + 'M 122.4545,-1.5555303 C 104.75322,5.7344897 77.764999,26.892615 70.396697,44.304966 65.927561,56.152831 64.595666,67.366945 64.64297,79.919079 c -0.0088,1.568853 -0.01926,3.137697 -0.03133,4.706528 -0.02754,4.203537 -0.03127,8.406777 -0.03028,12.610391 -0.0032,4.413592 -0.02883,8.827052 -0.05198,13.240572 -0.04035,8.33285 -0.06085,16.66561 -0.07397,24.99854 -0.01594,9.49723 -0.05439,18.99434 -0.09459,28.4915 -0.08212,19.51341 -0.136384,39.02681 -0.171948,58.54036 -3.137967,0.048 -6.275837,0.0763 -9.414062,0.10156 l -2.652588,0.0413 C 23.81406,222.81977 1.5565346,207.45478 -18.375829,188.75648 l -2.718735,-2.53775 c -8.235245,-7.71396 -16.371582,-15.53064 -24.472001,-23.38588 -5.420898,-5.25288 -10.871421,-10.46504 -16.411803,-15.59199 -41.322082,-38.29606 -41.811362,-38.47284 -39.697042,-61.82222 2.114319,-23.349383 3.306846,-25.445419 3.989278,-27.72417 6.748595,-20.960043 21.800751,-37.772405 41,-48.4375003 18.641419,-9.40752742 36.480818,-9.88154862 87.01386,-9.85750697 50.533043,0.0240417 73.811792,-1.09807633 92.064262,-0.98624303', + '#264863', + 'translate(112.81113,8.4930303)', + ), + // path5 — purple + _makeSvg( + 'm 0,0 131.25,-0.25 v 63.875 l 0.11035,19.705566 c 0.0181,7.998291 0.0181,7.998291 0.0214,11.753754 0.006,2.598872 0.0186,5.19694 0.0405,7.79565 0.033,3.95755 0.0333,7.91478 0.0318,11.87247 l 0.0436,3.45275 c -0.0875,17.24733 -6.73078,32.78677 -18.61099,45.21278 C 106.22208,169.37917 98.630621,174.4762 90,177 l -11.397775,1.80377 -1.45256,0.16395 c -7.104685,-0.0441 -3.43355,-0.0556 -10.538337,-0.0776 -2.653987,-0.0101 -5.307964,-0.0237 -7.961914,-0.041 -3.807645,-0.0242 -7.615144,-0.0356 -11.422852,-0.0444 l -3.614349,-0.0313 -3.336334,-4.6e-4 -2.947357,-0.0135 c -2.521553,0.0131 41.626196,-2.01422 39.819227,0.21323 L 33,179 H 30.1875 24 c -1.532555,0.0113 -3.065107,0.0231 -4.597656,0.0352 -1.821614,0.009 -3.643229,0.0184 -5.464844,0.0273 l -2.798828,0.0254 -2.6738282,0.01 -2.4494629,0.0159 C 4,179 5.0139855,178.84803 4.0508683,177.7768 l 1.9580347,1.35049 -19.547353,-0.15833 -0.795115,0.0476 L -16,179 l -3.272949,0.0317 c -3.999316,0.0361 -7.998616,0.0591 -11.998047,0.0781 -1.732447,0.0101 -3.464876,0.0237 -5.197266,0.041 -2.487184,0.0242 -4.974145,0.0356 -7.461425,0.0444 l -2.35495,0.0313 c -1.906618,4e-4 -13.996389,0.0497 -15.899078,-0.0727 l 18.898307,-4.96534 C -32.858078,170.63158 -17.423649,159.0936 -8,141 c 5.0244847,-11.01947 7.29498468,-20.88967 7.31884766,-32.92383 l 0.0307674,-3.60735 c 0.0305076,-3.83335 0.0477557,-7.666634 0.0644474,-11.50007 0.0169446,-2.374681 0.0347624,-4.749356 0.0534668,-7.124023 C -0.4694063,77.063221 -0.4232179,68.2816 -0.375,59.5 Z', + '#773aaa', + 'translate(177,231)', + ), + // path6 — orange + _makeSvg( + 'm 130.75,72.125 c 0,0 -130.9459176,38.46063 -130.5354588,-0.05093 L 130.75,72.125 l 0.5,86.75 L 0,159 0.2145412,72.074065', + '#ef7a34', + 'translate(177,72)', + ), +]; + +// ── LoaderWaveFloat ───────────────────────────────────────────────────────── +// Wave opacity sweeping through pieces + slow rotation + vertical float. + +class LoaderWaveFloat extends StatefulWidget { + final double size; + const LoaderWaveFloat({Key? key, required this.size}) : super(key: key); + + @override + State createState() => _LoaderWaveFloatState(); +} + +class _LoaderWaveFloatState extends State + with TickerProviderStateMixin { + late final AnimationController _wave; + late final AnimationController _rotate; + late final AnimationController _float; + late final Animation _floatAnim; + + static const _opacityMin = 0.82; + static const _opacityMax = 0.97; + static const List _phases = [0.0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6]; + + @override + void initState() { + super.initState(); + _wave = AnimationController(vsync: this, duration: const Duration(milliseconds: 4000)) + ..repeat(); + _rotate = AnimationController(vsync: this, duration: const Duration(milliseconds: 6000)) + ..repeat(); + _float = AnimationController(vsync: this, duration: const Duration(milliseconds: 1800)) + ..repeat(reverse: true); + _floatAnim = Tween(begin: -4.0, end: 4.0) + .animate(CurvedAnimation(parent: _float, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _wave.dispose(); + _rotate.dispose(); + _float.dispose(); + super.dispose(); + } + + double _opacity(int i) { + final t = _wave.value; + final phase = _phases[i]; + final v = 0.5 - 0.5 * dart_math.cos(2 * dart_math.pi * ((t - phase) % 1.0)); + return _opacityMin + (_opacityMax - _opacityMin) * v; + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([_wave, _rotate, _float]), + builder: (_, __) { + return Transform.translate( + offset: Offset(0, _floatAnim.value), + child: Transform.rotate( + angle: _rotate.value * 2 * dart_math.pi, + child: SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + children: List.generate( + 6, + (i) => Opacity( + opacity: _opacity(i), + child: SvgPicture.string( + _svgStrings[i], + width: widget.size, + height: widget.size, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/Models/managerContext.dart b/lib/Models/managerContext.dart index 5f62546..55678eb 100644 --- a/lib/Models/managerContext.dart +++ b/lib/Models/managerContext.dart @@ -17,6 +17,8 @@ class ManagerAppContext with ChangeNotifier{ bool? isLoading = false; UserRole? role; + bool get canEdit => role != null && role!.value <= 2; + ManagerAppContext({this.email, this.accessToken}); // Implement toString to make it easier to see information about diff --git a/lib/Screens/ApiKeys/api_keys_screen.dart b/lib/Screens/ApiKeys/api_keys_screen.dart index 7ee3600..607164a 100644 --- a/lib/Screens/ApiKeys/api_keys_screen.dart +++ b/lib/Screens/ApiKeys/api_keys_screen.dart @@ -19,8 +19,9 @@ class _ApiKeysScreenState extends State { List> _keys = []; bool _loading = true; - static String _appTypeName(int? v) { - switch (v) { + static String _appTypeName(dynamic v) { + if (v is String) return v; + switch (v as int?) { case 0: return 'VisitApp'; case 1: return 'TabletApp'; default: return 'Other'; @@ -42,9 +43,9 @@ class _ApiKeysScreenState extends State { } } - Future _createKey(ManagerAppContext ctx, String name, ApiKeyAppType appType) async { + Future _createKey(ManagerAppContext ctx, String name, ApiKeyAppType appType, DateTime? dateExpiration) async { final response = await ctx.clientAPI!.apiKeyApi!.apiKeyCreateApiKeyWithHttpInfo( - CreateApiKeyRequest(name: name, appType: appType), + CreateApiKeyRequest(name: name, appType: appType, dateExpiration: dateExpiration), ); if (response.statusCode == 200 || response.statusCode == 201) { final Map json = jsonDecode(utf8.decode(response.bodyBytes)); @@ -62,6 +63,7 @@ class _ApiKeysScreenState extends State { void _showCreateDialog(BuildContext context, ManagerAppContext ctx) { final nameCtrl = TextEditingController(); ApiKeyAppType selectedType = ApiKeyAppType.number0; + DateTime? selectedExpiration; showDialog( context: context, @@ -85,13 +87,44 @@ class _ApiKeysScreenState extends State { ), ], ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + selectedExpiration == null + ? 'Pas d\'expiration' + : 'Expire le ${selectedExpiration!.day.toString().padLeft(2, '0')}/${selectedExpiration!.month.toString().padLeft(2, '0')}/${selectedExpiration!.year}', + style: const TextStyle(fontSize: 14), + ), + ), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: ctx2, + initialDate: DateTime.now().add(const Duration(days: 365)), + firstDate: DateTime.now().add(const Duration(days: 1)), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + ); + if (picked != null) setLocal(() => selectedExpiration = picked); + }, + child: const Text('Choisir'), + ), + if (selectedExpiration != null) + IconButton( + icon: const Icon(Icons.close, size: 16), + onPressed: () => setLocal(() => selectedExpiration = null), + tooltip: 'Supprimer l\'expiration', + ), + ], + ), ]), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx2), child: const Text('Annuler')), + TextButton(onPressed: () => Navigator.of(ctx2, rootNavigator: true).pop(), child: const Text('Annuler')), ElevatedButton( onPressed: () async { - Navigator.pop(ctx2); - final plainKey = await _createKey(ctx, nameCtrl.text, selectedType); + Navigator.of(ctx2, rootNavigator: true).pop(); + final plainKey = await _createKey(ctx, nameCtrl.text, selectedType, selectedExpiration); if (plainKey != null && context.mounted) { _showPlainKeyDialog(context, plainKey); } @@ -135,7 +168,7 @@ class _ApiKeysScreenState extends State { ]), actions: [ ElevatedButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('J\'ai copié la clé'), ), ], @@ -146,15 +179,15 @@ class _ApiKeysScreenState extends State { void _confirmRevoke(BuildContext context, ManagerAppContext ctx, Map key) { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Révoquer la clé API'), content: Text('Révoquer « ${key['name']} » ? Les apps utilisant cette clé perdront l\'accès.'), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.red), onPressed: () async { - Navigator.pop(context); + Navigator.pop(dialogContext); await _revokeKey(ctx, key['id'] as String); }, child: const Text('Révoquer', style: TextStyle(color: Colors.white)), @@ -199,47 +232,75 @@ class _ApiKeysScreenState extends State { const Center(child: Text('Aucune clé API')) else Expanded( - child: SingleChildScrollView( - child: DataTable( - columns: const [ - DataColumn(label: Text('Nom')), - DataColumn(label: Text('Type')), - DataColumn(label: Text('Créée le')), - DataColumn(label: Text('Statut')), - DataColumn(label: Text('Actions')), - ], - rows: _keys.map((key) { - final isActive = key['isActive'] as bool? ?? false; - final dateRaw = key['dateCreation'] as String?; - final date = dateRaw != null - ? DateTime.tryParse(dateRaw)?.toLocal() - : null; - final dateStr = date != null - ? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}' - : '—'; + child: Card( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.grey.shade200), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: DataTable( + horizontalMargin: 16, + columnSpacing: 24, + headingRowColor: WidgetStateProperty.all(Colors.grey.shade50), + dividerThickness: 1, + columns: const [ + DataColumn(label: Text('Nom', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Type', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Créée le', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Expiration', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Actions', style: TextStyle(fontWeight: FontWeight.w600))), + ], + rows: _keys.map((key) { + final isActive = key['isActive'] as bool? ?? false; + final dateRaw = key['dateCreation'] as String?; + final date = dateRaw != null ? DateTime.tryParse(dateRaw)?.toLocal() : null; + final dateStr = date != null + ? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}' + : '—'; + final expRaw = key['dateExpiration'] as String?; + final exp = expRaw != null ? DateTime.tryParse(expRaw)?.toLocal() : null; + final expStr = exp != null + ? '${exp.day.toString().padLeft(2, '0')}/${exp.month.toString().padLeft(2, '0')}/${exp.year}' + : '—'; - return DataRow(cells: [ - DataCell(Text(key['name'] as String? ?? '')), - DataCell(Text(_appTypeName(key['appType'] as int?))), - DataCell(Text(dateStr)), - DataCell(Chip( - label: Text(isActive ? 'Active' : 'Révoquée', - style: TextStyle(color: isActive ? Colors.green.shade700 : Colors.red.shade700, fontSize: 12)), - backgroundColor: isActive ? Colors.green.shade50 : Colors.red.shade50, - side: BorderSide(color: isActive ? Colors.green.shade200 : Colors.red.shade200), - )), - DataCell(isActive - ? IconButton( - icon: const Icon(Icons.block, color: Colors.red), - tooltip: 'Révoquer', - onPressed: () => _confirmRevoke(context, managerCtx, key), - ) - : const SizedBox()), - ]); - }).toList(), + return DataRow(cells: [ + DataCell(Text(key['name'] as String? ?? '')), + DataCell(Text(_appTypeName(key['appType']))), + DataCell(Text(dateStr, style: const TextStyle(color: Colors.grey))), + DataCell(Text(expStr, style: TextStyle(color: exp != null && exp.isBefore(DateTime.now()) ? Colors.red : Colors.grey))), + DataCell(Chip( + label: Text( + isActive ? 'Active' : 'Révoquée', + style: TextStyle( + color: isActive ? Colors.green.shade700 : Colors.red.shade700, + fontSize: 12, + ), + ), + backgroundColor: isActive ? Colors.green.shade50 : Colors.red.shade50, + side: BorderSide(color: isActive ? Colors.green.shade200 : Colors.red.shade200), + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + )), + DataCell(isActive + ? IconButton( + icon: const Icon(Icons.block, color: Colors.red, size: 20), + tooltip: 'Révoquer', + onPressed: () => _confirmRevoke(context, managerCtx, key), + ) + : const SizedBox()), + ]); + }).toList(), + ), ), ), ), + ), ], ); } diff --git a/lib/Screens/Configurations/Section/SubSection/Agenda/agenda_config.dart b/lib/Screens/Configurations/Section/SubSection/Agenda/agenda_config.dart index 27e92c2..b11f147 100644 --- a/lib/Screens/Configurations/Section/SubSection/Agenda/agenda_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Agenda/agenda_config.dart @@ -86,7 +86,7 @@ class _AgendaConfigState extends State { mainAxisSize: MainAxisSize.min, children: [ _buildAgendaHeader(size, mapProviderIn), - if (agendaDTO.isOnlineAgenda == false) ...[ + ...[ const Divider(height: 32), Padding( padding: @@ -94,7 +94,7 @@ class _AgendaConfigState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text("Évènements Manuels", + const Text("Évènements", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ElevatedButton.icon( @@ -146,7 +146,7 @@ class _AgendaConfigState extends State { padding: const EdgeInsets.symmetric(vertical: 8), child: events.isEmpty ? const Center( - child: Text("Aucun évènement manuel", + child: Text("Aucun évènement", style: TextStyle(fontStyle: FontStyle.italic))) : ListView.builder( itemCount: events.length, @@ -288,25 +288,40 @@ class _AgendaConfigState extends State { }); }, ), - SingleSelectContainer( - label: "Service carte :", - color: Colors.black, - initialValue: mapProviderIn, - inputValues: const ["Google", "MapBox"], - onChanged: (String value) { + CheckInputContainer( + label: "Vue carte :", + isChecked: agendaDTO.agendaMapProvider != null, + onChanged: (value) { setState(() { - switch (value) { - case "Google": - agendaDTO.agendaMapProvider = MapProvider.Google; - break; - case "MapBox": - agendaDTO.agendaMapProvider = MapProvider.MapBox; - break; + if (value) { + agendaDTO.agendaMapProvider = MapProvider.Google; + } else { + agendaDTO.agendaMapProvider = null; } widget.onChanged(agendaDTO); }); }, ), + if (agendaDTO.agendaMapProvider != null) + SingleSelectContainer( + label: "Service carte :", + color: Colors.black, + initialValue: mapProviderIn, + inputValues: const ["Google", "MapBox"], + onChanged: (String value) { + setState(() { + switch (value) { + case "Google": + agendaDTO.agendaMapProvider = MapProvider.Google; + break; + case "MapBox": + agendaDTO.agendaMapProvider = MapProvider.MapBox; + break; + } + widget.onChanged(agendaDTO); + }); + }, + ), if (agendaDTO.isOnlineAgenda == true) MultiStringInputContainer( label: "Fichiers json :", diff --git a/lib/Screens/Configurations/Section/SubSection/Agenda/showNewOrUpdateEventAgenda.dart b/lib/Screens/Configurations/Section/SubSection/Agenda/showNewOrUpdateEventAgenda.dart index 27488b7..846e08b 100644 --- a/lib/Screens/Configurations/Section/SubSection/Agenda/showNewOrUpdateEventAgenda.dart +++ b/lib/Screens/Configurations/Section/SubSection/Agenda/showNewOrUpdateEventAgenda.dart @@ -6,6 +6,7 @@ import 'package:manager_app/Components/rounded_button.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/string_input_container.dart'; import 'package:manager_app/Components/geometry_input_container.dart'; +import 'package:manager_app/Components/resource_input_container.dart'; void showNewOrUpdateEventAgenda( BuildContext context, @@ -95,6 +96,117 @@ void showNewOrUpdateEventAgenda( ], ), Divider(height: 24), + // Dates + Row( + children: [ + SizedBox( + width: halfWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Date de début :"), + SizedBox(height: 4), + OutlinedButton.icon( + icon: Icon(Icons.calendar_today, size: 16), + label: Text(workingEvent.dateFrom != null + ? "${workingEvent.dateFrom!.day.toString().padLeft(2, '0')}/${workingEvent.dateFrom!.month.toString().padLeft(2, '0')}/${workingEvent.dateFrom!.year}" + : "Choisir une date"), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: workingEvent.dateFrom ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) setState(() => workingEvent.dateFrom = picked); + }, + ), + ], + ), + ), + SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Date de fin :"), + SizedBox(height: 4), + OutlinedButton.icon( + icon: Icon(Icons.calendar_today, size: 16), + label: Text(workingEvent.dateTo != null + ? "${workingEvent.dateTo!.day.toString().padLeft(2, '0')}/${workingEvent.dateTo!.month.toString().padLeft(2, '0')}/${workingEvent.dateTo!.year}" + : "Choisir une date"), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: workingEvent.dateTo ?? workingEvent.dateFrom ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) setState(() => workingEvent.dateTo = picked); + }, + ), + ], + ), + ), + ], + ), + Divider(height: 24), + // Ressources + Row( + children: [ + SizedBox( + width: halfWidth, + child: ResourceInputContainer( + label: "Image :", + initialValue: workingEvent.resourceId, + inResourceTypes: const [ResourceType.Image, ResourceType.ImageUrl], + onChanged: (res) => setState(() { + workingEvent.resourceId = res.id; + workingEvent.resource = EventAgendaDTOResource.fromJson(res.toJson()); + }), + ), + ), + SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: ResourceInputContainer( + label: "Vidéo :", + initialValue: workingEvent.videoResourceId, + inResourceTypes: const [ResourceType.Video, ResourceType.VideoUrl], + onChanged: (res) => setState(() { + workingEvent.videoResourceId = res.id; + workingEvent.videoResource = EventAgendaDTOResource.fromJson(res.toJson()); + }), + ), + ), + ], + ), + Divider(height: 24), + // Vidéo externe + Row( + children: [ + SizedBox( + width: halfWidth, + child: StringInputContainer( + label: "ID YouTube :", + initialValue: workingEvent.idVideoYoutube ?? "", + onChanged: (val) => setState(() => workingEvent.idVideoYoutube = val.isEmpty ? null : val), + ), + ), + SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: StringInputContainer( + label: "Lien vidéo direct :", + initialValue: workingEvent.videoLink ?? "", + onChanged: (val) => setState(() => workingEvent.videoLink = val.isEmpty ? null : val), + ), + ), + ], + ), + Divider(height: 24), // Site | Tel | Email Row( children: [ diff --git a/lib/Screens/Configurations/Section/SubSection/Event/event_config.dart b/lib/Screens/Configurations/Section/SubSection/Event/event_config.dart index 40d9de2..3856129 100644 --- a/lib/Screens/Configurations/Section/SubSection/Event/event_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Event/event_config.dart @@ -8,6 +8,9 @@ import 'package:provider/provider.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Components/message_notification.dart'; +import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart'; +import 'package:manager_app/Components/dropDown_input_container.dart'; +import 'showNewOrUpdateMapAnnotation.dart'; class EventConfig extends StatefulWidget { final SectionEventDTO initialValue; @@ -25,12 +28,17 @@ class EventConfig extends StatefulWidget { class _EventConfigState extends State { late SectionEventDTO eventDTO; + List availableMaps = []; @override void initState() { super.initState(); eventDTO = widget.initialValue; - WidgetsBinding.instance.addPostFrameCallback((_) => _loadProgrammeBlocks()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadProgrammeBlocks(); + _loadGlobalAnnotations(); + _loadAvailableMaps(); + }); } Future _loadProgrammeBlocks() async { @@ -48,6 +56,36 @@ class _EventConfigState extends State { } } + Future _loadGlobalAnnotations() async { + if (eventDTO.id == null || !mounted) return; + final appContext = Provider.of(context, listen: false); + final api = (appContext.getContext() as ManagerAppContext).clientAPI!.sectionEventApi!; + try { + final annotations = await api.sectionEventGetGlobalMapAnnotations(eventDTO.id!); + if (annotations == null || !mounted) return; + setState(() { + eventDTO.globalMapAnnotations = annotations; + }); + } catch (e) { + // Silently keep initial value on error + } + } + + Future _loadAvailableMaps() async { + if (eventDTO.configurationId == null || !mounted) return; + final appContext = Provider.of(context, listen: false); + final api = (appContext.getContext() as ManagerAppContext).clientAPI!.sectionApi!; + try { + final sections = await api.sectionGetFromConfiguration(eventDTO.configurationId!); + if (sections == null || !mounted) return; + setState(() { + availableMaps = sections.where((s) => s.type == SectionType.Map).toList(); + }); + } catch (e) { + // Silently keep empty on error + } + } + @override Widget build(BuildContext context) { return Column( @@ -182,6 +220,12 @@ class _EventConfigState extends State { ), ), Divider(), + // --- Carte de base --- + _buildBaseSectionMapSection(), + Divider(), + // --- Annotations globales --- + _buildGlobalAnnotationsSection(), + Divider(), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Row( @@ -383,7 +427,170 @@ class _EventConfigState extends State { }, ), ), + Divider(), + // --- Parcours --- + if (eventDTO.id != null) + ParcoursConfig( + initialValue: const [], + parentId: eventDTO.id!, + isEvent: true, + onChanged: (paths) { + // parcours are managed independently, no DTO update needed here + }, + ), ], ); } + + Widget _buildBaseSectionMapSection() { + final mapItems = ['Aucune', ...availableMaps.map((m) => m.label ?? m.id ?? '')]; + final currentValue = eventDTO.baseSectionMapId != null + ? (availableMaps + .where((m) => m.id == eventDTO.baseSectionMapId) + .map((m) => m.label ?? m.id ?? '') + .firstOrNull ?? + 'Aucune') + : 'Aucune'; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Carte de base", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + DropDownInputContainer( + label: "Carte :", + values: mapItems, + initialValue: currentValue, + onChange: (val) { + setState(() { + if (val == 'Aucune') { + eventDTO.baseSectionMapId = null; + } else { + final match = availableMaps.firstWhere( + (m) => (m.label ?? m.id ?? '') == val, + orElse: () => availableMaps.first); + eventDTO.baseSectionMapId = match.id; + } + widget.onChanged(eventDTO); + }); + }, + ), + ], + ), + ); + } + + Widget _buildGlobalAnnotationsSection() { + final annotations = eventDTO.globalMapAnnotations ?? []; + final appContext = Provider.of(context, listen: false); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Annotations globales", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("Ajouter une annotation"), + onPressed: eventDTO.id == null + ? null + : () { + showNewOrUpdateMapAnnotation(context, null, + (newAnnotation) async { + try { + final created = await (appContext.getContext() + as ManagerAppContext) + .clientAPI! + .sectionEventApi! + .sectionEventCreateGlobalMapAnnotation( + eventDTO.id!, newAnnotation); + if (created != null && mounted) { + setState(() { + eventDTO.globalMapAnnotations = [ + ...annotations, + created + ]; + }); + showNotification(kSuccess, kWhite, + 'Annotation créée avec succès', context, null); + } + } catch (e) { + showNotification(kError, kWhite, + 'Erreur lors de la création de l\'annotation', + context, null); + } + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: kSuccess, + foregroundColor: kWhite, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + const SizedBox(height: 8), + if (annotations.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text("Aucune annotation globale", + style: TextStyle(fontStyle: FontStyle.italic)), + ) + else + ...annotations.asMap().entries.map((entry) { + final idx = entry.key; + final ann = entry.value; + final labelText = ann.label != null && ann.label!.isNotEmpty + ? (ann.label!.firstWhere((t) => t.language == 'FR', + orElse: () => ann.label![0]) + .value ?? 'Annotation ${idx + 1}') + : 'Annotation ${idx + 1}'; + return Card( + margin: + const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + child: ListTile( + leading: const Icon(Icons.place, color: kPrimaryColor), + title: Text(labelText), + trailing: IconButton( + icon: const Icon(Icons.delete, color: kError), + onPressed: () async { + try { + if (ann.id != null) { + await (appContext.getContext() as ManagerAppContext) + .clientAPI! + .sectionEventApi! + .sectionEventDeleteMapAnnotation(ann.id!); + } + if (mounted) { + setState(() { + final updated = List.from( + eventDTO.globalMapAnnotations ?? []); + updated.removeAt(idx); + eventDTO.globalMapAnnotations = updated; + }); + showNotification(kSuccess, kWhite, + 'Annotation supprimée', context, null); + } + } catch (e) { + showNotification(kError, kWhite, + 'Erreur lors de la suppression', context, null); + } + }, + ), + ), + ); + }), + ], + ), + ); + } } diff --git a/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateMapAnnotation.dart b/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateMapAnnotation.dart new file mode 100644 index 0000000..a0755f3 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateMapAnnotation.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:manager_app/constants.dart'; +import 'package:manager_app/Components/rounded_button.dart'; +import 'package:manager_app/Components/multi_string_input_container.dart'; +import 'package:manager_app/Components/string_input_container.dart'; +import 'package:manager_app/Components/dropDown_input_container.dart'; + +void showNewOrUpdateMapAnnotation( + BuildContext context, + MapAnnotationDTO? annotation, + Function(MapAnnotationDTO) onSave, +) { + MapAnnotationDTO working = annotation != null + ? MapAnnotationDTO( + id: annotation.id, + label: List.from(annotation.label ?? []), + type: annotation.type != null ? List.from(annotation.type!) : [], + geometryType: annotation.geometryType, + polyColor: annotation.polyColor, + icon: annotation.icon, + ) + : MapAnnotationDTO( + label: [], + type: [], + geometryType: GeometryType.number0, // Point + polyColor: '#FF0000', + ); + + final List geometryTypeLabels = ['Point', 'Polyline', 'Circle', 'Polygon']; + + String geometryTypeToLabel(GeometryType? gt) { + if (gt == null) return 'Point'; + switch (gt.value) { + case 0: return 'Point'; + case 1: return 'Polyline'; + case 2: return 'Circle'; + case 3: return 'Polygon'; + default: return 'Point'; + } + } + + GeometryType labelToGeometryType(String label) { + switch (label) { + case 'Polyline': return GeometryType.number1; + case 'Circle': return GeometryType.number2; + case 'Polygon': return GeometryType.number3; + default: return GeometryType.number0; + } + } + + showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (context, setState) { + final double screenWidth = MediaQuery.of(context).size.width; + final double screenHeight = MediaQuery.of(context).size.height; + final double dialogWidth = screenWidth * 0.6; + final double contentWidth = dialogWidth - 48; + final double halfWidth = (contentWidth - 20) / 2; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + width: dialogWidth, + constraints: BoxConstraints(maxHeight: screenHeight * 0.8), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + annotation == null ? "Nouvelle Annotation" : "Modifier l'Annotation", + style: TextStyle( + color: kPrimaryColor, + fontSize: 20, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: halfWidth, + child: MultiStringInputContainer( + label: "Label :", + modalLabel: "Label de l'annotation", + initialValue: working.label ?? [], + onGetResult: (val) => + setState(() => working.label = val), + maxLines: 1, + isTitle: true, + isHTML: false, + ), + ), + const SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: DropDownInputContainer( + label: "Géométrie :", + values: geometryTypeLabels, + initialValue: geometryTypeToLabel(working.geometryType), + onChange: (val) => setState(() => + working.geometryType = labelToGeometryType(val)), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + SizedBox( + width: halfWidth, + child: StringInputContainer( + label: "Couleur (hex) :", + initialValue: working.polyColor ?? '#FF0000', + onChanged: (val) => + setState(() => working.polyColor = val), + ), + ), + const SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: StringInputContainer( + label: "Icône (material) :", + initialValue: working.icon ?? '', + onChanged: (val) => + setState(() => working.icon = val.isEmpty ? null : val), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 46, + child: RoundedButton( + text: "Annuler", + press: () => Navigator.pop(context), + color: kSecond, + fontSize: 15, + horizontal: 24, + ), + ), + const SizedBox(width: 12), + SizedBox( + height: 46, + child: RoundedButton( + text: "Sauvegarder", + press: () { + onSave(working); + Navigator.pop(context); + }, + color: kPrimaryColor, + fontSize: 15, + horizontal: 24, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); +} diff --git a/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateProgrammeBlock.dart b/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateProgrammeBlock.dart index 3fef5fa..6b47b90 100644 --- a/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateProgrammeBlock.dart +++ b/lib/Screens/Configurations/Section/SubSection/Event/showNewOrUpdateProgrammeBlock.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; import 'package:manager_app/constants.dart'; +import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/rounded_button.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:intl/intl.dart'; +import 'showNewOrUpdateMapAnnotation.dart'; void showNewOrUpdateProgrammeBlock( BuildContext context, @@ -17,14 +19,21 @@ void showNewOrUpdateProgrammeBlock( description: List.from(block.description ?? []), startTime: block.startTime, endTime: block.endTime, + mapAnnotations: List.from(block.mapAnnotations ?? []), ) : ProgrammeBlock( title: [], description: [], startTime: DateTime.now(), endTime: DateTime.now().add(Duration(hours: 1)), + mapAnnotations: [], ); + MapAnnotationDTO _toDTO(MapAnnotation a) => + MapAnnotationDTO.fromJson(a.toJson())!; + MapAnnotation _fromDTO(MapAnnotationDTO a) => + MapAnnotation.fromJson(a.toJson())!; + showDialog( context: context, builder: (BuildContext context) { @@ -198,6 +207,119 @@ void showNewOrUpdateProgrammeBlock( ), ], ), + Divider(height: 24), + // Annotations + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Annotations", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15)), + IconButton( + icon: Icon(Icons.add_circle_outline, + color: kSuccess), + onPressed: () { + showNewOrUpdateMapAnnotation( + context, + null, + (newAnnotation) { + setState(() { + workingBlock.mapAnnotations = [ + ...(workingBlock.mapAnnotations ?? []), + _fromDTO(newAnnotation), + ]; + }); + }, + ); + }, + ), + ], + ), + if (workingBlock.mapAnnotations == null || + workingBlock.mapAnnotations!.isEmpty) + Padding( + padding: + const EdgeInsets.symmetric(vertical: 8), + child: Text( + "Aucune annotation configurée.", + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey[600]), + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: + workingBlock.mapAnnotations!.length, + itemBuilder: (context, aIndex) { + final annotation = + workingBlock.mapAnnotations![aIndex]; + final label = annotation.label + ?.firstWhere( + (t) => t.language == 'FR', + orElse: () => + annotation.label!.first, + ) + .value ?? + "Annotation $aIndex"; + return ListTile( + dense: true, + title: Text(label), + subtitle: Text( + annotation.geometryType?.value == 1 + ? 'Polyline' + : annotation.geometryType + ?.value == + 2 + ? 'Circle' + : annotation.geometryType + ?.value == + 3 + ? 'Polygon' + : 'Point'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.edit, + size: 18, + color: kPrimaryColor), + onPressed: () { + showNewOrUpdateMapAnnotation( + context, + _toDTO(annotation), + (updated) { + setState(() { + workingBlock.mapAnnotations![ + aIndex] = + _fromDTO(updated); + }); + }, + ); + }, + ), + IconButton( + icon: Icon(Icons.delete, + size: 18, color: kError), + onPressed: () { + showConfirmationDialog( + "Supprimer cette annotation ?", + () {}, + () => setState(() => workingBlock + .mapAnnotations! + .removeAt(aIndex)), + context, + ); + }, + ), + ], + ), + ); + }, + ), ], ), ), diff --git a/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart b/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart index 7396dbf..1fec7af 100644 --- a/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart @@ -13,6 +13,7 @@ import 'package:manager_app/Components/fetch_section_icon.dart'; import 'package:manager_app/Components/resource_input_container.dart'; import 'package:manager_app/Components/multi_select_container.dart'; import 'package:manager_app/Components/single_select_container.dart'; +import 'package:manager_app/Components/check_input_container.dart'; import 'package:manager_app/Components/slider_input_container.dart'; import 'package:manager_app/Components/string_input_container.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Map/showNewOrUpdateGeoPoint.dart'; @@ -662,6 +663,16 @@ class _MapConfigState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ + CheckInputContainer( + label: "Vue liste :", + isChecked: mapDTO.isListViewEnabled ?? false, + onChanged: (value) { + setState(() { + mapDTO.isListViewEnabled = value; + widget.onChanged(mapDTO); + }); + }, + ), if (mapDTO.mapProvider == MapProvider.Google) DropDownInputContainer( label: "Type :", diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart index 2568988..80aaab9 100644 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart @@ -6,6 +6,7 @@ import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/geometry_input_container.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/rounded_button.dart'; +import 'package:manager_app/Components/string_input_container.dart'; import 'package:manager_app/constants.dart'; import 'showNewOrUpdateQuizQuestion.dart'; @@ -108,6 +109,78 @@ void showNewOrUpdateGuidedStep( }); }, ), + Divider(height: 24), + // Comportement de l'étape + Text("Comportement", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SwitchListTile( + dense: true, + title: Text("Cachée initialement"), + value: workingStep.isHiddenInitially ?? false, + onChanged: (val) => setState(() => workingStep.isHiddenInitially = val), + activeThumbColor: kPrimaryColor, + ), + ), + Expanded( + child: SwitchListTile( + dense: true, + title: Text("Verrouillée"), + value: workingStep.isStepLocked ?? false, + onChanged: (val) => setState(() => workingStep.isStepLocked = val), + activeThumbColor: kPrimaryColor, + ), + ), + Expanded( + child: SwitchListTile( + dense: true, + title: Text("Timer"), + value: workingStep.isStepTimer ?? false, + onChanged: (val) => setState(() => workingStep.isStepTimer = val), + activeThumbColor: kPrimaryColor, + ), + ), + ], + ), + if (workingStep.isStepTimer == true) ...[ + SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: halfWidth, + child: StringInputContainer( + label: "Durée (secondes) :", + initialValue: workingStep.timerSeconds?.toString() ?? "", + onChanged: (val) => setState(() => + workingStep.timerSeconds = int.tryParse(val)), + ), + ), + SizedBox(width: 20), + SizedBox( + width: halfWidth, + child: MultiStringInputContainer( + label: "Message d'expiration :", + modalLabel: "Message d'expiration du timer", + initialValue: workingStep.timerExpiredMessage ?? [], + onGetResult: (val) => setState(() => + workingStep.timerExpiredMessage = val.isEmpty ? null : val), + maxLines: 2, + isTitle: false, + isHTML: false, + ), + ), + ], + ), + ], + SizedBox(height: 8), + StringInputContainer( + label: "GeoPoint de déclenchement (ID) :", + initialValue: workingStep.triggerGeoPointId?.toString() ?? "", + onChanged: (val) => setState(() => + workingStep.triggerGeoPointId = int.tryParse(val)), + ), // Questions — uniquement en mode Escape Game if (isEscapeMode) ...[ Divider(height: 24), diff --git a/lib/Screens/Configurations/Section/section_detail_screen.dart b/lib/Screens/Configurations/Section/section_detail_screen.dart index 0f676a1..78b3718 100644 --- a/lib/Screens/Configurations/Section/section_detail_screen.dart +++ b/lib/Screens/Configurations/Section/section_detail_screen.dart @@ -391,6 +391,7 @@ class _SectionDetailScreenState extends State { } getButtons(SectionDTO sectionDTO, AppContext appContext) { + final canEdit = (appContext.getContext() as ManagerAppContext).canEdit; return Align( alignment: AlignmentDirectional.bottomCenter, child: Row( @@ -409,7 +410,7 @@ class _SectionDetailScreenState extends State { }, ), ), - Padding( + if (canEdit) Padding( padding: const EdgeInsets.all(8.0), child: RoundedButton( text: "Supprimer", @@ -422,7 +423,7 @@ class _SectionDetailScreenState extends State { }, ), ), - Padding( + if (canEdit) Padding( padding: const EdgeInsets.all(8.0), child: RoundedButton( text: "Sauvegarder", diff --git a/lib/Screens/Configurations/configuration_detail_screen.dart b/lib/Screens/Configurations/configuration_detail_screen.dart index 2889d85..d8858dc 100644 --- a/lib/Screens/Configurations/configuration_detail_screen.dart +++ b/lib/Screens/Configurations/configuration_detail_screen.dart @@ -346,6 +346,7 @@ class _ConfigurationDetailScreenState extends State { } getButtons(ConfigurationDTO configurationDTO, AppContext appContext) { + final canEdit = (appContext.getContext() as ManagerAppContext).canEdit; return Align( alignment: AlignmentDirectional.bottomCenter, child: Row( @@ -364,7 +365,7 @@ class _ConfigurationDetailScreenState extends State { }, ), ), - Padding( + if (canEdit) Padding( padding: const EdgeInsets.all(5.0), child: RoundedButton( text: "Supprimer", @@ -377,7 +378,7 @@ class _ConfigurationDetailScreenState extends State { }, ), ), - Padding( + if (canEdit) Padding( padding: const EdgeInsets.all(5.0), child: RoundedButton( text: "Sauvegarder", diff --git a/lib/Screens/Configurations/configurations_screen.dart b/lib/Screens/Configurations/configurations_screen.dart index 6b3f0f9..655bd3a 100644 --- a/lib/Screens/Configurations/configurations_screen.dart +++ b/lib/Screens/Configurations/configurations_screen.dart @@ -43,7 +43,7 @@ class _ConfigurationsScreenState extends State { builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { var tempOutput = new List.from(snapshot.data); - tempOutput.add(ConfigurationDTO(id: null)); + if (managerAppContext.canEdit) tempOutput.add(ConfigurationDTO(id: null)); return bodyGrid(tempOutput, size, appContext, context); } else if (snapshot.connectionState == ConnectionState.none) { return Text("No data"); diff --git a/lib/Screens/Main/main_screen.dart b/lib/Screens/Main/main_screen.dart index 1abdec8..18cedcf 100644 --- a/lib/Screens/Main/main_screen.dart +++ b/lib/Screens/Main/main_screen.dart @@ -14,6 +14,7 @@ import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart'; import 'package:manager_app/Screens/Resources/resources_screen.dart'; import 'package:manager_app/Screens/Statistics/statistics_screen.dart'; import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart'; +import 'package:manager_app/Screens/Notifications/notifications_screen.dart'; import 'package:manager_app/Screens/Users/users_screen.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; @@ -40,6 +41,11 @@ class _MainScreenState extends State { final ValueNotifier currentPosition = ValueNotifier(null); + // Keys for nudge animation — one per section type + final _nudgeKeys = >{}; + GlobalKey<_NudgeIconState> _nudgeKey(String type) => + _nudgeKeys.putIfAbsent(type, () => GlobalKey<_NudgeIconState>()); + @override void initState() { super.initState(); @@ -115,7 +121,7 @@ class _MainScreenState extends State { onTap: isCurrent ? null : () async { - Navigator.pop(context); + Navigator.of(context, rootNavigator: true).pop(); final newInstance = await managerCtx.clientAPI!.instanceApi!.instanceGetDetail(inst.id); if (newInstance != null && context.mounted) { setState(() { @@ -135,12 +141,25 @@ class _MainScreenState extends State { ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Fermer')), + TextButton(onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('Fermer')), ], ), ); } + static IconData _sectionIcon(String type) { + switch (type) { + case 'devices': return Icons.apps; + case 'configurations': return Icons.settings_outlined; + case 'resources': return Icons.folder_open_outlined; + case 'statistics': return Icons.bar_chart; + case 'notifications': return Icons.notifications_none; + case 'users': return Icons.people_outline; + case 'apikeys': return Icons.vpn_key_outlined; + default: return Icons.circle_outlined; + } + } + Widget buildMenu(BuildContext context, AppContext appContext, ManagerAppContext managerAppContext, bool isDrawer) { return Container( width: isDrawer ? null : 250, // fixed width on sidebar, null on drawer for full width @@ -190,6 +209,7 @@ class _MainScreenState extends State { if (section.subMenu.isEmpty) { return Container( + key: ValueKey(section.type), decoration: currentPath!.contains(section.type) ? BoxDecoration( border: Border( @@ -201,21 +221,38 @@ class _MainScreenState extends State { ) : null, child: ListTile( + leading: _NudgeIcon( + key: _nudgeKey(section.type), + icon: _sectionIcon(section.type), + isActive: currentPath.contains(section.type), + color: currentPath.contains(section.type) ? kPrimaryColor : kBodyTextColor, + size: 22, + ), title: Text(section.name, style: TextStyle(color: currentPath.contains(section.type) ? kPrimaryColor : kBodyTextColor, fontSize: 22, fontWeight: currentPath.contains(section.type) ? FontWeight.w500 : FontWeight.w100)), selected: currentPosition == section.menuId, onTap: () { - //currentPosition.value = section.menuId; + WidgetsBinding.instance.addPostFrameCallback((_) => + _nudgeKey(section.type).currentState?.nudge()); context.go('/main/${section.type}'); - if (isDrawer) Navigator.of(context).pop(); // Close drawer on mobile + if (isDrawer) Navigator.of(context).pop(); }, ), ); } else { + final isAppActive = currentPath!.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr"); return Container( + key: ValueKey(section.type), child: ExpansionTile( - iconColor: currentPath!.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr") ? kPrimaryColor : kBodyTextColor, - collapsedIconColor: currentPath.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr") ? kPrimaryColor : kBodyTextColor, - title: Text(section.name, style: TextStyle(color: currentPath.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr") ? kPrimaryColor : kBodyTextColor, fontSize: 22, fontWeight: currentPath.contains("mobile") || currentPath.contains("kiosk") || currentPath.contains("web") || currentPath.contains("vr") ? FontWeight.w500 : FontWeight.w100)), + leading: _NudgeIcon( + key: _nudgeKey(section.type), + icon: _sectionIcon(section.type), + isActive: isAppActive, + color: isAppActive ? kPrimaryColor : kBodyTextColor, + size: 22, + ), + iconColor: isAppActive ? kPrimaryColor : kBodyTextColor, + collapsedIconColor: isAppActive ? kPrimaryColor : kBodyTextColor, + title: Text(section.name, style: TextStyle(color: isAppActive ? kPrimaryColor : kBodyTextColor, fontSize: 22, fontWeight: isAppActive ? FontWeight.w500 : FontWeight.w100)), children: section.subMenu.map((subSection) { return Container( decoration: currentPath.contains(subSection.type) @@ -246,11 +283,11 @@ class _MainScreenState extends State { ), selected: currentPosition.value == subSection.menuId, onTap: () { - - - if(currentPath != null && currentPath.contains(subSection.type)) { - // DO NOTHING, we are already display the correct interface + if (currentPath != null && currentPath.contains(subSection.type)) { + // already on this page } else { + WidgetsBinding.instance.addPostFrameCallback((_) => + _nudgeKey(section.type).currentState?.nudge()); context.go('/main/${subSection.type}'); } if (isDrawer) Navigator.of(context).pop(); @@ -316,6 +353,12 @@ class _MainScreenState extends State { // Synchronise les items de menu sensibles au rôle à chaque rebuild final role = managerAppContext.role; final hasAdminItems = menu.sections!.any((s) => s.menuId == 8); + final hasNotifItem = menu.sections!.any((s) => s.menuId == 10); + if (role != null && role.value <= 1 && managerAppContext.instanceDTO?.isPushNotification == true && !hasNotifItem) { + menu.sections!.add(MenuSection(name: "Notifications", type: "notifications", menuId: 10, subMenu: [])); + } else if ((role == null || role.value > 1 || managerAppContext.instanceDTO?.isPushNotification != true) && hasNotifItem) { + menu.sections!.removeWhere((s) => s.menuId == 10); + } if (role != null && role.value <= 1 && !hasAdminItems) { menu.sections!.add(MenuSection(name: "Utilisateurs", type: "users", menuId: 8, subMenu: [])); menu.sections!.add(MenuSection(name: "Clés API", type: "apikeys", menuId: 9, subMenu: [])); @@ -403,6 +446,9 @@ class _MainScreenState extends State { case "apikeys": currentPosition = 9; break; + case "notifications": + currentPosition = 10; + break; } } @@ -473,7 +519,59 @@ class _MainScreenState extends State { padding: EdgeInsets.all(8.0), child: ApiKeysScreen() ); + case 'notifications': + return const Padding( + padding: EdgeInsets.all(8.0), + child: NotificationsScreen() + ); default: return Text('Hellow default'); } +} + +class _NudgeIcon extends StatefulWidget { + final IconData icon; + final bool isActive; + final Color color; + final double size; + + const _NudgeIcon({Key? key, required this.icon, required this.isActive, required this.color, required this.size}) : super(key: key); + + @override + State<_NudgeIcon> createState() => _NudgeIconState(); +} + +class _NudgeIconState extends State<_NudgeIcon> with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _dx; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); + _dx = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 5.0), weight: 35), + TweenSequenceItem(tween: Tween(begin: 5.0, end: 0.0), weight: 65), + ]).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + } + + void nudge() => _ctrl.forward(from: 0); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _dx, + builder: (_, child) => Transform.translate( + offset: Offset(_dx.value, 0), + child: child, + ), + child: Icon(widget.icon, color: widget.color, size: widget.size), + ); + } } \ No newline at end of file diff --git a/lib/Screens/Notifications/notifications_screen.dart b/lib/Screens/Notifications/notifications_screen.dart new file mode 100644 index 0000000..ba885ad --- /dev/null +++ b/lib/Screens/Notifications/notifications_screen.dart @@ -0,0 +1,454 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:manager_app/Models/managerContext.dart'; +import 'package:manager_app/app_context.dart'; +import 'package:manager_app/constants.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:provider/provider.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({Key? key}) : super(key: key); + + @override + _NotificationsScreenState createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + List _notifications = []; + bool _loading = true; + String? _statusFilter; // null = toutes + int _page = 0; + static const _pageSize = 15; + + // ─── KPI counts ─────────────────────────────────────────── + int get _totalCount => _notifications.length; + int get _sentCount => _notifications.where((n) => n.status == 'Sent').length; + int get _scheduledCount => _notifications.where((n) => n.status == 'Scheduled').length; + int get _failedCount => _notifications.where((n) => n.status == 'Failed').length; + + // ─── Filtre + pagination ─────────────────────────────────── + List get _filtered => _statusFilter == null + ? _notifications + : _notifications.where((n) => n.status == _statusFilter).toList(); + + int get _pageCount => (_filtered.isEmpty ? 1 : (_filtered.length / _pageSize).ceil()); + List get _paginated => + _filtered.skip(_page * _pageSize).take(_pageSize).toList(); + + void _setFilter(String? status) { + setState(() { + _statusFilter = _statusFilter == status ? null : status; + _page = 0; + }); + } + + // ─── API helpers ────────────────────────────────────────── + Future _load(ManagerAppContext ctx) async { + try { + final response = await ctx.clientAPI!.notificationApi!.notificationGetWithHttpInfo(); + if (response.statusCode == 200) { + final List json = jsonDecode(utf8.decode(response.bodyBytes)); + setState(() { + _notifications = PushNotificationDTO.listFromJson(json); + _loading = false; + }); + } + } catch (e) { + setState(() => _loading = false); + } + } + + Future _send(ManagerAppContext ctx, String title, String body) async { + final response = await ctx.clientAPI!.notificationApi!.notificationSendWithHttpInfo( + SendNotificationRequest(title: title, body: body), + ); + return response.statusCode == 200; + } + + Future _schedule(ManagerAppContext ctx, String title, String body, DateTime scheduledAt) async { + final response = await ctx.clientAPI!.notificationApi!.notificationScheduleWithHttpInfo( + ScheduleNotificationRequest(title: title, body: body, scheduledAt: scheduledAt), + ); + return response.statusCode == 200; + } + + Future _cancel(ManagerAppContext ctx, String id) async { + final response = await ctx.clientAPI!.notificationApi!.notificationCancelWithHttpInfo(id); + return response.statusCode == 202; + } + + // ─── Dialogs ────────────────────────────────────────────── + void _showSendModal(BuildContext context, ManagerAppContext ctx) { + final titleCtrl = TextEditingController(); + final bodyCtrl = TextEditingController(); + bool isScheduled = false; + DateTime? scheduledDate; + TimeOfDay? scheduledTime; + + showDialog( + context: context, + builder: (_) => StatefulBuilder(builder: (ctx2, setLocal) { + return AlertDialog( + title: const Text('Nouveau message'), + content: SizedBox( + width: 480, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField( + controller: titleCtrl, + decoration: const InputDecoration(labelText: 'Titre'), + ), + const SizedBox(height: 8), + TextField( + controller: bodyCtrl, + decoration: const InputDecoration(labelText: 'Message'), + maxLines: 3, + ), + const SizedBox(height: 16), + Row( + children: [ + Checkbox( + value: isScheduled, + onChanged: (v) => setLocal(() => isScheduled = v ?? false), + ), + const Text('Planifier'), + ], + ), + if (isScheduled) ...[ + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text(scheduledDate != null + ? '${scheduledDate!.day.toString().padLeft(2, '0')}/${scheduledDate!.month.toString().padLeft(2, '0')}/${scheduledDate!.year}' + : 'Date'), + onPressed: () async { + final d = await showDatePicker( + context: ctx2, + initialDate: DateTime.now().add(const Duration(hours: 1)), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (d != null) setLocal(() => scheduledDate = d); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.access_time, size: 16), + label: Text(scheduledTime != null + ? scheduledTime!.format(ctx2) + : 'Heure'), + onPressed: () async { + final t = await showTimePicker( + context: ctx2, + initialTime: TimeOfDay.now(), + ); + if (t != null) setLocal(() => scheduledTime = t); + }, + ), + ), + ]), + ], + ]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx2), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: titleCtrl.text.isEmpty ? null : () async { + Navigator.pop(ctx2); + bool ok; + if (isScheduled && scheduledDate != null && scheduledTime != null) { + final dt = DateTime( + scheduledDate!.year, + scheduledDate!.month, + scheduledDate!.day, + scheduledTime!.hour, + scheduledTime!.minute, + ); + ok = await _schedule(ctx, titleCtrl.text, bodyCtrl.text, dt); + } else { + ok = await _send(ctx, titleCtrl.text, bodyCtrl.text); + } + if (ok && context.mounted) { + await _load(ctx); + } + }, + child: const Text('Envoyer'), + ), + ], + ); + }), + ); + } + + void _confirmCancel(BuildContext context, ManagerAppContext ctx, PushNotificationDTO notif) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Annuler la notification'), + content: Text('Annuler « ${notif.title} » ?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Non')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () async { + Navigator.pop(context); + final ok = await _cancel(ctx, notif.id!); + if (ok && context.mounted) await _load(ctx); + }, + child: const Text('Annuler', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + // ─── Helpers ────────────────────────────────────────────── + static String _formatDate(DateTime? dt) { + if (dt == null) return '—'; + final d = dt.toLocal(); + return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } + + static Widget _statusChip(String status) { + Color bg; + Color fg; + switch (status) { + case 'Sent': + bg = Colors.green.shade50; + fg = Colors.green.shade700; + break; + case 'Scheduled': + bg = Colors.blue.shade50; + fg = Colors.blue.shade700; + break; + case 'Failed': + bg = Colors.red.shade50; + fg = Colors.red.shade700; + break; + default: + bg = Colors.grey.shade100; + fg = Colors.grey.shade700; + } + return Chip( + label: Text(status, style: TextStyle(color: fg, fontSize: 12)), + backgroundColor: bg, + side: BorderSide(color: fg.withValues(alpha: 0.3)), + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + ); + } + + // ─── Build ──────────────────────────────────────────────── + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = Provider.of(context, listen: false).getContext() as ManagerAppContext; + _load(ctx); + }); + } + + @override + Widget build(BuildContext context) { + final appContext = Provider.of(context); + final ctx = appContext.getContext() as ManagerAppContext; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Notifications', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)), + ElevatedButton.icon( + onPressed: () => _showSendModal(context, ctx), + icon: const Icon(Icons.add), + label: const Text('Nouveau message'), + ), + ], + ), + const SizedBox(height: 16), + + // ── KPIs ── + Row( + children: [ + _KpiCard( + label: 'Toutes', + count: _totalCount, + color: Colors.blueGrey, + selected: _statusFilter == null, + onTap: () => _setFilter(null), + ), + const SizedBox(width: 12), + _KpiCard( + label: 'Envoyées', + count: _sentCount, + color: Colors.green, + selected: _statusFilter == 'Sent', + onTap: () => _setFilter('Sent'), + ), + const SizedBox(width: 12), + _KpiCard( + label: 'Planifiées', + count: _scheduledCount, + color: Colors.blue, + selected: _statusFilter == 'Scheduled', + onTap: () => _setFilter('Scheduled'), + ), + const SizedBox(width: 12), + _KpiCard( + label: 'Échouées', + count: _failedCount, + color: Colors.red, + selected: _statusFilter == 'Failed', + onTap: () => _setFilter('Failed'), + ), + ], + ), + const SizedBox(height: 16), + + // ── List ── + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (_notifications.isEmpty) + const Center(child: Text('Aucune notification envoyée')) + else if (_filtered.isEmpty) + const Center(child: Text('Aucune notification pour ce filtre')) + else + Expanded( + child: Card( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.grey.shade200), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: DataTable( + horizontalMargin: 16, + columnSpacing: 24, + headingRowColor: WidgetStateProperty.all(Colors.grey.shade50), + dividerThickness: 1, + columns: const [ + DataColumn(label: Text('Titre', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Topic', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('')), + ], + rows: _paginated.map((n) { + final isScheduled = n.status == 'Scheduled'; + final date = isScheduled ? n.scheduledAt : n.sentAt; + return DataRow(cells: [ + DataCell(Text(n.title ?? '')), + DataCell(Text(n.topic ?? '', style: const TextStyle(color: Colors.grey))), + DataCell(Text(_formatDate(date), style: const TextStyle(color: Colors.grey))), + DataCell(_statusChip(n.status ?? '')), + DataCell(isScheduled + ? IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + tooltip: 'Annuler', + onPressed: () => _confirmCancel(context, ctx, n), + ) + : const SizedBox()), + ]); + }).toList(), + ), + ), + ), + ), + // ── Pagination ── + Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Colors.grey.shade200)), + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _page > 0 ? () => setState(() => _page--) : null, + ), + Text('${_page + 1} / $_pageCount', style: const TextStyle(fontSize: 13)), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _page < _pageCount - 1 ? () => setState(() => _page++) : null, + ), + const SizedBox(width: 16), + Text( + '${_filtered.length} résultat${_filtered.length > 1 ? 's' : ''}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _KpiCard extends StatelessWidget { + final String label; + final int count; + final MaterialColor color; + final bool selected; + final VoidCallback onTap; + + const _KpiCard({ + required this.label, + required this.count, + required this.color, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 120, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: selected ? color.shade100 : color.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected ? color.shade400 : color.shade200, + width: selected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$count', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color.shade700)), + const SizedBox(height: 2), + Text(label, + style: TextStyle(fontSize: 12, color: color.shade600)), + ], + ), + ), + ); + } +} diff --git a/lib/Screens/Resources/resource_body_grid.dart b/lib/Screens/Resources/resource_body_grid.dart index 4659ea8..8e25536 100644 --- a/lib/Screens/Resources/resource_body_grid.dart +++ b/lib/Screens/Resources/resource_body_grid.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:manager_app/Components/fetch_resource_icon.dart'; import 'package:manager_app/Components/multi_select_container.dart'; import 'package:manager_app/Components/string_input_container.dart'; +import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_api_new/api.dart'; @@ -89,7 +90,7 @@ class _ResourceBodyGridState extends State { }, ), ), - if (widget.isAddButton) + if (widget.isAddButton && (appContext.getContext() as ManagerAppContext).canEdit) InkWell( onTap: () { widget.onSelect(ResourceDTO(id: widget.isSelectModal ? "-1" : null)); diff --git a/lib/Screens/Users/users_screen.dart b/lib/Screens/Users/users_screen.dart index aaa2cd5..c5e2c1c 100644 --- a/lib/Screens/Users/users_screen.dart +++ b/lib/Screens/Users/users_screen.dart @@ -17,14 +17,23 @@ class _UsersScreenState extends State { List> _users = []; bool _loading = true; - static String _roleName(int? v) { - switch (v) { - case 0: return 'SuperAdmin'; - case 1: return 'InstanceAdmin'; - case 2: return 'ContentEditor'; - case 3: return 'Viewer'; - default: return '—'; + static const _roleNames = ['SuperAdmin', 'InstanceAdmin', 'ContentEditor', 'Viewer']; + + static String _roleName(dynamic v) { + if (v is String) return v; + final i = v as int?; + if (i != null && i >= 0 && i < _roleNames.length) return _roleNames[i]; + return '—'; + } + + // Convertit une valeur de rôle (String ou int) en int pour les comparaisons + static int _roleToInt(dynamic v) { + if (v is int) return v; + if (v is String) { + final i = _roleNames.indexOf(v); + return i >= 0 ? i : 3; } + return 3; } // Roles the caller is allowed to assign (can't assign higher than own role) @@ -79,7 +88,7 @@ class _UsersScreenState extends State { } void _showCreateDialog(BuildContext context, ManagerAppContext ctx) { - final callerRole = ctx.role?.value ?? 2; + final callerRole = _roleToInt(ctx.role?.value); final emailCtrl = TextEditingController(); final firstCtrl = TextEditingController(); final lastCtrl = TextEditingController(); @@ -131,10 +140,10 @@ class _UsersScreenState extends State { } void _showEditDialog(BuildContext context, ManagerAppContext ctx, Map user) { - final callerRole = ctx.role?.value ?? 2; + final callerRole = _roleToInt(ctx.role?.value); final firstCtrl = TextEditingController(text: user['firstName'] as String? ?? ''); final lastCtrl = TextEditingController(text: user['lastName'] as String? ?? ''); - int selectedRole = (user['role'] as int?) ?? callerRole; + int selectedRole = _roleToInt(user['role']); showDialog( context: context, @@ -208,6 +217,15 @@ class _UsersScreenState extends State { }); } + static Color _roleColor(dynamic v) { + switch (_roleToInt(v)) { + case 0: return Colors.purple; + case 1: return Colors.blue; + case 2: return Colors.teal; + default: return Colors.grey; + } + } + @override Widget build(BuildContext context) { final appContext = Provider.of(context); @@ -234,36 +252,64 @@ class _UsersScreenState extends State { const Center(child: Text('Aucun utilisateur')) else Expanded( - child: SingleChildScrollView( - child: DataTable( - columns: const [ - DataColumn(label: Text('Email')), - DataColumn(label: Text('Prénom')), - DataColumn(label: Text('Nom')), - DataColumn(label: Text('Rôle')), - DataColumn(label: Text('Actions')), - ], - rows: _users.map((user) { - return DataRow(cells: [ - DataCell(Text(user['email'] as String? ?? '')), - DataCell(Text(user['firstName'] as String? ?? '')), - DataCell(Text(user['lastName'] as String? ?? '')), - DataCell(Text(_roleName(user['role'] as int?))), - DataCell(Row(children: [ - IconButton( - icon: Icon(Icons.edit, color: kPrimaryColor), - onPressed: () => _showEditDialog(context, managerCtx, user), - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () => _confirmDelete(context, managerCtx, user), - ), - ])), - ]); - }).toList(), + child: Card( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.grey.shade200), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: DataTable( + horizontalMargin: 16, + columnSpacing: 24, + headingRowColor: WidgetStateProperty.all(Colors.grey.shade50), + dividerThickness: 1, + columns: const [ + DataColumn(label: Text('Email', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Prénom', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Nom', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Rôle', style: TextStyle(fontWeight: FontWeight.w600))), + DataColumn(label: Text('Actions', style: TextStyle(fontWeight: FontWeight.w600))), + ], + rows: _users.map((user) { + final roleColor = _roleColor(user['role']); + return DataRow(cells: [ + DataCell(Text(user['email'] as String? ?? '')), + DataCell(Text(user['firstName'] as String? ?? '')), + DataCell(Text(user['lastName'] as String? ?? '')), + DataCell(Chip( + label: Text( + _roleName(user['role']), + style: TextStyle(color: roleColor, fontSize: 12), + ), + backgroundColor: roleColor.withValues(alpha: 0.08), + side: BorderSide(color: roleColor.withValues(alpha: 0.3)), + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + )), + DataCell(Row(children: [ + IconButton( + icon: Icon(Icons.edit, color: kPrimaryColor, size: 20), + tooltip: 'Modifier', + onPressed: () => _showEditDialog(context, managerCtx, user), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + tooltip: 'Supprimer', + onPressed: () => _confirmDelete(context, managerCtx, user), + ), + ])), + ]); + }).toList(), + ), ), ), ), + ), ], ); } diff --git a/lib/client.dart b/lib/client.dart index 57ab715..87c4022 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -47,6 +47,9 @@ class Client { ApiKeyApi? _apiKeyApi; ApiKeyApi? get apiKeyApi => _apiKeyApi; + NotificationApi? _notificationApi; + NotificationApi? get notificationApi => _notificationApi; + Client(String path) { _apiClient = ApiClient(basePath: path); //basePath: "https://192.168.31.140"); @@ -66,5 +69,6 @@ class Client { _sectionEventApi = SectionEventApi(_apiClient); _statsApi = StatsApi(_apiClient); _apiKeyApi = ApiKeyApi(_apiClient); + _notificationApi = NotificationApi(_apiClient); } } diff --git a/lib/main.dart b/lib/main.dart index e191adc..7cf68b1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -263,10 +263,15 @@ class _MyAppState extends State { //const Locale('fr', 'FR'), ],*/ theme: ThemeData( - primarySwatch: Colors.blue, primaryColor: kPrimaryColor, scaffoldBackgroundColor: kBackgroundColor, - //fontFamily: "Vollkorn", + colorScheme: ColorScheme.light( + primary: kPrimaryColor, + onPrimary: Colors.white, + secondary: kPrimaryColor, + onSecondary: Colors.white, + error: kError, + ), textTheme: TextTheme(bodyLarge: TextStyle(color: kBodyTextColor)), visualDensity: VisualDensity.adaptivePlatformDensity, ), diff --git a/manager_api_new/lib/api.dart b/manager_api_new/lib/api.dart index d7182bd..5af683e 100644 --- a/manager_api_new/lib/api.dart +++ b/manager_api_new/lib/api.dart @@ -41,9 +41,14 @@ part 'api/section_agenda_api.dart'; part 'api/section_event_api.dart'; part 'api/section_map_api.dart'; part 'api/section_quiz_api.dart'; +part 'api/notification_api.dart'; part 'api/stats_api.dart'; part 'api/user_api.dart'; +part 'model/push_notification_dto.dart'; +part 'model/send_notification_request.dart'; +part 'model/schedule_notification_request.dart'; + part 'model/agenda_dto.dart'; part 'model/agenda_dto_all_of_agenda_map_provider.dart'; part 'model/ai_card_dto.dart'; diff --git a/manager_api_new/lib/api/notification_api.dart b/manager_api_new/lib/api/notification_api.dart new file mode 100644 index 0000000..1261078 --- /dev/null +++ b/manager_api_new/lib/api/notification_api.dart @@ -0,0 +1,97 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationApi { + NotificationApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// GET /api/Notification — list notifications for the current instance + Future notificationGetWithHttpInfo() async { + final path = r'/api/Notification'; + final queryParams = []; + final headerParams = {}; + final formParams = {}; + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + null, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// POST /api/Notification/send — send a notification immediately + Future notificationSendWithHttpInfo( + SendNotificationRequest sendNotificationRequest, + ) async { + final path = r'/api/Notification/send'; + final queryParams = []; + final headerParams = {}; + final formParams = {}; + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + sendNotificationRequest, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// POST /api/Notification/schedule — schedule a notification + Future notificationScheduleWithHttpInfo( + ScheduleNotificationRequest scheduleNotificationRequest, + ) async { + final path = r'/api/Notification/schedule'; + final queryParams = []; + final headerParams = {}; + final formParams = {}; + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + scheduleNotificationRequest, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// DELETE /api/Notification/{id} — cancel a scheduled notification + Future notificationCancelWithHttpInfo(String id) async { + final path = r'/api/Notification/{id}'.replaceAll('{id}', id); + final queryParams = []; + final headerParams = {}; + final formParams = {}; + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + null, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } +} diff --git a/manager_api_new/lib/api/section_agenda_api.dart b/manager_api_new/lib/api/section_agenda_api.dart index 69e8148..96aed6c 100644 --- a/manager_api_new/lib/api/section_agenda_api.dart +++ b/manager_api_new/lib/api/section_agenda_api.dart @@ -193,6 +193,54 @@ class SectionAgendaApi { return null; } + /// Performs an HTTP 'GET /api/SectionAgenda/{sectionAgendaId}/events/upcoming' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] sectionAgendaId (required): + Future sectionAgendaGetUpcomingEventsWithHttpInfo( + String sectionAgendaId, + ) async { + final path = r'/api/SectionAgenda/{sectionAgendaId}/events/upcoming' + .replaceAll('{sectionAgendaId}', sectionAgendaId); + + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] sectionAgendaId (required): + Future?> sectionAgendaGetUpcomingEvents( + String sectionAgendaId, + ) async { + final response = await sectionAgendaGetUpcomingEventsWithHttpInfo(sectionAgendaId); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } + return null; + } + /// Performs an HTTP 'PUT /api/SectionAgenda/event' operation and returns the [Response]. /// Parameters: /// diff --git a/manager_api_new/lib/api/section_api.dart b/manager_api_new/lib/api/section_api.dart index 7ada9c4..b710e8a 100644 --- a/manager_api_new/lib/api/section_api.dart +++ b/manager_api_new/lib/api/section_api.dart @@ -662,7 +662,7 @@ class SectionApi { /// Parameters: /// /// * [String] id (required): - Future?> sectionGetFromConfigurationDetail( + Future sectionGetFromConfigurationDetail( String id, ) async { final response = await sectionGetFromConfigurationDetailWithHttpInfo( @@ -671,16 +671,10 @@ class SectionApi { if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. + // Sections are polymorphic — bypass generated deserialization and return raw JSON if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') - as List) - .cast() - .toList(growable: false); + return json.decode(await _decodeBodyBytes(response)); } return null; } diff --git a/manager_api_new/lib/api/section_event_api.dart b/manager_api_new/lib/api/section_event_api.dart index c0c2887..b7cbd23 100644 --- a/manager_api_new/lib/api/section_event_api.dart +++ b/manager_api_new/lib/api/section_event_api.dart @@ -481,4 +481,91 @@ class SectionEventApi { } return null; } + + /// Performs an HTTP 'GET /api/SectionEvent/{sectionEventId}/global-map-annotations' operation and returns the [Response]. + Future sectionEventGetGlobalMapAnnotationsWithHttpInfo( + String sectionEventId, + ) async { + final path = r'/api/SectionEvent/{sectionEventId}/global-map-annotations' + .replaceAll('{sectionEventId}', sectionEventId); + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + null, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// GET global map annotations for a section event. + Future?> sectionEventGetGlobalMapAnnotations( + String sectionEventId, + ) async { + final response = await sectionEventGetGlobalMapAnnotationsWithHttpInfo(sectionEventId); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } + return null; + } + + /// Performs an HTTP 'POST /api/SectionEvent/{sectionEventId}/global-map-annotations' operation and returns the [Response]. + Future sectionEventCreateGlobalMapAnnotationWithHttpInfo( + String sectionEventId, + MapAnnotationDTO mapAnnotationDTO, + ) async { + final path = r'/api/SectionEvent/{sectionEventId}/global-map-annotations' + .replaceAll('{sectionEventId}', sectionEventId); + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + mapAnnotationDTO, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// POST create a global map annotation on a section event. + Future sectionEventCreateGlobalMapAnnotation( + String sectionEventId, + MapAnnotationDTO mapAnnotationDTO, + ) async { + final response = await sectionEventCreateGlobalMapAnnotationWithHttpInfo( + sectionEventId, + mapAnnotationDTO, + ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync( + await _decodeBodyBytes(response), + 'MapAnnotationDTO', + ) as MapAnnotationDTO; + } + return null; + } } diff --git a/manager_api_new/lib/model/ai_chat_response_navigation.dart b/manager_api_new/lib/model/ai_chat_response_navigation.dart index a29e258..30ac897 100644 --- a/manager_api_new/lib/model/ai_chat_response_navigation.dart +++ b/manager_api_new/lib/model/ai_chat_response_navigation.dart @@ -16,6 +16,7 @@ class AiChatResponseNavigation { this.sectionId, this.sectionTitle, this.sectionType, + this.imageUrl, }); String? sectionId; @@ -24,6 +25,8 @@ class AiChatResponseNavigation { String? sectionType; + String? imageUrl; + @override bool operator ==(Object other) => identical(this, other) || @@ -60,6 +63,11 @@ class AiChatResponseNavigation { } else { json[r'sectionType'] = null; } + if (this.imageUrl != null) { + json[r'imageUrl'] = this.imageUrl; + } else { + json[r'imageUrl'] = null; + } return json; } @@ -87,6 +95,7 @@ class AiChatResponseNavigation { sectionId: mapValueOfType(json, r'sectionId'), sectionTitle: mapValueOfType(json, r'sectionTitle'), sectionType: mapValueOfType(json, r'sectionType'), + imageUrl: mapValueOfType(json, r'imageUrl'), ); } return null; diff --git a/manager_api_new/lib/model/create_api_key_request.dart b/manager_api_new/lib/model/create_api_key_request.dart index ead0dc9..a3ce0f2 100644 --- a/manager_api_new/lib/model/create_api_key_request.dart +++ b/manager_api_new/lib/model/create_api_key_request.dart @@ -15,6 +15,7 @@ class CreateApiKeyRequest { CreateApiKeyRequest({ this.name, this.appType, + this.dateExpiration, }); String? name; @@ -27,21 +28,25 @@ class CreateApiKeyRequest { /// ApiKeyAppType? appType; + DateTime? dateExpiration; + @override bool operator ==(Object other) => identical(this, other) || other is CreateApiKeyRequest && other.name == name && - other.appType == appType; + other.appType == appType && + other.dateExpiration == dateExpiration; @override int get hashCode => // ignore: unnecessary_parenthesis (name == null ? 0 : name!.hashCode) + - (appType == null ? 0 : appType!.hashCode); + (appType == null ? 0 : appType!.hashCode) + + (dateExpiration == null ? 0 : dateExpiration!.hashCode); @override - String toString() => 'CreateApiKeyRequest[name=$name, appType=$appType]'; + String toString() => 'CreateApiKeyRequest[name=$name, appType=$appType, dateExpiration=$dateExpiration]'; Map toJson() { final json = {}; @@ -55,6 +60,11 @@ class CreateApiKeyRequest { } else { json[r'appType'] = null; } + if (this.dateExpiration != null) { + json[r'dateExpiration'] = this.dateExpiration!.toUtc().toIso8601String(); + } else { + json[r'dateExpiration'] = null; + } return json; } @@ -81,6 +91,7 @@ class CreateApiKeyRequest { return CreateApiKeyRequest( name: mapValueOfType(json, r'name'), appType: ApiKeyAppType.fromJson(json[r'appType']), + dateExpiration: mapDateTime(json, r'dateExpiration', r''), ); } return null; diff --git a/manager_api_new/lib/model/event_agenda_dto.dart b/manager_api_new/lib/model/event_agenda_dto.dart index a94fb41..368b4e1 100644 --- a/manager_api_new/lib/model/event_agenda_dto.dart +++ b/manager_api_new/lib/model/event_agenda_dto.dart @@ -28,6 +28,11 @@ class EventAgendaDTO { this.email, this.sectionAgendaId, this.sectionEventId, + this.isSynced, + this.idVideoYoutube, + this.videoLink, + this.videoResourceId, + this.videoResource, }); /// @@ -66,6 +71,16 @@ class EventAgendaDTO { String? sectionEventId; + bool? isSynced; + + String? idVideoYoutube; + + String? videoLink; + + String? videoResourceId; + + EventAgendaDTOResource? videoResource; + @override bool operator ==(Object other) => identical(this, other) || @@ -84,7 +99,12 @@ class EventAgendaDTO { other.phone == phone && other.email == email && other.sectionAgendaId == sectionAgendaId && - other.sectionEventId == sectionEventId; + other.sectionEventId == sectionEventId && + other.isSynced == isSynced && + other.idVideoYoutube == idVideoYoutube && + other.videoLink == videoLink && + other.videoResourceId == videoResourceId && + other.videoResource == videoResource; @override int get hashCode => @@ -103,7 +123,12 @@ class EventAgendaDTO { (phone == null ? 0 : phone!.hashCode) + (email == null ? 0 : email!.hashCode) + (sectionAgendaId == null ? 0 : sectionAgendaId!.hashCode) + - (sectionEventId == null ? 0 : sectionEventId!.hashCode); + (sectionEventId == null ? 0 : sectionEventId!.hashCode) + + (isSynced == null ? 0 : isSynced!.hashCode) + + (idVideoYoutube == null ? 0 : idVideoYoutube!.hashCode) + + (videoLink == null ? 0 : videoLink!.hashCode) + + (videoResourceId == null ? 0 : videoResourceId!.hashCode) + + (videoResource == null ? 0 : videoResource!.hashCode); @override String toString() => @@ -186,6 +211,31 @@ class EventAgendaDTO { } else { json[r'sectionEventId'] = null; } + if (this.isSynced != null) { + json[r'isSynced'] = this.isSynced; + } else { + json[r'isSynced'] = null; + } + if (this.idVideoYoutube != null) { + json[r'idVideoYoutube'] = this.idVideoYoutube; + } else { + json[r'idVideoYoutube'] = null; + } + if (this.videoLink != null) { + json[r'videoLink'] = this.videoLink; + } else { + json[r'videoLink'] = null; + } + if (this.videoResourceId != null) { + json[r'videoResourceId'] = this.videoResourceId; + } else { + json[r'videoResourceId'] = null; + } + if (this.videoResource != null) { + json[r'videoResource'] = this.videoResource; + } else { + json[r'videoResource'] = null; + } return json; } @@ -225,6 +275,11 @@ class EventAgendaDTO { email: mapValueOfType(json, r'email'), sectionAgendaId: mapValueOfType(json, r'sectionAgendaId'), sectionEventId: mapValueOfType(json, r'sectionEventId'), + isSynced: mapValueOfType(json, r'isSynced'), + idVideoYoutube: mapValueOfType(json, r'idVideoYoutube'), + videoLink: mapValueOfType(json, r'videoLink'), + videoResourceId: mapValueOfType(json, r'videoResourceId'), + videoResource: EventAgendaDTOResource.fromJson(json[r'videoResource']), ); } return null; diff --git a/manager_api_new/lib/model/map_dto.dart b/manager_api_new/lib/model/map_dto.dart index 7c063fe..55311f9 100644 --- a/manager_api_new/lib/model/map_dto.dart +++ b/manager_api_new/lib/model/map_dto.dart @@ -32,6 +32,7 @@ class MapDTO { this.meterZoneGPS, this.isBeacon, this.beaconId, + this.isListViewEnabled, this.zoom, this.mapType, this.mapTypeMapbox, @@ -114,6 +115,8 @@ class MapDTO { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// + bool? isListViewEnabled; + int? zoom; MapTypeApp? mapType; @@ -161,6 +164,7 @@ class MapDTO { other.meterZoneGPS == meterZoneGPS && other.isBeacon == isBeacon && other.beaconId == beaconId && + other.isListViewEnabled == isListViewEnabled && other.zoom == zoom && other.mapType == mapType && other.mapTypeMapbox == mapTypeMapbox && @@ -196,6 +200,7 @@ class MapDTO { (meterZoneGPS == null ? 0 : meterZoneGPS!.hashCode) + (isBeacon == null ? 0 : isBeacon!.hashCode) + (beaconId == null ? 0 : beaconId!.hashCode) + + (isListViewEnabled == null ? 0 : isListViewEnabled!.hashCode) + (zoom == null ? 0 : zoom!.hashCode) + (mapType == null ? 0 : mapType!.hashCode) + (mapTypeMapbox == null ? 0 : mapTypeMapbox!.hashCode) + @@ -310,6 +315,11 @@ class MapDTO { } else { json[r'beaconId'] = null; } + if (this.isListViewEnabled != null) { + json[r'isListViewEnabled'] = this.isListViewEnabled; + } else { + json[r'isListViewEnabled'] = null; + } if (this.zoom != null) { json[r'zoom'] = this.zoom; } else { @@ -413,6 +423,7 @@ class MapDTO { meterZoneGPS: mapValueOfType(json, r'meterZoneGPS'), isBeacon: mapValueOfType(json, r'isBeacon'), beaconId: mapValueOfType(json, r'beaconId'), + isListViewEnabled: mapValueOfType(json, r'isListViewEnabled'), zoom: mapValueOfType(json, r'zoom'), mapType: MapTypeApp.fromJson(json[r'mapType']), mapTypeMapbox: MapTypeMapBox.fromJson(json[r'mapTypeMapbox']), diff --git a/manager_api_new/lib/model/push_notification_dto.dart b/manager_api_new/lib/model/push_notification_dto.dart new file mode 100644 index 0000000..6657de9 --- /dev/null +++ b/manager_api_new/lib/model/push_notification_dto.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PushNotificationDTO { + PushNotificationDTO({ + this.id, + this.instanceId, + this.title, + this.body, + this.topic, + this.status, + this.scheduledAt, + this.sentAt, + this.dateCreation, + }); + + String? id; + String? instanceId; + String? title; + String? body; + String? topic; + String? status; + DateTime? scheduledAt; + DateTime? sentAt; + DateTime? dateCreation; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PushNotificationDTO && other.id == id; + + @override + int get hashCode => (id == null ? 0 : id!.hashCode); + + @override + String toString() => 'PushNotificationDTO[id=$id, title=$title, status=$status]'; + + Map toJson() { + final json = {}; + json[r'id'] = id; + json[r'instanceId'] = instanceId; + json[r'title'] = title; + json[r'body'] = body; + json[r'topic'] = topic; + json[r'status'] = status; + if (scheduledAt != null) json[r'scheduledAt'] = scheduledAt!.toUtc().toIso8601String(); + if (sentAt != null) json[r'sentAt'] = sentAt!.toUtc().toIso8601String(); + if (dateCreation != null) json[r'dateCreation'] = dateCreation!.toUtc().toIso8601String(); + return json; + } + + static PushNotificationDTO? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return PushNotificationDTO( + id: mapValueOfType(json, r'id'), + instanceId: mapValueOfType(json, r'instanceId'), + title: mapValueOfType(json, r'title'), + body: mapValueOfType(json, r'body'), + topic: mapValueOfType(json, r'topic'), + status: mapValueOfType(json, r'status'), + scheduledAt: mapDateTime(json, r'scheduledAt', r''), + sentAt: mapDateTime(json, r'sentAt', r''), + dateCreation: mapDateTime(json, r'dateCreation', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PushNotificationDTO.fromJson(row); + if (value != null) result.add(value); + } + } + return result.toList(growable: growable); + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/schedule_notification_request.dart b/manager_api_new/lib/model/schedule_notification_request.dart new file mode 100644 index 0000000..f2e9dfa --- /dev/null +++ b/manager_api_new/lib/model/schedule_notification_request.dart @@ -0,0 +1,75 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ScheduleNotificationRequest { + ScheduleNotificationRequest({ + this.title, + this.body, + this.scheduledAt, + }); + + String? title; + String? body; + DateTime? scheduledAt; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ScheduleNotificationRequest && + other.title == title && + other.body == body && + other.scheduledAt == scheduledAt; + + @override + int get hashCode => + (title == null ? 0 : title!.hashCode) + + (body == null ? 0 : body!.hashCode) + + (scheduledAt == null ? 0 : scheduledAt!.hashCode); + + @override + String toString() => 'ScheduleNotificationRequest[title=$title, body=$body, scheduledAt=$scheduledAt]'; + + Map toJson() { + final json = {}; + json[r'title'] = title; + json[r'body'] = body; + if (scheduledAt != null) { + json[r'scheduledAt'] = scheduledAt!.toUtc().toIso8601String(); + } + return json; + } + + static ScheduleNotificationRequest? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return ScheduleNotificationRequest( + title: mapValueOfType(json, r'title'), + body: mapValueOfType(json, r'body'), + scheduledAt: mapDateTime(json, r'scheduledAt', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ScheduleNotificationRequest.fromJson(row); + if (value != null) result.add(value); + } + } + return result.toList(growable: growable); + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/section_event_dto.dart b/manager_api_new/lib/model/section_event_dto.dart index 00ae563..e94cdd7 100644 --- a/manager_api_new/lib/model/section_event_dto.dart +++ b/manager_api_new/lib/model/section_event_dto.dart @@ -34,7 +34,9 @@ class SectionEventDTO { this.beaconId, this.startDate, this.endDate, + this.baseSectionMapId, this.parcoursIds = const [], + this.globalMapAnnotations = const [], this.programme = const [], }); @@ -116,8 +118,12 @@ class SectionEventDTO { /// DateTime? endDate; + String? baseSectionMapId; + List? parcoursIds; + List? globalMapAnnotations; + List? programme; @override @@ -145,7 +151,9 @@ class SectionEventDTO { other.beaconId == beaconId && other.startDate == startDate && other.endDate == endDate && + other.baseSectionMapId == baseSectionMapId && _deepEquality.equals(other.parcoursIds, parcoursIds) && + _deepEquality.equals(other.globalMapAnnotations, globalMapAnnotations) && _deepEquality.equals(other.programme, programme); @override @@ -172,7 +180,9 @@ class SectionEventDTO { (beaconId == null ? 0 : beaconId!.hashCode) + (startDate == null ? 0 : startDate!.hashCode) + (endDate == null ? 0 : endDate!.hashCode) + + (baseSectionMapId == null ? 0 : baseSectionMapId!.hashCode) + (parcoursIds == null ? 0 : parcoursIds!.hashCode) + + (globalMapAnnotations == null ? 0 : globalMapAnnotations!.hashCode) + (programme == null ? 0 : programme!.hashCode); @override @@ -286,11 +296,21 @@ class SectionEventDTO { } else { json[r'endDate'] = null; } + if (this.baseSectionMapId != null) { + json[r'baseSectionMapId'] = this.baseSectionMapId; + } else { + json[r'baseSectionMapId'] = null; + } if (this.parcoursIds != null) { json[r'parcoursIds'] = this.parcoursIds; } else { json[r'parcoursIds'] = null; } + if (this.globalMapAnnotations != null) { + json[r'globalMapAnnotations'] = this.globalMapAnnotations; + } else { + json[r'globalMapAnnotations'] = null; + } if (this.programme != null) { json[r'programme'] = this.programme; } else { @@ -341,11 +361,13 @@ class SectionEventDTO { beaconId: mapValueOfType(json, r'beaconId'), startDate: mapDateTime(json, r'startDate', r''), endDate: mapDateTime(json, r'endDate', r''), + baseSectionMapId: mapValueOfType(json, r'baseSectionMapId'), parcoursIds: json[r'parcoursIds'] is Iterable ? (json[r'parcoursIds'] as Iterable) .cast() .toList(growable: false) : const [], + globalMapAnnotations: MapAnnotationDTO.listFromJson(json[r'globalMapAnnotations']), programme: ProgrammeBlock.listFromJson(json[r'programme']), ); } diff --git a/manager_api_new/lib/model/send_notification_request.dart b/manager_api_new/lib/model/send_notification_request.dart new file mode 100644 index 0000000..434bcaa --- /dev/null +++ b/manager_api_new/lib/model/send_notification_request.dart @@ -0,0 +1,67 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SendNotificationRequest { + SendNotificationRequest({ + this.title, + this.body, + }); + + String? title; + String? body; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SendNotificationRequest && + other.title == title && + other.body == body; + + @override + int get hashCode => + (title == null ? 0 : title!.hashCode) + + (body == null ? 0 : body!.hashCode); + + @override + String toString() => 'SendNotificationRequest[title=$title, body=$body]'; + + Map toJson() { + final json = {}; + json[r'title'] = title; + json[r'body'] = body; + return json; + } + + static SendNotificationRequest? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return SendNotificationRequest( + title: mapValueOfType(json, r'title'), + body: mapValueOfType(json, r'body'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SendNotificationRequest.fromJson(row); + if (value != null) result.add(value); + } + } + return result.toList(growable: growable); + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/stats_summary_dto.dart b/manager_api_new/lib/model/stats_summary_dto.dart index dc7c9f2..bf5b073 100644 --- a/manager_api_new/lib/model/stats_summary_dto.dart +++ b/manager_api_new/lib/model/stats_summary_dto.dart @@ -19,7 +19,10 @@ class StatsSummaryDTO { this.topAgendaEvents = const [], this.quizStats = const [], this.gameStats = const [], - }); + this.topArticles = const [], + this.topMenuItems = const [], + QrScanStatDTO? qrScans, + }) : qrScans = qrScans ?? QrScanStatDTO(); int totalSessions; int avgVisitDurationSeconds; @@ -31,6 +34,9 @@ class StatsSummaryDTO { List topAgendaEvents; List quizStats; List gameStats; + List topArticles; + List topMenuItems; + QrScanStatDTO qrScans; static StatsSummaryDTO? fromJson(dynamic value) { if (value is Map) { @@ -50,6 +56,9 @@ class StatsSummaryDTO { topAgendaEvents: AgendaEventStatDTO.listFromJson(json[r'topAgendaEvents']), quizStats: QuizStatDTO.listFromJson(json[r'quizStats']), gameStats: GameStatDTO.listFromJson(json[r'gameStats']), + topArticles: ArticleStatDTO.listFromJson(json[r'topArticles']), + topMenuItems: MenuItemStatDTO.listFromJson(json[r'topMenuItems']), + qrScans: QrScanStatDTO.fromJson(json[r'qrScans']), ); } return null; @@ -267,3 +276,83 @@ class GameStatDTO { return result; } } + +class ArticleStatDTO { + ArticleStatDTO({this.sectionId, this.reads = 0}); + + String? sectionId; + int reads; + + static ArticleStatDTO? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return ArticleStatDTO( + sectionId: mapValueOfType(json, r'sectionId'), + reads: mapValueOfType(json, r'reads') ?? 0, + ); + } + return null; + } + + static List listFromJson(dynamic json) { + final result = []; + if (json is List) { + for (final row in json) { + final value = ArticleStatDTO.fromJson(row); + if (value != null) result.add(value); + } + } + return result; + } +} + +class MenuItemStatDTO { + MenuItemStatDTO({this.targetSectionId, this.menuItemTitle, this.taps = 0}); + + String? targetSectionId; + String? menuItemTitle; + int taps; + + static MenuItemStatDTO? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return MenuItemStatDTO( + targetSectionId: mapValueOfType(json, r'targetSectionId'), + menuItemTitle: mapValueOfType(json, r'menuItemTitle'), + taps: mapValueOfType(json, r'taps') ?? 0, + ); + } + return null; + } + + static List listFromJson(dynamic json) { + final result = []; + if (json is List) { + for (final row in json) { + final value = MenuItemStatDTO.fromJson(row); + if (value != null) result.add(value); + } + } + return result; + } +} + +class QrScanStatDTO { + QrScanStatDTO({this.totalScans = 0, this.validScans = 0, this.invalidScans = 0}); + + int totalScans; + int validScans; + int invalidScans; + + static QrScanStatDTO fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return QrScanStatDTO( + totalScans: mapValueOfType(json, r'totalScans') ?? 0, + validScans: mapValueOfType(json, r'validScans') ?? 0, + invalidScans: mapValueOfType(json, r'invalidScans') ?? 0, + ); + } + return QrScanStatDTO(); + } +} diff --git a/manager_api_new/lib/model/visit_event_dto.dart b/manager_api_new/lib/model/visit_event_dto.dart index a92cc51..af33913 100644 --- a/manager_api_new/lib/model/visit_event_dto.dart +++ b/manager_api_new/lib/model/visit_event_dto.dart @@ -232,6 +232,7 @@ class VisitEventType { static const agendaEventTap = 'AgendaEventTap'; static const menuItemTap = 'MenuItemTap'; static const assistantMessage = 'AssistantMessage'; + static const articleRead = 'ArticleRead'; /// The list of required keys that must be present in a JSON. static const requiredKeys = {};