added support for translations and printing

This commit is contained in:
Fabian Freund
2026-03-05 07:29:15 +01:00
parent ce014e40bf
commit c3e39b3a1d
28 changed files with 3547 additions and 2837 deletions
@@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/find_result.d
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
part 'tab.g.dart';
@@ -75,6 +76,7 @@ class TabState extends WebPageInfo {
final HistoryState historyState;
final ReaderableState readerableState;
final FindResultState findResultState;
final TranslationState translationState;
TabState({
required this.id,
@@ -93,6 +95,7 @@ class TabState extends WebPageInfo {
required this.historyState,
required this.readerableState,
required this.findResultState,
required this.translationState,
}) : super(title: title.trim());
factory TabState.$default(String tabId) => TabState(
@@ -111,6 +114,7 @@ class TabState extends WebPageInfo {
historyState: HistoryState.$default(),
readerableState: ReaderableState.$default(),
findResultState: FindResultState.$default(),
translationState: TranslationState.$default(),
);
@override
@@ -130,5 +134,6 @@ class TabState extends WebPageInfo {
historyState,
readerableState,
findResultState,
translationState,
];
}
@@ -37,6 +37,8 @@ abstract class _$TabStateCWProxy {
TabState findResultState(FindResultState findResultState);
TabState translationState(TranslationState translationState);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`.
///
@@ -60,6 +62,7 @@ abstract class _$TabStateCWProxy {
HistoryState historyState,
ReaderableState readerableState,
FindResultState findResultState,
TranslationState translationState,
});
}
@@ -120,6 +123,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
TabState findResultState(FindResultState findResultState) =>
call(findResultState: findResultState);
@override
TabState translationState(TranslationState translationState) =>
call(translationState: translationState);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`.
@@ -144,6 +151,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
Object? historyState = const $CopyWithPlaceholder(),
Object? readerableState = const $CopyWithPlaceholder(),
Object? findResultState = const $CopyWithPlaceholder(),
Object? translationState = const $CopyWithPlaceholder(),
}) {
return TabState(
id: _value.id,
@@ -217,6 +225,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
? _value.findResultState
// ignore: cast_nullable_to_non_nullable
: findResultState as FindResultState,
translationState:
translationState == const $CopyWithPlaceholder() ||
translationState == null
? _value.translationState
// ignore: cast_nullable_to_non_nullable
: translationState as TranslationState,
);
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
class TranslationState with FastEquatable {
final bool isTranslated;
final bool isTranslateProcessing;
final bool isOfferTranslate;
final bool isExpectedTranslate;
final String? detectedLanguageCode;
final String? userPreferredLanguageCode;
final String? requestedFromLanguage;
final String? requestedToLanguage;
final String? translationErrorName;
final bool? displayError;
TranslationState({
required this.isTranslated,
required this.isTranslateProcessing,
required this.isOfferTranslate,
required this.isExpectedTranslate,
this.detectedLanguageCode,
this.userPreferredLanguageCode,
this.requestedFromLanguage,
this.requestedToLanguage,
this.translationErrorName,
this.displayError,
});
factory TranslationState.$default() => TranslationState(
isTranslated: false,
isTranslateProcessing: false,
isOfferTranslate: false,
isExpectedTranslate: false,
);
factory TranslationState.fromData(TabTranslationStateData data) =>
TranslationState(
isTranslated: data.isTranslated,
isTranslateProcessing: data.isTranslateProcessing,
isOfferTranslate: data.isOfferTranslate,
isExpectedTranslate: data.isExpectedTranslate,
detectedLanguageCode: data.detectedLanguageCode,
userPreferredLanguageCode: data.userPreferredLanguageCode,
requestedFromLanguage: data.requestedFromLanguage,
requestedToLanguage: data.requestedToLanguage,
translationErrorName: data.translationErrorName,
displayError: data.displayError,
);
bool get hasError =>
translationErrorName != null && (displayError ?? true);
@override
List<Object?> get hashParameters => [
isTranslated,
isTranslateProcessing,
isOfferTranslate,
isExpectedTranslate,
detectedLanguageCode,
userPreferredLanguageCode,
requestedFromLanguage,
requestedToLanguage,
translationErrorName,
displayError,
];
}
@@ -239,6 +239,27 @@ Stream<MlProgressData> mlProgressEvents(Ref ref) {
return service.mlProgressEvents;
}
/// Translation engine state (browser-level: supported languages, engine availability)
@Riverpod(keepAlive: true)
class TranslationEngineState extends _$TranslationEngineState {
@override
TranslationEngineStateData? build() {
final eventService = ref.watch(eventServiceProvider);
final sub = eventService.translationEngineEvents.listen((value) {
if (ref.mounted) {
state = value;
}
});
ref.onDispose(() async {
await sub.cancel();
});
return eventService.translationEngineEvents.valueOrNull;
}
}
/// Tracks active ML model downloads
@Riverpod()
class MlDownloadState extends _$MlDownloadState {
@@ -389,6 +389,72 @@ final class MlProgressEventsProvider
String _$mlProgressEventsHash() => r'41c1e6aece7f9ee2bebe5d189c6bec753c2fefc8';
/// Translation engine state (browser-level: supported languages, engine availability)
@ProviderFor(TranslationEngineState)
final translationEngineStateProvider = TranslationEngineStateProvider._();
/// Translation engine state (browser-level: supported languages, engine availability)
final class TranslationEngineStateProvider
extends
$NotifierProvider<TranslationEngineState, TranslationEngineStateData?> {
/// Translation engine state (browser-level: supported languages, engine availability)
TranslationEngineStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'translationEngineStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$translationEngineStateHash();
@$internal
@override
TranslationEngineState create() => TranslationEngineState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TranslationEngineStateData? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TranslationEngineStateData?>(value),
);
}
}
String _$translationEngineStateHash() =>
r'04592e18715b0e237e9bc027287a880a4bb023da';
/// Translation engine state (browser-level: supported languages, engine availability)
abstract class _$TranslationEngineState
extends $Notifier<TranslationEngineStateData?> {
TranslationEngineStateData? build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<TranslationEngineStateData?, TranslationEngineStateData?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
TranslationEngineStateData?,
TranslationEngineStateData?
>,
TranslationEngineStateData?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// Tracks active ML model downloads
@ProviderFor(MlDownloadState)
@@ -76,6 +76,26 @@ class TabSession extends _$TabSession {
return _sessionService.saveToPdf();
}
Future<void> printContent() {
return _sessionService.printContent();
}
Future<void> translate({
required String fromLanguage,
required String toLanguage,
bool? downloadModel,
}) {
return _sessionService.translate(
fromLanguage: fromLanguage,
toLanguage: toLanguage,
options: TranslationOptions(downloadModel: downloadModel ?? true),
);
}
Future<void> translateRestore() {
return _sessionService.translateRestore();
}
@override
void build({required String? tabId}) {
_sessionService = (tabId != null)
@@ -57,7 +57,7 @@ final class TabSessionProvider extends $NotifierProvider<TabSession, void> {
}
}
String _$tabSessionHash() => r'5be471574bb868889cc1eee37ae7e4570a9e21a4';
String _$tabSessionHash() => r'b9381c334672fd3f733a4cc86ce696fb7de6fa5d';
final class TabSessionFamily extends $Family
with $ClassFamilyOverride<TabSession, void, void, void, String?> {
@@ -31,6 +31,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
@@ -203,6 +204,16 @@ class TabStates extends _$TabStates {
);
}
void _onTabTranslationStateChange(TabTranslationEvent event) {
final TabTranslationEvent(:tabId, :state) = event;
final current = this.state[tabId] ?? TabState.$default(tabId);
this.state = {...this.state}
..[tabId] = current.copyWith.translationState(
TranslationState.fromData(state),
);
}
void _onFindResultsChange(FindResultsEvent event) {
final FindResultsEvent(:tabId, :results) = event;
final current = state[tabId] ?? TabState.$default(tabId);
@@ -317,6 +328,18 @@ class TabStates extends _$TabStates {
);
},
),
eventService.tabTranslationEvents.listen(
(event) {
_onTabTranslationStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab translation events',
error: error,
stackTrace: stackTrace,
);
},
),
];
ref.listen(
@@ -331,6 +354,7 @@ class TabStates extends _$TabStates {
onSecurityInfoStateChange: true,
onHistoryStateChange: true,
onFindResults: true,
onTranslationStateChange: true,
);
}
},
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'0fd97193ff963006e1943436b25b025dd3885c65';
String _$tabStatesHash() => r'6fc9a91b1343f9ae0b442100bed20e42448afa37';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -49,6 +49,7 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/content_selection_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
@@ -395,6 +396,9 @@ class _PageActionsCard extends HookConsumerWidget {
},
),
// Translate Page
_TranslatePageTile(selectedTabId: selectedTabId),
// Add to Home Screen (conditional)
_AddToHomeScreenTile(selectedTabId: selectedTabId),
],
@@ -571,6 +575,48 @@ class _AddToHomeScreenTile extends ConsumerWidget {
}
}
class _TranslatePageTile extends ConsumerWidget {
final String selectedTabId;
const _TranslatePageTile({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final engineState = ref.watch(translationEngineStateProvider);
final translationState = ref.watch(
tabStateProvider(selectedTabId).select((s) => s?.translationState),
);
final readerActive = ref.watch(
tabStateProvider(selectedTabId)
.select((s) => s?.readerableState.active ?? false),
);
// Hide when reader mode is active (Fenix-aligned)
if (readerActive || engineState?.isEngineSupported != true) {
return const SizedBox.shrink();
}
final isTranslated = translationState?.isTranslated ?? false;
return ListTile(
leading: Icon(
Icons.translate,
color: isTranslated ? Theme.of(context).colorScheme.primary : null,
),
title: Text(isTranslated ? 'Translated' : 'Translate Page'),
onTap: () async {
Navigator.pop(context);
if (context.mounted) {
await showTranslationBottomSheet(
context,
selectedTabId: selectedTabId,
);
}
},
);
}
}
class _FetchFeedsTile extends HookConsumerWidget {
final String selectedTabId;
@@ -1385,6 +1431,24 @@ class _ExportExpansion extends ConsumerWidget {
if (context.mounted) Navigator.pop(context);
},
),
// Print
_buildSubTile(
'Print',
icon: MdiIcons.printer,
onTap: () async {
try {
await ref
.read(tabSessionProvider(tabId: selectedTabId).notifier)
.printContent();
} catch (e) {
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Failed to print page');
}
}
if (context.mounted) Navigator.pop(context);
},
),
],
),
);
@@ -109,6 +109,36 @@ class SaveToPdfMenuItemButton extends HookConsumerWidget {
}
}
class PrintMenuItemButton extends HookConsumerWidget {
const PrintMenuItemButton({super.key, required this.selectedTabId});
final String? selectedTabId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return MenuItemButton(
leadingIcon: const Icon(MdiIcons.printer),
closeOnActivate: false,
onPressed: () async {
try {
await ref
.read(tabSessionProvider(tabId: selectedTabId).notifier)
.printContent();
} catch (e) {
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Failed to print page');
}
}
if (context.mounted) {
MenuController.maybeOf(context)?.close();
}
},
child: const Text('Print'),
);
}
}
class ShareMarkdownActionMenuItemButton extends HookConsumerWidget {
const ShareMarkdownActionMenuItemButton({
super.key,
@@ -31,12 +31,14 @@ import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart';
@@ -577,10 +579,53 @@ class TabMenu extends HookConsumerWidget {
},
),
SaveToPdfMenuItemButton(selectedTabId: selectedTabId),
PrintMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(MdiIcons.fileExport),
child: const Text('Export'),
),
Consumer(
builder: (context, ref, child) {
final engineState = ref.watch(translationEngineStateProvider);
final translationState = ref.watch(
tabStateProvider(selectedTabId)
.select((s) => s?.translationState),
);
final readerActive = ref.watch(
tabStateProvider(selectedTabId)
.select((s) => s?.readerableState.active ?? false),
);
// Hide when reader mode is active (Fenix-aligned)
if (readerActive || engineState?.isEngineSupported != true) {
return const SizedBox.shrink();
}
final isTranslated = translationState?.isTranslated ?? false;
return MenuItemButton(
closeOnActivate: false,
leadingIcon: Icon(
Icons.translate,
color: isTranslated
? Theme.of(context).colorScheme.primary
: null,
),
onPressed: () async {
controller.close();
if (context.mounted) {
await showTranslationBottomSheet(
context,
selectedTabId: selectedTabId,
);
}
},
child: Text(
isTranslated ? 'Translated' : 'Translate Page',
),
);
},
),
if (enablePinTab)
Consumer(
builder: (context, childRef, child) {
@@ -0,0 +1,309 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
enum _TranslatePhase { idle, submitting, processing }
Future<void> showTranslationBottomSheet(
BuildContext context, {
required String selectedTabId,
}) {
unawaited(
GeckoTabService().syncEvents(onTranslationStateChange: true).catchError((
Object e,
StackTrace s,
) {
logger.w(
'Failed to refresh translation state before opening sheet',
error: e,
stackTrace: s,
);
}),
);
return showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => TranslationBottomSheet(selectedTabId: selectedTabId),
);
}
class TranslationBottomSheet extends HookConsumerWidget {
final String selectedTabId;
const TranslationBottomSheet({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final tabState = ref.watch(tabStateProvider(selectedTabId));
final translationState = tabState?.translationState;
final engineState = ref.watch(translationEngineStateProvider);
final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? [];
final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? [];
final suggestedFrom =
translationState?.detectedLanguageCode ??
translationState?.requestedFromLanguage;
final suggestedTo =
translationState?.userPreferredLanguageCode ??
translationState?.requestedToLanguage;
final selectedFrom = useState<String?>(suggestedFrom);
final selectedTo = useState<String?>(suggestedTo);
useEffect(() {
if (selectedFrom.value == null && suggestedFrom != null) {
selectedFrom.value = suggestedFrom;
}
if (selectedTo.value == null && suggestedTo != null) {
selectedTo.value = suggestedTo;
}
return null;
}, [suggestedFrom, suggestedTo]);
final isTranslated = translationState?.isTranslated ?? false;
final isProcessing = translationState?.isTranslateProcessing ?? false;
final hasError = translationState?.hasError ?? false;
final errorName = translationState?.translationErrorName;
final translatePhase = useState(_TranslatePhase.idle);
final effectiveProcessing =
isProcessing || translatePhase.value != _TranslatePhase.idle;
useEffect(() {
switch (translatePhase.value) {
case _TranslatePhase.idle:
return null;
case _TranslatePhase.submitting:
if (isProcessing) {
translatePhase.value = _TranslatePhase.processing;
} else if (hasError) {
translatePhase.value = _TranslatePhase.idle;
}
return null;
case _TranslatePhase.processing:
if (!isProcessing && (isTranslated || hasError)) {
translatePhase.value = _TranslatePhase.idle;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.pop(context);
});
}
// Otherwise stay in processing — transient stop during retranslate.
return null;
}
}, [isProcessing, isTranslated, hasError, translatePhase.value]);
final canTranslate =
selectedFrom.value != null &&
selectedTo.value != null &&
selectedFrom.value != selectedTo.value &&
!effectiveProcessing;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Icon(Icons.translate, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Translate Page',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
if (effectiveProcessing)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 16),
// From language dropdown
_LanguageDropdown(
label: 'From',
languages: fromLanguages,
selectedCode: selectedFrom.value,
onChanged: effectiveProcessing
? null
: (code) => selectedFrom.value = code,
),
const SizedBox(height: 12),
// To language dropdown
_LanguageDropdown(
label: 'To',
languages: toLanguages,
selectedCode: selectedTo.value,
onChanged: effectiveProcessing
? null
: (code) => selectedTo.value = code,
),
const SizedBox(height: 16),
// Error display
if (hasError && errorName != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Translation error: $errorName',
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
),
// Action buttons
Row(
children: [
if (isTranslated)
Expanded(
child: OutlinedButton(
onPressed: effectiveProcessing
? null
: () async {
try {
await ref
.read(
tabSessionProvider(
tabId: selectedTabId,
).notifier,
)
.translateRestore();
} catch (e) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Failed to restore page',
);
}
}
if (context.mounted) Navigator.pop(context);
},
child: const Text('Show Original'),
),
),
if (isTranslated) const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: canTranslate
? () async {
translatePhase.value = _TranslatePhase.submitting;
try {
await ref
.read(
tabSessionProvider(
tabId: selectedTabId,
).notifier,
)
.translate(
fromLanguage: selectedFrom.value!,
toLanguage: selectedTo.value!,
);
} catch (e) {
translatePhase.value = _TranslatePhase.idle;
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Failed to translate page',
);
}
}
}
: null,
child: Text(isTranslated ? 'Retranslate' : 'Translate'),
),
),
],
),
],
),
),
);
}
}
class _LanguageDropdown extends StatelessWidget {
final String label;
final List<TranslationLanguage> languages;
final String? selectedCode;
final ValueChanged<String?>? onChanged;
const _LanguageDropdown({
required this.label,
required this.languages,
required this.selectedCode,
this.onChanged,
});
@override
Widget build(BuildContext context) {
// Ensure selectedCode exists in the list, otherwise use null
final effectiveSelection = languages.any((l) => l.code == selectedCode)
? selectedCode
: null;
return DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
initialValue: effectiveSelection,
isExpanded: true,
items: languages
.map(
(lang) => DropdownMenuItem(
value: lang.code,
child: Text(lang.localizedDisplayName),
),
)
.toList(),
onChanged: onChanged,
);
}
}