Compare commits
No commits in common. "66301805d8eeb90807d3fc8976a845dc8368a47f" and "e967678dd2e5274c29c0329fa8dc94567680a584" have entirely different histories.
66301805d8
...
e967678dd2
@ -1,6 +1,5 @@
|
||||
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';
|
||||
@ -266,15 +265,6 @@ 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(),
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
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 là-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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -40,17 +40,9 @@ 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;
|
||||
@ -71,7 +63,6 @@ class VoiceOrchestrator {
|
||||
Future<void> start() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
await _preloadSounds();
|
||||
await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
@ -83,7 +74,6 @@ class VoiceOrchestrator {
|
||||
await wakeWordEngine.stop();
|
||||
await sttEngine.cancel();
|
||||
await ttsEngine.stop();
|
||||
await _stopThinkingLoop();
|
||||
_running = false;
|
||||
debugPrint('[VoiceOrchestrator] Stopped');
|
||||
}
|
||||
@ -151,52 +141,29 @@ class VoiceOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
Future<void> _playSound(String asset) async {
|
||||
try {
|
||||
await _soundPlayer.setAsset(_wakeSound);
|
||||
await _thinkingPlayer.setAsset(_thinkingSound);
|
||||
await _thinkingPlayer.setLoopMode(LoopMode.one);
|
||||
_soundsReady = true;
|
||||
await _soundPlayer.setAsset(asset);
|
||||
await _soundPlayer.play();
|
||||
} catch (_) {
|
||||
debugPrint('[VoiceOrchestrator] Sons introuvables — pipeline silencieux');
|
||||
debugPrint('[VoiceOrchestrator] Sound not found: $asset');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playWakeSound() async {
|
||||
if (!_soundsReady) return;
|
||||
try {
|
||||
await _soundPlayer.seek(Duration.zero);
|
||||
_soundPlayer.play();
|
||||
} catch (_) {}
|
||||
}
|
||||
Future<void> _playWakeSound() => _playSound(_wakeSound);
|
||||
Future<void> _playDoneSound() => _playSound(_doneSound);
|
||||
|
||||
/// 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 (_) {}
|
||||
});
|
||||
try {
|
||||
await _thinkingPlayer.setAsset(_thinkingSound);
|
||||
await _thinkingPlayer.setLoopMode(LoopMode.one);
|
||||
_thinkingPlayer.play();
|
||||
} catch (_) {
|
||||
debugPrint('[VoiceOrchestrator] Thinking sound not found');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopThinkingLoop() async {
|
||||
_thinkingWanted = false;
|
||||
_thinkingTimer?.cancel();
|
||||
_thinkingTimer = null;
|
||||
await _thinkingPlayer.stop();
|
||||
}
|
||||
|
||||
@ -242,6 +209,7 @@ class VoiceOrchestrator {
|
||||
await _stopThinkingLoop();
|
||||
if (result.reply.isNotEmpty) {
|
||||
lastTtsText.value = result.reply;
|
||||
await _playDoneSound();
|
||||
await ttsEngine.speak(result.reply, languageCode: langCode);
|
||||
}
|
||||
} catch (e) {
|
||||
@ -404,67 +372,30 @@ class VoiceOrchestrator {
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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) {
|
||||
bool _isStopCommand(String text) {
|
||||
final t = text.toLowerCase().trim();
|
||||
return phrases.any(
|
||||
(p) => RegExp('(^|\\W)${RegExp.escape(p)}(\$|\\W)').hasMatch(t),
|
||||
);
|
||||
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')));
|
||||
}
|
||||
|
||||
bool _isStopCommand(String text) => _matchesAny(text, _stopPhrases);
|
||||
bool _isQrScanCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde');
|
||||
}
|
||||
|
||||
/// ⚠️ 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 _isPhotoCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('photo') || t.contains('prends') || t.contains('capture');
|
||||
}
|
||||
|
||||
bool _isPhotoCommand(String text) => _matchesAny(text, _photoPhrases);
|
||||
bool _isRepeatCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('répète') || t.contains('repete') || t.contains('encore');
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@ -27,11 +27,6 @@ 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;
|
||||
|
||||
@ -111,9 +106,6 @@ 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;
|
||||
@ -135,12 +127,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
|
||||
currentResourceIndex.value++;
|
||||
} else {
|
||||
// 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})");
|
||||
print("NOT SUCCESSS");
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,16 +234,6 @@ 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);
|
||||
@ -313,51 +290,22 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
if(valueNbr != -1 && valueNbr != 0)
|
||||
Center(
|
||||
|
||||
@ -25,7 +25,6 @@ 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",
|
||||
@ -69,7 +68,6 @@ 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",
|
||||
@ -113,7 +111,6 @@ 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",
|
||||
@ -157,7 +154,6 @@ 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",
|
||||
@ -201,7 +197,6 @@ 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",
|
||||
@ -245,7 +240,6 @@ 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",
|
||||
@ -289,7 +283,6 @@ 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",
|
||||
@ -333,7 +326,6 @@ List<Translation> translations = [
|
||||
"restart": "重新开始",
|
||||
"downloadInProgress": "下载中",
|
||||
"downloadFinish": "下载完成",
|
||||
"downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。",
|
||||
"upToDate": "已是最新",
|
||||
"weather.hourly": "接下来的几个小时",
|
||||
"weather.nextdays": "未来几天",
|
||||
@ -377,7 +369,6 @@ List<Translation> translations = [
|
||||
"restart": "Перезапустіть",
|
||||
"downloadInProgress": "Завантаження триває",
|
||||
"downloadFinish": "Завантаження завершено",
|
||||
"downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.",
|
||||
"upToDate": "Все актуально",
|
||||
"weather.hourly": "Наступні години",
|
||||
"weather.nextdays": "Наступні дні",
|
||||
@ -421,7 +412,6 @@ List<Translation> translations = [
|
||||
"restart": "إعادة تشغيل",
|
||||
"downloadInProgress": "جارٍ التنزيل",
|
||||
"downloadFinish": "اكتمل التنزيل",
|
||||
"downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.",
|
||||
"upToDate": "كل شيء محدث",
|
||||
"weather.hourly": "الساعات القادمة",
|
||||
"weather.nextdays": "الايام القادمة",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user