import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/form/text_form_field.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/modules/metadata_plugins/installed_plugin.dart'; import 'package:spotube/modules/metadata_plugins/plugin_repository.dart'; import 'package:spotube/provider/metadata_plugin/core/repositories.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/platform.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:sliver_tools/sliver_tools.dart'; @RoutePage() class SettingsMetadataProviderPage extends HookConsumerWidget { const SettingsMetadataProviderPage({super.key}); @override Widget build(BuildContext context, ref) { final formKey = useMemoized(() => GlobalKey(), []); final plugins = ref.watch(metadataPluginsProvider); final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); final pluginReposSnapshot = ref.watch(metadataPluginRepositoriesProvider); final pluginReposNotifier = ref.watch(metadataPluginRepositoriesProvider.notifier); final pluginRepos = useMemoized( () { final installedPluginIds = plugins.asData?.value.plugins .map((e) => e.repository) .nonNulls .toList() ?? []; final pluginRepos = pluginReposSnapshot.asData?.value.items ?? []; if (installedPluginIds.isEmpty) return pluginRepos; return pluginRepos .whereNot((repo) => installedPluginIds.contains(repo.repoUrl)) .toList(); }, [plugins.asData?.value.plugins, pluginReposSnapshot.asData?.value], ); return SafeArea( bottom: false, child: Scaffold( headers: [ TitleBar( title: Text(context.l10n.metadata_provider_plugins), ) ], child: Padding( padding: const EdgeInsets.all(8), child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: Row( spacing: 8, children: [ Expanded( child: FormBuilder( key: formKey, child: TextFormBuilderField( name: "plugin_url", validator: FormBuilderValidators.url( protocols: ["http", "https"]), placeholder: Text(context.l10n.paste_plugin_download_url), ), ), ), HookBuilder(builder: (context) { final isLoading = useState(false); return Tooltip( tooltip: TooltipContainer( child: Text(context .l10n.download_and_install_plugin_from_url), ).call, child: IconButton.secondary( icon: isLoading.value ? const SizedBox.square( dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(SpotubeIcons.download), enabled: !isLoading.value, onPressed: () async { try { if (formKey.currentState?.saveAndValidate() ?? false) { final url = formKey.currentState ?.fields["plugin_url"]?.value as String; if (url.isNotEmpty) { isLoading.value = true; final pluginConfig = await pluginsNotifier .downloadAndCachePlugin(url); await pluginsNotifier.addPlugin(pluginConfig); formKey.currentState?.fields["plugin_url"] ?.reset(); } } } catch (e, stackTrace) { AppLogger.reportError(e, stackTrace); if (context.mounted) { showToast( showDuration: const Duration(seconds: 5), context: context, builder: (context, overlay) { return SurfaceCard( child: Basic( leading: const Icon( SpotubeIcons.error, color: Colors.red, ), title: Text( context.l10n .failed_to_add_plugin_error( e.toString()), ), ), ); }, ); } } finally { isLoading.value = false; } }, ), ); }), Tooltip( tooltip: TooltipContainer( child: Text(context.l10n.upload_plugin_from_file), ).call, child: IconButton.primary( icon: const Icon(SpotubeIcons.upload), onPressed: () async { Uint8List bytes; if (kIsFlatpak) { final result = await openFile( acceptedTypeGroups: [ const XTypeGroup( label: 'Spotube Metadata Plugin', extensions: ['smplug'], ), ], ); if (result == null) return; bytes = await result.readAsBytes(); } else { final result = await FilePicker.platform.pickFiles( type: kIsAndroid ? FileType.any : FileType.custom, allowedExtensions: kIsAndroid ? [] : ["smplug"], withData: true, ); if (result == null) return; final file = result.files.first; if (file.bytes == null) return; bytes = file.bytes!; } final pluginConfig = await pluginsNotifier.extractPluginArchive(bytes); await pluginsNotifier.addPlugin(pluginConfig); }, ), ), ], ), ), const SliverGap(12), if (plugins.asData?.value.plugins.isNotEmpty ?? false) SliverToBoxAdapter( child: Row( children: [ const Gap(8), Text(context.l10n.installed).h4, const Gap(8), const Expanded(child: Divider()), const Gap(8), ], ), ), const SliverGap(20), SliverList.separated( itemCount: plugins.asData?.value.plugins.length ?? 0, separatorBuilder: (context, index) => const Gap(12), itemBuilder: (context, index) { final plugin = plugins.asData!.value.plugins[index]; final isDefault = plugins.asData!.value.defaultPlugin == index; return MetadataInstalledPluginItem( plugin: plugin, isDefault: isDefault, ); }, ), const SliverGap(12), SliverToBoxAdapter( child: Row( children: [ const Gap(8), Text(context.l10n.available_plugins).h4, const Gap(8), const Expanded(child: Divider()), const Gap(8), ], ), ), const SliverGap(12), SliverInfiniteList( isLoading: pluginReposSnapshot.isLoading && !pluginReposSnapshot.isLoadingNextPage, itemCount: pluginRepos.length, onFetchData: pluginReposNotifier.fetchMore, separatorBuilder: (context, index) { return const Gap(12); }, loadingBuilder: (context) { return Skeletonizer( enabled: true, child: MetadataPluginRepositoryItem( pluginRepo: MetadataPluginRepository( name: "Loading...", description: "Loading...", repoUrl: "", owner: "", ), ), ); }, itemBuilder: (context, index) { final pluginRepo = pluginRepos[index]; return MetadataPluginRepositoryItem( pluginRepo: pluginRepo, ); }, ), const SliverGap(20), SliverCrossAxisConstrained( maxCrossAxisExtent: 720, child: SliverFillRemaining( hasScrollBody: false, child: Container( alignment: Alignment.bottomCenter, margin: const EdgeInsets.only(bottom: 20), child: SafeArea( child: Card( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ Row( spacing: 8, children: [ const Icon(SpotubeIcons.warning, size: 16), Text( context.l10n.disclaimer, style: const TextStyle( fontWeight: FontWeight.bold), ).bold, ], ), Text(context.l10n.third_party_plugin_dmca_notice) .muted .xSmall, ], ), ), ), ), ), ), ], ), ), ), ); } }