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
241 lines
7.7 KiB
Dart
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,
|
|
],
|
|
);
|
|
}
|
|
}
|