Files
SpotiFLAC-Mobile/lib/screens/settings/extension_detail_page.dart
T

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),
),
],
);
}
}