Compare commits

...

2 Commits

Author SHA1 Message Date
Thomas Fransolet
66301805d8 Latence vocale : préchargement des sons et nappe de réflexion
⚠️ Travail d'une autre session, committé tel quel pour ne pas le laisser en
working tree — il n'est pas de cette session-ci et n'a pas été relu ici. Le plan
correspondant est DOCS/voice-latency-plan.md.

Les deux sons sont préchargés une fois pour toutes : setAsset décodait à chaque
wake word, sur le chemin le plus sensible à la latence de tout le pipeline. Et
une nappe de réflexion apparaît après 700 ms — en dessous, le visiteur vient de
finir de parler et n'attend pas encore de réponse.
2026-08-13 16:01:01 +02:00
Thomas Fransolet
f013d33bba D5 et mention RGPD aux visiteurs
D5 — les échecs de téléchargement ne disparaissent plus dans un print : ils sont
collectés et la visite n'est déclarée téléchargée que si elle l'est vraiment.
Nouvelle clé downloadIncomplete dans les 10 langues, sans quoi getFromLocale
aurait rendu une chaîne vide.

Trouvé en câblant l'écran, et c'est pire que le bug d'origine : sans état
d'échec, une visite incomplète restait sur « téléchargement en cours »
indéfiniment. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc
il n'atteignait jamais le total et l'écran ne concluait jamais. Relancer ne
re-télécharge que ce qui manque.

J4 — mention d'information accessible depuis l'assistant lui-même, là où la
collecte a lieu (§8.3 des CGU). Texte repris mot pour mot de
DOCS/mention-information-visiteurs.md.
FR/NL/EN seulement, repli sur l'anglais : traduire une mention de protection des
données sans relecture humaine serait pire que la servir en anglais. Les 7 autres
langues attendent une traduction relue, à demander avec la relecture juridique.

flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:01:00 +02:00
5 changed files with 289 additions and 51 deletions

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mymuseum_visitapp/Components/VisitorPrivacyNotice.dart';
import 'package:mymuseum_visitapp/Helpers/assistantSuggestions.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
@ -265,6 +266,15 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
},
),
const Spacer(),
// §8.3 des CGU : la mention doit être accessible depuis l'assistant
// lui-même, c'est-à-dire là où la collecte a lieu — pas seulement dans
// la politique de confidentialité du lieu.
IconButton(
icon: const Icon(Icons.privacy_tip_outlined, size: 20),
tooltip: VisitorPrivacyNotice.textFor(widget.visitAppContext.language).title,
onPressed: () =>
VisitorPrivacyNotice.show(context, widget.visitAppContext),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),

