mymuseum-visitapp/lib/Services/downloadConfiguration.dart
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

712 lines
31 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
import 'package:mymuseum_visitapp/Helpers/modelsHelper.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/apiService.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 'package:http/http.dart' as http;
import 'package:fluttertoast/fluttertoast.dart';
/// Types dont l'écran ne fait aucun appel réseau au rendu : ils savent s'afficher
/// à partir du seul payload stocké en base locale. Les autres restent volontairement
/// dehors — Web (webview), Weather (API météo) et Agenda (JSON distant) ne peuvent
/// pas fonctionner sans réseau ; Parcours et Event appellent encore l'API au rendu ;
/// Map attend un fond de carte hors ligne, sinon le visiteur ouvre une carte grise.
const List<SectionType> offlineCapableSectionTypes = [
SectionType.Article,
SectionType.Quiz,
SectionType.Menu,
SectionType.Slider,
SectionType.Pdf,
SectionType.Video,
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;
@override
State<DownloadConfigurationWidget> createState() => _DownloadConfigurationWidgetState();
}
class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidget> {
ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0);
ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1);
/// Le téléchargement était lancé depuis `build()`, donc relancé à chaque rebuild
/// du dialogue. Il démarre une fois, ici.
Future<DownloadOutcome>? _downloadFuture;
@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) {
isAllLanguages = visitAppContext.isAllLanguages!;
if(isAllLanguages) {
print("Admin here and all download all audios !");
/*ScaffoldMessenger.of(buildContext).showSnackBar(
const SnackBar(
content: Text("Tous les audios vont être téléchargés"),
backgroundColor: kMainColor),
);*/
}
}
// HERE CHECK VERSION APK
if(true) {
Map<Permission, PermissionStatus> statuses = await [
Permission.requestInstallPackages,
].request();
//tryOtaUpdate();
}
ExportConfigurationDTO? exportConfigurationDTO;
try{
// Retrieve all url from resource to download (get all resource from configuration en somme)
exportConfigurationDTO = await visitAppContext.clientAPI.configurationApi!.configurationExport(widget.configuration.id!, language: isAllLanguages ? null : visitAppContext.language); // tabletAppContext.configuration!.id! // 65c5f0ee4c030e63ce16bff5 TODO Remove
} catch(e) {
print("Erreur lors du téléchargement de la configuration et de ses ressources !");
print(e);
return DownloadOutcome.unreachable;
}
exportConfigurationDTO.resources!.forEach((element) {
print(element.id);
print(element.label);
});
if(exportConfigurationDTO.resources != null && exportConfigurationDTO.resources!.isNotEmpty) {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();
//if(statuses[Permission.storage] == PermissionStatus.granted) {
try{
try {
Directory directory = Directory('${visitAppContext.localPath}');
List<FileSystemEntity> allConfigurations = directory.listSync();
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
if(!allConfigurations.any((configurationDirectory) => configurationDirectory.uri.pathSegments.any((element) => element == widget.configuration.id))) {
// create directory
print("Trying to create directory");
configurationDirectory.createSync(recursive: true);
print('Répertoire créé avec succès.');
}
} catch(e) {
print("Listing failed, so try to create directory");
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
configurationDirectory.createSync(recursive: true);
print('Répertoire créé avec succès.');
}
Directory configurationDirectory = Directory('${visitAppContext.localPath}/${widget.configuration.id}');
List<FileSystemEntity> fileList = configurationDirectory.listSync();
for (var file in fileList) {
print(file.uri.pathSegments.last);
}
Map<String, DateTime?> localResourceDates = await readLocalResourceDates();
// Ressources dont le téléchargement a échoué. Vide = visite complète.
final List<String> failedResourceIds = [];
// Sections dont le payload n'a pas pu être récupéré ou enregistré.
final List<String> failedSectionIds = [];
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id]));
currentResourceNbr.value = resourcesToDownload.length;
// foreach ou on va tout télécharger - avec un joli etape 0 / length - on peut rendre tout lent on s'en fou ça ne ce fait qu'une fois
for (var resource in resourcesToDownload) {
String? filePath = await downloadResource(visitAppContext, widget.configuration, resource, visitAppContext.localPath!);
if (filePath != null)
{
// Insert in database
ResourceModel resourceModel = ResourceModel(id: resource.id, source: resource.url, path: filePath, type: resource.type, dateUpdate: resource.dateUpdate);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
localResourceDates[resource.id!] = resource.dateUpdate;
} catch (e) {
print("We got an issue inserting image metadata ${resource.id}");
}
currentResourceIndex.value++;
} else {
// D5 — un fichier raté ne disparaît plus dans un `print`. Une visite dont
// des ressources manquent ne doit pas s'annoncer téléchargée : hors ligne,
// le visiteur trouverait des images vides ou un audio muet sans comprendre
// pourquoi, et sans rien pouvoir y faire.
failedResourceIds.add(resource.id ?? '?');
debugPrint("[download] échec de la ressource ${resource.id} (${resource.url})");
}
}
// Delete others that are no more used
var resourceToDelete = fileList.where((fileL) =>
!exportConfigurationDTO!.resources!.any((resource) =>
resource.id != null && fileL.uri.pathSegments.last.contains(resource.id!)));
for (var resource in resourceToDelete) {
print("resource to DELETE");
print(resource.path);
try {
resource.deleteSync();
} catch (e) {
print("We got an issue deleting local file ${resource.path}");
}
}
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, ModelsHelper.configurationToMap(widget.configuration));
List<SectionDTO>? sections = exportConfigurationDTO.sections;
List<String> usedImageOrAudioIds = [];
if(sections!.isNotEmpty) {
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
List<SectionDTO> sectionsToKeep = sections.where((s) => offlineCapableSectionTypes.contains(s.type)).toList();
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
int newOrder = 0;
// Update local DB - Sections
for(var section in sectionsToKeep) {
section.order = newOrder;
// Le payload typé n'est pas dans l'export : `ExportConfigurationDTO.sections`
// est déserialisé en `SectionDTO`, qui ne porte que l'en-tête commun. C'est
// `sectionGetDetail` qui renvoie le JSON complet du sous-type — le même que
// celui dont `section_page` se sert en ligne.
String? rawSectionJson;
try {
rawSectionJson = jsonEncode(await visitAppContext.clientAPI.sectionApi!.sectionGetDetail(section.id!));
} catch (e) {
debugPrint("[download] détail indisponible pour la section ${section.id} : $e");
}
// Une section sans son payload ne sait rien afficher hors ligne. On préfère
// ne pas l'enregistrer et faire échouer la visite, plutôt que laisser le
// visiteur ouvrir un article vide au milieu du site.
if (rawSectionJson == null) {
failedSectionIds.add(section.id ?? '?');
newOrder = newOrder + 1;
continue;
}
try {
await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section, rawSectionJson));
} catch (e) {
failedSectionIds.add(section.id ?? '?');
debugPrint("[download] échec d'insertion de la section ${section.id} : $e");
}
// Download section image
if(section.imageId != null) {
usedImageOrAudioIds.add(section.imageId!);
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == section.imageId);
if(imageData.isNotEmpty) {
// TODO get all resources from API + store it in download directory of the app
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: section.imageSource, /*data: imageData.first.data,*/ type: imageData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) {
print("We got an issue inserting image data ${imageData.first.id}");
}
}
}
newOrder = newOrder + 1;
}
// Le bloc qui vivait ici parsait `section.data` type par type — et il était
// commenté, donc une visite téléchargée n'embarquait que l'image de chaque
// section : ni contenus d'articles, ni audios, ni icônes de carte.
//
// Il n'y a plus rien à redécouvrir : depuis le 2026-08-11 le serveur collecte
// les ressources via `GetReferencedResourceIds()`, implémentée sur les 13
// sous-types, et les met toutes dans `resources`. On enregistre la charge.
// Un nouveau type de section est couvert sans toucher à ce fichier.
//
// ⚠️ `insert` fait un UPDATE de toute la ligne quand l'id existe déjà : cette
// boucle repasse sur les ressources que la boucle de téléchargement vient
// d'enregistrer. Elle doit donc réécrire la date que celle-ci a posée, sinon
// elle l'efface et le fichier redevient « sans date connue » à chaque visite.
// Et pour une ressource dont le téléchargement a échoué, c'est l'ancienne date
// locale qui est conservée — surtout pas celle du serveur, qui la déclarerait
// à jour alors que le fichier est absent.
for (var resource in exportConfigurationDTO.resources ?? []) {
if (resource.id == null) continue;
usedImageOrAudioIds.add(resource.id!);
try {
await DatabaseHelper.instance.insert(
DatabaseTableType.resources,
ResourceModel(id: resource.id, source: resource.url, type: resource.type, dateUpdate: localResourceDates[resource.id]).toMap(),
);
} catch (e) {
print("We got an issue inserting resource ${resource.id}");
}
}
List<String?> sectionIdsToRemove = sectionsInDB.map((s) => s.id).where((sectionId) => !sectionsToKeep.map((sk) => sk.id).contains(sectionId)).toList();
for(var sectionIdToRemove in sectionIdsToRemove) {
print("section with id removed");
print(sectionIdToRemove);
try {
await DatabaseHelper.instance.delete(sectionIdToRemove!, DatabaseTableType.sections);
} catch (e) {
print("We got an issue deleting section id: ${sectionIdToRemove}");
}
}
// `cleanLocalResources` reste désactivée : la table `resources` n'a pas de
// colonne `configurationId`, elle est globale à toutes les visites. La purger
// avec les ids d'une seule configuration effacerait les lignes des autres.
// Les fichiers obsolètes, eux, sont bien supprimés plus haut — leur répertoire
// est propre à la configuration. Et rien ne lit la colonne `path` pour le rendu :
// `CachedCustomResource` retrouve le fichier en listant le répertoire.
}
// D5 — la visite n'est déclarée téléchargée que si elle l'est vraiment. Le
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que
// ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty || failedSectionIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}");
debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
"${failedSectionIds.join(', ')}");
return DownloadOutcome.incomplete;
}
} catch(e) {
print("ERRORRRR");
print(e);
if(statuses[Permission.storage] != PermissionStatus.granted) {
Fluttertoast.showToast(
msg: "PermissionStatus not granted, issue may be linked to that. Please check os version (if less than 13, real issue).",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: Colors.redAccent,
textColor: Colors.white,
fontSize: 16.0
);
}
return DownloadOutcome.incomplete;
}
/*} else {
print("PermissionStatus.granted NOT GRANTED");
Fluttertoast.showToast(
msg: "PermissionStatus not granted",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: Colors.redAccent,
textColor: Colors.white,
fontSize: 16.0
);
return false;
}*/
}
return currentResourceNbr.value > 0
? DownloadOutcome.complete
: DownloadOutcome.upToDate;
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
// Pas de `Center` ici : le dialogue ne fixe plus la hauteur du contenu, et un
// `Center` s'étirerait sur toute la hauteur disponible.
return FutureBuilder<DownloadOutcome>(
future: _downloadFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _progress(appContext);
}
// Une exception non rattrapée dans `download` laissait le dialogue sur
// « téléchargement en cours » elle aussi — le `Future` était en erreur,
// personne ne regardait.
if (snapshot.hasError) {
debugPrint("[download] échec inattendu : ${snapshot.error}");
return _message(Icons.error_outline, Colors.redAccent,
TranslationHelper.getFromLocale("downloadIncomplete", appContext.getContext()));
}
switch (snapshot.data!) {
case DownloadOutcome.upToDate:
return _label(TranslationHelper.getFromLocale("upToDate", appContext.getContext()));
case DownloadOutcome.complete:
return _label(TranslationHelper.getFromLocale("downloadFinish", appContext.getContext()));
case DownloadOutcome.incomplete:
return _message(Icons.cloud_off, Colors.orangeAccent,
TranslationHelper.getFromLocale("downloadIncomplete", appContext.getContext()));
case DownloadOutcome.unreachable:
return _message(Icons.wifi_off, Colors.redAccent,
TranslationHelper.getFromLocale("downloadFailed", appContext.getContext()));
}
},
);
}
/// Le texte et la barre pendant le téléchargement. `currentResourceNbr` vaut -1
/// tant que l'export n'a pas répondu : on annonce le travail sans prétendre en
/// connaître l'avancement.
Widget _progress(AppContext appContext) {
return ValueListenableBuilder<int>(
valueListenable: currentResourceNbr,
builder: (context, valueNbr, _) => ValueListenableBuilder<int>(
valueListenable: currentResourceIndex,
builder: (context, valueIndex, _) {
final ratio = valueNbr > 0 ? valueIndex / valueNbr : null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_label(TranslationHelper.getFromLocale("downloadInProgress", appContext.getContext())),
Padding(
padding: const EdgeInsets.all(8.0),
child: LinearProgressIndicator(
value: ratio,
semanticsLabel: 'Linear progress indicator',
color: kMainColor0,
),
),
if (ratio != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${(ratio * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w500),
),
),
],
);
},
),
);
}
/// Pas de `Center` autour : dans un `AlertDialog`, le contenu reçoit une hauteur
/// maximale très large, et un `Center` la prend toute — d'où un dialogue haut
/// comme l'écran pour deux lignes de texte. Une `Column` `min` s'ajuste au texte.
Widget _label(String text) {
return 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 Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 34, color: color),
const SizedBox(height: 10),
Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
],
),
);
}
}
/*class DownloadConfiguration {
static Future<List<ResourceModel>> download(BuildContext buildContext, AppContext appContext, ConfigurationDTO configuration) async {
VisitAppContext visitAppContext = (appContext.getContext() as VisitAppContext);
bool isAllLanguages = false;
List<ResourceModel> audiosNotWorking = [];
if(visitAppContext.isAllLanguages != null) {
isAllLanguages = visitAppContext.isAllLanguages!;
if(isAllLanguages) {
print("Admin here and all download all audios !");
ScaffoldMessenger.of(buildContext).showSnackBar(
const SnackBar(
content: Text("Tous les audios vont être téléchargés"),
backgroundColor: kMainColor),
);
}
}
print("Start export configuration to store it locally");
ExportConfigurationDTO? exportConfigurationDTO;
try{
exportConfigurationDTO = await ApiService.exportConfiguration(visitAppContext.clientAPI, configuration.id!, isAllLanguages ? null : visitAppContext.language!);
} catch(e) {
print("Erreur lors du téléchargement de la visite");
print(e);
}
if(exportConfigurationDTO != null) {
// Delete all from local DB ref to this configurationId then add it all
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, ModelsHelper.configurationToMap(configuration));
List<String> usedImageOrAudioIds = [];
var currentLanguage = (appContext.getContext() as VisitAppContext).language;
if(configuration.imageId != null) {
usedImageOrAudioIds.add(configuration.imageId!);
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == configuration.imageId);
if(imageData.isNotEmpty) {
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: configuration.imageSource, /*data: imageData.first.data,*/ type: imageData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) {
print("We got an issue inserting image data ${imageData.first.id}");
}
}
}
List<SectionDTO>? sections = exportConfigurationDTO.sections;
if(sections!.isNotEmpty) {
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!);
List<SectionDTO> sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quizz).toList(); // TODO: supporter tous les types de sections (Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda) — actuellement limité à Article et Quiz
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
int newOrder = 0;
// Update local DB - Sections
for(var section in sectionsToKeep) {
section.order = newOrder;
try {
await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section));
} catch (e) {
print("We got an issue inserting section data ${section.id}");
}
// Download section image
if(section.imageId != null) {
usedImageOrAudioIds.add(section.imageId!);
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == section.imageId);
if(imageData.isNotEmpty) {
// TODO get all resources from API + store it in download directory of the app
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: section.imageSource, /*data: imageData.first.data,*/ type: imageData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) {
print("We got an issue inserting image data ${imageData.first.id}");
}
}
}
// Download all images..
ArticleDTO? articleDTO = ArticleDTO.fromJson(jsonDecode(section.data!));
if(articleDTO != null) {
for(var image in articleDTO.contents!) {
usedImageOrAudioIds.add(image.resourceId!);
var imageData = exportConfigurationDTO.resources!.where((element) => element.id == image.resourceId);
if(imageData.isNotEmpty) {
// TODO get all resources from API + store it in download directory of the app
ResourceModel resourceModel = ResourceModel(id: imageData.first.id, source: image.resourceUrl, /*data: imageData.first.data,*/ type: imageData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
} catch (e) {
print("We got an issue inserting image data ${imageData.first.id}");
}
}
}
var audioIdsArticle = isAllLanguages ? articleDTO.audioIds! : articleDTO.audioIds!.where((audioId) => audioId.language == currentLanguage);
for(var audioId in audioIdsArticle) {
if(audioId.value != null) {
usedImageOrAudioIds.add(audioId.value!);
audiosNotWorking = await importAudio(visitAppContext, exportConfigurationDTO, audioId.value!, audiosNotWorking);
}
}
}
newOrder = newOrder + 1;
}
List<String?> sectionIdsToRemove = sectionsInDB.map((s) => s.id).where((sectionId) => !sectionsToKeep.map((sk) => sk.id).contains(sectionId)).toList();
for(var sectionIdToRemove in sectionIdsToRemove) {
print("section with id removed");
print(sectionIdToRemove);
try {
await DatabaseHelper.instance.delete(sectionIdToRemove!, DatabaseTableType.sections);
} catch (e) {
print("We got an issue deleting section id: ${sectionIdToRemove}");
}
}
cleanLocalResources(usedImageOrAudioIds, configuration);
}
}
return audiosNotWorking;
}
}
*/
Future<String?> downloadResource(VisitAppContext visitAppContext, ConfigurationDTO configurationDTO, ResourceDTO resourceDTO, String localPath) async {
try {
// Téléchargement de la ressource depuis l'URL
http.Response response = await http.get(Uri.parse(resourceDTO.url!));
if (response.statusCode == 200) {
// Vérification de l'en-tête Content-Type
String contentType = response.headers["content-type"] ?? "";
// Déduction de l'extension en fonction du Content-Type
String extension = _getExtensionFromContentType(contentType);
print("LOCAL PATTH");
print(localPath);
File file = File('$localPath/${configurationDTO.id}/${resourceDTO.id}.$extension');
// Écriture du contenu téléchargé dans le fichier local
await file.writeAsBytes(response.bodyBytes);
return file.path;
} else {
print("Échec du téléchargement de la ressource - ${response.statusCode}");
return null;
}
} catch (e) {
print("Erreur lors du téléchargement de la ressource !");
print(e);
return null;
}
}
/// Date de dernière mise à jour connue pour chaque ressource déjà sur le device.
/// Une valeur nulle signifie « fichier téléchargé avant la v4 de la base locale,
/// date inconnue » — pas « périmé ».
Future<Map<String, DateTime?>> readLocalResourceDates() async {
List<Map<String, dynamic>> rows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.resources);
return {
for (var row in rows) row["id"] as String: DatabaseHelper.instance.getResourceFromDB(row).dateUpdate
};
}
bool isResourceOutdated(ResourceDTO resource, List<FileSystemEntity> fileList, DateTime? localDateUpdate) {
var localFile = fileList.where((fileL) => fileL.uri.pathSegments.last.contains(resource.id!));
if (localFile.isEmpty) return true;
// Fichier écrit avec une extension que la table ne savait pas déduire — c'était le
// cas de tous les MP3 avant D3. Il est illisible, et sa date ne le dira jamais :
// le serveur n'a pas changé, c'est le client qui l'avait mal enregistré.
//
// La date nulle borne la reprise à UN essai : sans elle, un type MIME réellement
// absent de la table se re-téléchargerait à chaque visite pour se réécrire en
// `.unknown`. On ne peut pas non plus déduire l'extension de l'URL en repli — une
// URL Firebase n'en porte pas.
if (localFile.first.uri.pathSegments.last.endsWith(".unknown") && localDateUpdate == null) return true;
if (localDateUpdate == null || resource.dateUpdate == null) return false;
return resource.dateUpdate!.isAfter(localDateUpdate);
}
String _getExtensionFromContentType(String contentType) {
Map<String, String> contentTypeToExtension = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"video/mp4": "mp4",
"video/webm": "webm",
"video/avi": "avi",
"video/quicktime": "mov",
"application/pdf": "pdf",
"application/json": "json"
};
return contentTypeToExtension[contentType] ?? "unknown";
}
Future<List<ResourceModel>> importAudio(VisitAppContext visitAppContext, ExportConfigurationDTO exportConfigurationDTO, String audioId, List<ResourceModel> audiosNotWorking) async {
var audioData = exportConfigurationDTO.resources!.where((element) => element.id == audioId);
if(audioData.isNotEmpty) {
// TODO get all resources from API + store it in download directory of the app
ResourceModel resourceModel = ResourceModel(id: audioData.first.id, source: "", /*data: audioData.first.data,*/ type: audioData.first.type);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
return audiosNotWorking;
} catch (e) {
print("We got an issue inserting audio data ${audioData.first.id}");
resourceModel.label = audioData.first.label;
print(resourceModel.label);
// Update context with not working audios
//VisitAppContext visitAppContext = appContext.getContext() as VisitAppContext;
audiosNotWorking.add(resourceModel);
audiosNotWorking = audiosNotWorking.toSet().toList(); // Remove duplicates
// appContext.setContext(visitAppContext);
return audiosNotWorking;
}
}
return audiosNotWorking;
}
void cleanLocalResources(List<String> usedImageIds, ConfigurationDTO configuration) async {
List<Map<String, dynamic>> resourcesInDB = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.resources);
List<ResourceModel> resourcesModel = [];
for(var resource in resourcesInDB) {
resourcesModel.add(DatabaseHelper.instance.getResourceFromDB(resource));
}
List<String?> resourceIdsToRemove = resourcesModel.map((r) => r.id).where((resourceId) => !usedImageIds.contains(resourceId)).toList();
for(var resourceIdToRemove in resourceIdsToRemove) {
print("resource with id removed ________________ !!!!!");
print(resourceIdToRemove);
try {
await DatabaseHelper.instance.delete(resourceIdToRemove!, DatabaseTableType.resources);
} catch (e) {
print("We got an issue deleting resource id: ${resourceIdToRemove}");
}
}
}