mymuseum-visitapp/lib/Components/ScannerDialog.dart
Thomas Fransolet d39949a6bd Lunettes Meta, lecteur audio en onglet, et badge de version dans les Parametres
Ce commit boucle le travail en cours sur la branche (assistant lunettes Meta,
passage du lecteur audio flottant a un onglet, ajustements scanner / liste de
configurations / telechargement) et y ajoute le badge de version.

Le badge, en bas de la feuille Parametres, affiche « flavor . version . commit ».
Un APK pose sur une tablette du terrain n'etait rattachable a aucun commit
precis : la version du pubspec ne bougeait pas d'un build a l'autre et rien
n'indiquait le flavor reellement installe. kGitSha suit le meme schema que
kApiBaseUrl, injecte par --dart-define, et kFlavor expose le flavor deja calcule.

package_info_plus etait deja une dependance transitive ; il devient direct,
puisqu'il est desormais importe.

/!\ Un --dart-define modifie n'est PAS pris en compte sans `flutter clean` sur
ce projet : verifie a la sentinelle, le SHA restait absent de libapp.so tant que
le cache Dart n'etait pas vide. Un build de release destine au terrain doit donc
toujours passer par un clean, sinon le badge affiche le SHA du build precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:43:53 +02:00

274 lines
9.6 KiB
Dart

