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

713 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),
),
),
],
);
},
),
);
}
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: [
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}");
}
}
}