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>
347 lines
14 KiB
Dart
347 lines
14 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:diacritic/diacritic.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:mymuseum_visitapp/Components/SlideFromRouteRight.dart';
|
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
|
import 'package:mymuseum_visitapp/Components/SearchBox.dart';
|
|
import 'package:mymuseum_visitapp/Components/SearchNumberBox.dart';
|
|
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
|
import 'package:mymuseum_visitapp/Screens/section_page.dart';
|
|
import 'package:mymuseum_visitapp/Services/apiService.dart';
|
|
import 'package:mymuseum_visitapp/app_context.dart';
|
|
import 'package:mymuseum_visitapp/constants.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'section_card.dart';
|
|
|
|
class Body extends StatefulWidget {
|
|
const Body({Key? key, required this.configuration, this.openSectionId}) : super(key: key);
|
|
|
|
final ConfigurationDTO configuration;
|
|
final String? openSectionId;
|
|
|
|
@override
|
|
State<Body> createState() => _BodyState();
|
|
}
|
|
|
|
class _BodyState extends State<Body> {
|
|
late List<SectionDTO> sections;
|
|
late List<SectionDTO> _allSections;
|
|
/// Payload brut de chaque section, indexé par id. Il l'était par position dans
|
|
/// la liste de l'API : hors ligne cette liste n'existait pas (`late` jamais
|
|
/// assigné, donc `LateInitializationError` avalée dans le callback — le tap ne
|
|
/// faisait rien), et en ligne une recherche décalait les index, ouvrant une
|
|
/// autre section que celle touchée.
|
|
final Map<String, dynamic> _rawSectionById = {};
|
|
String? searchValue;
|
|
int? searchNumberValue;
|
|
|
|
final ValueNotifier<List<SectionDTO>> filteredSections = ValueNotifier([]);
|
|
|
|
late Future<List<SectionDTO>> _futureSections;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
|
_futureSections = getSections(appContext);
|
|
|
|
// La section demandée n'est ouverte qu'une fois les sections chargées : son
|
|
// payload brut n'existe pas avant, et c'est lui que `SectionPage` affiche.
|
|
if (widget.openSectionId != null) {
|
|
_futureSections.then((_) => _openRequestedSection(appContext));
|
|
}
|
|
}
|
|
|
|
void _openRequestedSection(AppContext appContext) {
|
|
final raw = _rawSectionById[widget.openSectionId];
|
|
if (raw == null || !mounted) return;
|
|
|
|
Navigator.push(
|
|
context,
|
|
SlideFromRightRoute(
|
|
page: SectionPage(
|
|
configuration: widget.configuration,
|
|
rawSection: raw,
|
|
visitAppContextIn: appContext.getContext(),
|
|
sectionId: widget.openSectionId!,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final appContext = Provider.of<AppContext>(context);
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
Size size = MediaQuery.of(context).size;
|
|
|
|
Color? primaryColor = widget.configuration.primaryColor != null ? Color(int.parse(widget.configuration.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : null;
|
|
|
|
return SafeArea(
|
|
bottom: false,
|
|
top: false,
|
|
child: Stack(
|
|
children: [
|
|
Hero(
|
|
tag: widget.configuration.id!,
|
|
child: 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: widget.configuration.imageSource == null ? const LinearGradient(
|
|
begin: Alignment.centerRight,
|
|
end: Alignment.centerLeft,
|
|
colors: [
|
|
/*Color(0xFFDD79C2),
|
|
Color(0xFFB65FBE),
|
|
Color(0xFF9146BA),
|
|
Color(0xFF7633B8),
|
|
Color(0xFF6528B6),
|
|
Color(0xFF6025B6)*/
|
|
kMainColor0, //Color(0xFFf6b3c4)
|
|
kMainColor1,
|
|
kMainColor2,
|
|
|
|
],
|
|
) : null,
|
|
image: widget.configuration.imageSource != null ? DecorationImage(
|
|
fit: BoxFit.cover,
|
|
opacity: 0.65,
|
|
image: NetworkImage(
|
|
widget.configuration.imageSource!,
|
|
),
|
|
): null,
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
children: <Widget>[
|
|
SizedBox(
|
|
height: size.height * 0.11,
|
|
width: size.width,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned(
|
|
top: 35,
|
|
left: 10,
|
|
child: SizedBox(
|
|
width: 50,
|
|
height: 50,
|
|
child: InkWell(
|
|
onTap: () {
|
|
//setState(() {
|
|
/**/
|
|
Navigator.of(context).pop();
|
|
visitAppContext.configuration = null;
|
|
visitAppContext.isScanningBeacons = false;
|
|
/*Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
|
|
builder: (context) => const HomePage3(),
|
|
),(route) => false);*/
|
|
//});
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: primaryColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
|
|
),
|
|
)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
SearchBox(onChanged: (value) {
|
|
searchValue = value?.trim();
|
|
applyFilters(visitAppContext);
|
|
}),
|
|
Expanded(
|
|
child: SearchNumberBox(onChanged: (value) {
|
|
if (value != null && value.isNotEmpty) {
|
|
searchNumberValue = int.tryParse(value);
|
|
} else {
|
|
searchNumberValue = null;
|
|
}
|
|
FocusScope.of(context).unfocus();
|
|
applyFilters(visitAppContext);
|
|
}
|
|
),
|
|
),
|
|
],
|
|
),
|
|
//const SizedBox(height: kDefaultPadding / 2),
|
|
Expanded(
|
|
child: Stack(
|
|
children: <Widget>[
|
|
// Our background
|
|
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(30),
|
|
topRight: Radius.circular(30),
|
|
),
|
|
),
|
|
),
|
|
FutureBuilder(
|
|
future: _futureSections,
|
|
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.done) {
|
|
/*print("SECTIONTODISPA");
|
|
print(sectionsToDisplay);*/
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 0),
|
|
child: RefreshIndicator(
|
|
color: kMainColor,
|
|
onRefresh: () async {
|
|
if(!widget.configuration.isOffline!) {
|
|
setState(() {
|
|
_futureSections = getSections(appContext);
|
|
});
|
|
} },
|
|
child: ValueListenableBuilder<List<SectionDTO>>(
|
|
valueListenable: filteredSections,
|
|
builder: (context, value, child) {
|
|
return ListView.builder(
|
|
itemCount: value.length,
|
|
itemBuilder: (context, index) => SectionCard(
|
|
configuration: widget.configuration,
|
|
itemCount: value.length,
|
|
itemIndex: index,
|
|
sectionDTO: value[index],
|
|
press: () {
|
|
Navigator.push(
|
|
context,
|
|
SlideFromRightRoute(page: SectionPage(
|
|
configuration: widget.configuration,
|
|
rawSection: _rawSectionById[value[index].id],
|
|
visitAppContextIn: appContext.getContext(),
|
|
sectionId: value[index].id!,
|
|
)),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
)
|
|
),
|
|
);
|
|
} else if (snapshot.connectionState == ConnectionState.none) {
|
|
return Text(TranslationHelper.getFromLocale("noData", appContext.getContext()));
|
|
} else {
|
|
return Center(
|
|
child: SizedBox(
|
|
height: size.height * 0.15,
|
|
child: const LoadingCommon()
|
|
)
|
|
);
|
|
}
|
|
}
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<List<SectionDTO>> getSections(AppContext appContext) async {
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
sections = [];
|
|
_rawSectionById.clear();
|
|
if(widget.configuration.isOffline == true)
|
|
{
|
|
// OFFLINE
|
|
// Les lignes brutes, pas `getData` : celui-ci passe par `getSectionFromDB`,
|
|
// qui laisse la colonne `data` de côté — or c'est là que vit le payload typé
|
|
// dont `section_page` a besoin pour afficher quoi que ce soit.
|
|
List<Map<String, dynamic>> rows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.sections);
|
|
sections = rows.map((row) => DatabaseHelper.instance.getSectionFromDB(row)).toList();
|
|
for (var row in rows) {
|
|
_rawSectionById[row["id"]] = jsonDecode(row["data"]);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// ONLINE
|
|
List<dynamic>? sectionsDownloaded = await ApiService.getAllSections(visitAppContext.clientAPI, widget.configuration.id!);
|
|
List<dynamic> rawSections = jsonDecode(jsonEncode(sectionsDownloaded));
|
|
var rawToSection = jsonDecode(jsonEncode(rawSections)).map((json) => SectionDTO.fromJson(json)).toList();
|
|
List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().toList();
|
|
for (var raw in rawSections) {
|
|
_rawSectionById[raw["id"]] = raw;
|
|
}
|
|
|
|
if(sectionList.isNotEmpty) {
|
|
sections = sectionList.toList();
|
|
}
|
|
}
|
|
|
|
// Only filter by configurationId if sections have it set
|
|
final id = widget.configuration.id;
|
|
if (id != null && sections.any((s) => s.configurationId != null)) {
|
|
sections = sections.where((s) => s.configurationId == id).toList();
|
|
}
|
|
sections.sort((a,b) => a.order!.compareTo(b.order!));
|
|
|
|
// Le scan de QR code valide l'id contre `sectionIds`, puis va chercher la
|
|
// section dans `currentSections`. Les deux venaient d'ailleurs : `sectionIds`
|
|
// du ConfigurationDTO — vide pour une visite relue en base, `configurationToMap`
|
|
// ne le stocke pas — et `currentSections` de la seule branche en ligne. On les
|
|
// dérive des sections qu'on vient de charger, qui font autorité dans les deux
|
|
// modes.
|
|
visitAppContext.sectionIds = sections.map((s) => s.id).toList();
|
|
visitAppContext.currentSections =
|
|
sections.map((s) => _rawSectionById[s.id]).where((raw) => raw != null).toList();
|
|
|
|
_allSections = sections;
|
|
applyFilters(visitAppContext);
|
|
|
|
return _allSections;
|
|
}
|
|
|
|
void applyFilters(VisitAppContext visitAppContext) {
|
|
List<SectionDTO> result = _allSections;
|
|
|
|
if (searchValue != null && searchValue!.isNotEmpty) {
|
|
result = result.where((s) =>
|
|
removeDiacritics(TranslationHelper.getPlain(s.title, visitAppContext).toLowerCase())
|
|
.contains(removeDiacritics(searchValue!.toLowerCase()))
|
|
).toList();
|
|
} else if (searchNumberValue != null) {
|
|
result = result.where((s) => s.order! + 1 == searchNumberValue).toList();
|
|
}
|
|
|
|
filteredSections.value = result;
|
|
}
|
|
|
|
}
|