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>
This commit is contained in:
Thomas Fransolet 2026-09-08 22:43:53 +02:00
parent 84ccaffcb5
commit d39949a6bd
22 changed files with 1762 additions and 880 deletions

View File

@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />--> <!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- BLUETOOTH legacy : exige par le SDK Meta Wearables DAT jusqu'a API 30 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Foreground Service — garde le wake word actif téléphone en poche --> <!-- Foreground Service — garde le wake word actif téléphone en poche -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -35,7 +38,7 @@
android:name="com.meta.wearable.mwdat.APPLICATION_ID" android:name="com.meta.wearable.mwdat.APPLICATION_ID"
android:value="@string/mwdat_app_id" /> android:value="@string/mwdat_app_id" />
<!-- FileProvider requis par meta_wearables_dat pour sauvegarder les photos capturées --> <!-- FileProvider : partage des photos de visite hors de l'app -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@ -70,6 +73,14 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Retour de l'app Meta AI : termine la registration / deconnexion DAT.
Route vers MetaWearablesDat.handleUrl() via app_links (cf. main.dart). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${mwdatCallbackScheme}" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

View File

@ -0,0 +1,49 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// Pastille circulaire en verre dépoli, posée sur une photo.
///
/// Remplace les ronds `Colors.black.withValues(alpha: 0.3)` qui s'écrasaient
/// sur les images sombres : le flou détache la pastille de n'importe quel fond,
/// le liseré clair garde un contour visible sur une photo claire.
///
/// Trois usages : réglages et lunettes sur le héros de l'accueil, badge de
/// téléchargement sur une tuile bento.
class GlassPill extends StatelessWidget {
final double size;
final Widget child;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const GlassPill({
super.key,
required this.child,
this.size = 46,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: ClipOval(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.28),
border: Border.all(color: Colors.white24, width: 1),
),
child: Center(child: child),
),
),
),
);
}
}

View File

