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

538 lines
22 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.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/Article/audio_player.dart';
import 'package:mymuseum_visitapp/Services/apiService.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/client.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'audio_player_tab.dart';
class ArticlePage extends StatefulWidget {
const ArticlePage({Key? key, required this.visitAppContextIn, required this.articleDTO, required this.resourcesModel, this.mainAudioId, this.sectionId}) : super(key: key);
final ArticleDTO articleDTO;
final VisitAppContext visitAppContextIn;
final List<ResourceModel?> resourcesModel;
final String? mainAudioId;
final String? sectionId;
@override
State<ArticlePage> createState() => _ArticlePageState();
}
class _ArticlePageState extends State<ArticlePage> {
ResourceModel? audioResourceModel;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
File? audioFile;
late VisitAppContext visitAppContext;
late List<ResourceModel?> resourcesModelToShow;
@override
void initState() {
widget.visitAppContextIn.isContentCurrentlyShown = true;
audioResourceModel = widget.resourcesModel.firstWhere((r) => r?.id == widget.mainAudioId, orElse: () => null);
resourcesModelToShow = widget.resourcesModel.where((r) => r?.id != widget.mainAudioId).toList();
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.visitAppContextIn.statisticsService?.track(
VisitEventType.articleRead,
sectionId: widget.sectionId,
);
});
super.initState();
}
@override
void dispose() {
visitAppContext.isContentCurrentlyShown = false;
super.dispose();
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
Size size = MediaQuery.of(context).size;
//final notchInset = MediaQuery.of(context).padding;
visitAppContext = appContext.getContext();
var title = TranslationHelper.get(widget.articleDTO.title, appContext.getContext());
String cleanedTitle = title.replaceAll('\n', ' ').replaceAll('<br>', ' ');
return Scaffold(
key: _scaffoldKey,
body: Stack(
children: [
Container(
height: size.height * 0.28,
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
color: kMainGrey,
spreadRadius: 0.5,
blurRadius: 5,
offset: Offset(0, 1), // changes position of shadow
),
],
gradient: const LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
kMainColor0,
kMainColor1,
kMainColor2,
],
),
image: widget.articleDTO.imageSource != null ? DecorationImage(
fit: BoxFit.cover,
opacity: 0.65,
image: NetworkImage(
widget.articleDTO.imageSource!,
),
): null,
),
),
Column(
children: [
// La bande d'en-tête inclut la safe area : sans elle, le titre passait
// sous la barre d'état et le poinçon de la caméra. Le `top: 22` en dur
// ne valait que pour l'écran sur lequel il a été réglé.
SizedBox(
height: size.height * 0.11 + MediaQuery.of(context).padding.top,
width: size.width,
// Une `Row` et non trois `Positioned` : les icônes étaient épinglées
// en haut de la bande pendant que le titre y était centré, donc rien
// n'était sur le même axe. Ici les trois partagent la ligne, et un
// titre sur deux lignes déplace tout le monde ensemble.
child: Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Row(
children: [
const SizedBox(width: 12),
// `GlassPill`, comme les pastilles de l'accueil : le disque
// plein de 50px en kMainColor tranchait sur la photo d'en-tête
// et n'existait nulle part ailleurs dans l'app.
GlassPill(
size: 40,
onTap: () => Navigator.of(context).pop(),
child: const Icon(Icons.arrow_back_ios_new, size: 18, color: Colors.white),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: HtmlWidget(
cleanedTitle,
// Blanc sur photo : sans ombre portée, un titre posé sur
// une zone claire de l'image devient illisible.
textStyle: const TextStyle(
color: Colors.white,
fontFamily: 'Roboto',
fontSize: 20,
shadows: [
Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 1)),
],
),
customStylesBuilder: (element)
{
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
},
),
),
),
InkWell(
onTap: () {
setState(() {
visitAppContext.isMaximizeTextSize = !visitAppContext.isMaximizeTextSize;
appContext.setContext(visitAppContext);
});
},
child: SizedBox(
width: 50,
child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, size: 30, color: Colors.white) : const Icon(Icons.format_size, size: 30, color: Colors.white)
),
),
const SizedBox(width: 4),
],
),
),
),
Expanded(
child: Container(
margin: const EdgeInsets.only(top: 0),
decoration: const BoxDecoration(
boxShadow: [
BoxShadow(
color: kMainGrey,
spreadRadius: 0.5,
blurRadius: 2,
offset: Offset(0, 1), // changes position of shadow
),
],
color: kBackgroundColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: OrientationBuilder(
builder: (context, orientation) {
if(size.height > size.width) {
return Column(
children: [
if(widget.articleDTO.isContentTop!)
getContent(size, appContext),
if(widget.articleDTO.isContentTop! && resourcesModelToShow.isNotEmpty)
getImages(size, widget.articleDTO.isContentTop!),
if(!widget.articleDTO.isContentTop! && resourcesModelToShow.isNotEmpty)
getImages(size, widget.articleDTO.isContentTop!),
if(!widget.articleDTO.isContentTop!)
getContent(size, appContext),
/*if(audioResourceModel != null)
AudioPlayerContainer(audioBytes: audiobytes, isAuto: articleDTO!.isReadAudioAuto!),*/
],
);
} else {
return SizedBox(
height: size.height,
width: size.width,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if(widget.articleDTO.isContentTop!)
getContent(size, appContext),
if(widget.articleDTO.isContentTop! && resourcesModelToShow.isNotEmpty)
getImages(size, widget.articleDTO.isContentTop!),
if(!widget.articleDTO.isContentTop! && resourcesModelToShow.isNotEmpty)
getImages(size, widget.articleDTO.isContentTop!),
if(!widget.articleDTO.isContentTop!)
getContent(size, appContext),
],
),
/*if(audioResourceModel != null)
AudioPlayerContainer(audioBytes: audiobytes, isAuto: articleDTO!.isReadAudioAuto!)*/
],
),
);
}
}
),
),
),
],
),
// Onglet audio collé au bord droit, sur le modèle du filtre de la carte
// (`geo_point_filter`) : replié il ne prend qu'une pastille, déplié il
// s'étire vers la gauche par-dessus la page pour laisser régler l'écoute.
// En `floatingActionButton`/`miniEndFloat`, le bouton couvrait en
// permanence les dernières lignes du texte.
if (audioResourceModel != null && audioResourceModel!.source != null)
AudioPlayerTab(
file: audioFile,
resourceURl: audioResourceModel!.source!,
isAuto: widget.articleDTO.isReadAudioAuto!,
),
],
),
);
}
Widget getImages(Size size, bool isContentTop) {
if(size.width > size.height) {
return SizedBox(
width: size.width *0.5,
height: size.height * 0.75,
child: Padding(
padding: isContentTop ? const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 8.0): const EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: kMainColor2,
width: 0.5,
),
color: Colors.white,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(20.0),
boxShadow: const [kDefaultShadow],
),
child: SliderImagesWidget(
resources: resourcesModelToShow,
height: size.height * 0.29,
contentsDTO: widget.articleDTO.contents!,
)
)
)
);
} else {
return SizedBox(
width: size.width,
height: size.height * 0.3,
child: Padding(
padding: isContentTop ? const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 8.0): const EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: kMainColor2,
width: 0.5,
),
color: Colors.white,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(20.0),
boxShadow: const [kDefaultShadow],
),
child: SliderImagesWidget(
resources: resourcesModelToShow,
height: size.height * 0.29,
contentsDTO: widget.articleDTO.contents!,
)
)
)
);
}
}
Widget getContent(Size size, AppContext appContext) {
if(size.width > size.height) {
return SingleChildScrollView(
child: Container(
width: size.width *0.5,
height: size.height * 0.76,
//color: Colors.blueAccent,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: kMainColor2,
width: 0.5,
),
color: Colors.white,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(20.0),
boxShadow: const [kDefaultShadow],
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: HtmlWidget(
TranslationHelper.get(widget.articleDTO.content, appContext.getContext()),
textStyle: TextStyle(fontSize: (appContext.getContext() as VisitAppContext).isMaximizeTextSize ? kArticleContentBiggerSize : kArticleContentSize),
customStylesBuilder: (element)
{
return {'font-family': "Roboto"};
}
//textAlign: TextAlign.left,
),
),
)
)
)
),
);
} else {
return Expanded(
child: Container(
width: size.width,
//height: size.height * 0.65,
//color: Colors.blueAccent,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: kMainColor2,
width: 0.5,
),
color: Colors.white,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(20.0),
boxShadow: const [kDefaultShadow],
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12.5),
child: HtmlWidget(
TranslationHelper.get(widget.articleDTO.content, appContext.getContext()),
//textAlign: TextAlign.left,
textStyle: TextStyle(fontSize: (appContext.getContext() as VisitAppContext).isMaximizeTextSize ? kArticleContentBiggerSize : kArticleContentSize, fontFamily: "Arial"),
),
),
)
)
)
),
);
}
}
/*Future<ArticleDTO?> getArticle(AppContext appContext, Client client, String articleId, bool isAudio) async {
try {
if(sectionDTO == null || articleDTO == null) {
bool isConfigOffline = (appContext.getContext() as VisitAppContext).configuration!.isOffline!;
if(isConfigOffline)
{
// OFFLINE
List<Map<String, dynamic>> sectionTest = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.sections, articleId);
if(sectionTest.isNotEmpty) {
sectionDTO = DatabaseHelper.instance.getSectionFromDB(sectionTest.first);
try {
SectionRead articleRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch);
await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, articleRead.toMap());
visitAppContext.readSections.add(articleRead);
appContext.setContext(visitAppContext);
} catch (e) {
print("DATABASE ERROR ARTICLEREAD");
print(e);
}
} else {
print("EMPTY SECTION");
}
} else
{
// ONLINE
SectionDTO? sectionOnline = await client.sectionApi!.sectionGetDetail(articleId);
if(sectionOnline != null) {
sectionDTO = sectionOnline;
try {
SectionRead articleRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch);
await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, articleRead.toMap());
visitAppContext.readSections.add(articleRead);
appContext.setContext(visitAppContext);
} catch (e) {
print("DATABASE ERROR ARTICLEREAD");
print(e);
}
} else {
print("EMPTY SECTION");
}
}
if(sectionDTO!.type == SectionType.Article) {
articleDTO = ArticleDTO.fromJson(jsonDecode(sectionDTO!.data!));
}
if(articleDTO != null && isAudio) {
var audioIdArticle = articleDTO!.audioIds!.where((audioId) => audioId.language == (appContext.getContext() as VisitAppContext).language);
if(audioIdArticle.isNotEmpty && audioIdArticle.first.value != null) {
if(isConfigOffline)
{
try{
// OFFLINE
List<Map<String, dynamic>> ressourceTest = await DatabaseHelper
.instance.queryWithColumnId(
DatabaseTableType.resources, audioIdArticle.first.value!);
if (ressourceTest.isNotEmpty) {
audioResourceModel = DatabaseHelper.instance.getResourceFromDB(ressourceTest.first);
print(audioResourceModel!.id);
if(audioResourceModel!.path != null) {
audioFile = File(audioResourceModel!.path!);
}
} else {
print("EMPTY resourcesModel - first");
}
/*List<Map<String, dynamic>> ressourceTest = await DatabaseHelper
.instance.queryWithColumnId(
DatabaseTableType.resources, audioIdArticle.first.value!);
if (ressourceTest.isNotEmpty) {
audioResourceModel = DatabaseHelper.instance.getResourceFromDB(ressourceTest.first);
print(audioResourceModel!.id);
Uint8List base64String = base64Decode(audioResourceModel!.path!); // TODO get from file
audiobytes = base64String;
} else {
print("EMPTY resourcesModel - first");
}*/
} catch(e) {
print("Error in audio loading: " + e.toString());
}
}
else
{
// TODO Get file instead.. if exist
ResourceDTO? resourceDTO = await client.resourceApi!.resourceGetDetail(audioIdArticle.first.value!);
if(resourceDTO != null && resourceDTO.url != null) {
// ONLINE
//ResourceModel? resourceAudioOnline = await ApiService.downloadAudio(client, resourceDTO.url!, resourceDTO.id!);
ResourceModel resourceAudioOnline = ResourceModel();
resourceAudioOnline.source = resourceDTO.url;
audioResourceModel = resourceAudioOnline;
/*Uint8List base64String = base64Decode(resourceAudioOnline.path!); // GET FROM FILE
audiobytes = base64String;*/
} else {
print("EMPTY resourcesModel online - audio");
}
}
}
}
if(articleDTO!.contents!.isNotEmpty && !isAudio) {
for (var image in articleDTO!.contents!) {
if(image.resourceId != null) {
if(isConfigOffline)
{
// OFFLINE
List<Map<String, dynamic>> ressourceArticle = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.resources, image.resourceId!);
if(ressourceArticle.isNotEmpty) {
resourcesModel.add(DatabaseHelper.instance.getResourceFromDB(ressourceArticle.first));
} else {
print("EMPTY resourcesModel - second");
}
}
else
{
// ONLINE
// Not needed as it's in display logic
//ResourceModel? resourceImageOnline = await ApiService.downloadImage(client, image);
//if(resourceImageOnline != null) {
resourcesModel.add(ResourceModel(id: image.resourceId, source: image.resource?.url, type: ResourceType.Image));
/*} else {
print("EMPTY resourcesModel online - audio");
}*/
}
}
}
}
setState(() {
//print(sectionDTO!.title);
});
} else {
return null; // TODO return local list..
}
} catch (e) {
print(e);
print("IN CATCH");
return null;
}
}*/
}