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

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

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

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

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

270 lines
10 KiB
Dart

//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';
// TODO // import 'package:flutter_beacon/flutter_beacon.dart';
import 'package:get/get.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'Helpers/DatabaseHelper.dart';
import 'Models/visitContext.dart';
import 'app_context.dart';
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());
}
class AppBootstrap extends StatefulWidget {
const AppBootstrap({Key? key}) : super(key: key);
@override
_AppBootstrapState createState() => _AppBootstrapState();
}
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) {
await Firebase.initializeApp();
}
VisitAppContext? localContext = await DatabaseHelper.instance.getData(DatabaseTableType.main);
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
String localPath = appDocumentsDirectory!.path;
MapboxOptions.setAccessToken("pk.eyJ1IjoidGZyYW5zb2xldCIsImEiOiJjbHRpcGNvZDYwYWhkMnFxdmF0ampveW10In0.7xHN0NGvUfQu5ThS3RGJRw"); // TODO put in json file or resource file
if (localContext != null) {
print("we've got an local db !");
print(localContext);
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());
List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead));
localContext.readSections = articleReadTest;
print(localContext.readSections);
print("NO LOCAL DB !");
}
localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null));
// Récupère publicApiKey (si pas encore en mémoire) et la voix du guide choisie par
// 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!)
.timeout(_bootNetworkTimeout);
if (localContext.apiKey == null && instanceDto?.publicApiKey != null) {
localContext.apiKey = instanceDto!.publicApiKey;
localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey);
DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext);
print('publicApiKey loaded and saved');
}
localContext.guideVoiceId = instanceDto?.guideVoiceId;
} catch (e) {
// Hors ligne : la voix retombe sur le repli du build, le reste de l'app tourne.
print('Could not load instance detail: $e');
}
}
// Push notifications — subscribe to instance topic if enabled
if (!Platform.isWindows && localContext.instanceId != null) {
try {
await PushNotificationService.initialize(localContext.instanceId!)
.timeout(_bootNetworkTimeout);
} catch (e) {
print('PushNotification init failed: $e');
}
}
localContext.localPath = localPath;
print("Local path $localPath");
// 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.
// ⚠️ 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();
}
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
statusBarColor: Colors.transparent,
));
if (mounted) setState(() => _ctx = localContext);
}
@override
Widget build(BuildContext context) {
if (_ctx == null) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: kAppTitle,
home: SplashScreen(),
);
}
return MyApp(visitAppContext: _ctx!, initialRoute: '/home');
}
}
class MyApp extends StatefulWidget {
String initialRoute = "";
VisitAppContext visitAppContext;
MyApp({Key? key, required this.initialRoute, required this.visitAppContext}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Le mode vocal ne démarre plus automatiquement — l'utilisateur choisit via VoiceModeSheet.
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final o = activeVoiceOrchestrator;
if (o != null && o.isRunning && !o.isInConversation) {
o.restartWakeWord();
}
}
}
@override
Widget build(BuildContext context) {
Get.put(RequirementStateController());
return MultiProvider(
providers: [
ChangeNotifierProvider<AppContext>(
create: (_) => AppContext(widget.visitAppContext),
),
ChangeNotifierProvider<VoiceController>(
create: (_) => VoiceController(),
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: kAppTitle,
initialRoute: widget.initialRoute,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const[
Locale('en', ''),
Locale('fr', ''),
],
theme: ThemeData(
primarySwatch: Colors.blue,
scaffoldBackgroundColor: kBackgroundColor,
//fontFamily: "Vollkorn",
textTheme: TextTheme(bodyLarge: TextStyle(color: widget.visitAppContext.configuration != null ? widget.visitAppContext.configuration!.primaryColor != null ? Color(int.parse(widget.visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)): kMainColor1 : kMainColor1)),
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: const AppBarTheme(
iconTheme: IconThemeData(color: Colors.white), // Change the color here
),
),
routes: {
'/home': (context) => const HomePage3(),
}
),
);
}
}