@ -1,10 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta_wearables_dat/meta_wearables_dat.dart'; import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart'; import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
/// Panneau de debug pour l'intégration Ray-Ban Meta. /// Panneau de debug pour l'intégration Ray-Ban Meta.
/// À ouvrir via un bouton discret dans l'app (ex: appui long sur le logo). /// À ouvrir via un bouton discret dans l'app (ex: appui long sur le logo).
@ -53,22 +52,30 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
_addLog('⏹ Monitor arrêté'); _addLog('⏹ Monitor arrêté');
return; return;
} }
_subs.add(Wearables.instance.registrationStateStream.listen( _subs.add(MetaWearablesDat.registrationStateStream().listen(
(s) => _addLog('📋 registration: ${s.state} err=${s.error}'), (s) => _addLog('📋 registration: $s'),
onError: (e) => _addLog('📋 registration error: $e'), onError: (e) => _addLog('📋 registration error: $e'),
)); ));
_subs.add(Wearables.instance.devicesStream.listen( _subs.add(MetaWearablesDat.activeDeviceStream().listen(
(d) => _addLog('📱 devices: $d'), (available) => _addLog('📱 activeDevice: $available'),
onError: (e) => _addLog('📱 devices error: $e'), onError: (e) => _addLog('📱 activeDevice error: $e'),
)); ));
_subs.add(Wearables.instance.streamStateStream.listen( _subs.add(MetaWearablesDat.streamSessionStateStream().listen(
(s) => _addLog('🎥 streamState: $s'), (s) => _addLog('🎥 streamState: $s'),
onError: (e) => _addLog('🎥 streamState error: $e'), onError: (e) => _addLog('🎥 streamState error: $e'),
)); ));
_subs.add(Wearables.instance.videoFramesStream.listen( _subs.add(MetaWearablesDat.streamSessionErrorStream().listen(
(f) => _addLog('🖼 videoFrame: ${f.length} bytes'), (e) => _addLog('⚠ streamError: $e'),
onError: (e) => _addLog('⚠ streamError stream error: $e'),
));
_subs.add(MetaWearablesDat.videoFramesStream().listen(
(f) => _addLog('🖼 videoFrame: ${f.bytes.length} bytes ${f.width}x${f.height}'),
onError: (e) => _addLog('🖼 videoFrame error: $e'), onError: (e) => _addLog('🖼 videoFrame error: $e'),
)); ));
_subs.add(MetaWearablesDat.deviceStateStream().listen(
(d) => _addLog('🌡 deviceState: $d'),
onError: (e) => _addLog('🌡 deviceState error: $e'),
));
setState(() => _monitoring = true); setState(() => _monitoring = true);
_addLog('▶ Monitor démarré — interagis avec les lunettes'); _addLog('▶ Monitor démarré — interagis avec les lunettes');
} }
@ -152,21 +159,17 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
label: 'Activer caméra', label: 'Activer caméra',
icon: Icons.camera_alt, icon: Icons.camera_alt,
onTap: () => _run('requestCameraPermission + startStream', () async { onTap: () => _run('requestCameraPermission + startStream', () async {
await Wearables.instance.requestCameraPermission(); final granted = await MetaWearablesDat.requestCameraPermission();
await Wearables.instance.startStream( _addLog('camera granted: $granted');
videoQuality: 'MEDIUM', await MetaGlassesService.instance.startStream();
frameRate: 24,
);
}), }),
), ),
_ActionButton( _ActionButton(
label: 'Start stream', label: 'Start stream',
icon: Icons.videocam, icon: Icons.videocam,
onTap: () => _run('startStream (direct)', () async { onTap: () => _run('startStream (direct)', () async {
await Wearables.instance.startStream( await MetaGlassesService.instance.startStream();
videoQuality: 'MEDIUM', _addLog('textureId: ${MetaGlassesService.instance.textureId}');
frameRate: 24,
);
}), }),
), ),
_ActionButton( _ActionButton(
@ -211,7 +214,7 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
icon: Icons.stop, icon: Icons.stop,
color: Colors.red, color: Colors.red,
onTap: () => _run('stopStream', () async { onTap: () => _run('stopStream', () async {
await Wearables.instance.stopStream(); await MetaGlassesService.instance.stopStream();
}), }),
), ),
_ActionButton( _ActionButton(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart'; import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
@ -42,15 +43,12 @@ class GlassesStatusWidget extends StatelessWidget {
_ => Colors.white38, _ => Colors.white38,
}; };
return GestureDetector( return GlassPill(
size: 46,
onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext), onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext),
child: Container( child: SizedBox(
width: 50, width: 46,
height: 50, height: 46,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@ -64,8 +62,8 @@ class GlassesStatusWidget extends StatelessWidget {
// Point vert en bas à droite si mode actif // Point vert en bas à droite si mode actif
if (modeActive) if (modeActive)
Positioned( Positioned(
bottom: 8, bottom: 7,
right: 8, right: 7,
child: Container( child: Container(
width: 8, width: 8,
height: 8, height: 8,

View File

@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.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/Screens/section_page.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
@ -90,7 +92,7 @@ class _ScannerDialogState extends State<ScannerDialog> {
); );
} }
void _onDetect(BarcodeCapture capture) { Future<void> _onDetect(BarcodeCapture capture) async {
if (isProcessing) return; if (isProcessing) return;
final barcode = capture.barcodes.first; final barcode = capture.barcodes.first;
@ -129,14 +131,42 @@ class _ScannerDialogState extends State<ScannerDialog> {
if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) { if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) {
visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId}); visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2), // 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
Navigator.of(context).pop(); // 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 { } else {
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true}); visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
dynamic rawSection = visitAppContext.currentSections!.firstWhere((cs) => cs!['id'] == sectionId)!; // `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(); Navigator.of(context).pop();
if (rawSection == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
);
return;
}
Navigator.push( Navigator.push(
context, context,
SlideFromRightRoute(page: SectionPage( SlideFromRightRoute(page: SectionPage(
@ -150,6 +180,78 @@ class _ScannerDialogState extends State<ScannerDialog> {
} }
} }
/// 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 @override
void dispose() { void dispose() {
controller.dispose(); controller.dispose();

View File

@ -1,10 +1,15 @@
import 'dart:io'; import 'dart:io';
/// Une résolution DNS sans timeout peut rester pendante des dizaines de secondes
/// sur un réseau captif (wifi de musée qui répond mais ne route pas). L'accueil
/// attend ce résultat : mieux vaut se déclarer hors ligne et servir le contenu
/// local que garder le visiteur devant un écran vide.
Future<bool> hasNetwork() async { Future<bool> hasNetwork() async {
try { try {
final result = await InternetAddress.lookup('google.be'); final result = await InternetAddress.lookup('google.be')
.timeout(const Duration(seconds: 3));
return result.isNotEmpty && result[0].rawAddress.isNotEmpty; return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} on SocketException catch (_) { } catch (_) {
return false; return false;
} }
} }

View File

@ -19,9 +19,10 @@ import 'package:provider/provider.dart';
import 'section_card.dart'; import 'section_card.dart';
class Body extends StatefulWidget { class Body extends StatefulWidget {
const Body({Key? key, required this.configuration}) : super(key: key); const Body({Key? key, required this.configuration, this.openSectionId}) : super(key: key);
final ConfigurationDTO configuration; final ConfigurationDTO configuration;
final String? openSectionId;
@override @override
State<Body> createState() => _BodyState(); State<Body> createState() => _BodyState();
@ -30,7 +31,12 @@ class Body extends StatefulWidget {
class _BodyState extends State<Body> { class _BodyState extends State<Body> {
late List<SectionDTO> sections; late List<SectionDTO> sections;
late List<SectionDTO> _allSections; late List<SectionDTO> _allSections;
late List<dynamic> rawSections; /// Payload brut de chaque section, indexé par id. Il l'était par position dans
/// la liste de l'API : hors ligne cette liste n'existait pas (`late` jamais
/// assigné, donc `LateInitializationError` avalée dans le callback le tap ne
/// faisait rien), et en ligne une recherche décalait les index, ouvrant une
/// autre section que celle touchée.
final Map<String, dynamic> _rawSectionById = {};
String? searchValue; String? searchValue;
int? searchNumberValue; int? searchNumberValue;
@ -43,6 +49,29 @@ class _BodyState extends State<Body> {
super.initState(); super.initState();
final appContext = Provider.of<AppContext>(context, listen: false); final appContext = Provider.of<AppContext>(context, listen: false);
_futureSections = getSections(appContext); _futureSections = getSections(appContext);
// La section demandée n'est ouverte qu'une fois les sections chargées : son
// payload brut n'existe pas avant, et c'est lui que `SectionPage` affiche.
if (widget.openSectionId != null) {
_futureSections.then((_) => _openRequestedSection(appContext));
}
}
void _openRequestedSection(AppContext appContext) {
final raw = _rawSectionById[widget.openSectionId];
if (raw == null || !mounted) return;
Navigator.push(
context,
SlideFromRightRoute(
page: SectionPage(
configuration: widget.configuration,
rawSection: raw,
visitAppContextIn: appContext.getContext(),
sectionId: widget.openSectionId!,
),
),
);
} }
@override @override
@ -210,7 +239,7 @@ class _BodyState extends State<Body> {
context, context,
SlideFromRightRoute(page: SectionPage( SlideFromRightRoute(page: SectionPage(
configuration: widget.configuration, configuration: widget.configuration,
rawSection: rawSections[index], rawSection: _rawSectionById[value[index].id],
visitAppContextIn: appContext.getContext(), visitAppContextIn: appContext.getContext(),
sectionId: value[index].id!, sectionId: value[index].id!,
)), )),
@ -247,19 +276,29 @@ class _BodyState extends State<Body> {
Future<List<SectionDTO>> getSections(AppContext appContext) async { Future<List<SectionDTO>> getSections(AppContext appContext) async {
VisitAppContext visitAppContext = appContext.getContext(); VisitAppContext visitAppContext = appContext.getContext();
sections = []; sections = [];
_rawSectionById.clear();
if(widget.configuration.isOffline == true) if(widget.configuration.isOffline == true)
{ {
// OFFLINE // OFFLINE
sections = List<SectionDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.sections)); // Les lignes brutes, pas `getData` : celui-ci passe par `getSectionFromDB`,
// qui laisse la colonne `data` de côté or c'est là que vit le payload typé
// dont `section_page` a besoin pour afficher quoi que ce soit.
List<Map<String, dynamic>> rows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.sections);
sections = rows.map((row) => DatabaseHelper.instance.getSectionFromDB(row)).toList();
for (var row in rows) {
_rawSectionById[row["id"]] = jsonDecode(row["data"]);
}
} }
else else
{ {
// ONLINE // ONLINE
List<dynamic>? sectionsDownloaded = await ApiService.getAllSections(visitAppContext.clientAPI, widget.configuration.id!); List<dynamic>? sectionsDownloaded = await ApiService.getAllSections(visitAppContext.clientAPI, widget.configuration.id!);
rawSections = jsonDecode(jsonEncode(sectionsDownloaded)); List<dynamic> rawSections = jsonDecode(jsonEncode(sectionsDownloaded));
var rawToSection = jsonDecode(jsonEncode(rawSections)).map((json) => SectionDTO.fromJson(json)).toList(); var rawToSection = jsonDecode(jsonEncode(rawSections)).map((json) => SectionDTO.fromJson(json)).toList();
List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().toList(); List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().toList();
visitAppContext.currentSections = rawSections; for (var raw in rawSections) {
_rawSectionById[raw["id"]] = raw;
}
if(sectionList.isNotEmpty) { if(sectionList.isNotEmpty) {
sections = sectionList.toList(); sections = sectionList.toList();
@ -273,6 +312,16 @@ class _BodyState extends State<Body> {
} }
sections.sort((a,b) => a.order!.compareTo(b.order!)); sections.sort((a,b) => a.order!.compareTo(b.order!));
// Le scan de QR code valide l'id contre `sectionIds`, puis va chercher la
// section dans `currentSections`. Les deux venaient d'ailleurs : `sectionIds`
// du ConfigurationDTO vide pour une visite relue en base, `configurationToMap`
// ne le stocke pas et `currentSections` de la seule branche en ligne. On les
// dérive des sections qu'on vient de charger, qui font autorité dans les deux
// modes.
visitAppContext.sectionIds = sections.map((s) => s.id).toList();
visitAppContext.currentSections =
sections.map((s) => _rawSectionById[s.id]).where((raw) => raw != null).toList();
_allSections = sections; _allSections = sections;
applyFilters(visitAppContext); applyFilters(visitAppContext);

View File

@ -27,11 +27,16 @@ import 'package:provider/provider.dart';
import 'components/body.dart'; import 'components/body.dart';
class ConfigurationPage extends StatefulWidget { class ConfigurationPage extends StatefulWidget {
const ConfigurationPage({Key? key,required this.configuration, required this.isAlreadyAllowed}) : super(key: key); const ConfigurationPage({Key? key,required this.configuration, required this.isAlreadyAllowed, this.openSectionId}) : super(key: key);
final ConfigurationDTO configuration; final ConfigurationDTO configuration;
final bool isAlreadyAllowed; final bool isAlreadyAllowed;
/// Section à ouvrir dès que la visite est chargée. Renseignée par le scan d'un
/// QR code appartenant à une autre visite : le visiteur a désigné un point
/// précis, l'amener sur la liste lui demanderait de le retrouver lui-même.
final String? openSectionId;
@override @override
State<ConfigurationPage> createState() => _ConfigurationPageState(); State<ConfigurationPage> createState() => _ConfigurationPageState();
} }
@ -342,7 +347,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
isHomeButton: true, isHomeButton: true,
),*/ ),*/
backgroundColor: kBackgroundGrey, backgroundColor: kBackgroundGrey,
body: Body(configuration: widget.configuration), body: Body(configuration: widget.configuration, openSectionId: widget.openSectionId),
floatingActionButton: Stack( floatingActionButton: Stack(
children: [ children: [
if (visitAppContext.applicationInstanceDTO?.isAssistant == true) if (visitAppContext.applicationInstanceDTO?.isAssistant == true)

View File

@ -351,10 +351,7 @@ class _ConfigurationsListState extends State<ConfigurationsList> {
), ),
content: SizedBox( content: SizedBox(
width: 350, width: 350,
height: 125, child: DownloadConfigurationWidget(configuration: configuration),
child: Center(
child: DownloadConfigurationWidget(configuration: configuration)
),
), ),
), context: context ), context: context
); );

View File

@ -3,11 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:myinfomate_layout/myinfomate_layout.dart'; import 'package:myinfomate_layout/myinfomate_layout.dart';
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart'; import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart'; import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/Components/GlassesStatusWidget.dart'; import 'package:mymuseum_visitapp/Components/GlassesStatusWidget.dart';
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart'; import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
import 'package:mymuseum_visitapp/Components/AdminPopup.dart'; import 'package:mymuseum_visitapp/Components/AdminPopup.dart';
@ -31,6 +33,12 @@ import 'package:mymuseum_visitapp/client.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
/// Fond de l'accueil et des surfaces qui s'ouvrent par-dessus (feuille de
/// réglages, dialogues de téléchargement) : une feuille blanche dans une app
/// noire était le contraste le plus violent de l'écran.
const Color _kSurface = Color(0xFF1A1A1A);
const Color _kBackground = Color(0xFF111111);
class _WeightedConfig { class _WeightedConfig {
final ConfigurationDTO configuration; final ConfigurationDTO configuration;
final int colSpan; final int colSpan;
@ -61,7 +69,13 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
late List<ConfigurationDTO> configurations = []; late List<ConfigurationDTO> configurations = [];
List<_WeightedConfig> weightedConfigs = []; List<_WeightedConfig> weightedConfigs = [];
List<String?> alreadyDownloaded = []; /// Visites hors ligne dont le contenu est réellement présent sur le device.
///
/// Délibérément déduit des sections en base locale, et non des lignes de la
/// table `configurations` : `_fetchConfigurations` y écrit une ligne par
/// visite pour cacher `order`/`gridSpan`, donc « la ligne existe » ne veut
/// pas dire « le contenu est ».
Set<String> downloadedConfigIds = {};
late VisitAppContext visitAppContext; late VisitAppContext visitAppContext;
late Future<List<_WeightedConfig>?> _futureConfigurations; late Future<List<_WeightedConfig>?> _futureConfigurations;
@ -87,16 +101,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
kMainColor1; kMainColor1;
final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!; final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!;
final isOffline = config.isOffline == true;
final needsDownload = isOffline && !downloadedConfigIds.contains(config.id);
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () { onTap: () => _openConfiguration(context, config),
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ConfigurationPage(
configuration: config,
isAlreadyAllowed: visitAppContext.isScanBeaconAlreadyAllowed,
),
));
},
child: Hero( child: Hero(
tag: config.id!, tag: config.id!,
child: Material( child: Material(
@ -140,6 +150,31 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
), ),
), ),
), ),
// Une visite hors ligne pas encore récupérée est volontairement
// ternie : la tuile dit qu'elle n'est pas prête avant toute lecture.
if (needsDownload)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.black.withValues(alpha: 0.3),
),
),
),
if (isOffline)
Positioned(
top: 8,
right: 8,
child: GlassPill(
size: 32,
onTap: () => _promptDownload(context, config),
child: Icon(
needsDownload ? Icons.arrow_downward_rounded : Icons.check_rounded,
size: 17,
color: needsDownload ? Colors.white : Colors.greenAccent,
),
),
),
// Titre + chevron sur une même ligne : le chevron reste centré // Titre + chevron sur une même ligne : le chevron reste centré
// verticalement sur le bloc de titre, quel que soit son nombre de lignes. // verticalement sur le bloc de titre, quel que soit son nombre de lignes.
Positioned( Positioned(
@ -184,6 +219,188 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
); );
} }
Future<void> _openConfiguration(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of<AppContext>(context, listen: false);
final ctx = appCtx.getContext() as VisitAppContext;
// Une visite hors ligne non téléchargée ouvrait un détail vide : `body.dart`
// lit ses sections uniquement en base locale quand `isOffline` est vrai.
if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) {
await _promptDownload(context, config);
return;
}
if (config.languages != null && !config.languages!.contains(ctx.language)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(TranslationHelper.getFromLocale("languageNotSupported", ctx)),
backgroundColor: kMainColor2,
),
);
return;
}
ctx.configuration = config;
ctx.sectionIds = config.sectionIds;
appCtx.setContext(ctx);
if (!context.mounted) return;
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ConfigurationPage(
configuration: config,
isAlreadyAllowed: ctx.isScanBeaconAlreadyAllowed,
),
));
}
/// Choix de la langue puis téléchargement. La langue est écrite dans le
/// contexte avant de lancer l'export : `DownloadConfigurationWidget` ne
/// récupère que les ressources de `visitAppContext.language`.
Future<void> _promptDownload(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of<AppContext>(context, listen: false);
final isUpdate = downloadedConfigIds.contains(config.id);
final configLanguages = config.languages ?? languages;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) {
return StatefulBuilder(builder: (dialogCtx2, setLocal) {
final ctx = appCtx.getContext() as VisitAppContext;
return AlertDialog(
backgroundColor: _kSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
contentPadding: const EdgeInsets.fromLTRB(24, 28, 24, 8),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_download_outlined, size: 34, color: kMainColor1),
const SizedBox(height: 16),
Text(
TranslationHelper.getFromLocale(
isUpdate ? "downloadPromptUpdate" : "downloadPrompt", ctx),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.4),
),
const SizedBox(height: 24),
Text(
TranslationHelper.getFromLocale("downloadLanguage", ctx),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 14),
_languageFlags(
languagesEnabled: configLanguages,
current: ctx.language,
size: 44,
onSelected: (lang) async {
ctx.language = lang;
appCtx.setContext(ctx);
await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx.toMap());
setLocal(() {});
},
),
],
),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(false),
child: Text(
TranslationHelper.getFromLocale("close", ctx),
style: const TextStyle(color: Colors.white54),
),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: kMainColor),
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: Text(
TranslationHelper.getFromLocale("download", ctx),
style: const TextStyle(color: Colors.white),
),
),
],
);
});
},
);
if (confirmed != true || !context.mounted) return;
await showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
backgroundColor: _kSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
content: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
// Hauteur libre : la boîte de 125px coupait le message d'échec, plus long
// que « Téléchargement en cours ».
child: SizedBox(
width: 350,
child: DownloadConfigurationWidget(configuration: config),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: Text(
TranslationHelper.getFromLocale("close", appCtx.getContext()),
style: const TextStyle(color: Colors.white54),
),
),
],
),
);
await _refreshDownloadedState(weightedConfigs);
if (mounted) setState(() {});
}
/// Grille de drapeaux, partagée par la feuille de réglages et le dialogue de
/// téléchargement pour que le choix de langue se présente pareil aux deux
/// endroits.
Widget _languageFlags({
required List<String> languagesEnabled,
required String? current,
required Future<void> Function(String lang) onSelected,
double size = 48,
}) {
return Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: languagesEnabled.map((lang) {
final isSelected = current == lang;
return GestureDetector(
onTap: () async {
if (current != lang) await onSelected(lang);
},
child: Opacity(
opacity: isSelected ? 1 : 0.55,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? kMainColor : Colors.white24,
width: isSelected ? 2.5 : 1,
),
image: DecorationImage(
fit: BoxFit.cover,
image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'),
),
),
),
),
);
}).toList(),
);
}
void _showSettingsSheet(BuildContext ctx, AppContext appCtx) { void _showSettingsSheet(BuildContext ctx, AppContext appCtx) {
final visitAppContext = appCtx.getContext() as VisitAppContext; final visitAppContext = appCtx.getContext() as VisitAppContext;
final configLanguages = visitAppContext.configuration?.languages ?? languages; final configLanguages = visitAppContext.configuration?.languages ?? languages;
@ -191,6 +408,8 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
showModalBottomSheet( showModalBottomSheet(
context: ctx, context: ctx,
backgroundColor: _kSurface,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
@ -207,7 +426,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
child: Container( child: Container(
width: 40, height: 4, width: 40, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.white24,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -222,66 +441,52 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
leading: const Icon(Icons.mic, color: kMainColor1), leading: const Icon(Icons.mic, color: kMainColor1),
title: Text( title: Text(
TranslationHelper.getFromLocale('settings.voiceAssistant', ctx2), TranslationHelper.getFromLocale('settings.voiceAssistant', ctx2),
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
), ),
subtitle: Text(TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2)), subtitle: Text(
trailing: const Icon(Icons.chevron_right), TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2),
style: const TextStyle(color: Colors.white54),
),
trailing: const Icon(Icons.chevron_right, color: Colors.white38),
onTap: () { onTap: () {
Navigator.pop(ctx); Navigator.pop(ctx);
VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); VoiceModeSheet.show(ctx, visitAppContext: visitAppContext);
}, },
), ),
), ),
const Divider(), const Divider(color: Colors.white12),
], ],
Text( Text(
TranslationHelper.getFromLocale('settings.language', ctx2), TranslationHelper.getFromLocale('settings.language', ctx2),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( _languageFlags(
spacing: 16, languagesEnabled: configLanguages,
runSpacing: 16, current: ctx2.language,
children: configLanguages.map((lang) { onSelected: (lang) async {
final isSelected = ctx2.language == lang; ctx2.language = lang;
return GestureDetector( appCtx.setContext(ctx2);
onTap: () async { await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx2.toMap());
if (ctx2.language != lang) { setLocal(() {});
ctx2.language = lang; },
appCtx.setContext(ctx2);
await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx2.toMap());
setLocal(() {});
}
},
child: Container(
width: 48, height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null,
image: DecorationImage(
fit: BoxFit.cover,
image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'),
),
boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))],
),
),
);
}).toList(),
), ),
if (hasNotifications) ...[ if (hasNotifications) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
const Divider(), const Divider(color: Colors.white12),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
TranslationHelper.getFromLocale('settings.notifications', ctx2), TranslationHelper.getFromLocale('settings.notifications', ctx2),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
), ),
Switch( Switch(
value: ctx2.notificationsEnabled, value: ctx2.notificationsEnabled,
activeThumbColor: kMainColor, activeThumbColor: kMainColor,
inactiveThumbColor: Colors.white38,
inactiveTrackColor: Colors.white12,
onChanged: (value) async { onChanged: (value) async {
ctx2.notificationsEnabled = value; ctx2.notificationsEnabled = value;
appCtx.setContext(ctx2); appCtx.setContext(ctx2);
@ -297,6 +502,8 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
], ],
), ),
], ],
const SizedBox(height: 24),
Center(child: _buildBadge()),
], ],
), ),
); );
@ -305,6 +512,23 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
); );
} }
/// Identifie le build installe : flavor, version du pubspec et commit.
/// Sans ca, un APK pose sur une tablette du terrain n'etait rattachable a
/// aucun commit precis.
Widget _buildBadge() {
return FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
final info = snapshot.data;
if (info == null) return const SizedBox.shrink();
return Text(
'$kFlavor \u00b7 v${info.version}+${info.buildNumber} \u00b7 $kGitSha',
style: const TextStyle(color: Colors.white30, fontSize: 11),
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size; Size size = MediaQuery.of(context).size;
@ -318,7 +542,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
builder: (context, AsyncSnapshot<dynamic> snapshot) { builder: (context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
final mobileConfigIds = visitAppContext.applicationInstanceDTO final mobileConfigIds = visitAppContext.applicationInstanceDTO
?.configurations?.map((c) => c.configurationId).toSet() ?? {}; ?.configurations
?.where((c) => c.isActive == true)
.map((c) => c.configurationId)
.toSet() ?? {};
weightedConfigs = List<_WeightedConfig>.from(snapshot.data) weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id)) .where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
.toList(); .toList();
@ -336,7 +563,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
return Stack( return Stack(
children: [ children: [
// Dark background // Dark background
const ColoredBox(color: Color(0xFF111111), child: SizedBox.expand()), const ColoredBox(color: _kBackground, child: SizedBox.expand()),
SafeArea( SafeArea(
top: false, top: false,
bottom: false, bottom: false,
@ -487,7 +714,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
// Icône lunettes / mode vocal à gauche du bouton settings // Icône lunettes / mode vocal à gauche du bouton settings
Positioned( Positioned(
top: 35, top: 35,
right: 68, right: 64,
child: GlassesStatusWidget(visitAppContext: visitAppContext), child: GlassesStatusWidget(visitAppContext: visitAppContext),
), ),
Positioned( Positioned(
@ -495,7 +722,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
right: 10, right: 10,
child: Builder(builder: (ctx) { child: Builder(builder: (ctx) {
final appCtx = Provider.of<AppContext>(ctx, listen: false); final appCtx = Provider.of<AppContext>(ctx, listen: false);
return GestureDetector( final lang = (visitAppContext.language ?? defaultLanguage).toLowerCase();
// Le drapeau en pastille dit ce que la feuille contient
// d'abord : un engrenage ne rendait pas le choix de
// langue découvrable.
return GlassPill(
size: 46,
onTap: () => _showSettingsSheet(ctx, appCtx), onTap: () => _showSettingsSheet(ctx, appCtx),
onLongPress: () => showDialog( onLongPress: () => showDialog(
context: ctx, context: ctx,
@ -507,14 +739,31 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
child: Container( child: SizedBox(
width: 50, width: 46,
height: 50, height: 46,
decoration: BoxDecoration( child: Stack(
color: Colors.black.withValues(alpha: 0.3), alignment: Alignment.center,
shape: BoxShape.circle, children: [
const Icon(Icons.tune_rounded, color: Colors.white, size: 22),
Positioned(
bottom: 5,
right: 5,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.black54, width: 1),
image: DecorationImage(
fit: BoxFit.cover,
image: AssetImage('assets/images/old/$lang.png'),
),
),
),
),
],
), ),
child: const Icon(Icons.settings, color: Colors.white, size: 26),
), ),
); );
}), }),
@ -651,14 +900,29 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
} }
Future<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async { Future<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async {
final configs = await _fetchConfigurations(appContext);
await _refreshDownloadedState(configs ?? []);
return configs;
}
Future<void> _refreshDownloadedState(List<_WeightedConfig> configs) async {
final downloaded = <String>{};
for (final w in configs) {
final config = w.configuration;
if (config.isOffline != true || config.id == null) continue;
final sections = await DatabaseHelper.instance
.queryWithConfigurationId(DatabaseTableType.sections, config.id!);
if (sections.isNotEmpty) downloaded.add(config.id!);
}
downloadedConfigIds = downloaded;
}
Future<List<_WeightedConfig>?> _fetchConfigurations(AppContext appContext) async {
bool isOnline = await hasNetwork(); bool isOnline = await hasNetwork();
VisitAppContext visitAppContext = appContext.getContext(); VisitAppContext visitAppContext = appContext.getContext();
isOnline = true; // Todo remove if not local test
List<ConfigurationDTO>? configurations; List<ConfigurationDTO>? configurations;
configurations = List<ConfigurationDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.configurations)); configurations = List<ConfigurationDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.configurations));
alreadyDownloaded = configurations.map((c) => c.id).toList();
print("GOT configurations from LOCAL"); print("GOT configurations from LOCAL");
print(configurations.length); print(configurations.length);
print(configurations); print(configurations);
@ -756,7 +1020,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
.applicationInstanceGetAllApplicationLinkFromApplicationInstance( .applicationInstanceGetAllApplicationLinkFromApplicationInstance(
visitAppContext.applicationInstanceDTO!.id!); visitAppContext.applicationInstanceDTO!.id!);
final weightedConfigs = links final weightedConfigs = links
?.where((l) => l.configuration != null) ?.where((l) => l.isActive == true && l.configuration != null)
.map((l) => _WeightedConfig( .map((l) => _WeightedConfig(
configuration: l.configuration!, configuration: l.configuration!,
colSpan: l.gridColSpan ?? 1, colSpan: l.gridColSpan ?? 1,
@ -769,14 +1033,25 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
// Best-effort : garde order/spans en cache local pour que le rendu // Best-effort : garde order/spans en cache local pour que le rendu
// hors-ligne reste cohérent avec ce qui vient d'être vu en ligne. // hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
//
// `update` et non `insert` : ce dernier bascule sur un INSERT quand la
// ligne n'existe pas, et ces quatre colonnes ne suffisent pas à satisfaire
// les NOT NULL de la table. Toute visite pas encore téléchargée levait donc
// « NOT NULL constraint failed: configurations.instanceId » à chaque
// démarrage. Un UPDATE sans ligne cible ne touche rien, ce qui est la
// bonne sémantique : ces spans complètent une visite en cache, ils ne la
// créent pas.
for (final w in weightedConfigs) { for (final w in weightedConfigs) {
try { try {
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, { await DatabaseHelper.instance.update(
DatabaseHelper.columnId: w.configuration.id, DatabaseTableType.configurations,
DatabaseHelper.columnConfigOrder: w.order, {
DatabaseHelper.columnGridColSpan: w.colSpan, DatabaseHelper.columnConfigOrder: w.order,
DatabaseHelper.columnGridRowSpan: w.rowSpan, DatabaseHelper.columnGridColSpan: w.colSpan,
}); DatabaseHelper.columnGridRowSpan: w.rowSpan,
},
w.configuration.id!,
);
} catch (e) { } catch (e) {
print("Could not cache order/spans for configuration ${w.configuration.id}: $e"); print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
} }

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart'; import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/Components/loading_common.dart'; import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Components/SliderImages.dart'; import 'package:mymuseum_visitapp/Components/SliderImages.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart'; import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
@ -21,7 +22,7 @@ import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'audio_player_floating.dart'; import 'audio_player_tab.dart';
class ArticlePage extends StatefulWidget { class ArticlePage extends StatefulWidget {
const ArticlePage({Key? key, required this.visitAppContextIn, required this.articleDTO, required this.resourcesModel, this.mainAudioId, this.sectionId}) : super(key: key); const ArticlePage({Key? key, required this.visitAppContextIn, required this.articleDTO, required this.resourcesModel, this.mainAudioId, this.sectionId}) : super(key: key);
@ -112,20 +113,44 @@ class _ArticlePageState extends State<ArticlePage> {
), ),
Column( Column(
children: [ children: [
// La bande d'en-tête inclut la safe area : sans elle, le titre passait
// sous la barre d'état et le poinçon de la caméra. Le `top: 22` en dur
// ne valait que pour l'écran sur lequel il a été réglé.
SizedBox( SizedBox(
height: size.height * 0.11, height: size.height * 0.11 + MediaQuery.of(context).padding.top,
width: size.width, width: size.width,
child: Stack( // Une `Row` et non trois `Positioned` : les icônes étaient épinglées
fit: StackFit.expand, // en haut de la bande pendant que le titre y était centré, donc rien
children: [ // n'était sur le même axe. Ici les trois partagent la ligne, et un
Center( // titre sur deux lignes déplace tout le monde ensemble.
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 22.0), padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: SizedBox( child: Row(
width: size.width *0.7, children: [
const SizedBox(width: 12),
// `GlassPill`, comme les pastilles de l'accueil : le disque
// plein de 50px en kMainColor tranchait sur la photo d'en-tête
// et n'existait nulle part ailleurs dans l'app.
GlassPill(
size: 40,
onTap: () => Navigator.of(context).pop(),
child: const Icon(Icons.arrow_back_ios_new, size: 18, color: Colors.white),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: HtmlWidget( child: HtmlWidget(
cleanedTitle, cleanedTitle,
textStyle: const TextStyle(color: Colors.white, fontFamily: 'Roboto', fontSize: 20), // Blanc sur photo : sans ombre portée, un titre posé sur
// une zone claire de l'image devient illisible.
textStyle: const TextStyle(
color: Colors.white,
fontFamily: 'Roboto',
fontSize: 20,
shadows: [
Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 1)),
],
),
customStylesBuilder: (element) customStylesBuilder: (element)
{ {
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"}; return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
@ -133,11 +158,7 @@ class _ArticlePageState extends State<ArticlePage> {
), ),
), ),
), ),
), InkWell(
Positioned(
right: 10,
top: 45,
child: InkWell(
onTap: () { onTap: () {
setState(() { setState(() {
visitAppContext.isMaximizeTextSize = !visitAppContext.isMaximizeTextSize; visitAppContext.isMaximizeTextSize = !visitAppContext.isMaximizeTextSize;
@ -149,28 +170,9 @@ class _ArticlePageState extends State<ArticlePage> {
child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, size: 30, color: Colors.white) : const Icon(Icons.format_size, size: 30, color: Colors.white) child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, size: 30, color: Colors.white) : const Icon(Icons.format_size, size: 30, color: Colors.white)
), ),
), ),
), const SizedBox(width: 4),
Positioned( ],
top: 35, ),
left: 10,
child: SizedBox(
width: 50,
height: 50,
child: InkWell(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
decoration: const BoxDecoration(
color: kMainColor,
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
),
)
),
),
],
), ),
), ),
Expanded( Expanded(
@ -242,13 +244,19 @@ class _ArticlePageState extends State<ArticlePage> {
), ),
], ],
), ),
// Onglet audio collé au bord droit, sur le modèle du filtre de la carte
// (`geo_point_filter`) : replié il ne prend qu'une pastille, déplié il
// s'étire vers la gauche par-dessus la page pour laisser régler l'écoute.
// En `floatingActionButton`/`miniEndFloat`, le bouton couvrait en
// permanence les dernières lignes du texte.
if (audioResourceModel != null && audioResourceModel!.source != null)
AudioPlayerTab(
file: audioFile,
resourceURl: audioResourceModel!.source!,
isAuto: widget.articleDTO.isReadAudioAuto!,
),
], ],
), ),
floatingActionButton: Padding(
padding: const EdgeInsets.only(right: 0, top: 0), //size.height*0.1
child: audioResourceModel != null && audioResourceModel!.source != null ? AudioPlayerFloatingContainer(file: audioFile, resourceURl: audioResourceModel!.source!, isAuto: widget.articleDTO.isReadAudioAuto!) : null,
),
floatingActionButtonLocation: FloatingActionButtonLocation.miniEndFloat, //miniEndTop
); );
} }

View File

@ -1,263 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
import 'package:just_audio/just_audio.dart';
import 'package:just_audio_cache/just_audio_cache.dart';
class AudioPlayerFloatingContainer extends StatefulWidget {
const AudioPlayerFloatingContainer({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key);
final File? file;
final String resourceURl;
final bool isAuto;
@override
State<AudioPlayerFloatingContainer> createState() => _AudioPlayerFloatingContainerState();
}
class _AudioPlayerFloatingContainerState extends State<AudioPlayerFloatingContainer> {
AudioPlayer player = AudioPlayer();
Uint8List? audiobytes = null;
bool isplaying = false;
bool audioplayed = false;
int currentpos = 0;
int maxduration = 100;
Duration? durationAudio;
String currentpostlabel = "00:00";
@override
void initState() {
//print("IN INITSTATE AUDDDIOOOO");
Future.delayed(Duration.zero, () async {
if(widget.file != null) {
audiobytes = await fileToUint8List(widget.file!);
}
player.durationStream.listen((Duration? d) { //get the duration of audio
if(d != null) {
maxduration = d.inSeconds;
durationAudio = d;
}
});
//player.bufferedPositionStream
player.positionStream.listen((event) {
if(durationAudio != null) {
currentpos = event.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inHours;
int sminutes = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inMinutes;
int sseconds = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
}
if(mounted && player.duration != null) {
setState(() {
//refresh the UI
if(currentpos > player.duration!.inMilliseconds) {
print("RESET ALL");
player.stop();
player.seek(const Duration(seconds: 0));
isplaying = false;
audioplayed = false;
currentpostlabel = "00:00";
}
});
}
});
/*player.onPositionChanged.listen((Duration p){
currentpos = p.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:currentpos).inHours;
int sminutes = Duration(milliseconds:currentpos).inMinutes;
int sseconds = Duration(milliseconds:currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
setState(() {
//refresh the UI
});
});*/
if(audiobytes != null) {
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
await player.setAudioSource(LoadedSource(audiobytes!));
} else {
print("GET SOUND BY URL");
await player.dynamicSet(url: widget.resourceURl);
}
if(widget.isAuto) {
//player.play(BytesSource(audiobytes));
//
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}
});
super.initState();
}
@override
void dispose() async {
Future.microtask(() async {
await player.stop();
await player.dispose();
});
super.dispose();
}
Future<Uint8List> fileToUint8List(File file) async {
List<int> bytes = await file.readAsBytes();
return Uint8List.fromList(bytes);
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
return FloatingActionButton(
backgroundColor: kMainColor1,
onPressed: () async {
if(!isplaying && !audioplayed){
//player.play(BytesSource(audiobytes));
//await player.setUrl(widget.resourceURl);
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else if(audioplayed && !isplaying){
//player.resume();
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else{
player.pause();
setState(() {
isplaying = false;
});
}
},
child: isplaying ? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.pause, color: Colors.white),
Text(currentpostlabel, style: const TextStyle(color: Colors.white)),
],
) : audioplayed ? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.play_arrow, color: Colors.white),
Text(currentpostlabel, style: const TextStyle(color: Colors.white)),
],
): const Icon(Icons.play_arrow, color: Colors.white),
/*Column(
children: [
//Text(currentpostlabel, style: const TextStyle(fontSize: 25)),
Wrap(
spacing: 10,
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: kSecondColor, // Background color
),
onPressed: () async {
if(!isplaying && !audioplayed){
//player.play(BytesSource(audiobytes));
await player.setAudioSource(LoadedSource(audiobytes));
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else if(audioplayed && !isplaying){
//player.resume();
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}else{
player.pause();
setState(() {
isplaying = false;
});
}
},
icon: Icon(isplaying?Icons.pause:Icons.play_arrow),
//label:Text(isplaying?TranslationHelper.getFromLocale("pause", appContext.getContext()):TranslationHelper.getFromLocale("play", appContext.getContext()))
),
/*ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: kSecondColor, // Background color
),
onPressed: () async {
player.stop();
player.seek(const Duration(seconds: 0));
setState(() {
isplaying = false;
audioplayed = false;
currentpostlabel = "00:00";
});
},
icon: const Icon(Icons.stop),
//label: Text(TranslationHelper.getFromLocale("stop", appContext.getContext()))
),*/
],
)
],
),*/
);
}
}
// Feed your own stream of bytes into the player
class LoadedSource extends StreamAudioSource {
final List<int> bytes;
LoadedSource(this.bytes);
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start ??= 0;
end ??= bytes.length;
return StreamAudioResponse(
sourceLength: bytes.length,
contentLength: end - start,
offset: start,
stream: Stream.value(bytes.sublist(start, end)),
contentType: 'audio/mpeg',
);
}
}

