SpotiFLAC-Mobile/lib/screens/settings/extensions_page.dart
zarzet 2a2d817314 feat: add AAC lossy target and toggle for Apple Music eLRC word sync
The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
2026-05-12 02:23:04 +07:00

1054 lines
37 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget {
const ExtensionsPage({super.key});
@override
ConsumerState<ExtensionsPage> createState() => _ExtensionsPageState();
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
static final RegExp _platformExceptionPattern = RegExp(
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
);
static final RegExp _platformExceptionSimplePattern = RegExp(
r'PlatformException\([^,]+,\s*(.+?),\s*null',
);
static final RegExp _trailingNullsPattern = RegExp(
r',\s*null\s*,\s*null\)?$',
);
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
@override
void initState() {
super.initState();
_initializeExtensions();
}
Future<void> _initializeExtensions() async {
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
} else {
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
}
}
@override
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (extState.error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
extState.error!,
style: TextStyle(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionsProviderPrioritySection,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_DownloadFallbackItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
_HomeFeedProviderSelector(),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionsInstalledSection,
),
),
if (extState.extensions.isEmpty && !extState.isLoading)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
Icons.extension_outlined,
size: 48,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
),
),
),
if (extState.extensions.isNotEmpty)
SliverToBoxAdapter(
child: SettingsGroup(
children: extState.extensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
healthStatus: extState.healthStatuses[ext.id],
showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) =>
ExtensionDetailPage(extensionId: ext.id),
),
),
onToggle: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(ext.id, enabled),
);
}).toList(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
],
),
),
);
}
Future<void> _installExtension() async {
final result = await FilePicker.pickFiles(
type: FileType.any,
allowMultiple: true,
);
if (result != null && result.files.isNotEmpty) {
final selectedPaths = result.files
.map((file) => file.path)
.whereType<String>()
.toList();
final extensionPaths = selectedPaths
.where((path) => path.toLowerCase().endsWith('.spotiflac-ext'))
.toList();
if (extensionPaths.length != selectedPaths.length) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
);
}
return;
}
final installResult = await ref
.read(extensionProvider.notifier)
.installExtensions(extensionPaths);
if (mounted) {
final message = _getInstallResultMessage(installResult);
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
}
String _getInstallResultMessage(ExtensionInstallBatchResult result) {
if (result.attempted == 0) {
return context.l10n.snackbarSelectExtFile;
}
if (!result.hasFailures) {
if (result.installed == 1) {
return context.l10n.extensionsInstalledSuccess;
}
return context.l10n.extensionsInstalledCount(result.installed);
}
if (result.anyInstalled) {
return context.l10n.extensionsInstallPartialSuccess(
result.installed,
result.attempted,
);
}
final firstError = result.failures.values.firstOrNull;
return _getFriendlyErrorMessage(firstError);
}
String _getFriendlyErrorMessage(String? error) {
if (error == null) return context.l10n.snackbarFailedToInstall;
String message = error;
if (message.contains('PlatformException')) {
final match = _platformExceptionPattern.firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
}
}
}
message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(_leadingCommaPattern, '');
return message;
}
}
class _ExtensionItem extends StatelessWidget {
final Extension extension;
final ExtensionHealthStatus? healthStatus;
final bool showDivider;
final VoidCallback onTap;
final ValueChanged<bool> onToggle;
const _ExtensionItem({
required this.extension,
this.healthStatus,
required this.showDivider,
required this.onTap,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasError = extension.status == 'error';
final serviceHealthStatus = extension.hasServiceHealth
? healthStatus?.status
: null;
final serviceHealthColor = serviceHealthStatus == null
? null
: _extensionHealthColor(colorScheme, serviceHealthStatus);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: hasError
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child:
extension.iconPath != null &&
extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(extension.iconPath!),
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
hasError ? Icons.error_outline : Icons.extension,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
)
: Icon(
hasError ? Icons.error_outline : Icons.extension,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
: serviceHealthStatus == null
? 'v${extension.version}'
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
: serviceHealthColor ??
colorScheme.onSurfaceVariant,
),
),
],
),
),
Switch(
value: extension.enabled,
onChanged: hasError ? null : onToggle,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
Color _extensionHealthColor(ColorScheme colorScheme, String status) {
switch (status) {
case 'online':
return colorScheme.primary;
case 'degraded':
return colorScheme.tertiary;
case 'offline':
return colorScheme.error;
default:
return colorScheme.onSurfaceVariant;
}
}
String _extensionHealthLabel(BuildContext context, String status) {
switch (status) {
case 'online':
return context.l10n.extensionHealthOnline;
case 'degraded':
return context.l10n.extensionHealthDegraded;
case 'offline':
return context.l10n.extensionHealthOffline;
default:
return context.l10n.extensionHealthUnknown;
}
}
class _DownloadPriorityItem extends ConsumerWidget {
const _DownloadPriorityItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const ProviderPriorityPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.download,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsDownloadPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? context.l10n.extensionsDownloadPrioritySubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _MetadataPriorityItem extends ConsumerWidget {
const _MetadataPriorityItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasMetadataExtensions = extState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
return InkWell(
onTap: hasMetadataExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const MetadataProviderPriorityPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.search,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsMetadataPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasMetadataExtensions
? context.l10n.extensionsMetadataPrioritySubtitle
: context.l10n.extensionsNoMetadataProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _DownloadFallbackItem extends ConsumerWidget {
const _DownloadFallbackItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const DownloadFallbackExtensionsPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.alt_route,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsFallbackTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? context.l10n.extensionsFallbackSubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
final hasAnyProvider = searchProviders.isNotEmpty;
final resolvedProviderId =
(settings.searchProvider != null && settings.searchProvider!.isNotEmpty)
? settings.searchProvider!
: searchProviders.firstOrNull?.id;
String currentProviderName = context.l10n.optionsPrimaryProviderSubtitle;
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
final ext = searchProviders
.where((e) => e.id == resolvedProviderId)
.firstOrNull;
currentProviderName = ext?.displayName ?? resolvedProviderId;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: !hasAnyProvider
? null
: () => _showSearchProviderPicker(
context,
ref,
settings,
searchProviders,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.manage_search,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null,
),
),
const SizedBox(height: 2),
Text(
!hasAnyProvider
? context.l10n.extensionsNoCustomSearch
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
],
),
),
),
],
);
}
void _showSearchProviderPicker(
BuildContext context,
WidgetRef ref,
AppSettings settings,
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
ctx.l10n.extensionsSearchProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
...searchProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider(ext.id);
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _HomeFeedProviderSelector extends ConsumerWidget {
const _HomeFeedProviderSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final homeFeedProviders = extState.extensions
.where((e) => e.enabled && e.hasHomeFeed)
.toList();
final hasAnyProvider = homeFeedProviders.isNotEmpty;
final homeFeedDisabled =
settings.homeFeedProvider == AppSettings.homeFeedProviderOff;
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (homeFeedDisabled) {
currentProviderName = context.l10n.extensionsHomeFeedOff;
} else if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders
.where((e) => e.id == settings.homeFeedProvider)
.firstOrNull;
currentProviderName = ext?.displayName ?? settings.homeFeedProvider!;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _showHomeFeedProviderPicker(
context,
ref,
settings,
homeFeedProviders,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
!hasAnyProvider && !homeFeedDisabled
? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
),
),
],
);
}
void _showHomeFeedProviderPicker(
BuildContext context,
WidgetRef ref,
AppSettings settings,
List<Extension> homeFeedProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setHomeFeedProvider(null);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.block, color: colorScheme.error),
title: Text(ctx.l10n.extensionsHomeFeedOff),
subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle),
trailing:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(AppSettings.homeFeedProviderOff);
ref.read(exploreProvider.notifier).clear();
Navigator.pop(ctx);
},
),
if (homeFeedProviders.isEmpty)
ListTile(
enabled: false,
leading: Icon(
Icons.extension_off,
color: colorScheme.outline,
),
title: Text(ctx.l10n.extensionsNoHomeFeedExtensions),
),
...homeFeedProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(ext.id);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}