conduit/lib/core/utils/native_sheet_utils.dart
Kamil Maciąg a376e617ce
feat(l10n): add Polish localization (#617)
* feat(l10n): add Polish localization

- add complete Polish Flutter localization (app_pl.arb) mirroring app_en.arb
- add pl to preferred-supported-locales, language selector, and native language sheets
- add Polish iOS native resources (pl.lproj) and Xcode project registration
- add Polish release notes and language count documentation
- add focused tests for locale resolution, resource completeness, and Polish plurals

* fix(l10n): address Polish localization review feedback

- localize the 'polish' language name per catalog (Polish/Polnisch/Polonais/Poľština/...)
- label the credentials auth mode 'E-mail i hasło' instead of 'Hasło'
- pluralize the active-schedule count in hermesSchedulesSummary (1 aktywny / 2 aktywne / 5 aktywnych)
- use feminine 'Poprzednia' for the previous-version action label
- replace literal 'szybkie pigułki' with 'szybkie akcje'
- reword server-downgrade instruction, shared-chat notice, and direct-connection delete message
- use the official product name 'Open Web UI' in user-facing values
- make the placeholder parity test robust to Dart identity-based set/list equality

* fix(l10n): address remaining Polish review feedback

- use a username-inclusive label 'Dane logowania' for the credentials auth mode
- reword the prompt version delete confirmation to 'Ta wersja zostanie usunięta'
- keep channelMembers plural forms unchanged (with digits, '2 członków' is correct, not '2 członkowie')

Assisted-by: Open WebUI

* fix(l10n): rebase Polish catalog onto current English keys

Polish was complete against an older template; fill the 134 strings added on main (Hermes jobs/sessions, voice barge-in, sidebar) so locale validation can pass after merge.

---------

Co-authored-by: Kamil Maciąg <6450912+Dragonk@users.noreply.github.com>
Co-authored-by: cogwheel <172976095+cogwheel0@users.noreply.github.com>
2026-08-20 22:12:00 +05:30

641 lines
18 KiB
Dart

import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/utils/locale_display_formatters.dart';
import '../models/model.dart';
import '../models/server_memory.dart';
import '../models/socket_health.dart';
import '../services/native_sheet_bridge.dart';
import '../services/settings_service.dart';
import 'tts_voice_utils.dart';
String nativeQuickActionsTitle(AppLocalizations l10n) {
return l10n.quickActionsDescription;
}
String nativeSettingsTitle(AppLocalizations _) => 'Settings';
String nativeProfileTitle(AppLocalizations _) => 'Profile';
String nativeAppearanceTitle(AppLocalizations l10n) => l10n.settingsAppearance;
String nativeChatsTitle(AppLocalizations _) => 'Chats';
String nativeAiMemoryTitle(AppLocalizations _) => 'AI and Memory';
String nativeDataConnectionTitle(AppLocalizations l10n) =>
l10n.settingsDataAndConnection;
String? resolveNativeSheetModelName(List<Model> models, String? modelId) {
if (modelId == null || modelId.isEmpty) return null;
for (final model in models) {
if (model.id == modelId) return model.name;
}
return modelId;
}
String nativeSheetPreviewText(AppLocalizations l10n, String? value) {
if (value == null || value.trim().isEmpty) return l10n.notSet;
final text = value.trim();
if (text.length > 88) return '${text.substring(0, 85)}...';
return text;
}
String truncateNativeSheetMemory(String content) {
final normalized = content.trim().replaceAll('\n', ' ');
if (normalized.length <= 72) return normalized;
return '${normalized.substring(0, 69)}...';
}
String nativeSheetMemoryUpdatedSubtitle(
AppLocalizations l10n,
ServerMemory memory,
) {
final formatted = DateFormat.yMMMd().add_jm().format(memory.updatedAt);
return l10n.memoryUpdatedAt(formatted);
}
NativeSheetItemConfig buildNativeLoadingItem(
AppLocalizations l10n, {
String id = 'loading',
String? title,
String sfSymbol = 'ellipsis.circle',
}) {
return NativeSheetItemConfig(
id: id,
title: title ?? l10n.loadingShort,
sfSymbol: sfSymbol,
kind: NativeSheetItemKind.info,
);
}
NativeSheetDetailConfig buildNativeLoadingDetail({
required AppLocalizations l10n,
required String id,
required String title,
String? subtitle,
}) {
return NativeSheetDetailConfig(
id: id,
title: title,
subtitle: subtitle,
items: [buildNativeLoadingItem(l10n, id: '$id-loading')],
);
}
class NativeAudioSheetParts {
const NativeAudioSheetParts({
required this.mainSections,
required this.voicePickerDetail,
});
final List<NativeSheetSectionConfig> mainSections;
final NativeSheetDetailConfig voicePickerDetail;
}
NativeAudioSheetParts buildNativeAudioSheetParts(
AppLocalizations l10n,
AppSettings appSettings, {
List<Map<String, dynamic>> ttsVoices = const <Map<String, dynamic>>[],
}) {
final sttSegment = NativeSheetItemConfig(
id: 'stt-engine',
title: l10n.sttSettings,
subtitle: l10n.sttEngineDeviceDescription,
sfSymbol: 'mic',
kind: NativeSheetItemKind.segment,
value: appSettings.sttPreference.name,
options: [
NativeSheetOptionConfig(id: 'deviceOnly', label: l10n.sttEngineDevice),
NativeSheetOptionConfig(id: 'serverOnly', label: l10n.sttEngineServer),
],
);
final silenceDivisions =
((SettingsService.maxVoiceSilenceDurationMs -
SettingsService.minVoiceSilenceDurationMs) ~/
100)
.clamp(1, 1000)
.toInt();
final silenceSlider = NativeSheetItemConfig(
id: 'stt-silence-duration',
title: l10n.sttSilenceDuration,
subtitle: l10n.sttSilenceDurationDescription,
sfSymbol: 'timer',
kind: NativeSheetItemKind.slider,
value: appSettings.voiceSilenceDuration.toDouble(),
min: SettingsService.minVoiceSilenceDurationMs.toDouble(),
max: SettingsService.maxVoiceSilenceDurationMs.toDouble(),
divisions: silenceDivisions,
);
final sttLanguageField = NativeSheetItemConfig(
id: 'stt-language-code',
title: l10n.sttTranscriptionLanguage,
subtitle: appSettings.sttLanguageCode ?? l10n.sttTranscriptionLanguageAuto,
sfSymbol: 'globe',
kind: NativeSheetItemKind.textField,
value: appSettings.sttLanguageCode ?? '',
placeholder: l10n.sttTranscriptionLanguagePlaceholder,
);
final ttsSegment = NativeSheetItemConfig(
id: 'tts-engine',
title: l10n.ttsSettings,
subtitle: appSettings.ttsEngine == TtsEngine.server
? l10n.ttsEngineServerDescription
: l10n.ttsEngineDeviceDescription,
sfSymbol: 'speaker.wave.2',
kind: NativeSheetItemKind.segment,
value: appSettings.ttsEngine.name,
options: [
NativeSheetOptionConfig(id: 'device', label: l10n.ttsEngineDevice),
NativeSheetOptionConfig(id: 'server', label: l10n.ttsEngineServer),
],
);
final voiceOptions = buildTtsVoiceOptions(
l10n,
appSettings.ttsEngine,
ttsVoices,
);
final selectedVoiceId = selectedTtsVoiceOptionId(appSettings, ttsVoices);
final voicePickerNav = NativeSheetItemConfig(
id: 'tts-voice-picker',
title: l10n.ttsVoice,
subtitle: _nativeVoiceSubtitle(l10n, appSettings),
sfSymbol: 'person.wave.2',
kind: NativeSheetItemKind.searchablePicker,
value: selectedVoiceId,
options: [
NativeSheetOptionConfig(
id: ttsSystemDefaultVoiceId,
label: l10n.ttsSystemDefault,
),
for (final option in voiceOptions)
NativeSheetOptionConfig(
id: option.id,
label: option.label,
subtitle: option.subtitle,
sfSymbol: 'person.wave.2',
),
],
);
final speechRateSlider = NativeSheetItemConfig(
id: 'tts-speech-rate',
title: l10n.ttsSpeechRate,
sfSymbol: 'gauge.with.dots.needle.67percent',
kind: NativeSheetItemKind.slider,
value: appSettings.ttsSpeechRate,
min: 0.25,
max: 2.0,
divisions: 35,
);
final previewNav = NativeSheetItemConfig(
id: 'tts-preview',
title: l10n.ttsPreview,
subtitle: l10n.ttsPreviewText,
sfSymbol: 'play.circle',
value: l10n.ttsPreviewText,
);
final sttItems = <NativeSheetItemConfig>[
sttSegment,
if (appSettings.sttPreference == SttPreference.serverOnly) ...[
sttLanguageField,
silenceSlider,
],
];
final ttsItems = <NativeSheetItemConfig>[
ttsSegment,
voicePickerNav,
if (appSettings.ttsEngine == TtsEngine.device) speechRateSlider,
previewNav,
];
final voicePickerDetail = NativeSheetDetailConfig(
id: 'tts-voice-picker',
title: l10n.ttsSelectVoice,
subtitle: l10n.ttsVoice,
items: const [],
);
return NativeAudioSheetParts(
mainSections: [
NativeSheetSectionConfig(items: sttItems),
NativeSheetSectionConfig(items: ttsItems),
],
voicePickerDetail: voicePickerDetail,
);
}
String _nativeVoiceSubtitle(AppLocalizations l10n, AppSettings settings) {
if (settings.ttsEngine == TtsEngine.server) {
final voice =
settings.ttsServerVoiceName ??
settings.ttsServerVoiceId ??
l10n.ttsSystemDefault;
return formatTtsVoiceDisplayName(voice);
}
final voice =
settings.ttsVoiceName ?? settings.ttsVoice ?? l10n.ttsSystemDefault;
return formatTtsVoiceDisplayName(voice);
}
NativeSheetDetailConfig buildNativePasswordDetail(
AppLocalizations l10n, {
required bool passwordChangeEnabled,
String? subtitle,
}) {
final items = passwordChangeEnabled
? [
NativeSheetItemConfig(
id: 'current-password',
title: l10n.currentPassword,
subtitle: l10n.passwordHint,
sfSymbol: 'lock',
kind: NativeSheetItemKind.secureTextField,
placeholder: l10n.currentPassword,
),
NativeSheetItemConfig(
id: 'new-password',
title: l10n.newPassword,
subtitle: l10n.passwordHint,
sfSymbol: 'key',
kind: NativeSheetItemKind.secureTextField,
placeholder: l10n.newPassword,
),
NativeSheetItemConfig(
id: 'confirm-password',
title: l10n.confirmNewPassword,
subtitle: l10n.passwordHint,
sfSymbol: 'checkmark.shield',
kind: NativeSheetItemKind.secureTextField,
placeholder: l10n.confirmNewPassword,
),
]
: [
NativeSheetItemConfig(
id: 'password-unavailable',
title: l10n.changePasswordTitle,
subtitle: l10n.passwordChangeUnavailable,
sfSymbol: 'lock.slash',
kind: NativeSheetItemKind.info,
),
];
return NativeSheetDetailConfig(
id: 'password',
title: l10n.changePasswordTitle,
subtitle: passwordChangeEnabled ? subtitle : null,
items: items,
);
}
List<NativeSheetOptionConfig> buildNativeDefaultModelOptions(
AppLocalizations l10n,
List<Model> models,
) {
return [
NativeSheetOptionConfig(id: 'auto-select', label: l10n.autoSelect),
for (final model in models)
NativeSheetOptionConfig(id: model.id, label: model.name),
];
}
NativeSheetDetailConfig buildNativeDefaultModelDetail(
AppLocalizations l10n, {
required List<Model> models,
required String? selectedModelId,
String? subtitle,
}) {
return NativeSheetDetailConfig(
id: 'default-model',
title: l10n.defaultModel,
subtitle: subtitle ?? l10n.autoSelectDescription,
items: [
NativeSheetItemConfig(
id: 'default-model',
title: l10n.defaultModel,
subtitle: l10n.autoSelectDescription,
sfSymbol: 'wand.and.stars',
kind: NativeSheetItemKind.dropdown,
value: selectedModelId ?? 'auto-select',
options: buildNativeDefaultModelOptions(l10n, models),
),
],
);
}
NativeSheetItemConfig? buildNativeOpenRouterImageGenerationModelItem(
AppLocalizations l10n, {
required List<Model> models,
required String? selectedModelId,
}) {
final isAvailable = models.any(
(model) =>
model.capabilities?['openrouter'] == true &&
model.capabilities?['image_generation'] == true,
);
if (!isAvailable) return null;
return NativeSheetItemConfig(
id: 'default-image-generation-model',
title: l10n.defaultImageGenerationModel,
subtitle: selectedModelId ?? l10n.openRouterDefaultImageGenerationModel,
sfSymbol: 'photo.on.rectangle',
);
}
NativeSheetDetailConfig buildNativeOpenRouterImageGenerationModelDetail(
AppLocalizations l10n, {
required String value,
}) {
return NativeSheetDetailConfig(
id: 'default-image-generation-model',
title: l10n.defaultImageGenerationModel,
subtitle: l10n.defaultImageGenerationModelDescription,
items: [
NativeSheetItemConfig(
id: 'default-image-generation-model',
title: l10n.defaultImageGenerationModel,
subtitle: l10n.defaultImageGenerationModelDescription,
sfSymbol: 'photo.on.rectangle',
kind: NativeSheetItemKind.textField,
value: value,
placeholder: 'openai/gpt-5-image',
),
],
);
}
NativeSheetDetailConfig buildNativeSystemPromptDetail(
AppLocalizations l10n, {
required String value,
String? subtitle,
}) {
return NativeSheetDetailConfig(
id: 'system-prompt',
title: l10n.yourSystemPrompt,
subtitle: subtitle ?? l10n.yourSystemPromptDescription,
items: [
NativeSheetItemConfig(
id: 'system-prompt',
title: l10n.yourSystemPrompt,
subtitle: l10n.enterSystemPrompt,
sfSymbol: 'text.bubble',
kind: NativeSheetItemKind.multilineTextField,
value: value,
placeholder: l10n.enterSystemPrompt,
),
],
);
}
NativeSheetDetailConfig buildNativeMemoryAddDetail(AppLocalizations l10n) {
return NativeSheetDetailConfig(
id: 'memory-add',
title: l10n.addMemory,
subtitle: l10n.memoryEditorDescription,
items: [
NativeSheetItemConfig(
id: 'memory-add-content',
title: l10n.addMemory,
sfSymbol: 'plus.circle',
kind: NativeSheetItemKind.multilineTextField,
value: '',
placeholder: l10n.memoryHint,
),
],
);
}
List<NativeSheetDetailConfig> buildNativeMemoryEditDetails(
AppLocalizations l10n,
List<ServerMemory> memories,
) {
return [
for (final memory in memories)
NativeSheetDetailConfig(
id: 'memory-edit:${Uri.encodeComponent(memory.id)}',
title: l10n.editMemory,
subtitle: l10n.memoryEditorDescription,
items: [
NativeSheetItemConfig(
id: 'memory-save:${Uri.encodeComponent(memory.id)}',
title: l10n.editMemory,
sfSymbol: 'quote.bubble',
kind: NativeSheetItemKind.multilineTextField,
value: memory.content,
placeholder: l10n.memoryHint,
),
NativeSheetItemConfig(
id: 'memory-delete:${Uri.encodeComponent(memory.id)}',
title: l10n.deleteMemory,
subtitle: l10n.deleteMemoryConfirm,
sfSymbol: 'trash',
destructive: true,
),
],
),
];
}
List<NativeSheetDetailConfig> buildNativeModelPromptLoadingDetails(
AppLocalizations l10n,
List<Model> models,
) {
return [
for (final model in models)
NativeSheetDetailConfig(
id: 'model-prompt:${Uri.encodeComponent(model.id)}',
title: l10n.modelSystemPromptTitle(model.name),
items: [
buildNativeLoadingItem(
l10n,
id: 'model-prompt-loading:${Uri.encodeComponent(model.id)}',
),
],
),
];
}
String nativeLanguageLabel(AppLocalizations l10n, String code) {
switch (code) {
case 'system':
return l10n.system;
case 'en':
return l10n.english;
case 'cs':
return l10n.czech;
case 'sk':
return l10n.slovak;
case 'pl':
return l10n.polish;
case 'de':
return l10n.deutsch;
case 'fr':
return l10n.francais;
case 'it':
return l10n.italiano;
case 'es':
return l10n.espanol;
case 'nl':
return l10n.nederlands;
case 'ru':
return l10n.russian;
case 'zh':
return l10n.chineseSimplified;
case 'ko':
return l10n.korean;
case 'ja':
return l10n.japanese;
case 'zh-Hant':
return l10n.chineseTraditional;
default:
final normalized = code.replaceAll('_', '-').toLowerCase();
if (normalized == 'zh-hant') return l10n.chineseTraditional;
if (normalized == 'zh') return l10n.chineseSimplified;
if (normalized == 'ko') return l10n.korean;
if (normalized == 'ja') return l10n.japanese;
if (normalized == 'cs') return l10n.czech;
if (normalized == 'sk') return l10n.slovak;
if (normalized == 'pl') return l10n.polish;
return l10n.system;
}
}
List<NativeSheetOptionConfig> nativeLanguageDropdownOptions(
AppLocalizations l10n,
) {
return [
NativeSheetOptionConfig(id: 'system', label: l10n.system),
NativeSheetOptionConfig(id: 'en', label: l10n.english),
NativeSheetOptionConfig(id: 'cs', label: l10n.czech),
NativeSheetOptionConfig(id: 'sk', label: l10n.slovak),
NativeSheetOptionConfig(id: 'pl', label: l10n.polish),
NativeSheetOptionConfig(id: 'de', label: l10n.deutsch),
NativeSheetOptionConfig(id: 'es', label: l10n.espanol),
NativeSheetOptionConfig(id: 'fr', label: l10n.francais),
NativeSheetOptionConfig(id: 'it', label: l10n.italiano),
NativeSheetOptionConfig(id: 'nl', label: l10n.nederlands),
NativeSheetOptionConfig(id: 'ru', label: l10n.russian),
NativeSheetOptionConfig(id: 'zh', label: l10n.chineseSimplified),
NativeSheetOptionConfig(id: 'zh-Hant', label: l10n.chineseTraditional),
NativeSheetOptionConfig(id: 'ko', label: l10n.korean),
NativeSheetOptionConfig(id: 'ja', label: l10n.japanese),
];
}
String nativeSocketHealthSummary(AppLocalizations l10n, SocketHealth? health) {
if (health == null) return l10n.socketNotConnected;
if (!health.isConnected) return l10n.socketDisconnected;
final transport = _nativeSocketTransportLabel(l10n, health.transport);
if (health.hasLatencyInfo) {
return '$transport · ${health.latencyMs}ms';
}
return transport;
}
List<NativeSheetItemConfig> nativeSocketHealthItems(
AppLocalizations l10n,
SocketHealth? health,
) {
if (health == null) {
return [
NativeSheetItemConfig(
id: 'socket-health-null',
title: l10n.socketNotConnected,
sfSymbol: 'cloud.fill',
kind: NativeSheetItemKind.info,
),
];
}
final transportLabel = _nativeSocketTransportLabel(l10n, health.transport);
final items = <NativeSheetItemConfig>[
NativeSheetItemConfig(
id: 'socket-connected',
title: health.isConnected
? l10n.socketConnected
: l10n.socketDisconnected,
subtitle: transportLabel,
sfSymbol: health.isConnected
? 'checkmark.circle.fill'
: 'xmark.circle.fill',
kind: NativeSheetItemKind.info,
),
];
if (health.isConnected && health.hasLatencyInfo) {
items.add(
NativeSheetItemConfig(
id: 'socket-latency',
title: l10n.socketLatencyLabel,
subtitle:
'${health.latencyMs}ms · ${_nativeSocketQualityLabel(l10n, health.quality)}',
sfSymbol: 'gauge.with.dots.needle.67percent',
kind: NativeSheetItemKind.info,
),
);
}
items.add(
NativeSheetItemConfig(
id: 'socket-reconnects',
title: l10n.socketReconnectsLabel,
subtitle: '${health.reconnectCount}',
sfSymbol: 'arrow.clockwise',
kind: NativeSheetItemKind.info,
),
);
if (health.lastHeartbeat != null) {
items.add(
NativeSheetItemConfig(
id: 'socket-heartbeat',
title: l10n.socketLastHeartbeat(
_nativeFormatHeartbeatRelative(l10n, health.lastHeartbeat!),
),
sfSymbol: 'heart.fill',
kind: NativeSheetItemKind.info,
),
);
}
return items;
}
String _nativeSocketTransportLabel(AppLocalizations l10n, String transport) {
switch (transport) {
case 'websocket':
return l10n.socketTransportWebSocket;
case 'polling':
return l10n.socketTransportPolling;
default:
return l10n.socketTransportUnknown;
}
}
String _nativeSocketQualityLabel(AppLocalizations l10n, String quality) {
switch (quality) {
case 'excellent':
return l10n.socketQualityExcellent;
case 'good':
return l10n.socketQualityGood;
case 'fair':
return l10n.socketQualityFair;
case 'poor':
return l10n.socketQualityPoor;
default:
return '';
}
}
String _nativeFormatHeartbeatRelative(
AppLocalizations l10n,
DateTime lastHeartbeat,
) => LocaleDisplayFormatters.relativeTime(
l10n,
lastHeartbeat,
fallbackToDate: false,
);