View File

@ -0,0 +1,405 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
import 'package:just_audio/just_audio.dart';
import 'package:just_audio_cache/just_audio_cache.dart';
class AudioPlayerTab extends StatefulWidget {
const AudioPlayerTab({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key);
final File? file;
final String resourceURl;
final bool isAuto;
@override
State<AudioPlayerTab> createState() => _AudioPlayerTabState();
}
class _AudioPlayerTabState extends State<AudioPlayerTab>
with SingleTickerProviderStateMixin {
static const double _collapsedWidth = 56;
/// Hauteur unique : tout le lecteur tient sur une ligne, la pastille ne fait
/// que s'élargir. Elle ne bouge donc pas verticalement en s'ouvrant.
static const double _height = 60;
/// Bornée à la largeur de l'écran : 320 en dur occupait presque toute la
/// largeur d'un téléphone (~394 en logique), la pastille ne se lisait plus
/// comme un panneau posé sur la page.
double get _expandedWidth => screenSize.width * 0.78 < 320 ? screenSize.width * 0.78 : 320;
AudioPlayer player = AudioPlayer();
Uint8List? audiobytes = null;
bool isplaying = false;
bool audioplayed = false;
int currentpos = 0;
int maxduration = 100;
Duration? durationAudio;
String currentpostlabel = "00:00";
bool _isExpanded = false;
bool _showContent = false;
late AnimationController _controller;
/// Progression 01, pas des pixels : la largeur dépliée dépend de l'écran, qui
/// n'est connu qu'à `didChangeDependencies`, après la création de l'animation.
late Animation<double> _expansion;
late Size screenSize;
@override
void didChangeDependencies() {
super.didChangeDependencies();
screenSize = MediaQuery.of(context).size;
}
@override
void initState() {
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
_expansion = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
//print("IN INITSTATE AUDDDIOOOO");
Future.delayed(Duration.zero, () async {
if(widget.file != null) {
audiobytes = await fileToUint8List(widget.file!);
}
player.durationStream.listen((Duration? d) { //get the duration of audio
if(d != null) {
maxduration = d.inSeconds;
durationAudio = d;
}
});
//player.bufferedPositionStream
player.positionStream.listen((event) {
if(durationAudio != null) {
currentpos = event.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inHours;
int sminutes = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inMinutes;
int sseconds = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
}
if(mounted && player.duration != null) {
setState(() {
//refresh the UI
if(currentpos > player.duration!.inMilliseconds) {
print("RESET ALL");
player.stop();
player.seek(const Duration(seconds: 0));
isplaying = false;
audioplayed = false;
currentpostlabel = "00:00";
}
});
}
});
/*player.onPositionChanged.listen((Duration p){
currentpos = p.inMilliseconds; //get the current position of playing audio
//generating the duration label
int shours = Duration(milliseconds:currentpos).inHours;
int sminutes = Duration(milliseconds:currentpos).inMinutes;
int sseconds = Duration(milliseconds:currentpos).inSeconds;
int rminutes = sminutes - (shours * 60);
int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60);
String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString();
String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString();
currentpostlabel = "$minutesToShow:$secondsToShow";
setState(() {
//refresh the UI
});
});*/
if(audiobytes != null) {
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
await player.setAudioSource(LoadedSource(audiobytes!));
} else {
print("GET SOUND BY URL");
await player.dynamicSet(url: widget.resourceURl);
}
if(widget.isAuto) {
//player.play(BytesSource(audiobytes));
//
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}
});
super.initState();
}
@override
void dispose() async {
_controller.dispose();
Future.microtask(() async {
await player.stop();
await player.dispose();
});
super.dispose();
}
Future<Uint8List> fileToUint8List(File file) async {
List<int> bytes = await file.readAsBytes();
return Uint8List.fromList(bytes);
}
void _togglePlay() {
if (isplaying) {
player.pause();
setState(() => isplaying = false);
} else {
player.play();
setState(() {
isplaying = true;
audioplayed = true;
});
}
}
void _toggleExpansion() {
setState(() {
if (_isExpanded) {
_showContent = false;
_isExpanded = false;
} else {
_isExpanded = true;
Future.delayed(const Duration(milliseconds: 300), () {
if (_isExpanded && mounted) setState(() => _showContent = true);
});
}
_isExpanded ? _controller.forward() : _controller.reverse();
});
}
double get _progress =>
maxduration > 0 ? (currentpos / (maxduration * 1000)).clamp(0.0, 1.0) : 0.0;
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
final primaryColor = visitAppContext.configuration?.primaryColor != null
? Color(int.parse(
visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0],
radix: 16))
: kMainColor1;
final double rounded =
visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0;
return AnimatedBuilder(
animation: _expansion,
builder: (context, child) {
final t = _expansion.value;
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
final radius = BorderRadius.only(
topLeft: Radius.circular(rounded),
bottomLeft: Radius.circular(rounded),
);
return Positioned(
right: 0,
top: screenSize.height / 2 - _height / 2,
child: CustomPaint(
// La progression est le contour lui-même : le tracé suit le RRect, donc
// il reste juste pendant que la pastille s'étire vers la gauche.
foregroundPainter: _ProgressBorderPainter(
progress: _progress,
radius: radius,
color: _isExpanded ? primaryColor : Colors.white,
),
child: Container(
width: width,
height: _height,
decoration: BoxDecoration(
color: _isExpanded
? kBackgroundColor
: primaryColor.withValues(alpha: 0.85),
borderRadius: radius,
// Posée sur une photo, la pastille a besoin de se détacher :
// sans ombre et avec un fond translucide, le lecteur déplié
// semblait flotter au milieu de l'image.
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 12, offset: Offset(-2, 4)),
],
),
child: _showContent
? _expandedContent(primaryColor)
: IconButton(
icon: Icon(isplaying ? Icons.pause : Icons.play_arrow,
color: Colors.white),
onPressed: _toggleExpansion,
),
),
),
);
},
);
}
/// Tout sur une seule ligne, croix comprise : avec la fermeture sur sa propre
/// rangée, la pastille devait doubler de hauteur pour s'ouvrir. Ici elle ne fait
/// que s'élargir.
Widget _expandedContent(Color primaryColor) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 4, 0),
child: Row(
children: [
InkWell(
onTap: _togglePlay,
customBorder: const CircleBorder(),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(color: primaryColor, shape: BoxShape.circle),
child: Icon(isplaying ? Icons.pause : Icons.play_arrow,
color: Colors.white, size: 26),
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
activeTrackColor: primaryColor,
inactiveTrackColor: kMainGrey,
thumbColor: primaryColor,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: _progress,
onChanged: maxduration > 0
? (v) => player
.seek(Duration(milliseconds: (v * maxduration * 1000).round()))
: null,
),
),
),
Text(
currentpostlabel,
style: const TextStyle(
fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()],
),
),
IconButton(
icon: Icon(Icons.close, color: primaryColor, size: 20),
onPressed: _toggleExpansion,
),
],
),
);
}
}
/// Ne dessine qu'une fraction du contour, mesurée le long du chemin plutôt qu'en
/// angles : un `RRect` n'a pas de centre unique, un arc ne suivrait pas ses coins.
class _ProgressBorderPainter extends CustomPainter {
const _ProgressBorderPainter({
required this.progress,
required this.radius,
required this.color,
});
final double progress;
final BorderRadius radius;
final Color color;
/// Contour tracé à la main plutôt que par `addRRect` : celui-ci démarre au
/// milieu du côté gauche, donc la progression naissait . On part du coin bas
/// droit et on tourne dans le sens horaire bas, côté arrondi, haut, puis le
/// bord collé à l'écran.
Path _borderPath(Size size) {
final w = size.width;
final h = size.height;
final tl = radius.topLeft.x;
final bl = radius.bottomLeft.x;
return Path()
..moveTo(w, h)
..lineTo(bl, h)
..arcToPoint(Offset(0, h - bl), radius: Radius.circular(bl), clockwise: true)
..lineTo(0, tl)
..arcToPoint(Offset(tl, 0), radius: Radius.circular(tl), clockwise: true)
..lineTo(w, 0)
..lineTo(w, h);
}
@override
void paint(Canvas canvas, Size size) {
final path = _borderPath(size);
// Piste complète d'abord : sans elle, la pastille n'a aucun contour tant que
// l'audio n'a pas démarré, et rien ne dit qu'elle en gagnera un.
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = color.withValues(alpha: 0.25),
);
if (progress <= 0) return;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color;
for (final metric in path.computeMetrics()) {
canvas.drawPath(metric.extractPath(0, metric.length * progress), paint);
}
}
@override
bool shouldRepaint(_ProgressBorderPainter old) =>
old.progress != progress || old.color != color || old.radius != radius;
}
// Feed your own stream of bytes into the player
class LoadedSource extends StreamAudioSource {
final List<int> bytes;
LoadedSource(this.bytes);
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start ??= 0;
end ??= bytes.length;
return StreamAudioResponse(
sourceLength: bytes.length,
contentLength: end - start,
offset: start,
stream: Stream.value(bytes.sublist(start, end)),
contentType: 'audio/mpeg',
);
}
}

