mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-23 18:11:02 +02:00
1545 lines
52 KiB
Dart
1545 lines
52 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/repo_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
|
|
|
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
|
final String extensionId;
|
|
|
|
const ExtensionDetailPage({super.key, required this.extensionId});
|
|
|
|
@override
|
|
ConsumerState<ExtensionDetailPage> createState() =>
|
|
_ExtensionDetailPageState();
|
|
}
|
|
|
|
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|
Map<String, dynamic> _settings = {};
|
|
bool _isLoadingSettings = true;
|
|
bool _isRefreshingHealth = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSettings();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_refreshHealth();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadSettings() async {
|
|
final settings = await ref
|
|
.read(extensionProvider.notifier)
|
|
.getExtensionSettings(widget.extensionId);
|
|
setState(() {
|
|
_settings = settings;
|
|
_isLoadingSettings = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _refreshHealth({bool force = false}) async {
|
|
final ext = ref
|
|
.read(extensionProvider)
|
|
.extensions
|
|
.where((extension) => extension.id == widget.extensionId)
|
|
.firstOrNull;
|
|
if (ext == null || !ext.hasServiceHealth) return;
|
|
|
|
setState(() => _isRefreshingHealth = true);
|
|
try {
|
|
await ref
|
|
.read(extensionProvider.notifier)
|
|
.checkExtensionHealth(widget.extensionId, force: force);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isRefreshingHealth = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final extState = ref.watch(extensionProvider);
|
|
final extension = extState.extensions.firstWhere(
|
|
(e) => e.id == widget.extensionId,
|
|
orElse: () => const Extension(
|
|
id: '',
|
|
name: '',
|
|
displayName: 'Unknown',
|
|
version: '0.0.0',
|
|
description: '',
|
|
enabled: false,
|
|
status: 'error',
|
|
),
|
|
);
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final hasError = extension.status == 'error';
|
|
final healthStatus = extState.healthStatuses[extension.id];
|
|
|
|
return PopScope(
|
|
canPop: true,
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
AppSliverHeader.page(title: extension.displayName),
|
|
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: hasError
|
|
? colorScheme.errorContainer
|
|
: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child:
|
|
extension.iconPath != null &&
|
|
extension.iconPath!.isNotEmpty
|
|
? ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Image.file(
|
|
File(extension.iconPath!),
|
|
width: 56,
|
|
height: 56,
|
|
fit: BoxFit.cover,
|
|
errorBuilder:
|
|
(context, error, stackTrace) => Icon(
|
|
hasError
|
|
? Icons.error_outline
|
|
: Icons.extension,
|
|
size: 28,
|
|
color: hasError
|
|
? colorScheme.error
|
|
: colorScheme
|
|
.onPrimaryContainer,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
hasError
|
|
? Icons.error_outline
|
|
: Icons.extension,
|
|
size: 28,
|
|
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.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
'v${extension.version}',
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: extension.enabled,
|
|
onChanged: hasError
|
|
? null
|
|
: (enabled) => ref
|
|
.read(extensionProvider.notifier)
|
|
.setExtensionEnabled(
|
|
widget.extensionId,
|
|
enabled,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (extension.description.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
extension.description,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
_InfoRow(
|
|
label: context.l10n.extensionId,
|
|
value: extension.id,
|
|
),
|
|
_InfoRow(
|
|
label: context.l10n.extensionsVersion(
|
|
extension.version,
|
|
),
|
|
value: '',
|
|
),
|
|
if (hasError && extension.errorMessage != null)
|
|
_InfoRow(
|
|
label: context.l10n.extensionError,
|
|
value: context.friendlyError(
|
|
extension.errorMessage,
|
|
fallback: context.l10n.extensionsErrorLoading,
|
|
),
|
|
isError: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (extension.settings.isNotEmpty) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionSettings,
|
|
),
|
|
),
|
|
if (_isLoadingSettings)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
)
|
|
else
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: extension.settings.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final setting = entry.value;
|
|
return _SettingItem(
|
|
setting: setting,
|
|
value: _settings[setting.key] ?? setting.defaultValue,
|
|
showDivider: index < extension.settings.length - 1,
|
|
onChanged: (value) =>
|
|
_updateSetting(setting.key, value),
|
|
extensionId: widget.extensionId,
|
|
onActionPayload: _handleExtensionActionPayload,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
|
|
if (extension.hasServiceHealth) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionServiceStatus,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_HealthSummaryItem(
|
|
status: healthStatus,
|
|
isRefreshing: _isRefreshingHealth,
|
|
onRefresh: () => _refreshHealth(force: true),
|
|
),
|
|
if (healthStatus != null)
|
|
...healthStatus.checks.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final check = entry.value;
|
|
return _HealthCheckItem(
|
|
check: check,
|
|
showDivider: index < healthStatus.checks.length - 1,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
if (extension.hasURLHandler &&
|
|
extension.urlHandler!.patterns.isNotEmpty) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionUrlHandler,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_URLHandlerInfo(patterns: extension.urlHandler!.patterns),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
if (extension.hasDownloadProvider &&
|
|
extension.qualityOptions.isNotEmpty) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionQualityOptions,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: extension.qualityOptions.asMap().entries.map((
|
|
entry,
|
|
) {
|
|
final index = entry.key;
|
|
final quality = entry.value;
|
|
return _QualityOptionItem(
|
|
quality: quality,
|
|
showDivider: index < extension.qualityOptions.length - 1,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
|
|
if (extension.hasPostProcessing &&
|
|
extension.postProcessing!.hooks.isNotEmpty) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionPostProcessingHooks,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: extension.postProcessing!.hooks.asMap().entries.map(
|
|
(entry) {
|
|
final index = entry.key;
|
|
final hook = entry.value;
|
|
return _PostProcessingHookItem(
|
|
hook: hook,
|
|
showDivider:
|
|
index < extension.postProcessing!.hooks.length - 1,
|
|
);
|
|
},
|
|
).toList(),
|
|
),
|
|
),
|
|
],
|
|
|
|
if (extension.permissions.isNotEmpty) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionPermissions,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: extension.permissions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final permission = entry.value;
|
|
return _PermissionItem(
|
|
permission: permission,
|
|
showDivider: index < extension.permissions.length - 1,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.extensionCapabilities,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_CapabilityItem(
|
|
icon: Icons.search,
|
|
title: context.l10n.extensionMetadataProvider,
|
|
enabled: extension.hasMetadataProvider,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.download,
|
|
title: context.l10n.extensionDownloadProvider,
|
|
enabled: extension.hasDownloadProvider,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.lyrics,
|
|
title: context.l10n.extensionLyricsProvider,
|
|
enabled: extension.hasLyricsProvider,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.manage_search,
|
|
title: context.l10n.extensionsSearchProvider,
|
|
enabled: extension.hasCustomSearch,
|
|
subtitle: extension.searchBehavior?.placeholder,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.compare_arrows,
|
|
title: context.l10n.extensionCustomTrackMatching,
|
|
enabled: extension.hasCustomMatching,
|
|
subtitle: extension.trackMatching?.strategy != null
|
|
? context.l10n.extensionStrategy(
|
|
extension.trackMatching!.strategy!,
|
|
)
|
|
: null,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.auto_fix_high,
|
|
title: context.l10n.extensionPostProcessing,
|
|
enabled: extension.hasPostProcessing,
|
|
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
|
? context.l10n.extensionHooksAvailable(
|
|
extension.postProcessing!.hooks.length,
|
|
)
|
|
: null,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.link,
|
|
title: context.l10n.extensionUrlHandler,
|
|
enabled: extension.hasURLHandler,
|
|
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
|
? context.l10n.extensionPatternsCount(
|
|
extension.urlHandler!.patterns.length,
|
|
)
|
|
: null,
|
|
),
|
|
_CapabilityItem(
|
|
icon: Icons.monitor_heart_outlined,
|
|
title: context.l10n.extensionServiceHealth,
|
|
enabled: extension.hasServiceHealth,
|
|
subtitle: extension.hasServiceHealth
|
|
? context.l10n.extensionHealthChecksConfigured(
|
|
extension.serviceHealth.length,
|
|
)
|
|
: null,
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _confirmRemove(context),
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: Text(context.l10n.extensionRemoveButton),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.error,
|
|
side: BorderSide(color: colorScheme.error),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _updateSetting(String key, dynamic value) async {
|
|
setState(() {
|
|
_settings[key] = value;
|
|
});
|
|
await ref
|
|
.read(extensionProvider.notifier)
|
|
.setExtensionSettings(widget.extensionId, _settings);
|
|
}
|
|
|
|
/// Extensions may return `setting_updates` from button actions (e.g. OAuth URL field).
|
|
Future<void> _handleExtensionActionPayload(
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
final raw = payload['setting_updates'];
|
|
if (raw is! Map) return;
|
|
final partial = <String, dynamic>{};
|
|
for (final entry in raw.entries) {
|
|
partial[entry.key.toString()] = entry.value;
|
|
}
|
|
if (partial.isEmpty) return;
|
|
final merged = Map<String, dynamic>.from(_settings);
|
|
merged.addAll(partial);
|
|
await ref
|
|
.read(extensionProvider.notifier)
|
|
.setExtensionSettings(widget.extensionId, merged);
|
|
if (mounted) {
|
|
setState(() => _settings = merged);
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmRemove(BuildContext context) async {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.dialogRemoveExtension),
|
|
content: Text(context.l10n.dialogRemoveExtensionMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
|
|
child: Text(context.l10n.dialogRemove),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true && mounted) {
|
|
final success = await ref
|
|
.read(extensionProvider.notifier)
|
|
.removeExtension(widget.extensionId);
|
|
if (success && mounted) {
|
|
ref.read(repoProvider.notifier).refresh();
|
|
Navigator.pop(this.context);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Long OAuth URLs: selectable text so users can copy without relying on snackbars.
|
|
class _OauthLoginLinkPreview extends StatelessWidget {
|
|
final String? value;
|
|
final ColorScheme colorScheme;
|
|
|
|
const _OauthLoginLinkPreview({
|
|
required this.value,
|
|
required this.colorScheme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final text = value?.trim() ?? '';
|
|
if (text.isEmpty) {
|
|
return Text(
|
|
context.l10n.extensionOauthConnectHint,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
);
|
|
}
|
|
return SelectionArea(
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoRow extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final bool isError;
|
|
|
|
const _InfoRow({
|
|
required this.label,
|
|
required this.value,
|
|
this.isError = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 60,
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: isError ? colorScheme.error : colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CapabilityItem extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final bool enabled;
|
|
final bool showDivider;
|
|
final String? subtitle;
|
|
|
|
const _CapabilityItem({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.enabled,
|
|
this.showDivider = true,
|
|
this.subtitle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: enabled ? colorScheme.primary : colorScheme.outline,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
|
if (subtitle != null && enabled) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
enabled ? Icons.check_circle : Icons.cancel_outlined,
|
|
color: enabled ? colorScheme.primary : colorScheme.outline,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 56,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Color _healthStatusColor(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;
|
|
}
|
|
}
|
|
|
|
IconData _healthStatusIcon(String status) {
|
|
switch (status) {
|
|
case 'online':
|
|
return Icons.check_circle;
|
|
case 'degraded':
|
|
return Icons.warning_amber_rounded;
|
|
case 'offline':
|
|
return Icons.error_outline;
|
|
default:
|
|
return Icons.help_outline;
|
|
}
|
|
}
|
|
|
|
String _healthStatusLabel(BuildContext context, String status) {
|
|
switch (status) {
|
|
case 'online':
|
|
return context.l10n.extensionHealthOnline;
|
|
case 'degraded':
|
|
return context.l10n.extensionHealthDegraded;
|
|
case 'offline':
|
|
return context.l10n.extensionHealthOffline;
|
|
case 'unsupported':
|
|
return context.l10n.extensionHealthNotConfigured;
|
|
default:
|
|
return context.l10n.extensionHealthUnknown;
|
|
}
|
|
}
|
|
|
|
class _HealthSummaryItem extends StatelessWidget {
|
|
final ExtensionHealthStatus? status;
|
|
final bool isRefreshing;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _HealthSummaryItem({
|
|
required this.status,
|
|
required this.isRefreshing,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final statusValue = status?.status ?? 'unknown';
|
|
final color = _healthStatusColor(colorScheme, statusValue);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(_healthStatusIcon(statusValue), color: color),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_healthStatusLabel(context, statusValue),
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (status?.checkedAt != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
context.l10n.extensionLastChecked(
|
|
TimeOfDay.fromDateTime(
|
|
status!.checkedAt!.toLocal(),
|
|
).format(context),
|
|
),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: context.l10n.extensionRefreshStatus,
|
|
onPressed: isRefreshing ? null : onRefresh,
|
|
icon: isRefreshing
|
|
? SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: colorScheme.primary,
|
|
),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 56,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HealthCheckItem extends StatelessWidget {
|
|
final ExtensionHealthCheckStatus check;
|
|
final bool showDivider;
|
|
|
|
const _HealthCheckItem({required this.check, this.showDivider = true});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final color = _healthStatusColor(colorScheme, check.status);
|
|
final detailParts = <String>[
|
|
_healthStatusLabel(context, check.status),
|
|
if (check.httpStatus != null) 'HTTP ${check.httpStatus}',
|
|
if (check.serviceKey?.isNotEmpty == true) check.serviceKey!,
|
|
if (check.latencyMs > 0) '${check.latencyMs} ms',
|
|
if (check.required) context.l10n.extensionHealthRequired,
|
|
];
|
|
final message = check.error?.trim().isNotEmpty == true
|
|
? context.friendlyError(
|
|
check.error,
|
|
fallback: context.l10n.extensionHealthServiceUnknown,
|
|
)
|
|
: check.message;
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(_healthStatusIcon(check.status), color: color),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
check.displayLabel,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
detailParts.join(' · '),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (message != null && message.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
message,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: check.error != null
|
|
? colorScheme.error
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 56,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PermissionItem extends StatelessWidget {
|
|
final String permission;
|
|
final bool showDivider;
|
|
|
|
const _PermissionItem({required this.permission, this.showDivider = true});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
IconData icon = Icons.security;
|
|
String description = permission;
|
|
|
|
if (permission.startsWith('network:')) {
|
|
icon = Icons.language;
|
|
description = 'Network access to: ${permission.substring(8)}';
|
|
} else if (permission.startsWith('storage:')) {
|
|
icon = Icons.folder;
|
|
description = 'Storage access: ${permission.substring(8)}';
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: colorScheme.onSurfaceVariant),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Text(
|
|
description,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 56,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingItem extends StatefulWidget {
|
|
final ExtensionSetting setting;
|
|
final dynamic value;
|
|
final bool showDivider;
|
|
final ValueChanged<dynamic> onChanged;
|
|
final String extensionId;
|
|
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
|
|
|
|
const _SettingItem({
|
|
required this.setting,
|
|
required this.value,
|
|
required this.onChanged,
|
|
required this.extensionId,
|
|
this.onActionPayload,
|
|
this.showDivider = true,
|
|
});
|
|
|
|
@override
|
|
State<_SettingItem> createState() => _SettingItemState();
|
|
}
|
|
|
|
class _SettingItemState extends State<_SettingItem> {
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
Widget trailing;
|
|
switch (widget.setting.type) {
|
|
case 'boolean':
|
|
trailing = Switch(
|
|
value: widget.value as bool? ?? false,
|
|
onChanged: widget.onChanged,
|
|
);
|
|
break;
|
|
case 'select':
|
|
trailing = DropdownButton<String>(
|
|
value: widget.value as String?,
|
|
items: widget.setting.options?.map((opt) {
|
|
return DropdownMenuItem(value: opt, child: Text(opt));
|
|
}).toList(),
|
|
onChanged: widget.onChanged,
|
|
underline: const SizedBox(),
|
|
);
|
|
break;
|
|
case 'button':
|
|
trailing = _isLoading
|
|
? const SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: FilledButton.tonal(
|
|
onPressed: () => _invokeAction(context),
|
|
child: Text(widget.setting.label),
|
|
);
|
|
break;
|
|
default:
|
|
trailing = Icon(
|
|
Icons.chevron_right,
|
|
color: colorScheme.onSurfaceVariant,
|
|
);
|
|
}
|
|
|
|
if (widget.setting.type == 'button') {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (widget.setting.description != null) ...[
|
|
Text(
|
|
widget.setting.description!,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
trailing,
|
|
],
|
|
),
|
|
),
|
|
if (widget.showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 16,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
InkWell(
|
|
onTap:
|
|
widget.setting.type == 'string' || widget.setting.type == 'number'
|
|
? () => _showEditDialog(context)
|
|
: null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.setting.label,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
if (widget.setting.description != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
widget.setting.description!,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
if (widget.setting.type == 'string' ||
|
|
widget.setting.type == 'number') ...[
|
|
const SizedBox(height: 4),
|
|
if (widget.setting.key == 'oauth_login_url')
|
|
_OauthLoginLinkPreview(
|
|
value: widget.value?.toString(),
|
|
colorScheme: colorScheme,
|
|
)
|
|
else
|
|
Text(
|
|
widget.value?.toString() ??
|
|
context.l10n.extensionSettingNotSet,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: colorScheme.primary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
trailing,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (widget.showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 16,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _invokeAction(BuildContext context) async {
|
|
if (widget.setting.action == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarNoActionDefined)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final result = await PlatformBridge.invokeExtensionAction(
|
|
widget.extensionId,
|
|
widget.setting.action!,
|
|
);
|
|
|
|
if (context.mounted) {
|
|
// Go may return either a flat map or { success, result: { ... } }.
|
|
Map<String, dynamic> payload = result;
|
|
final nested = result['result'];
|
|
if (nested is Map) {
|
|
payload = Map<String, dynamic>.from(nested);
|
|
}
|
|
|
|
final success = payload['success'] as bool? ?? false;
|
|
if (!success) {
|
|
final error =
|
|
payload['error'] as String? ??
|
|
result['error'] as String? ??
|
|
context.l10n.extensionActionFailed;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.friendlyError(
|
|
error,
|
|
fallback: context.l10n.extensionActionFailed,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
if (widget.onActionPayload != null) {
|
|
await widget.onActionPayload!(payload);
|
|
}
|
|
final openAuth = payload['open_auth_url'] as String?;
|
|
if (openAuth != null && openAuth.isNotEmpty) {
|
|
final uri = Uri.parse(openAuth);
|
|
final launched = await launchExtensionAuthUrl(
|
|
uri,
|
|
browserMode: 'external_first',
|
|
);
|
|
if (!launched && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.snackbarError('Could not open browser'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
final message = payload['message'] as String?;
|
|
if (message != null && message.isNotEmpty && context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showEditDialog(BuildContext context) {
|
|
final controller = TextEditingController(
|
|
text: widget.value?.toString() ?? '',
|
|
);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(widget.setting.label),
|
|
content: TextField(
|
|
controller: controller,
|
|
keyboardType: widget.setting.type == 'number'
|
|
? TextInputType.number
|
|
: TextInputType.text,
|
|
decoration: InputDecoration(
|
|
hintText:
|
|
widget.setting.description ?? context.l10n.extensionEnterValue,
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final newValue = widget.setting.type == 'number'
|
|
? num.tryParse(controller.text)
|
|
: controller.text;
|
|
widget.onChanged(newValue);
|
|
Navigator.pop(context);
|
|
},
|
|
child: Text(context.l10n.dialogSave),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PostProcessingHookItem extends StatelessWidget {
|
|
final PostProcessingHook hook;
|
|
final bool showDivider;
|
|
|
|
const _PostProcessingHookItem({required this.hook, this.showDivider = true});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(
|
|
Icons.auto_fix_high,
|
|
color: colorScheme.onTertiaryContainer,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
hook.name,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
if (hook.description != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
hook.description!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
if (hook.supportedFormats.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: 4,
|
|
children: hook.supportedFormats.map((format) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
format.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (hook.defaultEnabled)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
context.l10n.extensionsHomeFeedAuto,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 72,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _URLHandlerInfo extends StatelessWidget {
|
|
final List<String> patterns;
|
|
|
|
const _URLHandlerInfo({required this.patterns});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
Icons.link,
|
|
color: colorScheme.onTertiaryContainer,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.extensionCustomUrlHandling,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
context.l10n.extensionCustomUrlHandlingSubtitle,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: patterns.map((pattern) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.language, size: 16, color: colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
pattern,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline, size: 20, color: colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.extensionCustomUrlHandlingShareHint,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QualityOptionItem extends StatelessWidget {
|
|
final QualityOption quality;
|
|
final bool showDivider;
|
|
|
|
const _QualityOptionItem({required this.quality, this.showDivider = true});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(
|
|
Icons.high_quality,
|
|
color: colorScheme.onSecondaryContainer,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
quality.label,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
if (quality.description != null &&
|
|
quality.description!.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
quality.description!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
quality.id,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (quality.settings.isNotEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
context.l10n.extensionSettingsCount(
|
|
quality.settings.length,
|
|
),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 72,
|
|
endIndent: 16,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|