tablet-app-new/lib/Screens/Article/article_view.dart
Thomas Fransolet 172530e00a Studio lot 8d : portrait du narrateur à côté du lecteur audio
Portrait et nom à gauche du lecteur de l'article.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 22:18:22 +02:00

241 lines
7.7 KiB
Dart

import 'package:tablet_app/Components/narrator_avatar.dart';
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:provider/provider.dart';
import 'package:tablet_app/Components/cached_custom_resource.dart';
import 'package:tablet_app/Helpers/ImageCustomProvider.dart';
import 'package:tablet_app/Helpers/translationHelper.dart';
import 'package:tablet_app/Models/tabletContext.dart';
import 'package:tablet_app/app_context.dart';
import 'package:tablet_app/constants.dart';
/// Largeur de lecture. Une borne fait 1280 px de large : étirer le texte sur
/// toute la dalle le rend illisible, l'œil perd la ligne au retour.
const double kArticleMeasure = 720.0;
const double kMediaRailFlex = 38.0;
const double kArticleTextFlex = 62.0;
class ArticleView extends StatefulWidget {
final ArticleDTO section;
const ArticleView({Key? key, required this.section}) : super(key: key);
@override
State<ArticleView> createState() => _ArticleViewState();
}
class _ArticleViewState extends State<ArticleView> {
String _audioId = "";
ResourceDTO? _audioResource;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAudio());
}
/// L'audio est porté par langue : `audioIds` est une List<TranslationDTO>.
/// Pas d'entrée pour la langue choisie = pas de lecteur, sans repli sur une
/// autre langue — servir l'audio français à un néerlandophone est pire que
/// le silence.
Future<void> _resolveAudio() async {
final tabletAppContext =
Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
final audioId = TranslationHelper.get(widget.section.audioIds, tabletAppContext);
if (audioId.isEmpty) return;
final embedded = widget.section.contents
?.where((content) => content.resourceId == audioId && content.resource != null)
.firstOrNull;
if (embedded != null) {
if (mounted) {
setState(() {
_audioId = audioId;
_audioResource = embedded.resource;
});
}
return;
}
try {
final resource = await tabletAppContext.clientAPI!.resourceApi!.resourceGetDetail(audioId);
if (resource != null && mounted) {
setState(() {
_audioId = audioId;
_audioResource = resource;
});
}
} catch (e) {
print("ERROR _resolveAudio ArticleView");
print(e);
}
}
List<ContentDTO> _medias() {
final contents = widget.section.contents
?.where((content) => content.resource != null && content.resourceId != _audioId)
.toList() ??
[];
contents.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
return contents;
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
final tabletAppContext = appContext.getContext() as TabletAppContext;
final medias = _medias();
if (medias.isEmpty) {
return _buildTextOnly(appContext, tabletAppContext);
}
final rail = Expanded(
flex: kMediaRailFlex.toInt(),
child: _buildMediaRail(appContext, tabletAppContext, medias),
);
final text = Expanded(
flex: kArticleTextFlex.toInt(),
child: _buildTextColumn(tabletAppContext, showAudio: false),
);
// isContentTop veut dire « le texte avant le média ». En portrait c'était
// au-dessus ; en paysage il n'y a plus de dessus, le drapeau devient le côté.
final contentFirst = widget.section.isContentTop ?? false;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: contentFirst ? [text, rail] : [rail, text],
);
}
Widget _buildTextOnly(AppContext appContext, TabletAppContext tabletAppContext) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kArticleMeasure),
child: _buildTextColumn(tabletAppContext, showAudio: true),
),
);
}
Widget _buildMediaRail(
AppContext appContext,
TabletAppContext tabletAppContext,
List<ContentDTO> medias,
) {
final main = medias.first;
final thumbs = medias.skip(1).toList();
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: CachedCustomResource(
resourceDTO: main.resource!,
isAuto: false,
webView: false,
fit: BoxFit.cover,
),
),
),
if (thumbs.isNotEmpty) ...[
const SizedBox(height: 12.0),
SizedBox(
height: 90.0,
child: Row(
children: thumbs
.map((content) => Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(6.0),
child: Image(
image: ImageCustomProvider.getImageProvider(
appContext,
content.resourceId,
content.resource!.url ?? "",
),
fit: BoxFit.cover,
),
),
),
))
.toList(),
),
),
],
if (_audioResource != null) ...[
const SizedBox(height: 12.0),
_buildAudio(),
],
],
),
);
}
Widget _buildTextColumn(TabletAppContext tabletAppContext, {required bool showAudio}) {
final title = TranslationHelper.get(widget.section.title, tabletAppContext);
final content = TranslationHelper.get(widget.section.content, tabletAppContext);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 32.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kArticleMeasure),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title.isNotEmpty)
Text(
title,
style: const TextStyle(
fontSize: kSectionTitleDetailSize,
fontWeight: FontWeight.bold,
color: kMainGrey,
),
),
if (showAudio && _audioResource != null) ...[
const SizedBox(height: 16.0),
_buildAudio(),
],
const SizedBox(height: 16.0),
HtmlWidget(
content,
textStyle: const TextStyle(
fontSize: kSectionDescriptionDetailSize,
color: kSecondGrey,
),
),
],
),
),
);
}
/// Le personnage qui parle, à gauche du lecteur, quand l'audio est une narration (lot 8d).
Widget _buildAudio() {
final player = CachedCustomResource(
resourceDTO: _audioResource!,
isAuto: widget.section.isReadAudioAuto ?? 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,
],
);
}
}