View File

@ -31,6 +31,12 @@ const List<SectionType> offlineCapableSectionTypes = [
SectionType.Game, SectionType.Game,
]; ];
/// Ce que le visiteur doit lire à la fin. `unreachable` et `incomplete` étaient
/// tous deux invisibles : l'écran ne regardait jamais le résultat du `Future`, il
/// déduisait son texte de `currentResourceNbr`, resté à -1 quand l'export échoue —
/// donc « téléchargement en cours », pour toujours.
enum DownloadOutcome { upToDate, complete, incomplete, unreachable }
class DownloadConfigurationWidget extends StatefulWidget { class DownloadConfigurationWidget extends StatefulWidget {
DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key); DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key);
final ConfigurationDTO configuration; final ConfigurationDTO configuration;
@ -43,14 +49,18 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0); ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0);
ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1); ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1);
/// D5 nombre de ressources dont le téléchargement a échoué. Alimente le message /// Le téléchargement était lancé depuis `build()`, donc relancé à chaque rebuild
/// d'échec : « 3 fichiers n'ont pas pu être téléchargés » dit au visiteur ce qui /// du dialogue. Il démarre une fois, ici.
/// s'est passé, là où l'écran d'erreur générique le laissait supposer une panne. Future<DownloadOutcome>? _downloadFuture;
ValueNotifier<int> downloadFailureCount = ValueNotifier<int>(0);
bool isAlreadyDownloading = false;
//OtaEvent? currentEvent;
Future<bool> download(BuildContext buildContext, VisitAppContext visitAppContext) async { @override
void didChangeDependencies() {
super.didChangeDependencies();
_downloadFuture ??= download(
Provider.of<AppContext>(context, listen: false).getContext());
}
Future<DownloadOutcome> download(VisitAppContext visitAppContext) async {
bool isAllLanguages = true; bool isAllLanguages = true;
if(visitAppContext.isAllLanguages != null) { if(visitAppContext.isAllLanguages != null) {
@ -67,259 +77,241 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
} }
} }
if(!isAlreadyDownloading) { // HERE CHECK VERSION APK
isAlreadyDownloading = true; if(true) {
Map<Permission, PermissionStatus> statuses = await [
Permission.requestInstallPackages,
].request();
//tryOtaUpdate();
}
// HERE CHECK VERSION APK ExportConfigurationDTO? exportConfigurationDTO;
if(true) { try{
Map<Permission, PermissionStatus> statuses = await [ // Retrieve all url from resource to download (get all resource from configuration en somme)
Permission.requestInstallPackages, exportConfigurationDTO = await visitAppContext.clientAPI.configurationApi!.configurationExport(widget.configuration.id!, language: isAllLanguages ? null : visitAppContext.language); // tabletAppContext.configuration!.id! // 65c5f0ee4c030e63ce16bff5 TODO Remove
].request(); } catch(e) {
//tryOtaUpdate(); print("Erreur lors du téléchargement de la configuration et de ses ressources !");
} print(e);
return DownloadOutcome.unreachable;
}
ExportConfigurationDTO? exportConfigurationDTO; exportConfigurationDTO.resources!.forEach((element) {
print(element.id);
print(element.label);
});
if(exportConfigurationDTO.resources != null && exportConfigurationDTO.resources!.isNotEmpty) {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();
//if(statuses[Permission.storage] == PermissionStatus.granted) {
try{ try{
// Retrieve all url from resource to download (get all resource from configuration en somme) try {
exportConfigurationDTO = await visitAppContext.clientAPI.configurationApi!.configurationExport(widget.configuration.id!, language: isAllLanguages ? null : visitAppContext.language); // tabletAppContext.configuration!.id! // 65c5f0ee4c030e63ce16bff5 TODO Remove Directory directory = Directory('${visitAppContext.localPath}');
} catch(e) { List<FileSystemEntity> allConfigurations = directory.listSync();
print("Erreur lors du téléchargement de la configuration et de ses ressources !"); Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
print(e); if(!allConfigurations.any((configurationDirectory) => configurationDirectory.uri.pathSegments.any((element) => element == widget.configuration.id))) {
return false; // create directory
} print("Trying to create directory");
exportConfigurationDTO.resources!.forEach((element) {
print(element.id);
print(element.label);
});
if(exportConfigurationDTO.resources != null && exportConfigurationDTO.resources!.isNotEmpty) {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();
//if(statuses[Permission.storage] == PermissionStatus.granted) {
try{
try {
Directory directory = Directory('${visitAppContext.localPath}');
List<FileSystemEntity> allConfigurations = directory.listSync();
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
if(!allConfigurations.any((configurationDirectory) => configurationDirectory.uri.pathSegments.any((element) => element == widget.configuration.id))) {
// create directory
print("Trying to create directory");
configurationDirectory.createSync(recursive: true);
print('Répertoire créé avec succès.');
}
} catch(e) {
print("Listing failed, so try to create directory");
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
configurationDirectory.createSync(recursive: true); configurationDirectory.createSync(recursive: true);
print('Répertoire créé avec succès.'); print('Répertoire créé avec succès.');
} }
} catch(e) {
print("Listing failed, so try to create directory");
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}'); Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
List<FileSystemEntity> fileList = configurationDirectory.listSync();
for (var file in fileList) { configurationDirectory.createSync(recursive: true);
print(file.uri.pathSegments.last); print('Répertoire créé avec succès.');
} }
Map<String, DateTime?> localResourceDates = await readLocalResourceDates(); Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
List<FileSystemEntity> fileList = configurationDirectory.listSync();
// Ressources dont le téléchargement a échoué. Vide = visite complète. for (var file in fileList) {
final List<String> failedResourceIds = []; print(file.uri.pathSegments.last);
}
// Sections dont le payload n'a pas pu être récupéré ou enregistré. Map<String, DateTime?> localResourceDates = await readLocalResourceDates();
final List<String> failedSectionIds = [];
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id])); // Ressources dont le téléchargement a échoué. Vide = visite complète.
final List<String> failedResourceIds = [];
currentResourceNbr.value = resourcesToDownload.length; // Sections dont le payload n'a pas pu être récupéré ou enregistré.
final List<String> failedSectionIds = [];
// foreach ou on va tout télécharger - avec un joli etape 0 / length - on peut rendre tout lent on s'en fou ça ne ce fait qu'une fois var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id]));
for (var resource in resourcesToDownload) {
String? filePath = await downloadResource(visitAppContext, widget.configuration, resource, visitAppContext.localPath!);
if (filePath != null) currentResourceNbr.value = resourcesToDownload.length;
{
// Insert in database
ResourceModel resourceModel = ResourceModel(id: resource.id, source: resource.url, path: filePath, type: resource.type, dateUpdate: resource.dateUpdate);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
localResourceDates[resource.id!] = resource.dateUpdate;
} catch (e) {
print("We got an issue inserting image metadata ${resource.id}");
}
currentResourceIndex.value++; // foreach ou on va tout télécharger - avec un joli etape 0 / length - on peut rendre tout lent on s'en fou ça ne ce fait qu'une fois
} else { for (var resource in resourcesToDownload) {
// D5 un fichier raté ne disparaît plus dans un `print`. Une visite dont String? filePath = await downloadResource(visitAppContext, widget.configuration, resource, visitAppContext.localPath!);
// des ressources manquent ne doit pas s'annoncer téléchargée : hors ligne,
// le visiteur trouverait des images vides ou un audio muet sans comprendre
// pourquoi, et sans rien pouvoir y faire.
failedResourceIds.add(resource.id ?? '?');
debugPrint("[download] échec de la ressource ${resource.id} (${resource.url})");
}
}
// Delete others that are no more used if (filePath != null)
var resourceToDelete = fileList.where((fileL) => {
!exportConfigurationDTO!.resources!.any((resource) => // Insert in database
resource.id != null && fileL.uri.pathSegments.last.contains(resource.id!))); ResourceModel resourceModel = ResourceModel(id: resource.id, source: resource.url, path: filePath, type: resource.type, dateUpdate: resource.dateUpdate);
for (var resource in resourceToDelete) {
print("resource to DELETE");
print(resource.path);
try { try {
resource.deleteSync(); await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
localResourceDates[resource.id!] = resource.dateUpdate;
} catch (e) { } catch (e) {
print("We got an issue deleting local file ${resource.path}"); print("We got an issue inserting image metadata ${resource.id}");
} }
currentResourceIndex.value++;
} else {
// D5 un fichier raté ne disparaît plus dans un `print`. Une visite dont
// des ressources manquent ne doit pas s'annoncer téléchargée : hors ligne,
// le visiteur trouverait des images vides ou un audio muet sans comprendre
// pourquoi, et sans rien pouvoir y faire.
failedResourceIds.add(resource.id ?? '?');
debugPrint("[download] échec de la ressource ${resource.id} (${resource.url})");
} }
}
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, ModelsHelper.configurationToMap(widget.configuration)); // Delete others that are no more used
var resourceToDelete = fileList.where((fileL) =>
!exportConfigurationDTO!.resources!.any((resource) =>
resource.id != null && fileL.uri.pathSegments.last.contains(resource.id!)));
List<SectionDTO>? sections = exportConfigurationDTO.sections; for (var resource in resourceToDelete) {
List<String> usedImageOrAudioIds = []; print("resource to DELETE");
print(resource.path);
try {
resource.deleteSync();
} catch (e) {
print("We got an issue deleting local file ${resource.path}");
}
}
if(sections!.isNotEmpty) { await DatabaseHelper.instance.insert(DatabaseTableType.configurations, ModelsHelper.configurationToMap(widget.configuration));
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!); List<SectionDTO>? sections = exportConfigurationDTO.sections;
List<SectionDTO> sectionsToKeep = sections.where((s) => offlineCapableSectionTypes.contains(s.type)).toList(); List<String> usedImageOrAudioIds = [];
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!)); if(sections!.isNotEmpty) {
int newOrder = 0;
// Update local DB - Sections
for(var section in sectionsToKeep) {
section.order = newOrder;
// Le payload typé n'est pas dans l'export : `ExportConfigurationDTO.sections` List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
// est déserialisé en `SectionDTO`, qui ne porte que l'en-tête commun. C'est List<SectionDTO> sectionsToKeep = sections.where((s) => offlineCapableSectionTypes.contains(s.type)).toList();
// `sectionGetDetail` qui renvoie le JSON complet du sous-type le même que
// celui dont `section_page` se sert en ligne.
String? rawSectionJson;
try {
rawSectionJson = jsonEncode(await visitAppContext.clientAPI.sectionApi!.sectionGetDetail(section.id!));
} catch (e) {
debugPrint("[download] détail indisponible pour la section ${section.id} : $e");
}
// Une section sans son payload ne sait rien afficher hors ligne. On préfère sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
// ne pas l'enregistrer et faire échouer la visite, plutôt que laisser le int newOrder = 0;
// visiteur ouvrir un article vide au milieu du site. // Update local DB - Sections
if (rawSectionJson == null) { for(var section in sectionsToKeep) {
failedSectionIds.add(section.id ?? '?'); section.order = newOrder;
newOrder = newOrder + 1;
continue;
}
try { // Le payload typé n'est pas dans l'export : `ExportConfigurationDTO.sections`
await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section, rawSectionJson)); // est déserialisé en `SectionDTO`, qui ne porte que l'en-tête commun. C'est
} catch (e) { // `sectionGetDetail` qui renvoie le JSON complet du sous-type le même que
failedSectionIds.add(section.id ?? '?'); // celui dont `section_page` se sert en ligne.
debugPrint("[download] échec d'insertion de la section ${section.id} : $e"); String? rawSectionJson;
} try {
rawSectionJson = jsonEncode(await visitAppContext.clientAPI.sectionApi!.sectionGetDetail(section.id!));
} catch (e) {
debugPrint("[download] détail indisponible pour la section ${section.id} : $e");
}
// Download section image // Une section sans son payload ne sait rien afficher hors ligne. On préfère
if(section.imageId != null) { // ne pas l'enregistrer et faire échouer la visite, plutôt que laisser le
usedImageOrAudioIds.add(section.imageId!); // visiteur ouvrir un article vide au milieu du site.
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == section.imageId); if (rawSectionJson == null) {
if(imageData.isNotEmpty) { failedSectionIds.add(section.id ?? '?');
// TODO get all resources from API + store it in download directory of the app newOrder = newOrder + 1;
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: section.imageSource, /*data: imageData.first.data,*/ type: imageData.first.type); continue;
try { }
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) { try {
print("We got an issue inserting image data ${imageData.first.id}"); await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section, rawSectionJson));
} } catch (e) {
failedSectionIds.add(section.id ?? '?');
debugPrint("[download] échec d'insertion de la section ${section.id} : $e");
}
// Download section image
if(section.imageId != null) {
usedImageOrAudioIds.add(section.imageId!);
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == section.imageId);
if(imageData.isNotEmpty) {
// TODO get all resources from API + store it in download directory of the app
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: section.imageSource, /*data: imageData.first.data,*/ type: imageData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) {
print("We got an issue inserting image data ${imageData.first.id}");
} }
} }
newOrder = newOrder + 1;
} }
// Le bloc qui vivait ici parsait `section.data` type par type et il était newOrder = newOrder + 1;
// commenté, donc une visite téléchargée n'embarquait que l'image de chaque }
// section : ni contenus d'articles, ni audios, ni icônes de carte.
// // Le bloc qui vivait ici parsait `section.data` type par type et il était
// Il n'y a plus rien à redécouvrir : depuis le 2026-08-11 le serveur collecte // commenté, donc une visite téléchargée n'embarquait que l'image de chaque
// les ressources via `GetReferencedResourceIds()`, implémentée sur les 13 // section : ni contenus d'articles, ni audios, ni icônes de carte.
// sous-types, et les met toutes dans `resources`. On enregistre la charge. //
// Un nouveau type de section est couvert sans toucher à ce fichier. // Il n'y a plus rien à redécouvrir : depuis le 2026-08-11 le serveur collecte
// // les ressources via `GetReferencedResourceIds()`, implémentée sur les 13
// `insert` fait un UPDATE de toute la ligne quand l'id existe déjà : cette // sous-types, et les met toutes dans `resources`. On enregistre la charge.
// boucle repasse sur les ressources que la boucle de téléchargement vient // Un nouveau type de section est couvert sans toucher à ce fichier.
// d'enregistrer. Elle doit donc réécrire la date que celle-ci a posée, sinon //
// elle l'efface et le fichier redevient « sans date connue » à chaque visite. // `insert` fait un UPDATE de toute la ligne quand l'id existe déjà : cette
// Et pour une ressource dont le téléchargement a échoué, c'est l'ancienne date // boucle repasse sur les ressources que la boucle de téléchargement vient
// locale qui est conservée surtout pas celle du serveur, qui la déclarerait // d'enregistrer. Elle doit donc réécrire la date que celle-ci a posée, sinon
// à jour alors que le fichier est absent. // elle l'efface et le fichier redevient « sans date connue » à chaque visite.
for (var resource in exportConfigurationDTO.resources ?? []) { // Et pour une ressource dont le téléchargement a échoué, c'est l'ancienne date
if (resource.id == null) continue; // locale qui est conservée surtout pas celle du serveur, qui la déclarerait
usedImageOrAudioIds.add(resource.id!); // à jour alors que le fichier est absent.
try { for (var resource in exportConfigurationDTO.resources ?? []) {
await DatabaseHelper.instance.insert( if (resource.id == null) continue;
DatabaseTableType.resources, usedImageOrAudioIds.add(resource.id!);
ResourceModel(id: resource.id, source: resource.url, type: resource.type, dateUpdate: localResourceDates[resource.id]).toMap(), try {
); await DatabaseHelper.instance.insert(
} catch (e) { DatabaseTableType.resources,
print("We got an issue inserting resource ${resource.id}"); ResourceModel(id: resource.id, source: resource.url, type: resource.type, dateUpdate: localResourceDates[resource.id]).toMap(),
} );
} catch (e) {
print("We got an issue inserting resource ${resource.id}");
} }
}
List<String?> sectionIdsToRemove = sectionsInDB.map((s) => s.id).where((sectionId) => !sectionsToKeep.map((sk) => sk.id).contains(sectionId)).toList(); List<String?> sectionIdsToRemove = sectionsInDB.map((s) => s.id).where((sectionId) => !sectionsToKeep.map((sk) => sk.id).contains(sectionId)).toList();
for(var sectionIdToRemove in sectionIdsToRemove) { for(var sectionIdToRemove in sectionIdsToRemove) {
print("section with id removed"); print("section with id removed");
print(sectionIdToRemove); print(sectionIdToRemove);
try { try {
await DatabaseHelper.instance.delete(sectionIdToRemove!, DatabaseTableType.sections); await DatabaseHelper.instance.delete(sectionIdToRemove!, DatabaseTableType.sections);
} catch (e) { } catch (e) {
print("We got an issue deleting section id: ${sectionIdToRemove}"); print("We got an issue deleting section id: ${sectionIdToRemove}");
}
} }
// `cleanLocalResources` reste désactivée : la table `resources` n'a pas de
// colonne `configurationId`, elle est globale à toutes les visites. La purger
// avec les ids d'une seule configuration effacerait les lignes des autres.
// Les fichiers obsolètes, eux, sont bien supprimés plus haut leur répertoire
// est propre à la configuration. Et rien ne lit la colonne `path` pour le rendu :
// `CachedCustomResource` retrouve le fichier en listant le répertoire.
} }
// D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le // `cleanLocalResources` reste désactivée : la table `resources` n'a pas de
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que // colonne `configurationId`, elle est globale à toutes les visites. La purger
// ce qui manque, `isResourceOutdated` voyant les fichiers présents. // avec les ids d'une seule configuration effacerait les lignes des autres.
if (failedResourceIds.isNotEmpty || failedSectionIds.isNotEmpty) { // Les fichiers obsolètes, eux, sont bien supprimés plus haut leur répertoire
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : " // est propre à la configuration. Et rien ne lit la colonne `path` pour le rendu :
"${failedResourceIds.join(', ')}"); // `CachedCustomResource` retrouve le fichier en listant le répertoire.
debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
"${failedSectionIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length + failedSectionIds.length;
return false;
}
} catch(e) {
print("ERRORRRR");
print(e);
if(statuses[Permission.storage] != PermissionStatus.granted) {
Fluttertoast.showToast(
msg: "PermissionStatus not granted, issue may be linked to that. Please check os version (if less than 13, real issue).",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: Colors.redAccent,
textColor: Colors.white,
fontSize: 16.0
);
}
return false;
} }
/*} else {
print("PermissionStatus.granted NOT GRANTED"); // D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que
// ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty || failedSectionIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}");
debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
"${failedSectionIds.join(', ')}");
return DownloadOutcome.incomplete;
}
} catch(e) {
print("ERRORRRR");
print(e);
if(statuses[Permission.storage] != PermissionStatus.granted) {
Fluttertoast.showToast( Fluttertoast.showToast(
msg: "PermissionStatus not granted", msg: "PermissionStatus not granted, issue may be linked to that. Please check os version (if less than 13, real issue).",
toastLength: Toast.LENGTH_SHORT, toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1, timeInSecForIosWeb: 1,
@ -327,111 +319,127 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
textColor: Colors.white, textColor: Colors.white,
fontSize: 16.0 fontSize: 16.0
); );
return false; }
}*/ return DownloadOutcome.incomplete;
} }
/*} else {
print("PermissionStatus.granted NOT GRANTED");
Fluttertoast.showToast(
msg: "PermissionStatus not granted",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: Colors.redAccent,
textColor: Colors.white,
fontSize: 16.0
);
return false;
}*/
} }
return true; return currentResourceNbr.value > 0
? DownloadOutcome.complete
: DownloadOutcome.upToDate;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context); final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
Size size = MediaQuery.of(context).size;
// Pas de `Center` ici : le dialogue ne fixe plus la hauteur du contenu, et un
// `Center` s'étirerait sur toute la hauteur disponible.
return FutureBuilder<DownloadOutcome>(
future: _downloadFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _progress(appContext);
}
// Une exception non rattrapée dans `download` laissait le dialogue sur
// « téléchargement en cours » elle aussi le `Future` était en erreur,
// personne ne regardait.
if (snapshot.hasError) {
debugPrint("[download] échec inattendu : ${snapshot.error}");
return _message(Icons.error_outline, Colors.redAccent,
TranslationHelper.getFromLocale("downloadIncomplete", appContext.getContext()));
}
switch (snapshot.data!) {
case DownloadOutcome.upToDate:
return _label(TranslationHelper.getFromLocale("upToDate", appContext.getContext()));
case DownloadOutcome.complete:
return _label(TranslationHelper.getFromLocale("downloadFinish", appContext.getContext()));
case DownloadOutcome.incomplete:
return _message(Icons.cloud_off, Colors.orangeAccent,
TranslationHelper.getFromLocale("downloadIncomplete", appContext.getContext()));
case DownloadOutcome.unreachable:
return _message(Icons.wifi_off, Colors.redAccent,
TranslationHelper.getFromLocale("downloadFailed", appContext.getContext()));
}
},
);
}
/// Le texte et la barre pendant le téléchargement. `currentResourceNbr` vaut -1
/// tant que l'export n'a pas répondu : on annonce le travail sans prétendre en
/// connaître l'avancement.
Widget _progress(AppContext appContext) {
return ValueListenableBuilder<int>(
valueListenable: currentResourceNbr,
builder: (context, valueNbr, _) => ValueListenableBuilder<int>(
valueListenable: currentResourceIndex,
builder: (context, valueIndex, _) {
final ratio = valueNbr > 0 ? valueIndex / valueNbr : null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_label(TranslationHelper.getFromLocale("downloadInProgress", appContext.getContext())),
Padding(
padding: const EdgeInsets.all(8.0),
child: LinearProgressIndicator(
value: ratio,
semanticsLabel: 'Linear progress indicator',
color: kMainColor0,
),
),
if (ratio != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${(ratio * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w500),
),
),
],
);
},
),
);
}
Widget _label(String text) {
return Center( return Center(
child: FutureBuilder(future: download(context, visitAppContext), builder: (context, snapshot) { child: Padding(
return ValueListenableBuilder<int>( padding: const EdgeInsets.all(8.0),
valueListenable: currentResourceNbr, child: Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20)),
builder: (context, valueNbr, _) { ),
return ValueListenableBuilder<int>( );
valueListenable: currentResourceIndex, }
builder: (context, valueIndex, _) {
var valueInPercentage = valueNbr > 0 ? (valueIndex / valueNbr) * 100 : 100; Widget _message(IconData icon, Color color, String text) {
String formattedPercentage = valueInPercentage.toStringAsFixed(0); return Center(
return Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.spaceAround, padding: const EdgeInsets.all(16.0),
crossAxisAlignment: CrossAxisAlignment.center, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
// D5 l'état d'échec passe avant les autres. Sans lui, une visite children: [
// incomplète restait sur « téléchargement en cours » indéfiniment : Icon(icon, size: 34, color: color),
// le compteur d'avancement n'est incrémenté qu'en cas de succès, donc const SizedBox(height: 10),
// il n'atteignait jamais le total et l'écran ne concluait jamais. Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
ValueListenableBuilder<int>( ],
valueListenable: downloadFailureCount, ),
builder: (context, failures, _) { ),
if (failures == 0) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
textAlign: TextAlign.center,
valueIndex == valueNbr && valueNbr != -1 ? valueNbr == 0 ?
TranslationHelper.getFromLocale(
"upToDate",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadFinish",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadInProgress",
appContext.getContext()),
style: const TextStyle(fontSize: 20),
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 34, color: Colors.orangeAccent),
const SizedBox(height: 10),
Text(
TranslationHelper.getFromLocale(
"downloadIncomplete", appContext.getContext()),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
],
),
),
);
},
),
if(valueNbr != -1 && valueNbr != 0)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: LinearProgressIndicator(
value: valueInPercentage / 100,
semanticsLabel: 'Linear progress indicator',
color: kMainColor0,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'$formattedPercentage%',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w500),
),
),
],
),
)
],
);
}
);
}
);
}),
); );
} }
} }

