Thomas Fransolet 6709726208 Splash, loader et mode hors ligne : ce qui etait invisible le reste
Le fil rouge de ces corrections : des replis silencieux. Une exception
rattrapee, une image absente, un fichier local jamais rattache — rien ne
plantait, mais rien ne marchait non plus, et aucun message ne le disait.

Splash et loader
- assets/splash et assets/loader ne contenaient que des PNG 1x1
  transparents. errorBuilder ne se declenche pas : le fichier est valide,
  juste vide. Logo et loader etaient donc invisibles PARTOUT, splash comme
  ecrans de chargement. assets/loader est supprime, kLoaderAsset avec.
- Le splash natif etait blanc (launch_background en @android:color/white,
  theme Light) puis l'app basculait en sombre. Fond unifie sur #111111,
  cote natif comme Dart, avec values-v31 : Android 12+ ignore
  windowBackground et repart sur le theme sans ces attributs.
- Le loader et l'image principale viennent maintenant du manager
  (Applications -> Mobile). InstanceImages les garde sur le device : le
  splash s'affiche avant tout appel API, il ne peut lire qu'un cache.
  Alimente au boot depuis instanceGetDetail, qui porte deja les
  ApplicationInstances — aucun appel supplementaire.
- Ouvrir une section montrait une page blanche avec un loader au milieu.
  SlideFromRightRoute devient non opaque et SectionPage garde un fond
  transparent tant que la section n'est pas prete : le visiteur garde sous
  les yeux l'ecran d'ou il vient.

Hors ligne
- L'audio d'un article ne repartait jamais du MP3 local : audioFile
  n'etait jamais renseigne, le lecteur retombait toujours sur l'URL.
- L'image de titre d'un article passait par NetworkImage, donc une croix
  rouge des que le reseau manque, alors que le fichier est sur le device.
- L'accueil ne gardait en base que order/gridSpan : une visite jamais
  telechargee disparaissait de la grille hors ligne. La visite entiere est
  desormais mise en cache, et une visite injoignable reste affichee,
  ternie et non ouvrable, plutot qu'absente.
- L'etat reseau datait du premier chargement et n'etait jamais remesure.
  WidgetsBindingObserver etait declare mais jamais enregistre : aucun
  rappel n'arrivait. L'accueil se recharge au retour dans l'app et
  remesure avant de refuser l'ouverture d'une visite.

Divers
- Le futur de getSectionDetail etait construit dans future:, donc relance
  a chaque rebuild — chaque rotation d'ecran refaisait l'appel reseau.
- ImageCustomProvider listait le repertoire d'une visite non telechargee :
  exception a chaque construction de chaque image, pour finir de toute
  facon sur le reseau.
- Scanner un QR d'une autre visite empilait les ConfigurationPage.
  pushAndRemoveUntil : une visite est une destination de premier niveau.
- Le scanner arrive sur l'accueil, en pastille de verre comme les autres
  actions de cet ecran. ScannerBouton prend size et transparent, et garde
  son apparence d'origine ailleurs.
- Le titre d'un article n'etait pas centre : customStylesBuilder ne
  s'applique qu'aux elements, et un titre sans balise n'en a aucun.
- Le lecteur audio replie ne lancait pas la lecture au premier appui, il
  ouvrait le panneau. Il lit, puis ouvre. En lecture auto il reste replie.
- Le dialogue de telechargement faisait la hauteur de l'ecran : un Center
  prend toute la hauteur maximale que lui donne un AlertDialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:47:27 +02:00

