Compare commits
5 Commits
6efd67611f
...
296b7a7519
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296b7a7519 | ||
|
|
8979ab2e63 | ||
|
|
172530e00a | ||
|
|
bac086f1e1 | ||
|
|
9e213968f5 |
49
lib/Components/ai_generated_badge.dart
Normal file
49
lib/Components/ai_generated_badge.dart
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
|
||||||
|
/// Mention « image générée par IA » posée sur une image produite par le Studio (lot 6).
|
||||||
|
///
|
||||||
|
/// Elle est due au visiteur, pas au client : une reconstitution n'est pas une photographie
|
||||||
|
/// d'archive, et rien dans l'image ne le dit. Le texte suit la langue de visite — l'app
|
||||||
|
/// visiteur n'a pas d'infrastructure de traduction pour ses propres libellés, les dix
|
||||||
|
/// langues publiées sont donc portées ici.
|
||||||
|
class AiGeneratedBadge extends StatelessWidget {
|
||||||
|
const AiGeneratedBadge({Key? key, required this.language}) : super(key: key);
|
||||||
|
|
||||||
|
final String? language;
|
||||||
|
|
||||||
|
static const _labels = {
|
||||||
|
'FR': 'Image générée par IA',
|
||||||
|
'NL': 'Beeld gegenereerd door AI',
|
||||||
|
'EN': 'AI-generated image',
|
||||||
|
'DE': 'KI-generiertes Bild',
|
||||||
|
'ES': 'Imagen generada por IA',
|
||||||
|
'IT': 'Immagine generata dall’IA',
|
||||||
|
'PL': 'Obraz wygenerowany przez SI',
|
||||||
|
'CN': '人工智能生成的图像',
|
||||||
|
'AR': 'صورة من إنتاج الذكاء الاصطناعي',
|
||||||
|
'UK': 'Зображення, створене ШІ',
|
||||||
|
'LB': 'Bild generéiert vun der KI',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Une image générée doit porter la mention ; une image téléversée, jamais.
|
||||||
|
static bool isGenerated(ResourceDTO? resource) =>
|
||||||
|
resource?.origin == ResourceOrigin.Generated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = _labels[(language ?? 'FR').toUpperCase()] ?? _labels['FR']!;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 9, letterSpacing: 0.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:tablet_app/Components/ai_generated_badge.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
@ -26,7 +27,27 @@ class CachedCustomResource extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// L'image porte sa mention quelle que soit la branche de rendu prise en dessous.
|
||||||
|
/// Même geste que dans l'app visiteur : la poser ici évite d'y penser à chaque écran.
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final resource = _resource(context);
|
||||||
|
if (!AiGeneratedBadge.isGenerated(resourceDTO)) return resource;
|
||||||
|
|
||||||
|
final tabletAppContext =
|
||||||
|
Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: resource),
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
bottom: 6,
|
||||||
|
child: AiGeneratedBadge(language: tabletAppContext.language),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resource(BuildContext context) {
|
||||||
final appContext = Provider.of<AppContext>(context);
|
final appContext = Provider.of<AppContext>(context);
|
||||||
TabletAppContext tabletAppContext = appContext.getContext();
|
TabletAppContext tabletAppContext = appContext.getContext();
|
||||||
Size size = MediaQuery.of(context).size;
|
Size size = MediaQuery.of(context).size;
|
||||||
|
|||||||
118
lib/Components/casting_intro_dialog.dart
Normal file
118
lib/Components/casting_intro_dialog.dart
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
|
||||||
|
/// Écran d'entrée « les personnes que vous allez rencontrer » (plan Studio décision 25).
|
||||||
|
///
|
||||||
|
/// Le serveur n'envoie le casting qu'avec l'interrupteur armé et au moins deux portraits. Montré une
|
||||||
|
/// fois par configuration et par lancement de l'app : revenir à la liste ne le rouvre pas. Comme la
|
||||||
|
/// mention IA, les libellés sont portés ici — l'app n'a pas de traductions pour ses propres textes.
|
||||||
|
class CastingIntroDialog extends StatelessWidget {
|
||||||
|
const CastingIntroDialog({Key? key, required this.casting, required this.language}) : super(key: key);
|
||||||
|
|
||||||
|
final List<CastingVisitorMemberDTO> casting;
|
||||||
|
final String? language;
|
||||||
|
|
||||||
|
static final Set<String> _shown = {};
|
||||||
|
|
||||||
|
static const _titles = {
|
||||||
|
'FR': 'Les personnes que vous allez rencontrer',
|
||||||
|
'NL': 'De personen die u zult ontmoeten',
|
||||||
|
'EN': 'The people you will meet',
|
||||||
|
'DE': 'Die Menschen, denen Sie begegnen werden',
|
||||||
|
'ES': 'Las personas que va a conocer',
|
||||||
|
'IT': 'Le persone che incontrerete',
|
||||||
|
'PL': 'Osoby, które spotkasz',
|
||||||
|
'CN': '您将遇到的人物',
|
||||||
|
'AR': 'الشخصيات التي ستلتقيها',
|
||||||
|
'UK': 'Люди, яких ви зустрінете',
|
||||||
|
'LB': "D'Leit, déi Dir kenne léiert",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const _starts = {
|
||||||
|
'FR': 'Commencer la visite',
|
||||||
|
'NL': 'Het bezoek beginnen',
|
||||||
|
'EN': 'Start the visit',
|
||||||
|
'DE': 'Besuch beginnen',
|
||||||
|
'ES': 'Empezar la visita',
|
||||||
|
'IT': 'Inizia la visita',
|
||||||
|
'PL': 'Rozpocznij zwiedzanie',
|
||||||
|
'CN': '开始参观',
|
||||||
|
'AR': 'ابدأ الزيارة',
|
||||||
|
'UK': 'Почати відвідування',
|
||||||
|
'LB': 'Besuch ufänken',
|
||||||
|
};
|
||||||
|
|
||||||
|
static void showIfNeeded(BuildContext context, ConfigurationDTO configuration, String? language) {
|
||||||
|
final casting = configuration.casting;
|
||||||
|
if (casting == null || casting.length < 2 || configuration.id == null) return;
|
||||||
|
if (!_shown.add(configuration.id!)) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => CastingIntroDialog(casting: casting, language: language),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _label(Map<String, String> labels) => labels[(language ?? 'FR').toUpperCase()] ?? labels['FR']!;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_label(_titles),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Color(0xFF1E2A33)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: casting
|
||||||
|
.map((member) => SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
child: Image.network(
|
||||||
|
member.portraitUrl ?? '',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(color: const Color(0xFFE8E3DA)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(member.name ?? '',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
if ((member.role ?? '').isNotEmpty)
|
||||||
|
Text(member.role!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7B86))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(_label(_starts)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lib/Components/narrator_avatar.dart
Normal file
36
lib/Components/narrator_avatar.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Le personnage qui parle, à côté du lecteur audio (plan Studio lot 8d) : portrait canon et
|
||||||
|
/// nom, sans libellé — un nom propre ne demande pas de traduction.
|
||||||
|
class NarratorAvatar extends StatelessWidget {
|
||||||
|
const NarratorAvatar({Key? key, required this.name, this.portraitUrl, this.size = 44}) : super(key: key);
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? portraitUrl;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final placeholder = Container(
|
||||||
|
color: const Color(0xFFE8E3DA),
|
||||||
|
child: Icon(Icons.person, size: size * 0.6, color: const Color(0xFF6B7B86)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: portraitUrl == null
|
||||||
|
? placeholder
|
||||||
|
: Image.network(portraitUrl!, fit: BoxFit.cover, errorBuilder: (_, __, ___) => placeholder),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/Components/point_audio.dart
Normal file
60
lib/Components/point_audio.dart
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
import 'package:tablet_app/Components/cached_custom_resource.dart';
|
||||||
|
import 'package:tablet_app/Components/narrator_avatar.dart';
|
||||||
|
|
||||||
|
/// Audio d'un point de carte (plan Studio lot 8e) : le point range un identifiant de ressource,
|
||||||
|
/// résolu ici, avec le portrait du narrateur quand c'est une narration.
|
||||||
|
class PointAudio extends StatefulWidget {
|
||||||
|
const PointAudio({Key? key, required this.resourceId, required this.resourceApi}) : super(key: key);
|
||||||
|
|
||||||
|
final String resourceId;
|
||||||
|
final ResourceApi resourceApi;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PointAudio> createState() => _PointAudioState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PointAudioState extends State<PointAudio> {
|
||||||
|
/// Gardé : la fiche du point se reconstruit souvent, et chaque reconstruction relancerait la requête.
|
||||||
|
late Future<ResourceDTO?> _resource;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_resource = widget.resourceApi.resourceGetDetail(widget.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PointAudio oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.resourceId != widget.resourceId) {
|
||||||
|
_resource = widget.resourceApi.resourceGetDetail(widget.resourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<ResourceDTO?>(
|
||||||
|
future: _resource,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final resource = snapshot.data;
|
||||||
|
if (resource?.url == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final player = CachedCustomResource(resourceDTO: resource!, isAuto: false, webView: false);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: resource.narratorName == null
|
||||||
|
? Align(alignment: Alignment.centerLeft, child: player)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
NarratorAvatar(name: resource.narratorName!, portraitUrl: resource.narratorPortraitUrl),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
player,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:tablet_app/Components/narrator_avatar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
@ -217,11 +218,23 @@ class _ArticleViewState extends State<ArticleView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Le personnage qui parle, à gauche du lecteur, quand l'audio est une narration (lot 8d).
|
||||||
Widget _buildAudio() {
|
Widget _buildAudio() {
|
||||||
return CachedCustomResource(
|
final player = CachedCustomResource(
|
||||||
resourceDTO: _audioResource!,
|
resourceDTO: _audioResource!,
|
||||||
isAuto: widget.section.isReadAudioAuto ?? false,
|
isAuto: widget.section.isReadAudioAuto ?? false,
|
||||||
webView: false,
|
webView: false,
|
||||||
);
|
);
|
||||||
|
final narratorName = _audioResource!.narratorName;
|
||||||
|
if (narratorName == null) return player;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
NarratorAvatar(name: narratorName, portraitUrl: _audioResource!.narratorPortraitUrl),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
player,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:tablet_app/Components/casting_intro_dialog.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@ -328,6 +329,11 @@ class _MainViewWidget extends State<MainViewWidget> {
|
|||||||
|
|
||||||
await getCurrentConfiguration(appContext);
|
await getCurrentConfiguration(appContext);
|
||||||
|
|
||||||
|
final loadedConfiguration = tabletAppContext.configuration;
|
||||||
|
if (mounted && loadedConfiguration != null) {
|
||||||
|
CastingIntroDialog.showIfNeeded(context, loadedConfiguration, tabletAppContext.language);
|
||||||
|
}
|
||||||
|
|
||||||
// Load applicationInstanceDTO to check if assistant/statistics are enabled
|
// Load applicationInstanceDTO to check if assistant/statistics are enabled
|
||||||
try {
|
try {
|
||||||
if (tabletAppContext.instanceId != null) {
|
if (tabletAppContext.instanceId != null) {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:tablet_app/Components/point_audio.dart';
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
@ -58,6 +59,11 @@ class _MarkerInfoWidget extends State<MarkerViewWidget> {
|
|||||||
ScrollController scrollDescription = new ScrollController();
|
ScrollController scrollDescription = new ScrollController();
|
||||||
ScrollController scrollPrice = new ScrollController();
|
ScrollController scrollPrice = new ScrollController();
|
||||||
|
|
||||||
|
// Un audio par langue, sans repli sur une autre : l'audio français à un néerlandophone est pire que le silence.
|
||||||
|
final pointAudioId = selectedPoint?.audioIds
|
||||||
|
?.where((a) => a.language == language && (a.value ?? '').isNotEmpty)
|
||||||
|
.firstOrNull
|
||||||
|
?.value;
|
||||||
var isPointPrice = selectedPoint != null && selectedPoint.prices != null && selectedPoint.prices!.isNotEmpty && selectedPoint.prices!.any((d) => d.language == language) && selectedPoint.prices!.firstWhere((d) => d.language == language).value != null && selectedPoint.prices!.firstWhere((d) => d.language == language).value!.trim().length > 0;
|
var isPointPrice = selectedPoint != null && selectedPoint.prices != null && selectedPoint.prices!.isNotEmpty && selectedPoint.prices!.any((d) => d.language == language) && selectedPoint.prices!.firstWhere((d) => d.language == language).value != null && selectedPoint.prices!.firstWhere((d) => d.language == language).value!.trim().length > 0;
|
||||||
|
|
||||||
Color primaryColor = new Color(int.parse(tabletAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16));
|
Color primaryColor = new Color(int.parse(tabletAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16));
|
||||||
@ -194,6 +200,12 @@ class _MarkerInfoWidget extends State<MarkerViewWidget> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (pointAudioId != null)
|
||||||
|
PointAudio(
|
||||||
|
key: ValueKey('point-audio-$pointAudioId'),
|
||||||
|
resourceId: pointAudioId,
|
||||||
|
resourceApi: tabletAppContext.clientAPI!.resourceApi!,
|
||||||
|
),
|
||||||
if(selectedPoint.description!.any((d) => d.language == language) && selectedPoint.description!.firstWhere((d) => d.language == language).value != null && selectedPoint.description!.firstWhere((d) => d.language == language).value!.trim().length > 0)
|
if(selectedPoint.description!.any((d) => d.language == language) && selectedPoint.description!.firstWhere((d) => d.language == language).value != null && selectedPoint.description!.firstWhere((d) => d.language == language).value!.trim().length > 0)
|
||||||
HtmlWidget(
|
HtmlWidget(
|
||||||
selectedPoint.description!.firstWhere((d) => d.language == language).value!,
|
selectedPoint.description!.firstWhere((d) => d.language == language).value!,
|
||||||
|
|||||||
@ -35,7 +35,11 @@ void main() async {
|
|||||||
if(localContext != null && localContext.host != null) {
|
if(localContext != null && localContext.host != null) {
|
||||||
print("we've got an local db !");
|
print("we've got an local db !");
|
||||||
print(localContext);
|
print(localContext);
|
||||||
localContext.clientAPI = new Client(localContext.host!); // retrieve instanceId from local DB
|
// La clé est persistée en base locale (`TabletAppContext.toMap`) : l'oublier ici
|
||||||
|
// reconstruisait un client anonyme au démarrage, et tous les appels suivants —
|
||||||
|
// à commencer par `deviceGetDetail` juste en dessous, qui donne la configuration
|
||||||
|
// à afficher — partaient sans identité.
|
||||||
|
localContext.clientAPI = new Client(localContext.host!, apiKey: localContext.apiKey);
|
||||||
isConfig = localContext.configuration != null;
|
isConfig = localContext.configuration != null;
|
||||||
print(localContext);
|
print(localContext);
|
||||||
print("localContext.deviceId");
|
print("localContext.deviceId");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user