View File

@ -1,24 +1,34 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meta_wearables_dat/meta_wearables_dat.dart'; import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart'; import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart'; import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart';
import 'package:path_provider/path_provider.dart';
enum GlassesState { disconnected, connecting, connected, streaming } enum GlassesState { disconnected, connecting, connected, streaming }
/// Gère la connexion aux lunettes Ray-Ban Meta via le SDK DAT. /// Gère la connexion aux lunettes Ray-Ban Meta via le SDK DAT.
/// ///
/// Cycle de vie intentionnel : /// Cycle de vie intentionnel :
/// 1. initialize() init SDK, écoute les streams d'état (au démarrage app) /// 1. initialize() permissions runtime + écoute des streams d'état (au démarrage app)
/// 2. connect() enregistrement DAT + permission caméra + HFP audio routing /// 2. connect() enregistrement DAT + permission caméra Meta AI
/// Le téléphone est connecté aux lunettes, micro HFP actif. /// Le téléphone est connecté aux lunettes, micro HFP actif.
/// PAS de stream vidéo encore. /// PAS de stream vidéo encore.
/// 3. startStream() démarre le stream vidéo (uniquement quand caméra nécessaire) /// 3. startStream() démarre le stream vidéo (uniquement quand caméra nécessaire)
/// 4. stopStream() arrête le stream, reste connecté /// 4. stopStream() arrête la session device (coûteux, cf. note ci-dessous)
/// 5. disconnect() déconnexion complète /// 5. disconnect() déconnexion complète
///
/// Depuis DAT 0.9.1, stopStreamSession() ferme toute la session device : les
/// lunettes jouent leur tonalité de fin et le prochain startStreamSession() est
/// une reconnexion complète, nettement plus lente qu'un simple ré-attach. On ne
/// cycle donc PLUS le stream à chaque capture photo il reste ouvert jusqu'à un
/// stopStream()/disconnect() explicite.
///
/// Le SDK DAT n'expose ni micro, ni haut-parleur, ni bouton des lunettes : tout
/// le pipeline vocal (wake word, STT, TTS, routing HFP/A2DP) vit hors de ce
/// service, dans AudioRoutingChannel et l'orchestrateur vocal.
class MetaGlassesService { class MetaGlassesService {
MetaGlassesService._(); MetaGlassesService._();
static final MetaGlassesService instance = MetaGlassesService._(); static final MetaGlassesService instance = MetaGlassesService._();
@ -28,6 +38,14 @@ class MetaGlassesService {
/// Appelé avec le chemin de la photo après capturePhoto(). /// Appelé avec le chemin de la photo après capturePhoto().
void Function(String photoPath)? onPhotoCaptured; void Function(String photoPath)? onPhotoCaptured;
/// Texture Flutter du stream en cours requise par [grabFrame] et par un
/// éventuel widget Texture d'aperçu. null hors streaming.
int? get textureId => _textureId;
int? _textureId;
RegistrationState _registration = RegistrationState.unavailable;
final List<StreamSubscription> _subs = [];
bool get isConnected => bool get isConnected =>
state.value == GlassesState.connected || state.value == GlassesState.streaming; state.value == GlassesState.connected || state.value == GlassesState.streaming;
@ -35,47 +53,75 @@ class MetaGlassesService {
Future<void> initialize() async { Future<void> initialize() async {
if (!Platform.isAndroid) return; if (!Platform.isAndroid) return;
if (!kEnableGlasses) {
try { debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche');
final ok = await Wearables.instance.initialize(); return;
if (!ok) {
debugPrint('[MetaGlassesService] SDK init failed');
return;
}
} catch (e) {
// ALREADY_INITIALIZED = hot restart, le SDK natif garde son état OK
if (e.toString().contains('ALREADY_INITIALIZED')) {
debugPrint('[MetaGlassesService] Already initialized (hot restart) — continuing');
} else {
debugPrint('[MetaGlassesService] Init error: $e');
return;
}
} }
Wearables.instance.registrationStateStream.listen(_onRegistrationState); try {
Wearables.instance.streamStateStream.listen(_onStreamState); final granted = await MetaWearablesDat.requestAndroidPermissions();
Wearables.instance.devicesStream.listen((devices) { if (!granted) {
debugPrint('[MetaGlassesService] Devices: $devices'); debugPrint('[MetaGlassesService] Permissions Bluetooth refusees');
if (devices.isNotEmpty) _onDeviceConnected(); return;
}); }
_registration = await MetaWearablesDat.getRegistrationState();
debugPrint('[MetaGlassesService] Registration initiale: $_registration');
} catch (e) {
debugPrint('[MetaGlassesService] Init error: $e');
return;
}
_subs.add(MetaWearablesDat.registrationStateStream().listen(_onRegistrationState));
_subs.add(MetaWearablesDat.streamSessionStateStream().listen(_onStreamState));
_subs.add(MetaWearablesDat.streamSessionErrorStream().listen(_onStreamError));
_subs.add(MetaWearablesDat.activeDeviceStream().listen(_onActiveDevice));
if (_registration == RegistrationState.registered) {
state.value = GlassesState.connected;
}
debugPrint('[MetaGlassesService] Initialized'); debugPrint('[MetaGlassesService] Initialized');
} }
/// À brancher sur le deep link entrant : c'est le retour de l'app Meta AI qui
/// termine l'enregistrement (ou la déconnexion).
Future<void> handleDeepLink(String url) async {
if (!Platform.isAndroid || !kEnableGlasses) return;
try {
final handled = await MetaWearablesDat.handleUrl(url);
debugPrint('[MetaGlassesService] handleUrl($url) -> $handled');
if (handled) await MetaWearablesDat.restartActiveDeviceMonitoring();
} catch (e) {
debugPrint('[MetaGlassesService] handleUrl error: $e');
}
}
// 2. Connexion (sans stream vidéo) // 2. Connexion (sans stream vidéo)
/// Connecte aux lunettes et active le routing audio HFP. /// Connecte aux lunettes. Le micro des lunettes devient actif pour le wake
/// Le micro des lunettes devient actif pour le wake word. /// word via le routing Bluetooth système, hors SDK.
/// PAS de stream vidéo utiliser [startStream] séparément. /// PAS de stream vidéo utiliser [startStream] séparément.
Future<void> connect() async { Future<void> connect() async {
if (!Platform.isAndroid) return; if (!Platform.isAndroid) return;
if (!kEnableGlasses) {
debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche');
return;
}
state.value = GlassesState.connecting; state.value = GlassesState.connecting;
await Permission.camera.request(); try {
await Wearables.instance.startRegistration(); if (_registration != RegistrationState.registered) {
await _ensureCameraPermission(); // Ouvre Meta AI ; la registration se termine au retour du deep link,
// traité par handleDeepLink().
debugPrint('[MetaGlassesService] Connected (no stream yet)'); await MetaWearablesDat.startRegistration();
}
await _ensureCameraPermission();
await MetaWearablesDat.restartActiveDeviceMonitoring();
debugPrint('[MetaGlassesService] Connect demande (no stream yet)');
} catch (e) {
debugPrint('[MetaGlassesService] connect error: $e');
state.value = GlassesState.disconnected;
}
} }
// 3. Stream vidéo (sur demande) // 3. Stream vidéo (sur demande)
@ -84,12 +130,25 @@ class MetaGlassesService {
/// Appeler uniquement quand la caméra est requise. /// Appeler uniquement quand la caméra est requise.
Future<void> startStream() async { Future<void> startStream() async {
if (!isConnected) return; if (!isConnected) return;
await Wearables.instance.startStream(videoQuality: 'MEDIUM', frameRate: 24); try {
debugPrint('[MetaGlassesService] Stream started'); _textureId = await MetaWearablesDat.startStreamSession(
null,
fps: 24,
streamQuality: StreamQuality.medium,
);
debugPrint('[MetaGlassesService] Stream started (texture=$_textureId)');
} on PlatformException catch (e) {
debugPrint('[MetaGlassesService] startStreamSession error: ${e.code} ${e.message}');
}
} }
Future<void> stopStream() async { Future<void> stopStream() async {
await Wearables.instance.stopStream(); try {
await MetaWearablesDat.stopStreamSession(null);
} catch (e) {
debugPrint('[MetaGlassesService] stopStreamSession error: $e');
}
_textureId = null;
if (state.value == GlassesState.streaming) { if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected; state.value = GlassesState.connected;
} }
@ -99,7 +158,7 @@ class MetaGlassesService {
// 4. Déconnexion // 4. Déconnexion
Future<void> disconnect() async { Future<void> disconnect() async {
await Wearables.instance.stopStream(); await stopStream();
await AudioRoutingChannel.restoreDefaultOutput(); await AudioRoutingChannel.restoreDefaultOutput();
state.value = GlassesState.disconnected; state.value = GlassesState.disconnected;
} }
@ -112,130 +171,141 @@ class MetaGlassesService {
onPhotoCaptured?.call(''); onPhotoCaptured?.call('');
return; return;
} }
bool streamStartedByUs = false; if (!await ensureStreaming()) {
if (state.value != GlassesState.streaming) { debugPrint('[MetaGlassesService] Stream not ready — capture aborted');
await startStream(); onPhotoCaptured?.call('');
streamStartedByUs = true; return;
// Attend que le stream soit réellement actif (max 4s)
final sw = Stopwatch()..start();
while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) {
await Future.delayed(const Duration(milliseconds: 200));
}
if (state.value != GlassesState.streaming) {
debugPrint('[MetaGlassesService] Stream not ready after 8s — capture aborted');
onPhotoCaptured?.call('');
return;
}
// Délai de stabilisation si le stream vient juste de démarrer
await Future.delayed(const Duration(milliseconds: 1500));
} }
try { try {
final photo = await Wearables.instance.capturePhoto(); final photo = await MetaWearablesDat.capturePhoto(null);
debugPrint('[MetaGlassesService] Photo captured: ${photo.path}'); final path = await _writeTempFile(photo.bytes, photo.fileExtension, 'photo');
onPhotoCaptured?.call(photo.path); debugPrint('[MetaGlassesService] Photo captured: $path');
onPhotoCaptured?.call(path);
} on PlatformException catch (e) {
debugPrint('[MetaGlassesService] capturePhoto error: ${e.code} ${e.details}');
onPhotoCaptured?.call('');
} catch (e) { } catch (e) {
debugPrint('[MetaGlassesService] capturePhoto error: $e'); debugPrint('[MetaGlassesService] capturePhoto error: $e');
onPhotoCaptured?.call(''); onPhotoCaptured?.call('');
} finally {
if (streamStartedByUs) {
await stopStream();
debugPrint('[MetaGlassesService] Stream stopped after photo');
}
} }
// Le stream reste volontairement ouvert : le refermer coûterait une
// reconnexion complète à la capture suivante (cf. note de classe).
} }
/// Démarre le stream et attend qu'il soit actif (max 4s). /// Démarre le stream si besoin et attend qu'il soit réellement actif.
/// Retourne true si le stream est prêt. /// Retourne true si le stream est prêt pour une capture.
Future<bool> ensureStreaming() async { Future<bool> ensureStreaming() async {
if (!isConnected) return false; if (!isConnected) return false;
if (state.value == GlassesState.streaming) return true; if (state.value == GlassesState.streaming) return true;
final alreadyStarted = state.value == GlassesState.streaming;
await startStream(); await startStream();
final sw = Stopwatch()..start(); final sw = Stopwatch()..start();
while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) { while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) {
await Future.delayed(const Duration(milliseconds: 200)); await Future.delayed(const Duration(milliseconds: 200));
} }
if (state.value != GlassesState.streaming) return false; return state.value == GlassesState.streaming;
// Si on vient de passer à streaming via 'started' (pas 'streaming'), attendre un peu
// que le SDK soit vraiment prêt pour capturePhoto()
if (!alreadyStarted) await Future.delayed(const Duration(milliseconds: 1500));
return true;
} }
/// Capture un seul frame depuis le stream (doit être déjà actif). /// Capture un seul frame depuis la texture du stream (doit être déjà actif).
/// Retourne le chemin du fichier temporaire, ou null si pas de frame en 2s. /// Rastérisation Dart pure, sans passer par les lunettes bien plus rapide
/// que capturePhoto, mais indisponible en arrière-plan (pas d'accès GPU).
/// Retourne le chemin d'un PNG temporaire, ou null.
Future<String?> grabFrame() async { Future<String?> grabFrame() async {
if (state.value != GlassesState.streaming) return null; final texture = _textureId;
final completer = Completer<Uint8List?>(); if (texture == null || state.value != GlassesState.streaming) return null;
StreamSubscription? sub; final frame = await MetaWearablesDat.captureStreamFrame(
sub = Wearables.instance.videoFramesStream.listen((frame) { texture,
if (!completer.isCompleted) completer.complete(frame); format: FrameFormat.png,
sub?.cancel(); );
}, onError: (_) { if (frame == null) return null;
if (!completer.isCompleted) completer.complete(null); return _writeTempFile(frame.bytes, 'png', 'qr_frame');
}); }
Future.delayed(const Duration(seconds: 2), () {
if (!completer.isCompleted) completer.complete(null); Future<String> _writeTempFile(Uint8List bytes, String extension, String prefix) async {
sub?.cancel();
});
final bytes = await completer.future;
if (bytes == null) return null;
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final file = File('${dir.path}/qr_frame_${DateTime.now().millisecondsSinceEpoch}.jpg'); final file = File(
'${dir.path}/${prefix}_${DateTime.now().millisecondsSinceEpoch}.$extension',
);
await file.writeAsBytes(bytes); await file.writeAsBytes(bytes);
return file.path; return file.path;
} }
// Audio HFP (micro lunettes)
/// Appelé quand les lunettes sont détectées.
/// On ne force PAS MODE_IN_COMMUNICATION ici ça dégraderait le TTS (HFP 8kHz).
/// Android route automatiquement le micro HFP vers SpeechRecognizer quand connecté.
/// A2DP reste actif pour la sortie TTS haute qualité.
void _onDeviceConnected() {
debugPrint('[MetaGlassesService] Glasses connected — A2DP + HFP active (no forced routing)');
}
// Callbacks SDK // Callbacks SDK
Future<void> _ensureCameraPermission() async { Future<void> _ensureCameraPermission() async {
try { try {
final status = await Wearables.instance.checkCameraPermission(); if (await MetaWearablesDat.getCameraPermissionStatus()) return;
debugPrint('[MetaGlassesService] Camera permission: $status'); final granted = await MetaWearablesDat.requestCameraPermission();
if (status.toLowerCase() == 'granted') return; debugPrint('[MetaGlassesService] Camera permission granted: $granted');
await Wearables.instance.requestCameraPermission();
} catch (e) { } catch (e) {
debugPrint('[MetaGlassesService] Camera permission error: $e'); debugPrint('[MetaGlassesService] Camera permission error: $e');
} }
} }
void _onRegistrationState(RegistrationState s) { void _onRegistrationState(RegistrationState s) {
debugPrint('[MetaGlassesService] Registration: ${s.state} error=${s.error}'); debugPrint('[MetaGlassesService] Registration: $s');
switch (s.state.toLowerCase()) { _registration = s;
case 'registered': switch (s) {
case 'available': case RegistrationState.registered:
if (state.value == GlassesState.connecting) { if (state.value == GlassesState.connecting) {
state.value = GlassesState.connected; state.value = GlassesState.connected;
} }
case 'unregistered': case RegistrationState.unavailable:
case 'unavailable':
state.value = GlassesState.disconnected; state.value = GlassesState.disconnected;
_textureId = null;
case RegistrationState.available:
case RegistrationState.registering:
break;
} }
} }
void _onStreamState(String s) { void _onActiveDevice(bool available) {
debugPrint('[MetaGlassesService] Stream state: $s'); debugPrint('[MetaGlassesService] Active device available: $available');
final lower = s.toLowerCase(); if (available) {
// 'streaming' = flux vidéo actif, capturePhoto() fonctionne immédiatement // A2DP + HFP sont gérés par Android ; on ne force pas
// 'started' = stream initialisé capturePhoto() peut fonctionner après un court délai // MODE_IN_COMMUNICATION ici, ça dégraderait le TTS (HFP 8 kHz).
if (lower.contains('streaming') || lower == 'started') { if (state.value == GlassesState.disconnected &&
state.value = GlassesState.streaming; _registration == RegistrationState.registered) {
} else if (lower == 'stopped' || lower == 'closed') {
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected; state.value = GlassesState.connected;
} }
} else if (state.value != GlassesState.connecting) {
state.value = GlassesState.disconnected;
_textureId = null;
} }
} }
void dispose() => state.dispose(); void _onStreamState(StreamSessionState s) {
debugPrint('[MetaGlassesService] Stream state: $s');
switch (s) {
case StreamSessionState.streaming:
state.value = GlassesState.streaming;
case StreamSessionState.stopped:
// Terminal : la texture native est désenregistrée avec l'état.
_textureId = null;
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected;
}
case StreamSessionState.paused:
// Le SDK entre et sort de paused seul (thermique). Ne pas détruire la
// session : la texture reste valide, les frames reprendront dessus.
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected;
}
case StreamSessionState.stopping:
case StreamSessionState.waitingForDevice:
case StreamSessionState.starting:
break;
}
}
void _onStreamError(StreamSessionError e) {
debugPrint('[MetaGlassesService] Stream error: $e');
}
void dispose() {
for (final s in _subs) {
s.cancel();
}
_subs.clear();
state.dispose();
}
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show appFlavor;
// Third-party integrations injectées via --dart-define, jamais committées. // Third-party integrations injectées via --dart-define, jamais committées.
// //
@ -29,18 +30,55 @@ const kGeminiTtsPrompt = String.fromEnvironment('GEMINI_TTS_PROMPT',
// ElevenLabs retiré du pipeline (trop cher). Fichier impl/elevenlabs_tts_engine.dart // ElevenLabs retiré du pipeline (trop cher). Fichier impl/elevenlabs_tts_engine.dart
// conservé comme référence mais non utilisé. // conservé comme référence mais non utilisé.
// Lunettes Ray-Ban Meta DESACTIVE par defaut.
//
// Avec meta_wearables_dat 0.1.3 (SDK DAT 0.3.0), le SDK plantait le processus
// au demarrage sur certains telephones : SIGABRT dans
// com.facebook.wearable.airshield.security.PrivateKey.<clinit>,
// « __cxa_guard_acquire detected recursive initialization ». C'est une
// initialisation statique C++ cote natif (mwdat-core / airshield) : aucun
// try/catch Dart ne peut l'attraper, l'app meurt avant d'afficher quoi que
// ce soit. Constate le 2026-09-07 sur Xiaomi klimt / Android 16 / arm64.
//
// Migre vers flutter_meta_wearables_dat 0.9.1 (SDK DAT 0.9.0) : le correctif
// est plausible mais NON VERIFIE a revalider sur le Xiaomi avant de
// retirer ce drapeau.
//
// Pour tester les lunettes : --dart-define=ENABLE_GLASSES=true
const kEnableGlasses = bool.fromEnvironment('ENABLE_GLASSES', defaultValue: false);
const kPicovoiceAccessKey = String.fromEnvironment('PICOVOICE_ACCESS_KEY', defaultValue: ''); const kPicovoiceAccessKey = String.fromEnvironment('PICOVOICE_ACCESS_KEY', defaultValue: '');
// API configuration injectées au build via --dart-define // API configuration injectées au build via --dart-define
// Ex: flutter build appbundle --flavor mdlf --dart-define=INSTANCE_ID=65ccc67265373befd15be511 --dart-define=API_BASE_URL=https://api.mymuseum.be --dart-define=API_KEY=xxxx // Ex: flutter build appbundle --flavor mdlf --dart-define=INSTANCE_ID=65ccc67265373befd15be511 --dart-define=API_BASE_URL=https://api.mymuseum.be --dart-define=API_KEY=xxxx
//
// Les replis suivent le flavor. Ils étaient constants et pointaient sur le PC de
// dev : un `flutter run --flavor fortsaintheribert` sans dart-define lançait une
// app cliente sur http://192.168.31.228:5000, et le boot restait bloqué le temps
// que le socket meure écran de splash blanc pendant deux minutes.
const kApiBaseUrl = String.fromEnvironment('API_BASE_URL', const kApiBaseUrl = String.fromEnvironment('API_BASE_URL',
defaultValue: 'http://192.168.31.228:5000'); defaultValue:
_flavor == 'dev' ? 'http://192.168.31.228:5000' : 'https://api.mymuseum.be');
const kApiKey = String.fromEnvironment('API_KEY', defaultValue: ''); const kApiKey = String.fromEnvironment('API_KEY', defaultValue: '');
const kInstanceId = String.fromEnvironment('INSTANCE_ID',
defaultValue: '63514fd67ed8c735aaa4b8f2');
// Flavor injecté au build via --dart-define=FLAVOR=mdlf // Empreinte du commit, injectee au build :
const _flavor = String.fromEnvironment('FLAVOR', defaultValue: 'dev'); // --dart-define=GIT_SHA=$(git rev-parse --short HEAD)
// Affichee avec le numero de version en bas de la feuille Parametres, pour
// relier un APK installe sur le terrain au commit exact qui l'a produit.
const kGitSha = String.fromEnvironment('GIT_SHA', defaultValue: 'dev');
const kInstanceId = String.fromEnvironment('INSTANCE_ID',
defaultValue: _flavor == 'mdlf'
? '65ccc67265373befd15be511'
: _flavor == 'fortsaintheribert'
? '633ee379d9405f32f166f047'
: '63514fd67ed8c735aaa4b8f2');
// Flavor lu depuis le --flavor gradle (appFlavor). Le --dart-define=FLAVOR reste
// accepté en repli pour les scripts de build existants.
const _flavor = appFlavor ?? String.fromEnvironment('FLAVOR', defaultValue: 'dev');
// Le meme flavor, lisible hors de ce fichier (badge de version des Parametres).
const kFlavor = _flavor;
// Colors définies par flavor // Colors définies par flavor
// Pour modifier les couleurs d'un client : changer les valeurs hex ci-dessous // Pour modifier les couleurs d'un client : changer les valeurs hex ci-dessous
@ -105,6 +143,12 @@ const kMainColor2 = Color(_flavor == 'mdlf'
0xFF309cb0 // test (défaut) 0xFF309cb0 // test (défaut)
); );
const kAppTitle = _flavor == 'mdlf'
? 'MDLF'
: _flavor == 'fortsaintheribert'
? 'Fort Saint-Héribert'
: 'MyMuseum Dev';
const kSplashLogoAsset = _flavor == 'mdlf' const kSplashLogoAsset = _flavor == 'mdlf'
? 'assets/splash/mdlf.png' ? 'assets/splash/mdlf.png'
: _flavor == 'fortsaintheribert' : _flavor == 'fortsaintheribert'