480 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:beacon_scanner/beacon_scanner.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
import 'package:mymuseum_visitapp/Components/ScannerBouton.dart';
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/ConfigurationPage/beaconArticleFound.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Article/article_page.dart';
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart';
import 'package:mymuseum_visitapp/Screens/section_page.dart';
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'components/body.dart';
class ConfigurationPage extends StatefulWidget {
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();
}
class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindingObserver {
//ConfigurationDTO? configuration;
int timeBetweenBeaconPopUp = 20000; // 20 sec
int meterToBeacon = 100; // 15 meters
bool modeDebugBeacon = false;
// Beacon specific
final controller = Get.find<RequirementStateController>();
StreamSubscription<ScanResult>? _streamRanging;
/*final _regionBeacons = <Region, List<Beacon>>{};
final _beacons = <Beacon>[];*/
bool _isDialogShowing = false;
DateTime? lastTimePopUpWasClosed;
//bool _isArticleOpened = false;
StreamSubscription? listener;
//final List<Region> regions = <Region>[];
late final VoidCallback _notificationListener;
@override
void initState() {
WidgetsBinding.instance.addObserver(this);
if (Platform.isIOS) {
// iOS platform, at least set identifier and proximityUUID for region scanning
/*regions.add(Region(
identifier: 'MyMuseumB',
proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825')
);*/
} else {
// Android platform, it can ranging out of beacon that filter all of Proximity UUID
//regions.add(Region(identifier: 'MyMuseumB'));
}
super.initState();
if(widget.isAlreadyAllowed) {
listeningState();
}
_notificationListener = () {
final message = PushNotificationService.tappedMessage.value;
if (message == null) return;
if (!mounted) return;
final title = message.notification?.title ?? '';
final body = message.notification?.body ?? '';
ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty) Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
if (body.isNotEmpty) Text(body),
],
),
actions: [
TextButton(
onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(),
child: const Text('OK'),
),
],
),
);
};
PushNotificationService.tappedMessage.addListener(_notificationListener);
WidgetsBinding.instance.addPostFrameCallback((_) {
final appContext = Provider.of<AppContext>(context, listen: false);
VisitAppContext visitAppContext = appContext.getContext();
visitAppContext.configuration = widget.configuration;
visitAppContext.sectionIds = widget.configuration.sectionIds;
appContext.setContext(visitAppContext);
listener = controller.startStream.listen((flag) async {
if (flag == true && mounted) {
final ctx = Provider.of<AppContext>(context, listen: false).getContext();
await initScanBeacon(ctx);
}
});
});
//listeningState();
}
listeningState() async {
await BeaconScanner.instance.initialize(true);
}
checkAllRequirements() async {
/*final bluetoothState = await flutterBeacon.bluetoothState;
controller.updateBluetoothState(bluetoothState);
print('BLUETOOTH $bluetoothState');
final authorizationStatus = await flutterBeacon.authorizationStatus;
controller.updateAuthorizationStatus(authorizationStatus);
print('AUTHORIZATION $authorizationStatus');
final locationServiceEnabled = await flutterBeacon.checkLocationServicesIfEnabled;
controller.updateLocationService(locationServiceEnabled);
print('LOCATION SERVICE $locationServiceEnabled');*/
var status = await Permission.bluetoothScan.status;
if (status.isDenied) {
print("IS DENIIIED");
// We didn't ask for permission yet or the permission has been denied before but not permanently.
}
// You can request multiple permissions at once.
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
print(statuses[Permission.bluetoothScan]);
print(statuses[Permission.bluetoothConnect]);
print(status);
/*if (controller.bluetoothEnabled &&
controller.authorizationStatusOk &&
controller.locationServiceEnabled) {
print('STATE READY');
//if (currentIndex == 0) {
print('SCANNING');
//controller.startScanning();
/*controller.startStream.listen((flag) {
if (flag == true) {
initScanBeacon();
}
});*/
/*} else {
print('BROADCASTING');
controller.startBroadcasting();
}*/
} else {
print('STATE NOT READY');
controller.pauseScanning();
}*/
}
initScanBeacon(VisitAppContext visitAppContext) async {
if (_streamRanging != null && !_streamRanging!.isPaused) return;
if (_streamRanging != null && _streamRanging!.isPaused) {
_streamRanging!.resume();
return;
}
final regions = <Region>[
if (Platform.isIOS)
Region(
identifier: 'MyMuseumB',
beaconId: IBeaconId(proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825'),
)
else
Region(identifier: 'MyMuseumB'),
];
_streamRanging = BeaconScanner.instance.ranging(regions).listen((ScanResult result) {
if (!mounted || result.beacons.isEmpty) return;
final beaconSections = visitAppContext.beaconSections;
if (beaconSections == null) return;
var matches = beaconSections.where((bs) =>
result.beacons.any((b) =>
b.id.minorId == bs!.minorBeaconId && b.accuracy < meterToBeacon
)
);
if (matches.isNotEmpty) {
matches = matches.where((bs) => bs!.configurationId == widget.configuration.id);
}
if (matches.isNotEmpty && !modeDebugBeacon) {
matches = matches.where((bs) =>
!visitAppContext.readSections.any((ra) => ra.id == bs!.sectionId)
);
}
if (matches.isEmpty) return;
final milliLastTime = lastTimePopUpWasClosed?.millisecondsSinceEpoch ?? 0;
final cooldownOk = (DateTime.now().millisecondsSinceEpoch - milliLastTime) > timeBetweenBeaconPopUp;
if (!_isDialogShowing && !visitAppContext.isContentCurrentlyShown && cooldownOk && visitAppContext.isScanningBeacons) {
final sorted = matches.toList()..sort((a, b) => a!.orderInConfig!.compareTo(b!.orderInConfig!));
_onBeaconFound(visitAppContext, sorted.first);
}
});
}
/*pauseScanBeacon() async {
_streamRanging?.pause();
if (_beacons.isNotEmpty) {
setState(() {
_beacons.clear();
});
}
}*/
/*int _compareParameters(Beacon a, Beacon b) {
int compare = a.proximityUUID.compareTo(b.proximityUUID);
if (compare == 0) {
compare = a.major.compareTo(b.major);
}
if (compare == 0) {
compare = a.minor.compareTo(b.minor);
}
return compare;
}*/
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
print('AppLifecycleState = $state');
if (state == AppLifecycleState.resumed) {
/*if (_streamBluetooth != null) {
if (_streamBluetooth!.isPaused) {
_streamBluetooth?.resume();
}
}
await checkAllRequirements();*/
} else if (state == AppLifecycleState.paused) {
//_streamBluetooth?.pause();
}
}
void _onBeaconFound(VisitAppContext visitAppContext, BeaconSection? beaconSection) {
_isDialogShowing = true;
PushNotificationService.showBeaconDiscoveryNotification(
title: TranslationHelper.getFromLocale('beaconFound', visitAppContext),
body: TranslationHelper.getFromLocale('beaconFoundBody', visitAppContext),
);
showDialog(
barrierDismissible: false,
builder: (BuildContext context) => AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0))
),
content: BeaconArticleFound(beaconSection: beaconSection),
actions: <Widget>[
TextButton(
child: Text(TranslationHelper.getFromLocale("close", visitAppContext), style: const TextStyle(color: kSecondGrey)),
onPressed: () {
_isDialogShowing = false; // set it `false` since dialog is closed
Navigator.of(context).pop();
lastTimePopUpWasClosed = DateTime.now();
},
),
TextButton(
child: Text(TranslationHelper.getFromLocale("open", visitAppContext), style: const TextStyle(color: kSecondGrey)),
onPressed: () {
_isDialogShowing = false; // set it `false` since dialog is closed
Navigator.of(context).pop();
//visitAppContext.isArticleCurrentlyShown = true;
lastTimePopUpWasClosed = DateTime.now();
// `SlideFromRightRoute` et non `MaterialPageRoute` : elle seule
// est non opaque, donc seule elle laisse le voile de chargement
// de la section se poser sur l'ecran d'ou vient le visiteur.
Navigator.push(
context,
SlideFromRightRoute(
page: SectionPage(
configuration: widget.configuration,
rawSection: null,
visitAppContextIn: visitAppContext,
sectionId: beaconSection!.sectionId!,
),
),
);
},
)
],
actionsAlignment: MainAxisAlignment.spaceAround,
contentPadding: EdgeInsets.zero,
), context: context
);
/*Future.delayed(const Duration(seconds: 6), () {
Navigator.pop(context); //pop dialog
_isDialogShowing = false;
});*/
}
@override
void dispose() {
PushNotificationService.tappedMessage.removeListener(_notificationListener);
listener?.cancel();
_streamRanging?.cancel();
controller.pauseScanning();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
return PopScope(
canPop: true,
child: Scaffold(
/*appBar: CustomAppBar(
title: TranslationHelper.get(configuration!.title, visitAppContext),
isHomeButton: true,
),*/
backgroundColor: kBackgroundGrey,
body: Body(configuration: widget.configuration, openSectionId: widget.openSectionId),
floatingActionButton: Stack(
children: [
if (visitAppContext.applicationInstanceDTO?.isAssistant == true)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 90, bottom: 1),
child: FloatingActionButton(
heroTag: 'assistant_config',
backgroundColor: kMainColor1,
onPressed: () {
AssistantChatSheet.show(
context,
visitAppContext: visitAppContext,
configurationId: widget.configuration.id,
onNavigateToSection: (sectionId, sectionTitle) {
Navigator.push(context, SlideFromRightRoute(
page: SectionPage(
configuration: widget.configuration,
rawSection: null,
visitAppContextIn: visitAppContext,
sectionId: sectionId,
),
));
},
);
},
child: const Icon(Icons.chat_bubble_outline, color: Colors.white),
),
),
),
visitAppContext.beaconSections != null && visitAppContext.beaconSections!.where((bs) => bs!.configurationId == widget.configuration.id).isNotEmpty ? Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 90, bottom: 1),
child: InkWell(
onTap: () async {
if (!visitAppContext.isScanningBeacons) {
await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
].request();
controller.startScanning();
visitAppContext.isScanningBeacons = true;
visitAppContext.isScanBeaconAlreadyAllowed = true;
appContext.setContext(visitAppContext);
} else {
_streamRanging?.pause();
controller.pauseScanning();
visitAppContext.isScanningBeacons = false;
appContext.setContext(visitAppContext);
}
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visitAppContext.isScanningBeacons ? kMainColor1 : Colors.grey,
),
height: 75.0,
width: 65.0,
child: const Icon(Icons.my_location, color: Colors.white),
),
),
),
) : const SizedBox(),
Align(
alignment: Alignment.bottomRight,
child: ScannerBouton(appContext: appContext),
),
],
),
),
);
}
handleOpenLocationSettings() async {
if (Platform.isAndroid) {
//await flutterBeacon.openLocationSettings;
} else if (Platform.isIOS) {
await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Location Services Off'),
content: Text(
'Please enable Location Services on Settings > Privacy > Location Services.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
);
},
);
}
}
handleOpenBluetooth() async {
if (Platform.isAndroid) {
try {
//await flutterBeacon.openBluetoothSettings;
} on PlatformException catch (e) {
print(e);
}
} else if (Platform.isIOS) {
await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Bluetooth is Off'),
content: Text('Please enable Bluetooth on Settings > Bluetooth.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
);
},
);
}
}
}