View File

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
/// Mention d'information aux visiteurs sur le traitement de leurs questions (§8.3 des CGU).
///
/// **Texte repris mot pour mot de `DOCS/mention-information-visiteurs.md`**, qui est le
/// document de référence remis au client. Toute correction se fait -bas d'abord : c'est lui
/// que le client publie dans sa politique de confidentialité, et les deux ne doivent pas
/// diverger. Il est **rédigé côté produit et non validé juridiquement** (lot J, J5).
///
/// **Trois langues seulement, repli sur l'anglais — c'est un choix, pas un oubli.** L'app
/// en porte dix, mais traduire une mention de protection des données sans relecture humaine
/// serait pire que la servir en anglais : une nuance perdue sur « nous n'enregistrons pas
/// votre adresse IP » n'est pas une coquille d'interface. Les sept autres langues attendent
/// une traduction relue, à demander en même temps que la relecture juridique.
class VisitorPrivacyNotice extends StatelessWidget {
final VisitAppContext visitAppContext;
const VisitorPrivacyNotice({Key? key, required this.visitAppContext}) : super(key: key);
static void show(BuildContext context, VisitAppContext visitAppContext) {
showDialog(
context: context,
builder: (_) => VisitorPrivacyNotice(visitAppContext: visitAppContext),
);
}
static const Map<String, ({String title, String body})> _texts = {
'FR': (
title: 'Vos questions au guide',
body:
"Lorsque vous posez une question à notre guide, votre question et la réponse qui vous "
"est donnée sont enregistrées. Cela nous sert à repérer ce que nos visiteurs cherchent "
"sans le trouver, et à compléter nos contenus en conséquence.\n\n"
"Nous n'enregistrons ni votre nom, ni votre compte, ni votre adresse IP. Un identifiant "
"de session tiré au hasard permet seulement de relier entre elles les questions d'une "
"même conversation ; il disparaît avec elle.\n\n"
"Votre question est transmise à notre fournisseur d'intelligence artificielle (Google) "
"pour produire la réponse. Le texte de vos questions est supprimé au bout de 90 jours ; "
"seuls des regroupements par sujet, sans le texte de vos questions, sont conservés "
"au-delà.\n\n"
"Le champ de question est libre : nous vous invitons à ne pas y saisir d'informations "
"personnelles."
),
'NL': (
title: 'Uw vragen aan de gids',
body:
"Wanneer u onze gids een vraag stelt, worden uw vraag en het gegeven antwoord "
"opgeslagen. Zo zien we wat onze bezoekers zoeken zonder het te vinden, en vullen we "
"onze inhoud aan.\n\n"
"Wij registreren noch uw naam, noch een account, noch uw IP-adres. Een willekeurig "
"gegenereerde sessie-identificatie dient enkel om de vragen van eenzelfde gesprek aan "
"elkaar te koppelen; ze verdwijnt samen met dat gesprek.\n\n"
"Uw vraag wordt doorgestuurd naar onze aanbieder van kunstmatige intelligentie (Google) "
"om het antwoord op te stellen. De tekst van uw vragen wordt na 90 dagen verwijderd; "
"daarna blijven enkel groeperingen per onderwerp bewaard, zonder de tekst van uw "
"vragen.\n\n"
"Het vraagveld is vrij in te vullen: wij raden u aan er geen persoonsgegevens in te "
"typen."
),
'EN': (
title: 'Your questions to the guide',
body:
"When you ask our guide a question, your question and the answer you are given are "
"recorded. This helps us see what our visitors look for without finding it, and improve "
"our content accordingly.\n\n"
"We record neither your name, nor an account, nor your IP address. A randomly generated "
"session identifier only links together the questions of a single conversation; it "
"disappears with it.\n\n"
"Your question is sent to our artificial intelligence provider (Google) to produce the "
"answer. The text of your questions is deleted after 90 days; beyond that, only "
"groupings by topic are kept, without the text of your questions.\n\n"
"The question field is free text: we invite you not to enter personal information in it."
),
};
static ({String title, String body}) textFor(String? language) =>
_texts[(language ?? 'FR').toUpperCase()] ?? _texts['EN']!;
@override
Widget build(BuildContext context) {
final text = textFor(visitAppContext.language);
return AlertDialog(
title: Text(text.title, style: const TextStyle(fontSize: 17)),
content: SingleChildScrollView(
child: Text(text.body, style: const TextStyle(fontSize: 13.5, height: 1.45)),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
}
}

View File

@ -40,9 +40,17 @@ class VoiceOrchestrator {
static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
static const String _thinkingSound = 'assets/sounds/thinking.mp3';
static const String _doneSound = 'assets/sounds/done.mp3';
final AudioPlayer _soundPlayer = AudioPlayer();
final AudioPlayer _thinkingPlayer = AudioPlayer();
bool _soundsReady = false;
/// Le son de réflexion n'apparaît qu'après ce délai : en dessous, le visiteur vient
/// de finir de parler et n'attend pas encore de réponse — le silence s'y lit comme
/// naturel, la nappe comme du bruit.
static const Duration _thinkingDelay = Duration(milliseconds: 700);
static const double _thinkingVolume = 0.45;
Timer? _thinkingTimer;
bool _thinkingWanted = false;
final Map<String, int> _lastQrTime = {};
static const int _qrCooldownMs = 10000;
@ -63,6 +71,7 @@ class VoiceOrchestrator {
Future<void> start() async {
if (_running) return;
_running = true;
await _preloadSounds();
await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
@ -74,6 +83,7 @@ class VoiceOrchestrator {
await wakeWordEngine.stop();
await sttEngine.cancel();
await ttsEngine.stop();
await _stopThinkingLoop();
_running = false;
debugPrint('[VoiceOrchestrator] Stopped');
}
@ -141,29 +151,52 @@ class VoiceOrchestrator {
}
}
Future<void> _playSound(String asset) async {
/// Charge les deux sons une fois pour toutes. Sans ça, `setAsset` décode à chaque
/// wake word sur le chemin le plus sensible à la latence de tout le pipeline.
Future<void> _preloadSounds() async {
if (_soundsReady) return;
try {
await _soundPlayer.setAsset(asset);
await _soundPlayer.play();
await _soundPlayer.setAsset(_wakeSound);
await _thinkingPlayer.setAsset(_thinkingSound);
await _thinkingPlayer.setLoopMode(LoopMode.one);
_soundsReady = true;
} catch (_) {
debugPrint('[VoiceOrchestrator] Sound not found: $asset');
debugPrint('[VoiceOrchestrator] Sons introuvables — pipeline silencieux');
}
}
Future<void> _playWakeSound() => _playSound(_wakeSound);
Future<void> _playDoneSound() => _playSound(_doneSound);
Future<void> _startThinkingLoop() async {
Future<void> _playWakeSound() async {
if (!_soundsReady) return;
try {
await _thinkingPlayer.setAsset(_thinkingSound);
await _thinkingPlayer.setLoopMode(LoopMode.one);
_thinkingPlayer.play();
} catch (_) {
debugPrint('[VoiceOrchestrator] Thinking sound not found');
}
await _soundPlayer.seek(Duration.zero);
_soundPlayer.play();
} catch (_) {}
}
/// Escalade plutôt que nappe permanente : rien pendant [_thinkingDelay], puis
/// fondu d'entrée. Une réponse rapide ne déclenche aucun son.
Future<void> _startThinkingLoop() async {
if (!_soundsReady) return;
_thinkingWanted = true;
_thinkingTimer?.cancel();
_thinkingTimer = Timer(_thinkingDelay, () async {
if (!_thinkingWanted) return;
try {
await _thinkingPlayer.seek(Duration.zero);
await _thinkingPlayer.setVolume(0);
_thinkingPlayer.play();
for (var step = 1; step <= 6 && _thinkingWanted; step++) {
await Future.delayed(const Duration(milliseconds: 100));
await _thinkingPlayer.setVolume(_thinkingVolume * step / 6);
}
} catch (_) {}
});
}
Future<void> _stopThinkingLoop() async {
_thinkingWanted = false;
_thinkingTimer?.cancel();
_thinkingTimer = null;
await _thinkingPlayer.stop();
}
@ -209,7 +242,6 @@ class VoiceOrchestrator {
await _stopThinkingLoop();
if (result.reply.isNotEmpty) {
lastTtsText.value = result.reply;
await _playDoneSound();
await ttsEngine.speak(result.reply, languageCode: langCode);
}
} catch (e) {
@ -372,30 +404,67 @@ class VoiceOrchestrator {
// Helpers
bool _isStopCommand(String text) {
// Les quatre listes couvrent les quatre langues de l'assistant vocal (FR/NL/EN/DE).
// Elles étaient en français seul écrites pour tester, jamais reprises ce qui rendait
// les trois autres langues muettes sur les commandes.
static const List<String> _stopPhrases = [
'non', 'rien', 'non merci', 'rien merci', 'laisse tomber', 'annule', 'annuler',
'arrête', 'arrete', 'arrêtez', 'au revoir', 'c\'est bon', 'ok merci',
'merci c\'est tout',
'no', 'nothing', 'no thanks', 'never mind', 'nevermind', 'cancel', 'goodbye',
'that\'s all', 'that\'s it', 'forget it',
'nee', 'niets', 'nee bedankt', 'laat maar', 'annuleer', 'tot ziens', 'het is goed',
'nein', 'nichts', 'nein danke', 'lass gut sein', 'abbrechen', 'tschüss', 'das war\'s',
'stop', 'stopp',
];
static const List<String> _qrScanPhrases = [
'scanne', 'scanner', 'scan', 'qr', 'qr code', 'code qr', 'qr-code', 'qrcode',
'regarde ce code', 'lis le code',
];
static const List<String> _photoPhrases = [
'photo', 'une photo', 'prends une photo', 'capture',
'picture', 'take a photo', 'take a picture',
'foto', 'neem een foto', 'maak een foto',
'mach ein foto', 'nimm ein foto',
];
static const List<String> _repeatPhrases = [
// `encore` nu est volontairement absent : il matchait « raconte encore une
// histoire », qui rejouait la réponse précédente au lieu d'en demander une nouvelle.
'répète', 'repete', 'répéter', 'répétez', 'encore une fois', 'redis',
'repeat', 'again', 'say that again',
'herhaal', 'opnieuw', 'nog eens',
'wiederhole', 'nochmal', 'noch einmal',
];
/// Match sur mot entier, pas sur sous-chaîne : `contains('prends')` reconnaissait
/// « je ne com**prends** pas » comme une demande de photo.
static bool _matchesAny(String text, List<String> phrases) {
final t = text.toLowerCase().trim();
return t == 'non' || t == 'rien' || t == 'non rien' || t == 'rien merci' ||
t == 'non merci' || t == 'laisse tomber' || t == 'annule' || t == 'annuler' ||
t.contains('stop') || t.contains('arrête') || t.contains('au revoir') ||
t.contains('c\'est bon') || t.contains('ok merci') || t.contains('laisse tomber') ||
(t.length < 10 && (t.contains('non') || t.contains('rien')));
return phrases.any(
(p) => RegExp('(^|\\W)${RegExp.escape(p)}(\$|\\W)').hasMatch(t),
);
}
bool _isQrScanCommand(String text) {
final t = text.toLowerCase();
return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde');
}
bool _isStopCommand(String text) => _matchesAny(text, _stopPhrases);
bool _isPhotoCommand(String text) {
final t = text.toLowerCase();
return t.contains('photo') || t.contains('prends') || t.contains('capture');
}
/// Ne matche plus `code` nu : « what's the code of this painting » déclenchait
/// un scan QR au lieu d'une réponse.
bool _isQrScanCommand(String text) => _matchesAny(text, _qrScanPhrases);
bool _isRepeatCommand(String text) {
final t = text.toLowerCase();
return t.contains('répète') || t.contains('repete') || t.contains('encore');
}
bool _isPhotoCommand(String text) => _matchesAny(text, _photoPhrases);
bool _isRepeatCommand(String text) => _matchesAny(text, _repeatPhrases);
/// L'assistant vocal est limité à FR/NL/EN/DE — décidé le 2026-08-13, cf.
/// `DOCS/voice-latency-plan.md` §1.6. Les six autres langues déclarées dans
/// `constants.dart` retombent ici sur `fr-FR` : un visiteur italien se fait répondre
/// en français, sans erreur ni trace. En ajouter une coûte peu les traductions
/// `voice.*` existent déjà pour les 10 langues mais il faut aussi compléter les
/// quatre listes de commandes ci-dessous, sinon la langue est à moitié supportée.
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';

View File

@ -27,6 +27,11 @@ class DownloadConfigurationWidget extends StatefulWidget {
class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidget> {
ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0);
ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1);
/// D5 nombre de ressources dont le téléchargement a échoué. Alimente le message
/// d'échec : « 3 fichiers n'ont pas pu être téléchargés » dit au visiteur ce qui
/// s'est passé, là où l'écran d'erreur générique le laissait supposer une panne.
ValueNotifier<int> downloadFailureCount = ValueNotifier<int>(0);
bool isAlreadyDownloading = false;
//OtaEvent? currentEvent;
@ -106,6 +111,9 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
Map<String, DateTime?> localResourceDates = await readLocalResourceDates();
// Ressources dont le téléchargement a échoué. Vide = visite complète.
final List<String> failedResourceIds = [];
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id]));
currentResourceNbr.value = resourcesToDownload.length;
@ -127,7 +135,12 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
currentResourceIndex.value++;
} else {
print("NOT SUCCESSS");
// D5 un fichier raté ne disparaît plus dans un `print`. Une visite dont
// des ressources manquent ne doit pas s'annoncer téléchargée : hors ligne,
// le visiteur trouverait des images vides ou un audio muet sans comprendre
// pourquoi, et sans rien pouvoir y faire.
failedResourceIds.add(resource.id ?? '?');
debugPrint("[download] échec de la ressource ${resource.id} (${resource.url})");
}
}
@ -234,6 +247,16 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
// `CachedCustomResource` retrouve le fichier en listant le répertoire.
}
// D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que
// ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length;
return false;
}
} catch(e) {
print("ERRORRRR");
print(e);
@ -290,22 +313,51 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
textAlign: TextAlign.center,
valueIndex == valueNbr && valueNbr != -1 ? valueNbr == 0 ?
TranslationHelper.getFromLocale(
"upToDate",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadFinish",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadInProgress",
appContext.getContext()),
style: const TextStyle(fontSize: 20),
),
),
// D5 l'état d'échec passe avant les autres. Sans lui, une visite
// incomplète restait sur « téléchargement en cours » indéfiniment :
// le compteur d'avancement n'est incrémenté qu'en cas de succès, donc
// il n'atteignait jamais le total et l'écran ne concluait jamais.
ValueListenableBuilder<int>(
valueListenable: downloadFailureCount,
builder: (context, failures, _) {
if (failures == 0) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
textAlign: TextAlign.center,
valueIndex == valueNbr && valueNbr != -1 ? valueNbr == 0 ?
TranslationHelper.getFromLocale(
"upToDate",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadFinish",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadInProgress",
appContext.getContext()),
style: const TextStyle(fontSize: 20),
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 34, color: Colors.orangeAccent),
const SizedBox(height: 10),
Text(
TranslationHelper.getFromLocale(
"downloadIncomplete", appContext.getContext()),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
],
),
),
);
},
),
if(valueNbr != -1 && valueNbr != 0)
Center(

View File

@ -25,6 +25,7 @@ List<Translation> translations = [
"restart": "Recommencer",
"downloadInProgress": "Téléchargement en cours",
"downloadFinish": "Téléchargement terminé",
"downloadIncomplete": "Téléchargement incomplet : certains fichiers manquent. Relancez le téléchargement, seuls les fichiers manquants seront récupérés.",
"upToDate": "Tout est à jour",
"weather.hourly": "Prochaines heures",
"weather.nextdays": "Prochains jours",
@ -68,6 +69,7 @@ List<Translation> translations = [
"restart": "Restart",
"downloadInProgress": "Download in progress",
"downloadFinish": "Download complete",
"downloadIncomplete": "Download incomplete: some files are missing. Start the download again — only the missing files will be fetched.",
"upToDate": "Up to date",
"weather.hourly": "Hourly",
"weather.nextdays": "Next days",
@ -111,6 +113,7 @@ List<Translation> translations = [
"restart": "Neu starten",
"downloadInProgress": "Download läuft",
"downloadFinish": "Download abgeschlossen",
"downloadIncomplete": "Download unvollständig: Einige Dateien fehlen. Starten Sie den Download erneut — es werden nur die fehlenden Dateien geladen.",
"upToDate": "Alles ist auf dem neuesten Stand",
"weather.hourly": "Nächste Stunden",
"weather.nextdays": "Nächsten Tage",
@ -154,6 +157,7 @@ List<Translation> translations = [
"restart": "Herstarten",
"downloadInProgress": "Download bezig",
"downloadFinish": "Download voltooid",
"downloadIncomplete": "Download onvolledig: sommige bestanden ontbreken. Start de download opnieuw — alleen de ontbrekende bestanden worden opgehaald.",
"upToDate": "Alles is up-to-date",
"weather.hourly": "Volgende uren",
"weather.nextdays": "Volgende dagen",
@ -197,6 +201,7 @@ List<Translation> translations = [
"restart": "Ricomincia",
"downloadInProgress": "Download in corso",
"downloadFinish": "Download completato",
"downloadIncomplete": "Download incompleto: alcuni file mancano. Riavvia il download — verranno recuperati solo i file mancanti.",
"upToDate": "Tutto è aggiornato",
"weather.hourly": "Le prossime ore",
"weather.nextdays": "Prossimi giorni",
@ -240,6 +245,7 @@ List<Translation> translations = [
"restart": "Reanudar",
"downloadInProgress": "Descarga en curso",
"downloadFinish": "Descarga completada",
"downloadIncomplete": "Descarga incompleta: faltan algunos archivos. Reinicie la descarga — solo se recuperarán los archivos que faltan.",
"upToDate": "Todo está al día",
"weather.hourly": "Próximas horas",
"weather.nextdays": "Proximos dias",
@ -283,6 +289,7 @@ List<Translation> translations = [
"restart": "Uruchom ponownie",
"downloadInProgress": "Pobieranie w toku",
"downloadFinish": "Pobieranie zakończone",
"downloadIncomplete": "Pobieranie niekompletne: brakuje niektórych plików. Uruchom pobieranie ponownie — zostaną pobrane tylko brakujące pliki.",
"upToDate": "Wszystko jest aktualne",
"weather.hourly": "Następne godziny",
"weather.nextdays": "Następne dni",
@ -326,6 +333,7 @@ List<Translation> translations = [
"restart": "重新开始",
"downloadInProgress": "下载中",
"downloadFinish": "下载完成",
"downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。",
"upToDate": "已是最新",
"weather.hourly": "接下来的几个小时",
"weather.nextdays": "未来几天",
@ -369,6 +377,7 @@ List<Translation> translations = [
"restart": "Перезапустіть",
"downloadInProgress": "Завантаження триває",
"downloadFinish": "Завантаження завершено",
"downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.",
"upToDate": "Все актуально",
"weather.hourly": "Наступні години",
"weather.nextdays": "Наступні дні",
@ -412,6 +421,7 @@ List<Translation> translations = [
"restart": "إعادة تشغيل",
"downloadInProgress": "جارٍ التنزيل",
"downloadFinish": "اكتمل التنزيل",
"downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.",
"upToDate": "كل شيء محدث",
"weather.hourly": "الساعات القادمة",
"weather.nextdays": "الايام القادمة",