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>
422 lines
18 KiB
Dart
422 lines
18 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
//import 'package:confetti/confetti.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
|
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
|
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
|
import 'package:mymuseum_visitapp/Models/resourceModel.dart';
|
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Agenda/agenda_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Article/article_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_context.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Menu/menu_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/PDF/pdf_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Game/game_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Slider/slider_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Video/video_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Weather/weather_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Web/web_page.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Parcours/parcours_page.dart';
|
|
import 'package:mymuseum_visitapp/app_context.dart';
|
|
import 'package:mymuseum_visitapp/client.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:image/image.dart' as IMG;
|
|
import 'dart:ui' as ui;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
class SectionPage extends StatefulWidget {
|
|
const SectionPage({Key? key, required this.rawSection, required this.visitAppContextIn, required this.configuration, required this.sectionId}) : super(key: key);
|
|
|
|
final Object? rawSection;
|
|
final String sectionId;
|
|
final ConfigurationDTO configuration;
|
|
final VisitAppContext visitAppContextIn;
|
|
|
|
@override
|
|
State<SectionPage> createState() => _SectionPageState();
|
|
}
|
|
|
|
class _SectionPageState extends State<SectionPage> {
|
|
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
|
SectionDTO? sectionDTO;
|
|
late VisitAppContext visitAppContext;
|
|
late dynamic rawSectionData;
|
|
List<ResourceModel?> resourcesModel = <ResourceModel?>[];
|
|
|
|
late final MapContext mapContext = MapContext(null);
|
|
List<Map<String, dynamic>>? icons;
|
|
|
|
String? mainAudioId;
|
|
|
|
/// Memorise : sans lui, `future:` reconstruisait l'appel a chaque rebuild.
|
|
Future<dynamic>? _sectionDetail;
|
|
DateTime? _sectionOpenTime;
|
|
|
|
@override
|
|
void initState() {
|
|
widget.visitAppContextIn.isContentCurrentlyShown = true;
|
|
_sectionOpenTime = DateTime.now();
|
|
super.initState();
|
|
// Track SectionView (fire-and-forget)
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.visitAppContextIn.statisticsService?.track(
|
|
VisitEventType.sectionView,
|
|
sectionId: widget.sectionId,
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
visitAppContext.isContentCurrentlyShown = false;
|
|
// Track SectionLeave with duration
|
|
final duration = _sectionOpenTime != null
|
|
? DateTime.now().difference(_sectionOpenTime!).inSeconds
|
|
: null;
|
|
widget.visitAppContextIn.statisticsService?.track(
|
|
VisitEventType.sectionLeave,
|
|
sectionId: widget.sectionId,
|
|
durationSeconds: duration,
|
|
);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final appContext = Provider.of<AppContext>(context);
|
|
Size size = MediaQuery.of(context).size;
|
|
visitAppContext = appContext.getContext();
|
|
|
|
var test = SectionDTO.fromJson(jsonDecode(jsonEncode(widget.rawSection)));
|
|
|
|
// Le `FutureBuilder` passe au-dessus du `Scaffold` : c'est le seul endroit
|
|
// d'où l'on sait si la section est prête, et c'est cette information qui
|
|
// décide de la couleur du fond.
|
|
//
|
|
// Le futur est aussi mémorisé ici. Il était construit dans `future:`, donc
|
|
// relancé à chaque rebuild : chaque changement d'orientation, chaque
|
|
// `setState` refaisait l'appel réseau de la section.
|
|
return FutureBuilder(
|
|
future: _sectionDetail ??=
|
|
getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId),
|
|
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
|
var sectionResult = snapshot.data;
|
|
final isReady = sectionDTO != null && sectionResult != null;
|
|
|
|
return Scaffold(
|
|
key: _scaffoldKey,
|
|
resizeToAvoidBottomInset: false,
|
|
// Transparent tant que la section n'est pas prête. Combiné à la route
|
|
// non opaque, le visiteur garde sous les yeux l'écran d'où il vient,
|
|
// assombri, avec le loader par-dessus — plutôt qu'une page blanche.
|
|
backgroundColor: isReady ? null : Colors.transparent,
|
|
body: MediaQuery.removeViewInsets(
|
|
context: context,
|
|
removeBottom: true,
|
|
child: OrientationBuilder(
|
|
builder: (context, orientation) {
|
|
// Rien d'autre que le loader : pas de voile. La page d'où vient
|
|
// le visiteur reste lisible telle quelle, le temps du chargement.
|
|
if (!isReady) {
|
|
return const Center(child: LoadingCommon());
|
|
}
|
|
switch(sectionDTO!.type) {
|
|
case SectionType.Agenda:
|
|
AgendaDTO agendaDTO = AgendaDTO.fromJson(sectionResult)!;
|
|
return AgendaPage(section: agendaDTO);
|
|
case SectionType.Article:
|
|
ArticleDTO articleDTO = ArticleDTO.fromJson(sectionResult)!;
|
|
return ArticlePage(
|
|
visitAppContextIn: widget.visitAppContextIn,
|
|
articleDTO: articleDTO,
|
|
resourcesModel: resourcesModel,
|
|
mainAudioId: mainAudioId,
|
|
sectionId: widget.sectionId,
|
|
);
|
|
case SectionType.Map:
|
|
MapDTO mapDTO = MapDTO.fromJson(sectionResult)!;
|
|
return ChangeNotifierProvider<MapContext>.value(
|
|
value: mapContext,
|
|
child: MapPage(section: mapDTO, icons: icons ?? []),
|
|
);
|
|
case SectionType.Menu:
|
|
MenuDTO menuDTO = MenuDTO.fromJson(sectionResult)!;
|
|
return MenuPage(section: menuDTO, isImageBackground: visitAppContext.currentAppConfigurationLink?.isSectionImageBackground ?? false);
|
|
case SectionType.Pdf:
|
|
PdfDTO pdfDTO = PdfDTO.fromJson(sectionResult)!;
|
|
return PDFPage(section: pdfDTO);
|
|
case SectionType.Game:
|
|
GameDTO gameDTO = GameDTO.fromJson(sectionResult)!;
|
|
return GamePage(section: gameDTO);
|
|
case SectionType.Quiz:
|
|
QuizDTO quizDTO = QuizDTO.fromJson(sectionResult)!;
|
|
return QuizPage(
|
|
visitAppContextIn: widget.visitAppContextIn,
|
|
quizDTO: quizDTO,
|
|
resourcesModel: resourcesModel,
|
|
sectionId: widget.sectionId,
|
|
);
|
|
case SectionType.Slider:
|
|
SliderDTO sliderDTO = SliderDTO.fromJson(sectionResult)!;
|
|
return SliderPage(section: sliderDTO);
|
|
case SectionType.Video:
|
|
VideoDTO videoDTO = VideoDTO.fromJson(sectionResult)!;
|
|
return VideoPage(section: videoDTO);
|
|
case SectionType.Weather:
|
|
WeatherDTO weatherDTO = WeatherDTO.fromJson(sectionResult)!;
|
|
return WeatherPage(section: weatherDTO);
|
|
case SectionType.Web:
|
|
WebDTO webDTO = WebDTO.fromJson(sectionResult)!;
|
|
return WebPage(section: webDTO);
|
|
case SectionType.Event:
|
|
SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!;
|
|
return EventPage(section: eventDTO, visitAppContextIn: visitAppContext);
|
|
case SectionType.Parcours:
|
|
ParcoursDTO parcoursDTO = ParcoursDTO.fromJson(sectionResult)!;
|
|
return ParcoursPage(section: parcoursDTO, visitAppContextIn: visitAppContext);
|
|
default:
|
|
return const Center(child: Text("Unsupported type"));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<dynamic> getSectionDetail(AppContext appContext, Client client, String sectionId) async {
|
|
try {
|
|
bool isConfigOffline = widget.configuration.isOffline == true;
|
|
if(widget.rawSection == null) {
|
|
if(isConfigOffline)
|
|
{
|
|
// OFFLINE
|
|
List<Map<String, dynamic>> sectionTest = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.sections, sectionId);
|
|
if(sectionTest.isNotEmpty) {
|
|
sectionDTO = DatabaseHelper.instance.getSectionFromDB(sectionTest.first);
|
|
// Le `switch` plus bas construit les sous-DTO à partir de `rawSectionData`.
|
|
// Hors ligne, il n'était jamais renseigné — `late dynamic` non initialisé,
|
|
// donc toute ouverture de section levait une LateInitializationError avant
|
|
// même d'atteindre le rendu. La colonne `data` porte le JSON que la branche
|
|
// en ligne reçoit de `sectionGetDetail` : c'est exactement la même forme.
|
|
rawSectionData = jsonDecode(sectionTest.first[DatabaseHelper.columnData] as String);
|
|
try {
|
|
SectionRead sectionRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch);
|
|
await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, sectionRead.toMap());
|
|
visitAppContext.readSections.add(sectionRead);
|
|
|
|
appContext.setContext(visitAppContext);
|
|
} catch (e) {
|
|
print("DATABASE ERROR SECTIONREAD");
|
|
print(e);
|
|
}
|
|
} else {
|
|
print("EMPTY SECTION");
|
|
}
|
|
} else
|
|
{
|
|
// ONLINE
|
|
rawSectionData = await client.sectionApi!.sectionGetDetail(sectionId);
|
|
SectionDTO sectionOnline = jsonDecode(jsonEncode(rawSectionData)).map((json) => SectionDTO.fromJson(json)).whereType<SectionDTO>().toList();
|
|
sectionDTO = sectionOnline;
|
|
}
|
|
|
|
/*setState(() {
|
|
//print(sectionDTO!.title);
|
|
});*/
|
|
} else {
|
|
rawSectionData = widget.rawSection;
|
|
sectionDTO = SectionDTO.fromJson(jsonDecode(jsonEncode(rawSectionData)));
|
|
}
|
|
|
|
switch(sectionDTO!.type)
|
|
{
|
|
case SectionType.Quiz:
|
|
QuizDTO? quizDTO = QuizDTO.fromJson(rawSectionData);
|
|
if(quizDTO != null) {
|
|
quizDTO.questions!.sort((a, b) => a.order!.compareTo(b.order!));
|
|
if(quizDTO.questions != null && quizDTO.questions!.isNotEmpty) {
|
|
quizDTO.questions!.sort((a, b) => a.order!.compareTo(b.order!));
|
|
for (var question in quizDTO.questions!) {
|
|
if(isConfigOffline)
|
|
{
|
|
// OFFLINE
|
|
if(question.imageBackgroundResourceId != null) {
|
|
List<Map<String, dynamic>> ressourceQuizz = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.resources, question.imageBackgroundResourceId!);
|
|
if(ressourceQuizz.isNotEmpty) {
|
|
resourcesModel.add(DatabaseHelper.instance.getResourceFromDB(ressourceQuizz.first));
|
|
} else {
|
|
print("EMPTY resourcesModel - second");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// ONLINE
|
|
if(question.imageBackgroundResourceId != null) {
|
|
resourcesModel.add(ResourceModel(id: question.imageBackgroundResourceId, source: question.imageBackgroundResourceUrl, type: ResourceType.Image));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case SectionType.Article:
|
|
ArticleDTO articleDTO = ArticleDTO.fromJson(rawSectionData)!;
|
|
var audioToDownload = articleDTO.audioIds!.firstWhere((a) => a.language == visitAppContext.language!).value;
|
|
var othersToDownload = articleDTO.contents!.map((c) => c.resourceId);
|
|
mainAudioId = audioToDownload;
|
|
var resourcesToDownload = [];
|
|
resourcesToDownload.add(audioToDownload);
|
|
resourcesToDownload.addAll(othersToDownload);
|
|
|
|
for (var resourceToDownload in resourcesToDownload) {
|
|
if(isConfigOffline)
|
|
{
|
|
// OFFLINE
|
|
List<Map<String, dynamic>> ressourceArticle = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.resources, resourceToDownload);
|
|
if(ressourceArticle.isNotEmpty) {
|
|
resourcesModel.add(DatabaseHelper.instance.getResourceFromDB(ressourceArticle.first));
|
|
} else {
|
|
print("EMPTY resourcesModel - second");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// ONLINE
|
|
ResourceDTO? resourceDTO = await client.resourceApi!.resourceGetDetail(resourceToDownload);
|
|
if(resourceDTO != null && resourceDTO.url != null) {
|
|
// ONLINE
|
|
//ResourceModel? resourceAudioOnline = await ApiService.downloadAudio(client, resourceDTO.url!, resourceDTO.id!);
|
|
ResourceModel resourceAudioOnline = ResourceModel();
|
|
resourceAudioOnline.id = resourceDTO.id;
|
|
resourceAudioOnline.source = resourceDTO.url;
|
|
resourceAudioOnline.type = resourceDTO.type;
|
|
|
|
resourcesModel.add(resourceAudioOnline);
|
|
|
|
/*Uint8List base64String = base64Decode(resourceAudioOnline.path!); // GET FROM FILE
|
|
audiobytes = base64String;*/
|
|
} else {
|
|
print("EMPTY resourcesModel online - audio");
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
case SectionType.Map:
|
|
MapDTO mapDTO = MapDTO.fromJson(rawSectionData)!;
|
|
icons = await getByteIcons(visitAppContext, mapDTO);
|
|
break;
|
|
case SectionType.Event:
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return rawSectionData;
|
|
|
|
} catch (e) {
|
|
print(e);
|
|
print("IN CATCH");
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Uint8List> getBytesFromAsset(ByteData data, int width) async {
|
|
//ByteData data = await rootBundle.load(path);
|
|
ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List(), targetWidth: width);
|
|
ui.FrameInfo fi = await codec.getNextFrame();
|
|
return (await fi.image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List();
|
|
}
|
|
|
|
Uint8List resizeImage(Uint8List data, int width) {
|
|
Uint8List resizedData = data;
|
|
IMG.Image img = IMG.decodeImage(data)!;
|
|
IMG.Image resized = IMG.copyResize(img, width: width);
|
|
resizedData = Uint8List.fromList(IMG.encodeJpg(resized));
|
|
return resizedData;
|
|
}
|
|
|
|
Future<File?> _checkIfLocalResourceExists(VisitAppContext visitAppContext, String iconId) async {
|
|
try {
|
|
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
|
|
String localPath = appDocumentsDirectory!.path;
|
|
Directory configurationDirectory = Directory('$localPath/${visitAppContext.configuration!.id}');
|
|
List<FileSystemEntity> fileList = configurationDirectory.listSync();
|
|
|
|
if(fileList.any((fileL) => fileL.uri.pathSegments.last.contains(iconId))) {
|
|
File file = File(fileList.firstWhere((fileL) => fileL.uri.pathSegments.last.contains(iconId)).path);
|
|
return file;
|
|
}
|
|
} catch(e) {
|
|
print("ERROR _checkIfLocalResourceExists CachedCustomResource");
|
|
print(e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> getByteIcons(VisitAppContext visitAppContext, MapDTO mapDTO) async {
|
|
//var mapDTO = MapDTO.fromJson(jsonDecode(section.data!));
|
|
Uint8List selectedMarkerIcon;
|
|
if (mapDTO.iconSource != null) {
|
|
if (kIsWeb) {
|
|
Uint8List fileData = await http.readBytes(Uri.parse(mapDTO.iconSource!));
|
|
selectedMarkerIcon = resizeImage(fileData, 40);
|
|
} else {
|
|
File? localIcon = mapDTO.iconResourceId != null
|
|
? await _checkIfLocalResourceExists(visitAppContext, mapDTO.iconResourceId!)
|
|
: null;
|
|
if(localIcon == null) {
|
|
final ByteData imageData = await NetworkAssetBundle(Uri.parse(mapDTO.iconSource!)).load("");
|
|
selectedMarkerIcon = await getBytesFromAsset(imageData, 50);
|
|
} else {
|
|
Uint8List bytes = await localIcon.readAsBytes();
|
|
selectedMarkerIcon = await getBytesFromAsset(ByteData.view(bytes.buffer), 50);
|
|
}
|
|
}
|
|
} else {
|
|
// Icône par défaut
|
|
final ByteData bytes = await rootBundle.load('assets/icons/marker.png');
|
|
selectedMarkerIcon = await getBytesFromAsset(bytes, 25);
|
|
}
|
|
|
|
List<Map<String, dynamic>> icons = [];
|
|
|
|
icons.add({'id': null, 'icon': selectedMarkerIcon});
|
|
|
|
// Utiliser Future.forEach() pour itérer de manière asynchrone sur la liste des catégories
|
|
await Future.forEach(mapDTO.categories!, (cat) async {
|
|
if (cat.resourceDTO != null && cat.resourceDTO!.url != null && cat.resourceDTO!.id != null) {
|
|
Uint8List categoryIcon;
|
|
if (kIsWeb) {
|
|
categoryIcon = await http.readBytes(Uri.parse(cat.resourceDTO!.url!));
|
|
} else {
|
|
File? localIcon = await _checkIfLocalResourceExists(visitAppContext, cat.resourceDTO!.id!);
|
|
if(localIcon == null) {
|
|
final ByteData imageData = await NetworkAssetBundle(Uri.parse(cat.resourceDTO!.url!)).load("");
|
|
categoryIcon = await getBytesFromAsset(imageData, 50);
|
|
} else {
|
|
Uint8List bytes = await localIcon.readAsBytes();
|
|
categoryIcon = await getBytesFromAsset(ByteData.view(bytes.buffer), 50);
|
|
}
|
|
}
|
|
icons.add({'id': cat.id, 'icon': categoryIcon});
|
|
}
|
|
});
|
|
|
|
return icons;
|
|
}
|