import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/ConfigurationPage/configuration_page.dart';
import 'package:mymuseum_visitapp/Screens/section_page.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScannerDialog extends StatefulWidget {
const ScannerDialog({Key? key, required this.appContext}) : super(key: key);
final AppContext? appContext;
@override
State<ScannerDialog> createState() => _ScannerDialogState();
}
class _ScannerDialogState extends State<ScannerDialog> {
final MobileScannerController controller = MobileScannerController();
bool isProcessing = false;
@override
void reassemble() {
super.reassemble();
if (Platform.isAndroid) {
controller.stop();
}
controller.start();
}
@override
Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size;
return Container(
height: size.height * 0.5,
width: size.width * 0.9,
child: Stack(
children: [
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: MobileScanner(
controller: controller,
//allowDuplicates: false,
onDetect: (barcodes) => _onDetect(barcodes),
),
),
),
_buildControlButton(
icon: Icons.flash_on,
onTap: () => controller.toggleTorch(),
alignment: Alignment.topRight,
),
_buildControlButton(
icon: Icons.flip_camera_android,
onTap: () => controller.switchCamera(),
alignment: Alignment.bottomRight,
),
],
),
);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback onTap,
required Alignment alignment,
}) {
return Align(
alignment: alignment,
child: Container(
width: 45,
height: 45,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: kMainColor1,
borderRadius: BorderRadius.circular(20.0),
),
child: InkWell(
onTap: onTap,
child: Icon(icon, color: Colors.white),
),
),
);
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (isProcessing) return;
final barcode = capture.barcodes.first;
final code = barcode.rawValue ?? "";
if (barcode.format == BarcodeFormat.qrCode && code.isNotEmpty) {
isProcessing = true;
RegExp regExp = RegExp(r'^(?:https:\/\/web\.myinfomate\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
RegExp regExp2 = RegExp(r'^(?:https:\/\/web\.mymuseum\.be\/([^\/]+)\/([^\/]+)\/([^\/]+)|([^\/]+))$');
var match = regExp.firstMatch(code);
var match2 = regExp2.firstMatch(code);
String? instanceId;
String? configurationId;
String? sectionId;
if(match == null) {
instanceId = match2?.group(1);
configurationId = match2?.group(2);
sectionId = match2?.group(3) ?? match2?.group(4);
} else {
instanceId = match.group(1);
configurationId = match.group(2);
sectionId = match.group(3) ?? match.group(4);
}
if ((match == null && match2 == null) || sectionId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("L'URL ne correspond pas au format attendu."), backgroundColor: kMainColor2),
);
Navigator.of(context).pop();
return;
}
VisitAppContext visitAppContext = widget.appContext!.getContext();
if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) {
visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId});
// Le QR porte l'id de sa configuration, jusqu'ici parsé puis jeté. Un code
// d'une autre visite du site tombait donc dans « QR code invalide », qui
// laisse croire à un code abîmé alors qu'il est parfaitement lisible.
final other = await _findOtherConfiguration(visitAppContext, configurationId);
if (!mounted) return;
// Capturés AVANT le pop : fermer la boîte du scanner démonte ce State, et
// tout ce qui repasse ensuite par son `context` — la navigation depuis le
// bouton « Ouvrir », touché quelques secondes plus tard — ne fait rien.
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
navigator.pop();
if (other != null) {
_proposeOtherVisit(navigator, visitAppContext, other, sectionId);
} else {
messenger.showSnackBar(
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
);
}
} else {
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
// `orElse` : sans lui, le moindre écart entre `sectionIds` et
// `currentSections` levait un StateError non rattrapé, en plein scan.
dynamic rawSection = visitAppContext.currentSections
?.firstWhere((cs) => cs?['id'] == sectionId, orElse: () => null);
Navigator.of(context).pop();
if (rawSection == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
);
return;
}
Navigator.push(
context,
SlideFromRightRoute(page: SectionPage(
configuration: visitAppContext.configuration!,
rawSection: rawSection,
visitAppContextIn: visitAppContext,
sectionId: rawSection['id'],
)),
);
}
}
}
/// La configuration du QR, si elle existe et n'est pas celle qu'on lit déjà.
/// La base locale d'abord — c'est la seule source hors ligne — puis l'API.
Future<ConfigurationDTO?> _findOtherConfiguration(
VisitAppContext visitAppContext, String? configurationId) async {
if (configurationId == null || configurationId == visitAppContext.configuration?.id) {
return null;
}
final local = await DatabaseHelper.instance
.queryWithColumnId(DatabaseTableType.configurations, configurationId);
if (local.isNotEmpty) {
return DatabaseHelper.instance.getConfigurationFromDB(local.first);
}
try {
return await visitAppContext.clientAPI.configurationApi!
.configurationGetDetail(configurationId);
} catch (e) {
debugPrint("[scan] configuration $configurationId introuvable : $e");
return null;
}
}
Future<void> _proposeOtherVisit(NavigatorState navigator,
VisitAppContext visitAppContext, ConfigurationDTO other, String sectionId) async {
final name = TranslationHelper.getPlain(other.title, visitAppContext);
// Une visite hors ligne non téléchargée n'a rien à afficher : on nomme la
// visite sans proposer de l'ouvrir sur du vide.
final sections = await DatabaseHelper.instance
.queryWithConfigurationId(DatabaseTableType.sections, other.id!);
final canOpen = other.isOffline != true || sections.isNotEmpty;
showDialog(
context: navigator.context,
builder: (dialogContext) => AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))),
title: Text(name, textAlign: TextAlign.center),
content: Text(
TranslationHelper.getFromLocale('qrOtherVisit', visitAppContext),
textAlign: TextAlign.center,
),
actionsAlignment: MainAxisAlignment.center,
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(TranslationHelper.getFromLocale('close', visitAppContext)),
),
if (canOpen)
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
visitAppContext.configuration = other;
visitAppContext.sectionIds = null;
widget.appContext!.setContext(visitAppContext);
navigator.push(
SlideFromRightRoute(
page: ConfigurationPage(
configuration: other,
isAlreadyAllowed: visitAppContext.isScanBeaconAlreadyAllowed,
openSectionId: sectionId,
),
),
);
},
child: Text(TranslationHelper.getFromLocale('open', visitAppContext)),
),
],
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
showScannerDialog(BuildContext context, AppContext appContext) {
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
content: ScannerDialog(appContext: appContext),
contentPadding: EdgeInsets.zero,
),
);
}