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>
538 lines
22 KiB
Dart
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;
|
|
}
|
|
}*/
|
|
}
|