View File

@ -1,6 +1,9 @@
//import 'package:audioplayers/audioplayers.dart'; //import 'package:audioplayers/audioplayers.dart';
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:app_links/app_links.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -25,6 +28,10 @@ import 'client.dart';
import 'constants.dart'; import 'constants.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
/// Plafond des appels réseau du démarrage. Aucun d'eux n'est nécessaire pour
/// afficher l'accueil : ils enrichissent le contexte, ils ne le conditionnent pas.
const _bootNetworkTimeout = Duration(seconds: 8);
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
runApp(const AppBootstrap()); runApp(const AppBootstrap());
@ -39,13 +46,30 @@ class AppBootstrap extends StatefulWidget {
class _AppBootstrapState extends State<AppBootstrap> { class _AppBootstrapState extends State<AppBootstrap> {
VisitAppContext? _ctx; VisitAppContext? _ctx;
StreamSubscription<Uri>? _deepLinkSub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_listenGlassesDeepLinks();
_initApp(); _initApp();
} }
@override
void dispose() {
_deepLinkSub?.cancel();
super.dispose();
}
/// L'app Meta AI renvoie vers notre scheme pour terminer la registration DAT.
void _listenGlassesDeepLinks() {
if (!kEnableGlasses || !Platform.isAndroid) return;
_deepLinkSub = AppLinks().uriLinkStream.listen(
(uri) => MetaGlassesService.instance.handleDeepLink(uri.toString()),
onError: (e) => print('Glasses deep link error: $e'),
);
}
Future<void> _initApp() async { Future<void> _initApp() async {
// Firebase init (requires google-services.json on Android, GoogleService-Info.plist on iOS) // Firebase init (requires google-services.json on Android, GoogleService-Info.plist on iOS)
if (!Platform.isWindows) { if (!Platform.isWindows) {
@ -65,6 +89,17 @@ class _AppBootstrapState extends State<AppBootstrap> {
List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead)); List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead));
localContext.readSections = articleReadTest; localContext.readSections = articleReadTest;
// L'instance vient du build, jamais du visiteur — la base locale ne fait que
// la mémoriser. Elle gagnait quand même : une app cliente lancée une fois sans
// `--dart-define=INSTANCE_ID` écrivait l'instance de dev en base, et continuait
// ensuite de l'interroger même relancée avec la bonne configuration.
if (localContext.instanceId != kInstanceId) {
print('instanceId local (${localContext.instanceId}) remplacé par celui du build ($kInstanceId)');
localContext.instanceId = kInstanceId;
localContext.apiKey = null;
DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext);
}
} else { } else {
localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false); localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false);
DatabaseHelper.instance.insert(DatabaseTableType.main, localContext.toMap()); DatabaseHelper.instance.insert(DatabaseTableType.main, localContext.toMap());
@ -82,10 +117,15 @@ class _AppBootstrapState extends State<AppBootstrap> {
// le client. La condition portait sur `apiKey == null` : une fois la clé en base, la // le client. La condition portait sur `apiKey == null` : une fois la clé en base, la
// réponse n'était plus jamais lue, et `guideVoiceId` avec elle. Elle porte désormais // réponse n'était plus jamais lue, et `guideVoiceId` avec elle. Elle porte désormais
// sur l'instance — un appel au démarrage, déjà non bloquant en cas d'échec réseau. // sur l'instance — un appel au démarrage, déjà non bloquant en cas d'échec réseau.
//
// Le `timeout` n'est pas une précaution : un socket qui ne répond pas (serveur
// éteint, port filtré) ne rend la main qu'au bout de deux minutes, et le boot
// attend ici. L'app restait sur son splash tout ce temps.
if (localContext.instanceId != null) { if (localContext.instanceId != null) {
try { try {
final instanceDto = await localContext.clientAPI.instanceApi! final instanceDto = await localContext.clientAPI.instanceApi!
.instanceGetDetail(localContext.instanceId!); .instanceGetDetail(localContext.instanceId!)
.timeout(_bootNetworkTimeout);
if (localContext.apiKey == null && instanceDto?.publicApiKey != null) { if (localContext.apiKey == null && instanceDto?.publicApiKey != null) {
localContext.apiKey = instanceDto!.publicApiKey; localContext.apiKey = instanceDto!.publicApiKey;
@ -104,7 +144,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
// Push notifications subscribe to instance topic if enabled // Push notifications subscribe to instance topic if enabled
if (!Platform.isWindows && localContext.instanceId != null) { if (!Platform.isWindows && localContext.instanceId != null) {
try { try {
await PushNotificationService.initialize(localContext.instanceId!); await PushNotificationService.initialize(localContext.instanceId!)
.timeout(_bootNetworkTimeout);
} catch (e) { } catch (e) {
print('PushNotification init failed: $e'); print('PushNotification init failed: $e');
} }
@ -115,7 +156,15 @@ class _AppBootstrapState extends State<AppBootstrap> {
// Glasses SDK init toujours initialisé pour détecter la connexion BT. // Glasses SDK init toujours initialisé pour détecter la connexion BT.
// L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça. // L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça.
if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) { // Historique : avec meta_wearables_dat 0.1.3 (SDK DAT 0.3.0), le SDK tuait
// le processus au chargement de ses libs natives sur certains telephones :
// SIGABRT dans facebook::jni::initialize, « __cxa_guard_acquire detected
// recursive initialization », non rattrapable en Dart. Constate le 2026-09-07
// sur Xiaomi klimt / Android 16. Le passage a flutter_meta_wearables_dat
// (SDK DAT 0.9.0, qui ne lie plus mwdat-mockdevice) est cense le corriger :
// A REVALIDER SUR CE TELEPHONE avant de retirer le drapeau, desactive par defaut :
// --dart-define=ENABLE_GLASSES=true
if (kEnableGlasses && !Platform.isWindows && Platform.isAndroid) {
await MetaGlassesService.instance.initialize(); await MetaGlassesService.instance.initialize();
} }
@ -132,6 +181,7 @@ class _AppBootstrapState extends State<AppBootstrap> {
if (_ctx == null) { if (_ctx == null) {
return const MaterialApp( return const MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: kAppTitle,
home: SplashScreen(), home: SplashScreen(),
); );
} }
@ -188,7 +238,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
], ],
child: MaterialApp( child: MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert' title: kAppTitle,
initialRoute: widget.initialRoute, initialRoute: widget.initialRoute,
localizationsDelegates: const [ localizationsDelegates: const [
AppLocalizations.delegate, AppLocalizations.delegate,

View File

@ -6,6 +6,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Pour suivre cette visite, il faut d'abord la télécharger", "visitDownloadWarning": "Pour suivre cette visite, il faut d'abord la télécharger",
"noData": "Pas de données", "noData": "Pas de données",
"invalidQRCode": "Code QR invalide", "invalidQRCode": "Code QR invalide",
"qrOtherVisit": "Ce QR code appartient à une autre visite.",
"languageNotSupported": "Cette visite ne prend pas en charge votre langue", "languageNotSupported": "Cette visite ne prend pas en charge votre langue",
"downloadConfiguration": "Téléchargement de la visite en cours...", "downloadConfiguration": "Téléchargement de la visite en cours...",
"noInternet": "Aucune connexion internet détectée", "noInternet": "Aucune connexion internet détectée",
@ -26,6 +27,7 @@ List<Translation> translations = [
"downloadInProgress": "Téléchargement en cours", "downloadInProgress": "Téléchargement en cours",
"downloadFinish": "Téléchargement terminé", "downloadFinish": "Téléchargement terminé",
"downloadIncomplete": "Téléchargement incomplet : certains fichiers manquent. Relancez le téléchargement, seuls les fichiers manquants seront récupérés.", "downloadIncomplete": "Téléchargement incomplet : certains fichiers manquent. Relancez le téléchargement, seuls les fichiers manquants seront récupérés.",
"downloadFailed": "Le contenu n'a pas pu être téléchargé. Vérifiez votre connexion, puis réessayez.",
"upToDate": "Tout est à jour", "upToDate": "Tout est à jour",
"weather.hourly": "Prochaines heures", "weather.hourly": "Prochaines heures",
"weather.nextdays": "Prochains jours", "weather.nextdays": "Prochains jours",
@ -55,6 +57,7 @@ List<Translation> translations = [
"visitDownloadWarning": "To follow this tour, you must first download it", "visitDownloadWarning": "To follow this tour, you must first download it",
"noData": "No data", "noData": "No data",
"invalidQRCode": "Invalid QR code", "invalidQRCode": "Invalid QR code",
"qrOtherVisit": "This QR code belongs to another tour.",
"languageNotSupported": "This tour doesn't support your language", "languageNotSupported": "This tour doesn't support your language",
"downloadConfiguration": "Loading tour...", "downloadConfiguration": "Loading tour...",
"noInternet": "No internet connection detected", "noInternet": "No internet connection detected",
@ -75,6 +78,7 @@ List<Translation> translations = [
"downloadInProgress": "Download in progress", "downloadInProgress": "Download in progress",
"downloadFinish": "Download complete", "downloadFinish": "Download complete",
"downloadIncomplete": "Download incomplete: some files are missing. Start the download again — only the missing files will be fetched.", "downloadIncomplete": "Download incomplete: some files are missing. Start the download again — only the missing files will be fetched.",
"downloadFailed": "The content could not be downloaded. Check your connection, then try again.",
"upToDate": "Up to date", "upToDate": "Up to date",
"weather.hourly": "Hourly", "weather.hourly": "Hourly",
"weather.nextdays": "Next days", "weather.nextdays": "Next days",
@ -104,6 +108,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Um dieser Tour zu folgen, müssen Sie sie zuerst herunterladen", "visitDownloadWarning": "Um dieser Tour zu folgen, müssen Sie sie zuerst herunterladen",
"noData": "keine Daten", "noData": "keine Daten",
"invalidQRCode": "Ungültiger QR-Code", "invalidQRCode": "Ungültiger QR-Code",
"qrOtherVisit": "Dieser QR-Code gehört zu einer anderen Tour.",
"languageNotSupported": "Diese Tour unterstützt Ihre Sprache nicht", "languageNotSupported": "Diese Tour unterstützt Ihre Sprache nicht",
"downloadConfiguration": "Tour laden...", "downloadConfiguration": "Tour laden...",
"noInternet": "Keine Internetverbindung erkannt", "noInternet": "Keine Internetverbindung erkannt",
@ -124,6 +129,7 @@ List<Translation> translations = [
"downloadInProgress": "Download läuft", "downloadInProgress": "Download läuft",
"downloadFinish": "Download abgeschlossen", "downloadFinish": "Download abgeschlossen",
"downloadIncomplete": "Download unvollständig: Einige Dateien fehlen. Starten Sie den Download erneut — es werden nur die fehlenden Dateien geladen.", "downloadIncomplete": "Download unvollständig: Einige Dateien fehlen. Starten Sie den Download erneut — es werden nur die fehlenden Dateien geladen.",
"downloadFailed": "Der Inhalt konnte nicht heruntergeladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"upToDate": "Alles ist auf dem neuesten Stand", "upToDate": "Alles ist auf dem neuesten Stand",
"weather.hourly": "Nächste Stunden", "weather.hourly": "Nächste Stunden",
"weather.nextdays": "Nächsten Tage", "weather.nextdays": "Nächsten Tage",
@ -153,6 +159,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Om deze tour te volgen, moet je deze eerst downloaden", "visitDownloadWarning": "Om deze tour te volgen, moet je deze eerst downloaden",
"noData": "Geen gegevens", "noData": "Geen gegevens",
"invalidQRCode": "Ongeldige QR-code", "invalidQRCode": "Ongeldige QR-code",
"qrOtherVisit": "Deze QR-code hoort bij een andere rondleiding.",
"languageNotSupported": "Deze tour ondersteunt je taal niet", "languageNotSupported": "Deze tour ondersteunt je taal niet",
"downloadConfiguration": "De rondleiding laden...", "downloadConfiguration": "De rondleiding laden...",
"noInternet": "Geen internetverbinding gedetecteerd", "noInternet": "Geen internetverbinding gedetecteerd",
@ -173,6 +180,7 @@ List<Translation> translations = [
"downloadInProgress": "Download bezig", "downloadInProgress": "Download bezig",
"downloadFinish": "Download voltooid", "downloadFinish": "Download voltooid",
"downloadIncomplete": "Download onvolledig: sommige bestanden ontbreken. Start de download opnieuw — alleen de ontbrekende bestanden worden opgehaald.", "downloadIncomplete": "Download onvolledig: sommige bestanden ontbreken. Start de download opnieuw — alleen de ontbrekende bestanden worden opgehaald.",
"downloadFailed": "De inhoud kon niet worden gedownload. Controleer uw verbinding en probeer het opnieuw.",
"upToDate": "Alles is up-to-date", "upToDate": "Alles is up-to-date",
"weather.hourly": "Volgende uren", "weather.hourly": "Volgende uren",
"weather.nextdays": "Volgende dagen", "weather.nextdays": "Volgende dagen",
@ -202,6 +210,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Per seguire questo tour, devi prima scaricarlo", "visitDownloadWarning": "Per seguire questo tour, devi prima scaricarlo",
"noData": "Nessun dato", "noData": "Nessun dato",
"invalidQRCode": "Codice QR non valido", "invalidQRCode": "Codice QR non valido",
"qrOtherVisit": "Questo codice QR appartiene a un altro tour.",
"languageNotSupported": "Questo tour non supporta la tua lingua", "languageNotSupported": "Questo tour non supporta la tua lingua",
"downloadConfiguration": "Caricamento del tour...", "downloadConfiguration": "Caricamento del tour...",
"noInternet": "Nessuna connessione Internet rilevata", "noInternet": "Nessuna connessione Internet rilevata",
@ -222,6 +231,7 @@ List<Translation> translations = [
"downloadInProgress": "Download in corso", "downloadInProgress": "Download in corso",
"downloadFinish": "Download completato", "downloadFinish": "Download completato",
"downloadIncomplete": "Download incompleto: alcuni file mancano. Riavvia il download — verranno recuperati solo i file mancanti.", "downloadIncomplete": "Download incompleto: alcuni file mancano. Riavvia il download — verranno recuperati solo i file mancanti.",
"downloadFailed": "Non è stato possibile scaricare il contenuto. Controlla la connessione e riprova.",
"upToDate": "Tutto è aggiornato", "upToDate": "Tutto è aggiornato",
"weather.hourly": "Le prossime ore", "weather.hourly": "Le prossime ore",
"weather.nextdays": "Prossimi giorni", "weather.nextdays": "Prossimi giorni",
@ -251,6 +261,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Para realizar este recorrido, primero debe descargarlo", "visitDownloadWarning": "Para realizar este recorrido, primero debe descargarlo",
"noData": "Sin datos", "noData": "Sin datos",
"invalidQRCode": "Código QR no válido", "invalidQRCode": "Código QR no válido",
"qrOtherVisit": "Este código QR pertenece a otro recorrido.",
"languageNotSupported": "Este tour no es compatible con tu idioma", "languageNotSupported": "Este tour no es compatible con tu idioma",
"downloadConfiguration": "Cargando el recorrido...", "downloadConfiguration": "Cargando el recorrido...",
"noInternet": "No se detectó conexión a Internet", "noInternet": "No se detectó conexión a Internet",
@ -271,6 +282,7 @@ List<Translation> translations = [
"downloadInProgress": "Descarga en curso", "downloadInProgress": "Descarga en curso",
"downloadFinish": "Descarga completada", "downloadFinish": "Descarga completada",
"downloadIncomplete": "Descarga incompleta: faltan algunos archivos. Reinicie la descarga — solo se recuperarán los archivos que faltan.", "downloadIncomplete": "Descarga incompleta: faltan algunos archivos. Reinicie la descarga — solo se recuperarán los archivos que faltan.",
"downloadFailed": "No se pudo descargar el contenido. Comprueba tu conexión y vuelve a intentarlo.",
"upToDate": "Todo está al día", "upToDate": "Todo está al día",
"weather.hourly": "Próximas horas", "weather.hourly": "Próximas horas",
"weather.nextdays": "Proximos dias", "weather.nextdays": "Proximos dias",
@ -300,6 +312,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Aby wziąć udział w tej wycieczce, musisz ją najpierw pobrać", "visitDownloadWarning": "Aby wziąć udział w tej wycieczce, musisz ją najpierw pobrać",
"noData": "Brak danych", "noData": "Brak danych",
"invalidQRCode": "Nieprawidłowy kod QR", "invalidQRCode": "Nieprawidłowy kod QR",
"qrOtherVisit": "Ten kod QR należy do innej wycieczki.",
"languageNotSupported": "Ta wycieczka nie obsługuje Twojego języka", "languageNotSupported": "Ta wycieczka nie obsługuje Twojego języka",
"downloadConfiguration": "Wczytuję prezentację...", "downloadConfiguration": "Wczytuję prezentację...",
"noInternet": "Nie wykryto połączenia internetowego", "noInternet": "Nie wykryto połączenia internetowego",
@ -320,6 +333,7 @@ List<Translation> translations = [
"downloadInProgress": "Pobieranie w toku", "downloadInProgress": "Pobieranie w toku",
"downloadFinish": "Pobieranie zakończone", "downloadFinish": "Pobieranie zakończone",
"downloadIncomplete": "Pobieranie niekompletne: brakuje niektórych plików. Uruchom pobieranie ponownie — zostaną pobrane tylko brakujące pliki.", "downloadIncomplete": "Pobieranie niekompletne: brakuje niektórych plików. Uruchom pobieranie ponownie — zostaną pobrane tylko brakujące pliki.",
"downloadFailed": "Nie udało się pobrać treści. Sprawdź połączenie i spróbuj ponownie.",
"upToDate": "Wszystko jest aktualne", "upToDate": "Wszystko jest aktualne",
"weather.hourly": "Następne godziny", "weather.hourly": "Następne godziny",
"weather.nextdays": "Następne dni", "weather.nextdays": "Następne dni",
@ -349,6 +363,7 @@ List<Translation> translations = [
"visitDownloadWarning": "要参加此导览,您需要先下载它", "visitDownloadWarning": "要参加此导览,您需要先下载它",
"noData": "没有数据", "noData": "没有数据",
"invalidQRCode": "二维码无效", "invalidQRCode": "二维码无效",
"qrOtherVisit": "此二维码属于另一个导览。",
"languageNotSupported": "此导览不支持您的语言", "languageNotSupported": "此导览不支持您的语言",
"downloadConfiguration": "正在加载导览...", "downloadConfiguration": "正在加载导览...",
"noInternet": "未检测到互联网连接", "noInternet": "未检测到互联网连接",
@ -369,6 +384,7 @@ List<Translation> translations = [
"downloadInProgress": "下载中", "downloadInProgress": "下载中",
"downloadFinish": "下载完成", "downloadFinish": "下载完成",
"downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。", "downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。",
"downloadFailed": "无法下载内容。请检查网络连接后重试。",
"upToDate": "已是最新", "upToDate": "已是最新",
"weather.hourly": "接下来的几个小时", "weather.hourly": "接下来的几个小时",
"weather.nextdays": "未来几天", "weather.nextdays": "未来几天",
@ -398,6 +414,7 @@ List<Translation> translations = [
"visitDownloadWarning": "Щоб стежити за цим оглядом, спершу його потрібно завантажити", "visitDownloadWarning": "Щоб стежити за цим оглядом, спершу його потрібно завантажити",
"noData": "Немає даних", "noData": "Немає даних",
"invalidQRCode": "Недійсний QR-код", "invalidQRCode": "Недійсний QR-код",
"qrOtherVisit": "Цей QR-код належить до іншого туру.",
"languageNotSupported": "Цей тур не підтримує вашу мову", "languageNotSupported": "Цей тур не підтримує вашу мову",
"downloadConfiguration": "Завантаження туру...", "downloadConfiguration": "Завантаження туру...",
"noInternet": "Підключення до Інтернету не виявлено", "noInternet": "Підключення до Інтернету не виявлено",
@ -418,6 +435,7 @@ List<Translation> translations = [
"downloadInProgress": "Завантаження триває", "downloadInProgress": "Завантаження триває",
"downloadFinish": "Завантаження завершено", "downloadFinish": "Завантаження завершено",
"downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.", "downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.",
"downloadFailed": "Не вдалося завантажити вміст. Перевірте з'єднання та спробуйте ще раз.",
"upToDate": "Все актуально", "upToDate": "Все актуально",
"weather.hourly": "Наступні години", "weather.hourly": "Наступні години",
"weather.nextdays": "Наступні дні", "weather.nextdays": "Наступні дні",
@ -447,6 +465,7 @@ List<Translation> translations = [
"visitDownloadWarning": "لمتابعة هذه الجولة ، يجب عليك أولاً تنزيلها", "visitDownloadWarning": "لمتابعة هذه الجولة ، يجب عليك أولاً تنزيلها",
"noData": "لايوجد بيانات", "noData": "لايوجد بيانات",
"invalidQRCode": "رمز الاستجابة السريعة غير صالح", "invalidQRCode": "رمز الاستجابة السريعة غير صالح",
"qrOtherVisit": "ينتمي رمز الاستجابة السريعة هذا إلى جولة أخرى.",
"languageNotSupported": "هذه الجولة لا تدعم لغتك", "languageNotSupported": "هذه الجولة لا تدعم لغتك",
"downloadConfiguration": "جارٍ تحميل الجولة ...", "downloadConfiguration": "جارٍ تحميل الجولة ...",
"noInternet": "لم يتم الكشف عن اتصال بالإنترنت", "noInternet": "لم يتم الكشف عن اتصال بالإنترنت",
@ -467,6 +486,7 @@ List<Translation> translations = [
"downloadInProgress": "جارٍ التنزيل", "downloadInProgress": "جارٍ التنزيل",
"downloadFinish": "اكتمل التنزيل", "downloadFinish": "اكتمل التنزيل",
"downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.", "downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.",
"downloadFailed": "تعذّر تنزيل المحتوى. تحقّق من اتصالك ثم أعد المحاولة.",
"upToDate": "كل شيء محدث", "upToDate": "كل شيء محدث",
"weather.hourly": "الساعات القادمة", "weather.hourly": "الساعات القادمة",
"weather.nextdays": "الايام القادمة", "weather.nextdays": "الايام القادمة",

View File

@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_sound/flutter_sound_plugin.h> #include <flutter_sound/flutter_sound_plugin.h>
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_sound_registrar = g_autoptr(FlPluginRegistrar) flutter_sound_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin");
flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); flutter_sound_plugin_register_with_registrar(flutter_sound_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_sound flutter_sound
gtk
url_launcher_linux url_launcher_linux
) )

View File

@ -25,6 +25,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.4.1" version: "6.4.1"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -539,6 +571,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" version: "7.0.2"
flutter_meta_wearables_dat:
dependency: "direct main"
description:
name: flutter_meta_wearables_dat
sha256: "13a6cb246c7b1cce229a5e96ed9bbd69c16a81d647208eb0f39a0ad474e8b668"
url: "https://pub.dev"
source: hosted
version: "0.9.1"
flutter_pdfview: flutter_pdfview:
dependency: "direct main" dependency: "direct main"
description: description:
@ -813,6 +853,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.3.1"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
html: html:
dependency: transitive dependency: transitive
description: description:
@ -1020,14 +1068,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
meta_wearables_dat:
dependency: "direct main"
description:
name: meta_wearables_dat
sha256: e9fbd76a9306b3267b4af8eab58f244901029e98a303cb531704fec8f04657fe
url: "https://pub.dev"
source: hosted
version: "0.1.3"
mgrs_dart: mgrs_dart:
dependency: transitive dependency: transitive
description: description:
@ -1108,7 +1148,7 @@ packages:
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
package_info_plus: package_info_plus:
dependency: transitive dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918
@ -1937,5 +1977,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=3.10.0-0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.44.0"

View File

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 2.0.2+21 version: 2.0.3+22
environment: environment:
sdk: ">=3.1.0 <4.0.0" sdk: ">=3.1.0 <4.0.0"
@ -71,6 +71,7 @@ dependencies:
flutter_map: ^7.0.2 #all flutter_map: ^7.0.2 #all
image: ^4.1.7 image: ^4.1.7
url_launcher: ^6.3.1 url_launcher: ^6.3.1
package_info_plus: ^8.0.2 # numero de version affiche dans les Parametres
speech_to_text: ^7.0.0 speech_to_text: ^7.0.0
geolocator: ^13.0.0 geolocator: ^13.0.0
@ -81,8 +82,8 @@ dependencies:
flutter_local_notifications: ^17.2.2 flutter_local_notifications: ^17.2.2
# Ray-Ban Meta glasses integration # Ray-Ban Meta glasses integration
meta_wearables_dat: ^0.1.3 # Android — SDK DAT v0.3.0 flutter_meta_wearables_dat: ^0.9.1 # Android + iOS — SDK DAT v0.9.0
# meta_wearables: ^0.0.1 — package vide, skip app_links: ^6.4.1 # Retour du deep link Meta AI -> handleUrl()
# porcupine_flutter: ^3.0.3 — wake word prod, à activer (payant) # porcupine_flutter: ^3.0.3 — wake word prod, à activer (payant)
flutter_tts: ^4.2.0 # TTS on-device gratuit (Google/Apple) — remplace ElevenLabs flutter_tts: ^4.2.0 # TTS on-device gratuit (Google/Apple) — remplace ElevenLabs
flutter_foreground_task: ^8.11.0 # Garde le main isolate en vie (Android foreground service + iOS) flutter_foreground_task: ^8.11.0 # Garde le main isolate en vie (Android foreground service + iOS)