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:
parent
84ccaffcb5
commit
d39949a6bd
@ -7,6 +7,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
|
||||
<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" />
|
||||
<!-- Foreground Service — garde le wake word actif téléphone en poche -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@ -35,7 +38,7 @@
|
||||
android:name="com.meta.wearable.mwdat.APPLICATION_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
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@ -70,6 +73,14 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
|
||||
49
lib/Components/GlassPill.dart
Normal file
49
lib/Components/GlassPill.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
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/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
|
||||
/// Panneau de debug pour l'intégration Ray-Ban Meta.
|
||||
/// À 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é');
|
||||
return;
|
||||
}
|
||||
_subs.add(Wearables.instance.registrationStateStream.listen(
|
||||
(s) => _addLog('📋 registration: ${s.state} err=${s.error}'),
|
||||
_subs.add(MetaWearablesDat.registrationStateStream().listen(
|
||||
(s) => _addLog('📋 registration: $s'),
|
||||
onError: (e) => _addLog('📋 registration error: $e'),
|
||||
));
|
||||
_subs.add(Wearables.instance.devicesStream.listen(
|
||||
(d) => _addLog('📱 devices: $d'),
|
||||
onError: (e) => _addLog('📱 devices error: $e'),
|
||||
_subs.add(MetaWearablesDat.activeDeviceStream().listen(
|
||||
(available) => _addLog('📱 activeDevice: $available'),
|
||||
onError: (e) => _addLog('📱 activeDevice error: $e'),
|
||||
));
|
||||
_subs.add(Wearables.instance.streamStateStream.listen(
|
||||
_subs.add(MetaWearablesDat.streamSessionStateStream().listen(
|
||||
(s) => _addLog('🎥 streamState: $s'),
|
||||
onError: (e) => _addLog('🎥 streamState error: $e'),
|
||||
));
|
||||
_subs.add(Wearables.instance.videoFramesStream.listen(
|
||||
(f) => _addLog('🖼 videoFrame: ${f.length} bytes'),
|
||||
_subs.add(MetaWearablesDat.streamSessionErrorStream().listen(
|
||||
(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'),
|
||||
));
|
||||
_subs.add(MetaWearablesDat.deviceStateStream().listen(
|
||||
(d) => _addLog('🌡 deviceState: $d'),
|
||||
onError: (e) => _addLog('🌡 deviceState error: $e'),
|
||||
));
|
||||
setState(() => _monitoring = true);
|
||||
_addLog('▶ Monitor démarré — interagis avec les lunettes');
|
||||
}
|
||||
@ -152,21 +159,17 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
label: 'Activer caméra',
|
||||
icon: Icons.camera_alt,
|
||||
onTap: () => _run('requestCameraPermission + startStream', () async {
|
||||
await Wearables.instance.requestCameraPermission();
|
||||
await Wearables.instance.startStream(
|
||||
videoQuality: 'MEDIUM',
|
||||
frameRate: 24,
|
||||
);
|
||||
final granted = await MetaWearablesDat.requestCameraPermission();
|
||||
_addLog('camera granted: $granted');
|
||||
await MetaGlassesService.instance.startStream();
|
||||
}),
|
||||
),
|
||||
_ActionButton(
|
||||
label: 'Start stream',
|
||||
icon: Icons.videocam,
|
||||
onTap: () => _run('startStream (direct)', () async {
|
||||
await Wearables.instance.startStream(
|
||||
videoQuality: 'MEDIUM',
|
||||
frameRate: 24,
|
||||
);
|
||||
await MetaGlassesService.instance.startStream();
|
||||
_addLog('textureId: ${MetaGlassesService.instance.textureId}');
|
||||
}),
|
||||
),
|
||||
_ActionButton(
|
||||
@ -211,7 +214,7 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
icon: Icons.stop,
|
||||
color: Colors.red,
|
||||
onTap: () => _run('stopStream', () async {
|
||||
await Wearables.instance.stopStream();
|
||||
await MetaGlassesService.instance.stopStream();
|
||||
}),
|
||||
),
|
||||
_ActionButton(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
|
||||
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
@ -42,15 +43,12 @@ class GlassesStatusWidget extends StatelessWidget {
|
||||
_ => Colors.white38,
|
||||
};
|
||||
|
||||
return GestureDetector(
|
||||
return GlassPill(
|
||||
size: 46,
|
||||
onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 46,
|
||||
height: 46,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
@ -64,8 +62,8 @@ class GlassesStatusWidget extends StatelessWidget {
|
||||
// Point vert en bas à droite si mode actif
|
||||
if (modeActive)
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
bottom: 7,
|
||||
right: 7,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
|
||||
@ -3,8 +3,10 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/ConfigurationPage/configuration_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/section_page.dart';
|
||||
import 'package:mymuseum_visitapp/app_context.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
@ -90,7 +92,7 @@ class _ScannerDialogState extends State<ScannerDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (isProcessing) return;
|
||||
|
||||
final barcode = capture.barcodes.first;
|
||||
@ -129,14 +131,42 @@ class _ScannerDialogState extends State<ScannerDialog> {
|
||||
|
||||
if (visitAppContext.sectionIds == null || !visitAppContext.sectionIds!.contains(sectionId)) {
|
||||
visitAppContext.statisticsService?.track(VisitEventType.qrScan, metadata: {'valid': false, 'sectionId': sectionId});
|
||||
|
||||
// Le QR porte l'id de sa configuration, jusqu'ici parsé puis jeté. Un code
|
||||
// d'une autre visite du site tombait donc dans « QR code invalide », qui
|
||||
// laisse croire à un code abîmé alors qu'il est parfaitement lisible.
|
||||
final other = await _findOtherConfiguration(visitAppContext, configurationId);
|
||||
if (!mounted) return;
|
||||
|
||||
// Capturés AVANT le pop : fermer la boîte du scanner démonte ce State, et
|
||||
// tout ce qui repasse ensuite par son `context` — la navigation depuis le
|
||||
// bouton « Ouvrir », touché quelques secondes plus tard — ne fait rien.
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
navigator.pop();
|
||||
|
||||
if (other != null) {
|
||||
_proposeOtherVisit(navigator, visitAppContext, other, sectionId);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
|
||||
// `orElse` : sans lui, le moindre écart entre `sectionIds` et
|
||||
// `currentSections` levait un StateError non rattrapé, en plein scan.
|
||||
dynamic rawSection = visitAppContext.currentSections
|
||||
?.firstWhere((cs) => cs?['id'] == sectionId, orElse: () => null);
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (rawSection == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(TranslationHelper.getFromLocale('invalidQRCode', visitAppContext)), backgroundColor: kMainColor2),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
visitAppContext.statisticsService?.track(VisitEventType.qrScan, sectionId: sectionId, metadata: {'valid': true});
|
||||
dynamic rawSection = visitAppContext.currentSections!.firstWhere((cs) => cs!['id'] == sectionId)!;
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
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
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
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 {
|
||||
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;
|
||||
} on SocketException catch (_) {
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -19,9 +19,10 @@ import 'package:provider/provider.dart';
|
||||
import 'section_card.dart';
|
||||
|
||||
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 String? openSectionId;
|
||||
|
||||
@override
|
||||
State<Body> createState() => _BodyState();
|
||||
@ -30,7 +31,12 @@ class Body extends StatefulWidget {
|
||||
class _BodyState extends State<Body> {
|
||||
late List<SectionDTO> sections;
|
||||
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;
|
||||
int? searchNumberValue;
|
||||
|
||||
@ -43,6 +49,29 @@ class _BodyState extends State<Body> {
|
||||
super.initState();
|
||||
final appContext = Provider.of<AppContext>(context, listen: false);
|
||||
_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
|
||||
@ -210,7 +239,7 @@ class _BodyState extends State<Body> {
|
||||
context,
|
||||
SlideFromRightRoute(page: SectionPage(
|
||||
configuration: widget.configuration,
|
||||
rawSection: rawSections[index],
|
||||
rawSection: _rawSectionById[value[index].id],
|
||||
visitAppContextIn: appContext.getContext(),
|
||||
sectionId: value[index].id!,
|
||||
)),
|
||||
@ -247,19 +276,29 @@ class _BodyState extends State<Body> {
|
||||
Future<List<SectionDTO>> getSections(AppContext appContext) async {
|
||||
VisitAppContext visitAppContext = appContext.getContext();
|
||||
sections = [];
|
||||
_rawSectionById.clear();
|
||||
if(widget.configuration.isOffline == true)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
// ONLINE
|
||||
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();
|
||||
List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().toList();
|
||||
visitAppContext.currentSections = rawSections;
|
||||
for (var raw in rawSections) {
|
||||
_rawSectionById[raw["id"]] = raw;
|
||||
}
|
||||
|
||||
if(sectionList.isNotEmpty) {
|
||||
sections = sectionList.toList();
|
||||
@ -273,6 +312,16 @@ class _BodyState extends State<Body> {
|
||||
}
|
||||
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;
|
||||
applyFilters(visitAppContext);
|
||||
|
||||
|
||||
@ -27,11 +27,16 @@ import 'package:provider/provider.dart';
|
||||
import 'components/body.dart';
|
||||
|
||||
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 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
|
||||
State<ConfigurationPage> createState() => _ConfigurationPageState();
|
||||
}
|
||||
@ -342,7 +347,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
||||
isHomeButton: true,
|
||||
),*/
|
||||
backgroundColor: kBackgroundGrey,
|
||||
body: Body(configuration: widget.configuration),
|
||||
body: Body(configuration: widget.configuration, openSectionId: widget.openSectionId),
|
||||
floatingActionButton: Stack(
|
||||
children: [
|
||||
if (visitAppContext.applicationInstanceDTO?.isAssistant == true)
|
||||
|
||||
@ -351,10 +351,7 @@ class _ConfigurationsListState extends State<ConfigurationsList> {
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
height: 125,
|
||||
child: Center(
|
||||
child: DownloadConfigurationWidget(configuration: configuration)
|
||||
),
|
||||
child: DownloadConfigurationWidget(configuration: configuration),
|
||||
),
|
||||
), context: context
|
||||
);
|
||||
|
||||
@ -3,11 +3,13 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:auto_size_text/auto_size_text.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:manager_api_new/api.dart';
|
||||
import 'package:myinfomate_layout/myinfomate_layout.dart';
|
||||
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.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/VoiceModeSheet.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: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 {
|
||||
final ConfigurationDTO configuration;
|
||||
final int colSpan;
|
||||
@ -61,7 +69,13 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
|
||||
late List<ConfigurationDTO> configurations = [];
|
||||
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 là ».
|
||||
Set<String> downloadedConfigIds = {};
|
||||
late VisitAppContext visitAppContext;
|
||||
|
||||
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
||||
@ -87,16 +101,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
kMainColor1;
|
||||
final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!;
|
||||
|
||||
final isOffline = config.isOffline == true;
|
||||
final needsDownload = isOffline && !downloadedConfigIds.contains(config.id);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => ConfigurationPage(
|
||||
configuration: config,
|
||||
isAlreadyAllowed: visitAppContext.isScanBeaconAlreadyAllowed,
|
||||
),
|
||||
));
|
||||
},
|
||||
onTap: () => _openConfiguration(context, config),
|
||||
child: Hero(
|
||||
tag: config.id!,
|
||||
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é
|
||||
// verticalement sur le bloc de titre, quel que soit son nombre de lignes.
|
||||
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) {
|
||||
final visitAppContext = appCtx.getContext() as VisitAppContext;
|
||||
final configLanguages = visitAppContext.configuration?.languages ?? languages;
|
||||
@ -191,6 +408,8 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: ctx,
|
||||
backgroundColor: _kSurface,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
@ -207,7 +426,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
child: Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
@ -222,66 +441,52 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
leading: const Icon(Icons.mic, color: kMainColor1),
|
||||
title: Text(
|
||||
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)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
subtitle: Text(
|
||||
TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2),
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white38),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
VoiceModeSheet.show(ctx, visitAppContext: visitAppContext);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(color: Colors.white12),
|
||||
],
|
||||
Text(
|
||||
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),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: configLanguages.map((lang) {
|
||||
final isSelected = ctx2.language == lang;
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
if (ctx2.language != lang) {
|
||||
_languageFlags(
|
||||
languagesEnabled: configLanguages,
|
||||
current: ctx2.language,
|
||||
onSelected: (lang) async {
|
||||
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) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const Divider(color: Colors.white12),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
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(
|
||||
value: ctx2.notificationsEnabled,
|
||||
activeThumbColor: kMainColor,
|
||||
inactiveThumbColor: Colors.white38,
|
||||
inactiveTrackColor: Colors.white12,
|
||||
onChanged: (value) async {
|
||||
ctx2.notificationsEnabled = value;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
Size size = MediaQuery.of(context).size;
|
||||
@ -318,7 +542,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
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)
|
||||
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
|
||||
.toList();
|
||||
@ -336,7 +563,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
return Stack(
|
||||
children: [
|
||||
// Dark background
|
||||
const ColoredBox(color: Color(0xFF111111), child: SizedBox.expand()),
|
||||
const ColoredBox(color: _kBackground, child: SizedBox.expand()),
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
@ -487,7 +714,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
// Icône lunettes / mode vocal — à gauche du bouton settings
|
||||
Positioned(
|
||||
top: 35,
|
||||
right: 68,
|
||||
right: 64,
|
||||
child: GlassesStatusWidget(visitAppContext: visitAppContext),
|
||||
),
|
||||
Positioned(
|
||||
@ -495,7 +722,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
right: 10,
|
||||
child: Builder(builder: (ctx) {
|
||||
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),
|
||||
onLongPress: () => showDialog(
|
||||
context: ctx,
|
||||
@ -507,14 +739,31 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 46,
|
||||
height: 46,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(Icons.tune_rounded, color: Colors.white, size: 22),
|
||||
Positioned(
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
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 {
|
||||
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();
|
||||
VisitAppContext visitAppContext = appContext.getContext();
|
||||
|
||||
|
||||
isOnline = true; // Todo remove if not local test
|
||||
List<ConfigurationDTO>? configurations;
|
||||
configurations = List<ConfigurationDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.configurations));
|
||||
alreadyDownloaded = configurations.map((c) => c.id).toList();
|
||||
print("GOT configurations from LOCAL");
|
||||
print(configurations.length);
|
||||
print(configurations);
|
||||
@ -756,7 +1020,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
.applicationInstanceGetAllApplicationLinkFromApplicationInstance(
|
||||
visitAppContext.applicationInstanceDTO!.id!);
|
||||
final weightedConfigs = links
|
||||
?.where((l) => l.configuration != null)
|
||||
?.where((l) => l.isActive == true && l.configuration != null)
|
||||
.map((l) => _WeightedConfig(
|
||||
configuration: l.configuration!,
|
||||
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
|
||||
// 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) {
|
||||
try {
|
||||
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, {
|
||||
DatabaseHelper.columnId: w.configuration.id,
|
||||
await DatabaseHelper.instance.update(
|
||||
DatabaseTableType.configurations,
|
||||
{
|
||||
DatabaseHelper.columnConfigOrder: w.order,
|
||||
DatabaseHelper.columnGridColSpan: w.colSpan,
|
||||
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
||||
});
|
||||
},
|
||||
w.configuration.id!,
|
||||
);
|
||||
} catch (e) {
|
||||
print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.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/SliderImages.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:path_provider/path_provider.dart';
|
||||
|
||||
import 'audio_player_floating.dart';
|
||||
import 'audio_player_tab.dart';
|
||||
|
||||
class ArticlePage extends StatefulWidget {
|
||||
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(
|
||||
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(
|
||||
height: size.height * 0.11,
|
||||
height: size.height * 0.11 + MediaQuery.of(context).padding.top,
|
||||
width: size.width,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Center(
|
||||
// Une `Row` et non trois `Positioned` : les icônes étaient épinglées
|
||||
// en haut de la bande pendant que le titre y était centré, donc rien
|
||||
// n'était sur le même axe. Ici les trois partagent la ligne, et un
|
||||
// titre sur deux lignes déplace tout le monde ensemble.
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22.0),
|
||||
child: SizedBox(
|
||||
width: size.width *0.7,
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
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(
|
||||
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)
|
||||
{
|
||||
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
|
||||
@ -133,11 +158,7 @@ class _ArticlePageState extends State<ArticlePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: 45,
|
||||
child: InkWell(
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
visitAppContext.isMaximizeTextSize = !visitAppContext.isMaximizeTextSize;
|
||||
@ -149,30 +170,11 @@ 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)
|
||||
),
|
||||
),
|
||||
),
|
||||
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)
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 0),
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
405
lib/Screens/Sections/Article/audio_player_tab.dart
Normal file
405
lib/Screens/Sections/Article/audio_player_tab.dart
Normal 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 0→1, 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 là. 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,12 @@ const List<SectionType> offlineCapableSectionTypes = [
|
||||
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 {
|
||||
DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key);
|
||||
final ConfigurationDTO configuration;
|
||||
@ -43,14 +49,18 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0);
|
||||
ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1);
|
||||
|
||||
/// D5 — nombre de ressources dont le téléchargement a échoué. Alimente le message
|
||||
/// d'échec : « 3 fichiers n'ont pas pu être téléchargés » dit au visiteur ce qui
|
||||
/// s'est passé, là où l'écran d'erreur générique le laissait supposer une panne.
|
||||
ValueNotifier<int> downloadFailureCount = ValueNotifier<int>(0);
|
||||
bool isAlreadyDownloading = false;
|
||||
//OtaEvent? currentEvent;
|
||||
/// Le téléchargement était lancé depuis `build()`, donc relancé à chaque rebuild
|
||||
/// du dialogue. Il démarre une fois, ici.
|
||||
Future<DownloadOutcome>? _downloadFuture;
|
||||
|
||||
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;
|
||||
|
||||
if(visitAppContext.isAllLanguages != null) {
|
||||
@ -67,9 +77,6 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
}
|
||||
}
|
||||
|
||||
if(!isAlreadyDownloading) {
|
||||
isAlreadyDownloading = true;
|
||||
|
||||
// HERE CHECK VERSION APK
|
||||
if(true) {
|
||||
Map<Permission, PermissionStatus> statuses = await [
|
||||
@ -85,7 +92,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
} catch(e) {
|
||||
print("Erreur lors du téléchargement de la configuration et de ses ressources !");
|
||||
print(e);
|
||||
return false;
|
||||
return DownloadOutcome.unreachable;
|
||||
}
|
||||
|
||||
exportConfigurationDTO.resources!.forEach((element) {
|
||||
@ -295,8 +302,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
"${failedResourceIds.join(', ')}");
|
||||
debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
|
||||
"${failedSectionIds.join(', ')}");
|
||||
downloadFailureCount.value = failedResourceIds.length + failedSectionIds.length;
|
||||
return false;
|
||||
return DownloadOutcome.incomplete;
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
@ -314,7 +320,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
fontSize: 16.0
|
||||
);
|
||||
}
|
||||
return false;
|
||||
return DownloadOutcome.incomplete;
|
||||
}
|
||||
/*} else {
|
||||
print("PermissionStatus.granted NOT GRANTED");
|
||||
@ -330,109 +336,111 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
return false;
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return currentResourceNbr.value > 0
|
||||
? DownloadOutcome.complete
|
||||
: DownloadOutcome.upToDate;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appContext = Provider.of<AppContext>(context);
|
||||
VisitAppContext visitAppContext = appContext.getContext();
|
||||
Size size = MediaQuery.of(context).size;
|
||||
|
||||
return Center(
|
||||
child: FutureBuilder(future: download(context, visitAppContext), builder: (context, snapshot) {
|
||||
// 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, _) {
|
||||
return ValueListenableBuilder<int>(
|
||||
builder: (context, valueNbr, _) => ValueListenableBuilder<int>(
|
||||
valueListenable: currentResourceIndex,
|
||||
builder: (context, valueIndex, _) {
|
||||
var valueInPercentage = valueNbr > 0 ? (valueIndex / valueNbr) * 100 : 100;
|
||||
String formattedPercentage = valueInPercentage.toStringAsFixed(0);
|
||||
final ratio = valueNbr > 0 ? valueIndex / valueNbr : null;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// D5 — l'état d'échec passe avant les autres. Sans lui, une visite
|
||||
// incomplète restait sur « téléchargement en cours » indéfiniment :
|
||||
// le compteur d'avancement n'est incrémenté qu'en cas de succès, donc
|
||||
// il n'atteignait jamais le total et l'écran ne concluait jamais.
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: downloadFailureCount,
|
||||
builder: (context, failures, _) {
|
||||
if (failures == 0) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
_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(
|
||||
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),
|
||||
'${(ratio * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(String text) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _message(IconData icon, Color color, String text) {
|
||||
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),
|
||||
Icon(icon, size: 34, color: color),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
TranslationHelper.getFromLocale(
|
||||
"downloadIncomplete", appContext.getContext()),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(text, 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,34 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
enum GlassesState { disconnected, connecting, connected, streaming }
|
||||
|
||||
/// Gère la connexion aux lunettes Ray-Ban Meta via le SDK DAT.
|
||||
///
|
||||
/// Cycle de vie intentionnel :
|
||||
/// 1. initialize() — init SDK, écoute les streams d'état (au démarrage app)
|
||||
/// 2. connect() — enregistrement DAT + permission caméra + HFP audio routing
|
||||
/// 1. initialize() — permissions runtime + écoute des streams d'état (au démarrage app)
|
||||
/// 2. connect() — enregistrement DAT + permission caméra Meta AI
|
||||
/// Le téléphone est connecté aux lunettes, micro HFP actif.
|
||||
/// PAS de stream vidéo encore.
|
||||
/// 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
|
||||
///
|
||||
/// ⚠️ 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 {
|
||||
MetaGlassesService._();
|
||||
static final MetaGlassesService instance = MetaGlassesService._();
|
||||
@ -28,6 +38,14 @@ class MetaGlassesService {
|
||||
/// Appelé avec le chemin de la photo après capturePhoto().
|
||||
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 =>
|
||||
state.value == GlassesState.connected || state.value == GlassesState.streaming;
|
||||
|
||||
@ -35,47 +53,75 @@ class MetaGlassesService {
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
try {
|
||||
final ok = await Wearables.instance.initialize();
|
||||
if (!ok) {
|
||||
debugPrint('[MetaGlassesService] SDK init failed');
|
||||
if (!kEnableGlasses) {
|
||||
debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final granted = await MetaWearablesDat.requestAndroidPermissions();
|
||||
if (!granted) {
|
||||
debugPrint('[MetaGlassesService] Permissions Bluetooth refusees');
|
||||
return;
|
||||
}
|
||||
_registration = await MetaWearablesDat.getRegistrationState();
|
||||
debugPrint('[MetaGlassesService] Registration initiale: $_registration');
|
||||
} 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);
|
||||
Wearables.instance.streamStateStream.listen(_onStreamState);
|
||||
Wearables.instance.devicesStream.listen((devices) {
|
||||
debugPrint('[MetaGlassesService] Devices: $devices');
|
||||
if (devices.isNotEmpty) _onDeviceConnected();
|
||||
});
|
||||
_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');
|
||||
}
|
||||
|
||||
/// À 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) ──────────────────────────────────────
|
||||
|
||||
/// Connecte aux lunettes et active le routing audio HFP.
|
||||
/// Le micro des lunettes devient actif pour le wake word.
|
||||
/// Connecte aux lunettes. Le micro des lunettes devient actif pour le wake
|
||||
/// word via le routing Bluetooth système, hors SDK.
|
||||
/// PAS de stream vidéo — utiliser [startStream] séparément.
|
||||
Future<void> connect() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!kEnableGlasses) {
|
||||
debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche');
|
||||
return;
|
||||
}
|
||||
state.value = GlassesState.connecting;
|
||||
|
||||
await Permission.camera.request();
|
||||
await Wearables.instance.startRegistration();
|
||||
try {
|
||||
if (_registration != RegistrationState.registered) {
|
||||
// Ouvre Meta AI ; la registration se termine au retour du deep link,
|
||||
// traité par handleDeepLink().
|
||||
await MetaWearablesDat.startRegistration();
|
||||
}
|
||||
await _ensureCameraPermission();
|
||||
|
||||
debugPrint('[MetaGlassesService] Connected (no stream yet)');
|
||||
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) ─────────────────────────────────────────
|
||||
@ -84,12 +130,25 @@ class MetaGlassesService {
|
||||
/// Appeler uniquement quand la caméra est requise.
|
||||
Future<void> startStream() async {
|
||||
if (!isConnected) return;
|
||||
await Wearables.instance.startStream(videoQuality: 'MEDIUM', frameRate: 24);
|
||||
debugPrint('[MetaGlassesService] Stream started');
|
||||
try {
|
||||
_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 {
|
||||
await Wearables.instance.stopStream();
|
||||
try {
|
||||
await MetaWearablesDat.stopStreamSession(null);
|
||||
} catch (e) {
|
||||
debugPrint('[MetaGlassesService] stopStreamSession error: $e');
|
||||
}
|
||||
_textureId = null;
|
||||
if (state.value == GlassesState.streaming) {
|
||||
state.value = GlassesState.connected;
|
||||
}
|
||||
@ -99,7 +158,7 @@ class MetaGlassesService {
|
||||
// ── 4. Déconnexion ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await Wearables.instance.stopStream();
|
||||
await stopStream();
|
||||
await AudioRoutingChannel.restoreDefaultOutput();
|
||||
state.value = GlassesState.disconnected;
|
||||
}
|
||||
@ -112,130 +171,141 @@ class MetaGlassesService {
|
||||
onPhotoCaptured?.call('');
|
||||
return;
|
||||
}
|
||||
bool streamStartedByUs = false;
|
||||
if (state.value != GlassesState.streaming) {
|
||||
await startStream();
|
||||
streamStartedByUs = true;
|
||||
// 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');
|
||||
if (!await ensureStreaming()) {
|
||||
debugPrint('[MetaGlassesService] Stream not ready — 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 {
|
||||
final photo = await Wearables.instance.capturePhoto();
|
||||
debugPrint('[MetaGlassesService] Photo captured: ${photo.path}');
|
||||
onPhotoCaptured?.call(photo.path);
|
||||
final photo = await MetaWearablesDat.capturePhoto(null);
|
||||
final path = await _writeTempFile(photo.bytes, photo.fileExtension, 'photo');
|
||||
debugPrint('[MetaGlassesService] Photo captured: $path');
|
||||
onPhotoCaptured?.call(path);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('[MetaGlassesService] capturePhoto error: ${e.code} ${e.details}');
|
||||
onPhotoCaptured?.call('');
|
||||
} catch (e) {
|
||||
debugPrint('[MetaGlassesService] capturePhoto error: $e');
|
||||
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).
|
||||
/// Retourne true si le stream est prêt.
|
||||
/// Démarre le stream si besoin et attend qu'il soit réellement actif.
|
||||
/// Retourne true si le stream est prêt pour une capture.
|
||||
Future<bool> ensureStreaming() async {
|
||||
if (!isConnected) return false;
|
||||
if (state.value == GlassesState.streaming) return true;
|
||||
final alreadyStarted = state.value == GlassesState.streaming;
|
||||
await startStream();
|
||||
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) return false;
|
||||
// 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;
|
||||
return state.value == GlassesState.streaming;
|
||||
}
|
||||
|
||||
/// Capture un seul frame depuis le stream (doit être déjà actif).
|
||||
/// Retourne le chemin du fichier temporaire, ou null si pas de frame en 2s.
|
||||
/// Capture un seul frame depuis la texture du stream (doit être déjà actif).
|
||||
/// 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 {
|
||||
if (state.value != GlassesState.streaming) return null;
|
||||
final completer = Completer<Uint8List?>();
|
||||
StreamSubscription? sub;
|
||||
sub = Wearables.instance.videoFramesStream.listen((frame) {
|
||||
if (!completer.isCompleted) completer.complete(frame);
|
||||
sub?.cancel();
|
||||
}, onError: (_) {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
});
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
sub?.cancel();
|
||||
});
|
||||
final bytes = await completer.future;
|
||||
if (bytes == null) return null;
|
||||
final texture = _textureId;
|
||||
if (texture == null || state.value != GlassesState.streaming) return null;
|
||||
final frame = await MetaWearablesDat.captureStreamFrame(
|
||||
texture,
|
||||
format: FrameFormat.png,
|
||||
);
|
||||
if (frame == null) return null;
|
||||
return _writeTempFile(frame.bytes, 'png', 'qr_frame');
|
||||
}
|
||||
|
||||
Future<String> _writeTempFile(Uint8List bytes, String extension, String prefix) async {
|
||||
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);
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _ensureCameraPermission() async {
|
||||
try {
|
||||
final status = await Wearables.instance.checkCameraPermission();
|
||||
debugPrint('[MetaGlassesService] Camera permission: $status');
|
||||
if (status.toLowerCase() == 'granted') return;
|
||||
await Wearables.instance.requestCameraPermission();
|
||||
if (await MetaWearablesDat.getCameraPermissionStatus()) return;
|
||||
final granted = await MetaWearablesDat.requestCameraPermission();
|
||||
debugPrint('[MetaGlassesService] Camera permission granted: $granted');
|
||||
} catch (e) {
|
||||
debugPrint('[MetaGlassesService] Camera permission error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onRegistrationState(RegistrationState s) {
|
||||
debugPrint('[MetaGlassesService] Registration: ${s.state} error=${s.error}');
|
||||
switch (s.state.toLowerCase()) {
|
||||
case 'registered':
|
||||
case 'available':
|
||||
debugPrint('[MetaGlassesService] Registration: $s');
|
||||
_registration = s;
|
||||
switch (s) {
|
||||
case RegistrationState.registered:
|
||||
if (state.value == GlassesState.connecting) {
|
||||
state.value = GlassesState.connected;
|
||||
}
|
||||
case 'unregistered':
|
||||
case 'unavailable':
|
||||
case RegistrationState.unavailable:
|
||||
state.value = GlassesState.disconnected;
|
||||
_textureId = null;
|
||||
case RegistrationState.available:
|
||||
case RegistrationState.registering:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onStreamState(String s) {
|
||||
void _onActiveDevice(bool available) {
|
||||
debugPrint('[MetaGlassesService] Active device available: $available');
|
||||
if (available) {
|
||||
// A2DP + HFP sont gérés par Android ; on ne force pas
|
||||
// MODE_IN_COMMUNICATION ici, ça dégraderait le TTS (HFP 8 kHz).
|
||||
if (state.value == GlassesState.disconnected &&
|
||||
_registration == RegistrationState.registered) {
|
||||
state.value = GlassesState.connected;
|
||||
}
|
||||
} else if (state.value != GlassesState.connecting) {
|
||||
state.value = GlassesState.disconnected;
|
||||
_textureId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onStreamState(StreamSessionState s) {
|
||||
debugPrint('[MetaGlassesService] Stream state: $s');
|
||||
final lower = s.toLowerCase();
|
||||
// 'streaming' = flux vidéo actif, capturePhoto() fonctionne immédiatement
|
||||
// 'started' = stream initialisé — capturePhoto() peut fonctionner après un court délai
|
||||
if (lower.contains('streaming') || lower == 'started') {
|
||||
switch (s) {
|
||||
case StreamSessionState.streaming:
|
||||
state.value = GlassesState.streaming;
|
||||
} else if (lower == 'stopped' || lower == 'closed') {
|
||||
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 dispose() => state.dispose();
|
||||
void _onStreamError(StreamSessionError e) {
|
||||
debugPrint('[MetaGlassesService] Stream error: $e');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
state.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show appFlavor;
|
||||
|
||||
// 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
|
||||
// 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: '');
|
||||
|
||||
// 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
|
||||
//
|
||||
// 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',
|
||||
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 kInstanceId = String.fromEnvironment('INSTANCE_ID',
|
||||
defaultValue: '63514fd67ed8c735aaa4b8f2');
|
||||
|
||||
// Flavor injecté au build via --dart-define=FLAVOR=mdlf
|
||||
const _flavor = String.fromEnvironment('FLAVOR', defaultValue: 'dev');
|
||||
// Empreinte du commit, injectee au build :
|
||||
// --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
|
||||
// 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)
|
||||
);
|
||||
|
||||
const kAppTitle = _flavor == 'mdlf'
|
||||
? 'MDLF'
|
||||
: _flavor == 'fortsaintheribert'
|
||||
? 'Fort Saint-Héribert'
|
||||
: 'MyMuseum Dev';
|
||||
|
||||
const kSplashLogoAsset = _flavor == 'mdlf'
|
||||
? 'assets/splash/mdlf.png'
|
||||
: _flavor == 'fortsaintheribert'
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
//import 'package:audioplayers/audioplayers.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -25,6 +28,10 @@ import 'client.dart';
|
||||
import 'constants.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() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const AppBootstrap());
|
||||
@ -39,13 +46,30 @@ class AppBootstrap extends StatefulWidget {
|
||||
|
||||
class _AppBootstrapState extends State<AppBootstrap> {
|
||||
VisitAppContext? _ctx;
|
||||
StreamSubscription<Uri>? _deepLinkSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenGlassesDeepLinks();
|
||||
_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 {
|
||||
// Firebase init (requires google-services.json on Android, GoogleService-Info.plist on iOS)
|
||||
if (!Platform.isWindows) {
|
||||
@ -65,6 +89,17 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
|
||||
List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead));
|
||||
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 {
|
||||
localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false);
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
try {
|
||||
final instanceDto = await localContext.clientAPI.instanceApi!
|
||||
.instanceGetDetail(localContext.instanceId!);
|
||||
.instanceGetDetail(localContext.instanceId!)
|
||||
.timeout(_bootNetworkTimeout);
|
||||
|
||||
if (localContext.apiKey == null && instanceDto?.publicApiKey != null) {
|
||||
localContext.apiKey = instanceDto!.publicApiKey;
|
||||
@ -104,7 +144,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
// Push notifications — subscribe to instance topic if enabled
|
||||
if (!Platform.isWindows && localContext.instanceId != null) {
|
||||
try {
|
||||
await PushNotificationService.initialize(localContext.instanceId!);
|
||||
await PushNotificationService.initialize(localContext.instanceId!)
|
||||
.timeout(_bootNetworkTimeout);
|
||||
} catch (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.
|
||||
// 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();
|
||||
}
|
||||
|
||||
@ -132,6 +181,7 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
if (_ctx == null) {
|
||||
return const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: kAppTitle,
|
||||
home: SplashScreen(),
|
||||
);
|
||||
}
|
||||
@ -188,7 +238,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert'
|
||||
title: kAppTitle,
|
||||
initialRoute: widget.initialRoute,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
|
||||
@ -6,6 +6,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Pour suivre cette visite, il faut d'abord la télécharger",
|
||||
"noData": "Pas de données",
|
||||
"invalidQRCode": "Code QR invalide",
|
||||
"qrOtherVisit": "Ce QR code appartient à une autre visite.",
|
||||
"languageNotSupported": "Cette visite ne prend pas en charge votre langue",
|
||||
"downloadConfiguration": "Téléchargement de la visite en cours...",
|
||||
"noInternet": "Aucune connexion internet détectée",
|
||||
@ -26,6 +27,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Téléchargement en cours",
|
||||
"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.",
|
||||
"downloadFailed": "Le contenu n'a pas pu être téléchargé. Vérifiez votre connexion, puis réessayez.",
|
||||
"upToDate": "Tout est à jour",
|
||||
"weather.hourly": "Prochaines heures",
|
||||
"weather.nextdays": "Prochains jours",
|
||||
@ -55,6 +57,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "To follow this tour, you must first download it",
|
||||
"noData": "No data",
|
||||
"invalidQRCode": "Invalid QR code",
|
||||
"qrOtherVisit": "This QR code belongs to another tour.",
|
||||
"languageNotSupported": "This tour doesn't support your language",
|
||||
"downloadConfiguration": "Loading tour...",
|
||||
"noInternet": "No internet connection detected",
|
||||
@ -75,6 +78,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Download in progress",
|
||||
"downloadFinish": "Download complete",
|
||||
"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",
|
||||
"weather.hourly": "Hourly",
|
||||
"weather.nextdays": "Next days",
|
||||
@ -104,6 +108,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Um dieser Tour zu folgen, müssen Sie sie zuerst herunterladen",
|
||||
"noData": "keine Daten",
|
||||
"invalidQRCode": "Ungültiger QR-Code",
|
||||
"qrOtherVisit": "Dieser QR-Code gehört zu einer anderen Tour.",
|
||||
"languageNotSupported": "Diese Tour unterstützt Ihre Sprache nicht",
|
||||
"downloadConfiguration": "Tour laden...",
|
||||
"noInternet": "Keine Internetverbindung erkannt",
|
||||
@ -124,6 +129,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Download läuft",
|
||||
"downloadFinish": "Download abgeschlossen",
|
||||
"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",
|
||||
"weather.hourly": "Nächste Stunden",
|
||||
"weather.nextdays": "Nächsten Tage",
|
||||
@ -153,6 +159,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Om deze tour te volgen, moet je deze eerst downloaden",
|
||||
"noData": "Geen gegevens",
|
||||
"invalidQRCode": "Ongeldige QR-code",
|
||||
"qrOtherVisit": "Deze QR-code hoort bij een andere rondleiding.",
|
||||
"languageNotSupported": "Deze tour ondersteunt je taal niet",
|
||||
"downloadConfiguration": "De rondleiding laden...",
|
||||
"noInternet": "Geen internetverbinding gedetecteerd",
|
||||
@ -173,6 +180,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Download bezig",
|
||||
"downloadFinish": "Download voltooid",
|
||||
"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",
|
||||
"weather.hourly": "Volgende uren",
|
||||
"weather.nextdays": "Volgende dagen",
|
||||
@ -202,6 +210,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Per seguire questo tour, devi prima scaricarlo",
|
||||
"noData": "Nessun dato",
|
||||
"invalidQRCode": "Codice QR non valido",
|
||||
"qrOtherVisit": "Questo codice QR appartiene a un altro tour.",
|
||||
"languageNotSupported": "Questo tour non supporta la tua lingua",
|
||||
"downloadConfiguration": "Caricamento del tour...",
|
||||
"noInternet": "Nessuna connessione Internet rilevata",
|
||||
@ -222,6 +231,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Download in corso",
|
||||
"downloadFinish": "Download completato",
|
||||
"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",
|
||||
"weather.hourly": "Le prossime ore",
|
||||
"weather.nextdays": "Prossimi giorni",
|
||||
@ -251,6 +261,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Para realizar este recorrido, primero debe descargarlo",
|
||||
"noData": "Sin datos",
|
||||
"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",
|
||||
"downloadConfiguration": "Cargando el recorrido...",
|
||||
"noInternet": "No se detectó conexión a Internet",
|
||||
@ -271,6 +282,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Descarga en curso",
|
||||
"downloadFinish": "Descarga completada",
|
||||
"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",
|
||||
"weather.hourly": "Próximas horas",
|
||||
"weather.nextdays": "Proximos dias",
|
||||
@ -300,6 +312,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Aby wziąć udział w tej wycieczce, musisz ją najpierw pobrać",
|
||||
"noData": "Brak danych",
|
||||
"invalidQRCode": "Nieprawidłowy kod QR",
|
||||
"qrOtherVisit": "Ten kod QR należy do innej wycieczki.",
|
||||
"languageNotSupported": "Ta wycieczka nie obsługuje Twojego języka",
|
||||
"downloadConfiguration": "Wczytuję prezentację...",
|
||||
"noInternet": "Nie wykryto połączenia internetowego",
|
||||
@ -320,6 +333,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Pobieranie w toku",
|
||||
"downloadFinish": "Pobieranie zakończone",
|
||||
"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",
|
||||
"weather.hourly": "Następne godziny",
|
||||
"weather.nextdays": "Następne dni",
|
||||
@ -349,6 +363,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "要参加此导览,您需要先下载它",
|
||||
"noData": "没有数据",
|
||||
"invalidQRCode": "二维码无效",
|
||||
"qrOtherVisit": "此二维码属于另一个导览。",
|
||||
"languageNotSupported": "此导览不支持您的语言",
|
||||
"downloadConfiguration": "正在加载导览...",
|
||||
"noInternet": "未检测到互联网连接",
|
||||
@ -369,6 +384,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "下载中",
|
||||
"downloadFinish": "下载完成",
|
||||
"downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。",
|
||||
"downloadFailed": "无法下载内容。请检查网络连接后重试。",
|
||||
"upToDate": "已是最新",
|
||||
"weather.hourly": "接下来的几个小时",
|
||||
"weather.nextdays": "未来几天",
|
||||
@ -398,6 +414,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "Щоб стежити за цим оглядом, спершу його потрібно завантажити",
|
||||
"noData": "Немає даних",
|
||||
"invalidQRCode": "Недійсний QR-код",
|
||||
"qrOtherVisit": "Цей QR-код належить до іншого туру.",
|
||||
"languageNotSupported": "Цей тур не підтримує вашу мову",
|
||||
"downloadConfiguration": "Завантаження туру...",
|
||||
"noInternet": "Підключення до Інтернету не виявлено",
|
||||
@ -418,6 +435,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "Завантаження триває",
|
||||
"downloadFinish": "Завантаження завершено",
|
||||
"downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.",
|
||||
"downloadFailed": "Не вдалося завантажити вміст. Перевірте з'єднання та спробуйте ще раз.",
|
||||
"upToDate": "Все актуально",
|
||||
"weather.hourly": "Наступні години",
|
||||
"weather.nextdays": "Наступні дні",
|
||||
@ -447,6 +465,7 @@ List<Translation> translations = [
|
||||
"visitDownloadWarning": "لمتابعة هذه الجولة ، يجب عليك أولاً تنزيلها",
|
||||
"noData": "لايوجد بيانات",
|
||||
"invalidQRCode": "رمز الاستجابة السريعة غير صالح",
|
||||
"qrOtherVisit": "ينتمي رمز الاستجابة السريعة هذا إلى جولة أخرى.",
|
||||
"languageNotSupported": "هذه الجولة لا تدعم لغتك",
|
||||
"downloadConfiguration": "جارٍ تحميل الجولة ...",
|
||||
"noInternet": "لم يتم الكشف عن اتصال بالإنترنت",
|
||||
@ -467,6 +486,7 @@ List<Translation> translations = [
|
||||
"downloadInProgress": "جارٍ التنزيل",
|
||||
"downloadFinish": "اكتمل التنزيل",
|
||||
"downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.",
|
||||
"downloadFailed": "تعذّر تنزيل المحتوى. تحقّق من اتصالك ثم أعد المحاولة.",
|
||||
"upToDate": "كل شيء محدث",
|
||||
"weather.hourly": "الساعات القادمة",
|
||||
"weather.nextdays": "الايام القادمة",
|
||||
|
||||
@ -7,12 +7,16 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_sound/flutter_sound_plugin.h>
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_sound_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin");
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_sound
|
||||
gtk
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
62
pubspec.lock
62
pubspec.lock
@ -25,6 +25,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -539,6 +571,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -813,6 +853,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1020,14 +1068,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1108,7 +1148,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918
|
||||
@ -1937,5 +1977,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@ -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.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 2.0.2+21
|
||||
version: 2.0.3+22
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.0 <4.0.0"
|
||||
@ -71,6 +71,7 @@ dependencies:
|
||||
flutter_map: ^7.0.2 #all
|
||||
image: ^4.1.7
|
||||
url_launcher: ^6.3.1
|
||||
package_info_plus: ^8.0.2 # numero de version affiche dans les Parametres
|
||||
|
||||
speech_to_text: ^7.0.0
|
||||
geolocator: ^13.0.0
|
||||
@ -81,8 +82,8 @@ dependencies:
|
||||
flutter_local_notifications: ^17.2.2
|
||||
|
||||
# Ray-Ban Meta glasses integration
|
||||
meta_wearables_dat: ^0.1.3 # Android — SDK DAT v0.3.0
|
||||
# meta_wearables: ^0.0.1 — package vide, skip
|
||||
flutter_meta_wearables_dat: ^0.9.1 # Android + iOS — SDK DAT v0.9.0
|
||||
app_links: ^6.4.1 # Retour du deep link Meta AI -> handleUrl()
|
||||
# porcupine_flutter: ^3.0.3 — wake word prod, à activer (payant)
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user