import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/extension.dart' as api_extension; import '../../../../api/model/store_extension.dart'; import '../../../../core/utils/breakpoints.dart'; import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_swipe_tabs.dart'; import '../../../../shared/widgets/app_primary_button.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; import '../../../home/presentation/widgets/primary_rail.dart'; import '../../application/extensions_controller.dart'; import '../../application/pending_extension_install.dart'; import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; import '../widgets/extension_setting_field.dart'; import '../widgets/extension_update_dialog.dart'; import '../widgets/extension_update_status.dart'; // Keep three columns in the 1024px reference window with the wider primary rail. const _extensionCardMinWidth = 280.0; const _extensionGridSpacing = 10.0; int _extensionGridColumnCount(double width) { return ((width + _extensionGridSpacing) / (_extensionCardMinWidth + _extensionGridSpacing)) .floor() .clamp(1, 12) .toInt(); } class ExtensionsPage extends ConsumerStatefulWidget { const ExtensionsPage({super.key}); @override ConsumerState createState() => _ExtensionsPageState(); } class _ExtensionsPageState extends ConsumerState { final _searchController = TextEditingController(); final _installController = TextEditingController(); final _installAnchorKey = GlobalKey(); final _installPopoverKey = GlobalKey<_InstallPopoverState>(); bool _installDevMode = false; final _listScrollController = ScrollController(); final Map _settingControllers = {}; ExtensionListItem? _detailItem; api_extension.Extension? _settingsExtension; shad.OverlayCompleter? _installPopover; bool _requestingNextPage = false; @override void initState() { super.initState(); _listScrollController.addListener(_loadNextPageIfNeeded); } @override void dispose() { _installPopover?.remove(); _searchController.dispose(); _installController.dispose(); _listScrollController ..removeListener(_loadNextPageIfNeeded) ..dispose(); for (final controller in _settingControllers.values) { controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final pendingInstall = ref.watch(pendingExtensionInstallProvider); if (pendingInstall != null) { // The request can arrive before this page is mounted. Consume it only // after the toolbar is laid out, so cold and warm links share the same UI. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; if (_installPopoverKey.currentState?._installing == true) return; final anchorContext = _installAnchorKey.currentContext; if (anchorContext == null) return; _installController.text = pendingInstall.url; _installDevMode = pendingInstall.devMode; _showInstallPopover(anchorContext); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; final popover = _installPopoverKey.currentState; if (popover == null || popover._installing) return; ref.read(pendingExtensionInstallProvider.notifier).clear(); unawaited(popover._install()); }); WidgetsBinding.instance.ensureVisualUpdate(); }); } final stateAsync = ref.watch(extensionsControllerProvider); final body = Stack( children: [ ColoredBox( color: palette.bg, child: Row( children: [ if (MediaQuery.sizeOf(context).width >= Breakpoints.mobile) const PrimaryRail(activeSection: RailSection.extensions), Expanded( child: Padding( padding: EdgeInsets.only( top: MediaQuery.sizeOf(context).width >= Breakpoints.mobile && AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0, ), child: stateAsync.when( loading: () => _buildContent(const ExtensionsState(loadingInstalled: true, loadingStore: true)), error: (error, _) => _ErrorState(onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData()), data: (state) { if (state.listFilter == ExtensionListFilter.market && state.storePagination?.hasNext == true && !state.loadingMoreStore) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadNextPageIfNeeded()); } return _buildContent(state); }, ), ), ), ], ), ), if (_settingsExtension != null) _ExtensionSettingsPanel( extension: _settingsExtension!, controllers: _settingControllers, onClose: () => setState(() => _settingsExtension = null), onSave: _saveExtensionSettings, ), ], ); return shad.Scaffold( backgroundColor: palette.bg, child: isDesktop ? body : Column( children: [ Expanded(child: SafeArea(bottom: false, child: body)), const PrimaryBottomNavigation(activeSection: RailSection.extensions), ], ), ); } Widget _buildContent(ExtensionsState state) { return _Content( state: state, scrollController: _listScrollController, searchController: _searchController, onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)), onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)), onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter, installAnchorKey: _installAnchorKey, onOpenInstall: _openInstallPopover, onDevelopExtension: _openExtensionDevelopmentDocs, onInstallFolder: _installFromFolder, onRefresh: () => _runAction(() => ref.read(extensionsControllerProvider.notifier).loadInitialData()), onItemAction: _runAction, onOpenDetails: _openExtensionDetails, onOpenSettings: _openExtensionSettings, detailItem: _detailItem == null ? null : state.findItem(_detailItem!.id) ?? _detailItem, onCloseDetails: () => setState(() => _detailItem = null), ); } Future _installFromUrl() async { final url = _installController.text.trim(); if (url.isEmpty) return; await _runAction( () => ref.read(extensionsControllerProvider.notifier).installFromUrl(url, devInstall: _installDevMode), ); } Future _loadNextPageIfNeeded() async { if (!mounted || _requestingNextPage || !_listScrollController.hasClients) return; if (_listScrollController.position.extentAfter > 240) return; final state = ref.read(extensionsControllerProvider).value; if (state == null || state.listFilter != ExtensionListFilter.market || state.storePagination?.hasNext != true || state.loadingMoreStore) { return; } _requestingNextPage = true; try { await _runAction(() => ref.read(extensionsControllerProvider.notifier).loadMoreStore()); } finally { _requestingNextPage = false; } } void _openInstallPopover(BuildContext anchorContext) { if (AppWindowChrome.isDesktopWindow) { ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); } _installDevMode = false; _showInstallPopover(anchorContext); } void _showInstallPopover(BuildContext anchorContext) { if (_installPopover != null) return; final overlay = const shad.PopoverOverlayHandler().show( context: anchorContext, alignment: Alignment.topRight, anchorAlignment: Alignment.bottomRight, offset: const Offset(0, 8), modal: false, consumeOutsideTaps: false, // Flutter's native selection toolbar uses the EditableText tap group. // Pasting from it must not dismiss the form and restore search focus. regionGroupId: EditableText, builder: (context) => _InstallPopover( key: _installPopoverKey, controller: _installController, onInstallUrl: _installFromUrl, onFinished: () { if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {}); }, ), ); _installPopover = overlay; unawaited( overlay.animationFuture.whenComplete(() { if (identical(_installPopover, overlay)) _installPopover = null; }), ); } Future _installFromFolder() async { final folder = await FilePicker.getDirectoryPath(); if (folder == null || folder.isEmpty) return; await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true)); } Future _openExtensionDevelopmentDocs() async { final opened = await launchUrl(Uri.parse('https://gopeed.com/docs/dev-extension')); if (!opened && mounted) { showAppToast(context, context.l10n.unableOpenExtensionDocs, type: AppToastType.error); } } void _openExtensionSettings(api_extension.Extension extension) { for (final controller in _settingControllers.values) { controller.dispose(); } _settingControllers ..clear() ..addEntries( (extension.settings ?? const []).map( (setting) => MapEntry(setting.name, TextEditingController(text: setting.value?.toString() ?? '')), ), ); setState(() { _detailItem = null; _settingsExtension = extension; }); } void _openExtensionDetails(ExtensionListItem item) { setState(() { _settingsExtension = null; _detailItem = item; }); } Future _saveExtensionSettings() async { final extension = _settingsExtension; if (extension == null) return; final values = {}; for (final setting in extension.settings ?? const []) { final text = _settingControllers[setting.name]?.text.trim() ?? ''; values[setting.name] = switch (setting.type) { api_extension.SettingType.number => num.tryParse(text) ?? 0, api_extension.SettingType.boolean => text == 'true' || text == '1' || text.toLowerCase() == 'yes', api_extension.SettingType.string => text, }; } await _runAction(() => ref.read(extensionsControllerProvider.notifier).saveExtensionSettings(extension, values)); if (mounted) { setState(() => _settingsExtension = null); } } Future _runAction(Future Function() action) async { try { await action(); } catch (error) { if (!mounted) return; showAppToast(context, error.toString(), type: AppToastType.error); } } } class _Content extends StatelessWidget { const _Content({ required this.state, required this.scrollController, required this.searchController, required this.onSearch, required this.onSort, required this.onFilter, required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, required this.onRefresh, required this.onItemAction, required this.onOpenDetails, required this.onOpenSettings, required this.detailItem, required this.onCloseDetails, }); final ExtensionsState state; final ScrollController scrollController; final TextEditingController searchController; final ValueChanged onSearch; final ValueChanged onSort; final ValueChanged onFilter; final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; final VoidCallback onRefresh; final Future Function(Future Function() action) onItemAction; final ValueChanged onOpenDetails; final ValueChanged onOpenSettings; final ExtensionListItem? detailItem; final VoidCallback onCloseDetails; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final horizontalPadding = isDesktop ? 32.0 : 16.0; final toolbar = _Toolbar( state: state, searchController: searchController, onSearch: onSearch, onSort: onSort, onRefresh: onRefresh, installAnchorKey: installAnchorKey, onOpenInstall: onOpenInstall, onDevelopExtension: onDevelopExtension, onInstallFolder: onInstallFolder, ); return Column( children: [ if (isDesktop) Padding( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( height: AppDesignTokens.contentHeaderHeight, child: Align(alignment: Alignment.center, child: toolbar), ), ) else Padding(padding: EdgeInsets.fromLTRB(horizontalPadding, 18, horizontalPadding, 0), child: toolbar), SizedBox(height: isDesktop ? 8 : 12), Expanded( child: Stack( fit: StackFit.expand, children: [ Column( children: [ Padding( key: const ValueKey('extensions-filter-bar'), padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), child: _FilterBar(state: state, onFilter: onFilter), ), Expanded( child: AppSwipeTabs( enabled: !isDesktop, values: const [ExtensionListFilter.market, ExtensionListFilter.installed], selectedValue: state.listFilter, onSelected: onFilter, child: CustomScrollView( key: const ValueKey('extensions-list-scroll-view'), controller: scrollController, slivers: [ if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty) SliverPadding( padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')), ) else if (state.displayItems.isEmpty) SliverFillRemaining( hasScrollBody: false, child: AppEmptyState( key: const ValueKey('extensions-empty-state'), illustration: state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty ? AppEmptyIllustrationType.search : AppEmptyIllustrationType.puzzle, illustrationKey: const ValueKey('extensions-empty-illustration'), message: state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty ? context.l10n.extensionStoreEmpty : context.l10n.noExtensions, ), ) else SliverPadding( padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), sliver: SliverLayoutBuilder( builder: (context, constraints) { final width = constraints.crossAxisExtent; final crossAxisCount = _extensionGridColumnCount(width); return SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => _ExtensionCard( item: state.displayItems[index], busy: state.busyExtensionIds.contains(state.displayItems[index].id), canUpdate: _canUpdate(state, state.displayItems[index]), onAction: onItemAction, onOpenDetails: onOpenDetails, onOpenSettings: onOpenSettings, ), childCount: state.displayItems.length, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, mainAxisSpacing: _extensionGridSpacing, crossAxisSpacing: _extensionGridSpacing, mainAxisExtent: 178, ), ); }, ), ), if (state.listFilter == ExtensionListFilter.market && state.loadingMoreStore) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 24, top: 4), child: Center( child: const SizedBox.square(dimension: 18, child: shad.CircularProgressIndicator()), ), ), ), if (state.listFilter == ExtensionListFilter.market && state.displayItems.isNotEmpty && state.storePagination != null && !state.storePagination!.hasNext && !state.loadingMoreStore) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 24, top: 4), child: Center( child: Text( context.l10n.extensionNoMore, key: const ValueKey('extensions-no-more-indicator'), style: TextStyle(color: palette.textMuted, fontSize: 12), ), ), ), ), ], ), ), ), ], ), ExtensionDetailDrawer(item: detailItem, onClose: onCloseDetails), ], ), ), ], ); } bool _canUpdate(ExtensionsState state, ExtensionListItem item) { return item.installed != null && state.updateFlags.containsKey(item.installed!.identity); } } class _ExtensionSkeletonGrid extends StatefulWidget { const _ExtensionSkeletonGrid({super.key}); @override State<_ExtensionSkeletonGrid> createState() => _ExtensionSkeletonGridState(); } class _ExtensionSkeletonGridState extends State<_ExtensionSkeletonGrid> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat(reverse: true); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return SliverLayoutBuilder( builder: (context, constraints) { final crossAxisCount = _extensionGridColumnCount(constraints.crossAxisExtent); final availableHeight = constraints.remainingPaintExtent.isFinite ? constraints.remainingPaintExtent : 600; final rowCount = ((availableHeight + _extensionGridSpacing) / (178 + _extensionGridSpacing)) .ceil() .clamp(1, 8) .toInt(); return AnimatedBuilder( animation: _controller, builder: (context, _) { final color = palette.surfaceSoft.withValues(alpha: 0.55 + _controller.value * 0.35); return SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => _ExtensionSkeletonCard(key: ValueKey('extension-skeleton-card-$index'), color: color), childCount: crossAxisCount * rowCount, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, mainAxisSpacing: _extensionGridSpacing, crossAxisSpacing: _extensionGridSpacing, mainAxisExtent: 178, ), ); }, ); }, ); } } class _ExtensionSkeletonCard extends StatelessWidget { const _ExtensionSkeletonCard({super.key, required this.color}); final Color color; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return ExcludeSemantics( child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: palette.cardBg, borderRadius: BorderRadius.circular(6), border: Border.all(color: palette.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _ExtensionSkeletonBlock(width: 40, height: 40, color: color, radius: 6), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FractionallySizedBox( widthFactor: 0.58, alignment: Alignment.centerLeft, child: _ExtensionSkeletonBlock(width: double.infinity, height: 13, color: color), ), const SizedBox(height: 7), FractionallySizedBox( widthFactor: 0.38, alignment: Alignment.centerLeft, child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), ), ], ), ), ], ), const SizedBox(height: 14), FractionallySizedBox( widthFactor: 0.92, alignment: Alignment.centerLeft, child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), ), const SizedBox(height: 7), FractionallySizedBox( widthFactor: 0.7, alignment: Alignment.centerLeft, child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), ), const Spacer(), Row( children: [ _ExtensionSkeletonBlock(width: 34, height: 9, color: color), const SizedBox(width: 12), _ExtensionSkeletonBlock(width: 42, height: 9, color: color), const Spacer(), _ExtensionSkeletonBlock(width: 24, height: 24, color: color, radius: 12), ], ), ], ), ), ); } } class _ExtensionSkeletonBlock extends StatelessWidget { const _ExtensionSkeletonBlock({required this.width, required this.height, required this.color, this.radius = 4}); final double width; final double height; final Color color; final double radius; @override Widget build(BuildContext context) { return Container( width: width, height: height, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(radius)), ); } } class _Toolbar extends StatelessWidget { const _Toolbar({ required this.state, required this.searchController, required this.onSearch, required this.onSort, required this.onRefresh, required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, }); final ExtensionsState state; final TextEditingController searchController; final ValueChanged onSearch; final ValueChanged onSort; final VoidCallback onRefresh; final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final search = AppSearchField( key: const ValueKey('extension-search-input'), controller: searchController, padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null, placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), onSubmitted: onSearch, ); final sorting = Row( mainAxisSize: MainAxisSize.min, children: [ _Segmented( key: const ValueKey('extension-sort-control'), compact: true, value: state.storeSort, values: [ (StoreExtensionSort.stars, context.l10n.extensionSortStars), (StoreExtensionSort.installs, context.l10n.extensionSortInstalls), (StoreExtensionSort.updated, context.l10n.extensionSortUpdated), ], onChanged: onSort, ), const SizedBox(width: 8), _OutlineToolbarIconButton( key: const ValueKey('refresh-extensions-button'), tooltip: context.l10n.refresh, icon: Icons.refresh, onPressed: onRefresh, ), ], ); final installActions = Row( mainAxisSize: MainAxisSize.min, children: [ _OutlineToolbarIconButton( key: const ValueKey('develop-extension-button'), tooltip: context.l10n.extensionDeveloperGuide, icon: Icons.menu_book_outlined, onPressed: onDevelopExtension, ), const SizedBox(width: 8), if (AppWindowChrome.isDesktopWindow && state.devMode) ...[ _OutlineToolbarIconButton( key: const ValueKey('load-local-extension-button'), tooltip: context.l10n.extensionLoadLocal, icon: Icons.folder_open_outlined, onPressed: onInstallFolder, ), const SizedBox(width: 8), ], Builder( key: installAnchorKey, builder: (buttonContext) => _OutlineToolbarIconButton( key: const ValueKey('install-extension-button'), tooltip: context.l10n.extensionInstallFromUrl, icon: Icons.add_link, onPressed: () => onOpenInstall(buttonContext), ), ), ], ); if (MediaQuery.sizeOf(context).width >= Breakpoints.mobile) { return Row( children: [ Expanded( child: Row( children: [ Flexible( child: ConstrainedBox( key: const ValueKey('extension-search-field-container'), constraints: const BoxConstraints(maxWidth: 240), child: search, ), ), const SizedBox(width: 10), Flexible( flex: 2, child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: sorting), ), ], ), ), const SizedBox(width: 10), installActions, ], ); } return Column( children: [ SizedBox(width: double.infinity, child: search), const SizedBox(height: 10), Row( children: [ Expanded( child: Align( alignment: Alignment.centerLeft, child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: sorting), ), ), const SizedBox(width: 10), installActions, ], ), ], ); } } class _FilterBar extends StatelessWidget { const _FilterBar({required this.state, required this.onFilter}); final ExtensionsState state; final ValueChanged onFilter; @override Widget build(BuildContext context) { return Align( alignment: Alignment.centerLeft, child: _Segmented( value: state.listFilter, values: [ (ExtensionListFilter.market, context.l10n.extensionFilterMarket), (ExtensionListFilter.installed, context.l10n.extensionFilterInstalled), ], onChanged: onFilter, ), ); } } class _OutlineToolbarIconButton extends StatelessWidget { const _OutlineToolbarIconButton({super.key, required this.tooltip, required this.icon, required this.onPressed}); final String tooltip; final IconData icon; final VoidCallback? onPressed; @override Widget build(BuildContext context) { return AppTooltip( message: tooltip, child: SizedBox.square( dimension: 32, child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 17)), ), ); } } class _InstallPopover extends StatefulWidget { const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished}); final TextEditingController controller; final Future Function() onInstallUrl; final VoidCallback onFinished; @override State<_InstallPopover> createState() => _InstallPopoverState(); } class _InstallPopoverState extends State<_InstallPopover> { bool _installing = false; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Container( key: const ValueKey('extension-install-popover'), width: 328, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: palette.cardBg, borderRadius: BorderRadius.circular(6), border: Border.all(color: palette.border), boxShadow: [BoxShadow(color: const Color(0x26000000), blurRadius: 18, offset: const Offset(0, 6))], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.extensionInstallFromUrl, style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), ), const SizedBox(height: 10), AppTextField( key: const ValueKey('extension-install-url-input'), controller: widget.controller, autofocus: true, placeholder: const Text('https://github.com/author/repo'), onSubmitted: (_) => _install(), features: [ shad.InputFeature.trailing( SizedBox.square( dimension: 28, child: shad.IconButton.outline( size: shad.ButtonSize.xSmall, onPressed: _installing ? null : _install, icon: _installing ? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator()) : const Icon(Icons.download_outlined, size: 16), ), ), ), ], ), ], ), ); } Future _install() async { if (_installing || widget.controller.text.trim().isEmpty) return; setState(() => _installing = true); await widget.onInstallUrl(); if (!mounted) return; await shad.closeOverlay(context); widget.onFinished(); } } class _Segmented extends StatelessWidget { const _Segmented({ super.key, required this.value, required this.values, required this.onChanged, this.compact = false, }); final T value; final List<(T, String)> values; final ValueChanged onChanged; final bool compact; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Container( height: compact ? 32 : null, padding: EdgeInsets.all(compact ? 2 : 3), decoration: BoxDecoration( color: palette.surfaceSoft, borderRadius: BorderRadius.circular(6), border: Border.all(color: palette.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: values.map((entry) { final selected = entry.$1 == value; return GestureDetector( onTap: () => onChanged(entry.$1), child: Container( padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10, vertical: compact ? 4 : 7), decoration: BoxDecoration( color: selected ? palette.cardBg : null, borderRadius: BorderRadius.circular(4), ), child: Text( entry.$2, style: TextStyle( color: selected ? palette.textPrimary : palette.textSecondary, fontSize: compact ? 11 : 12, fontWeight: selected ? (compact ? FontWeight.w600 : FontWeight.w700) : FontWeight.w500, ), ), ), ); }).toList(), ), ); } } class _ExtensionCard extends ConsumerWidget { const _ExtensionCard({ required this.item, required this.busy, required this.canUpdate, required this.onAction, required this.onOpenDetails, required this.onOpenSettings, }); final ExtensionListItem item; final bool busy; final bool canUpdate; final Future Function(Future Function() action) onAction; final ValueChanged onOpenDetails; final ValueChanged onOpenSettings; @override Widget build(BuildContext context, WidgetRef ref) { final palette = AppPalette.of(context); final installed = item.installed; final card = Container( key: ValueKey('extension-card-${item.id}'), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: palette.cardBg, borderRadius: BorderRadius.circular(6), border: Border.all(color: palette.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ ExtensionIcon(item: item, size: 40, borderRadius: BorderRadius.circular(6), fallbackFit: BoxFit.cover), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), ), ), if (canUpdate && installed != null) ...[ const SizedBox(width: 4), Flexible( child: ExtensionUpdateStatus( key: ValueKey('extension-card-update-status-${item.id}'), label: context.l10n.extensionCanUpdate, onTap: busy ? null : () => showExtensionUpdateDialog(context, installed), compact: true, ), ), ], ], ), const SizedBox(height: 2), Text( '${item.author} ยท v${item.version}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: palette.textMuted, fontSize: 11), ), ], ), ), if (installed != null) shad.Switch( value: !installed.disabled, onChanged: busy ? null : (enabled) => onAction( () => ref.read(extensionsControllerProvider.notifier).toggleExtension(installed, enabled), ), ), ], ), const SizedBox(height: 10), Text( item.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.25), ), const Spacer(), Row( children: [ if (item.store != null) Expanded( child: Align( alignment: Alignment.centerLeft, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Row( key: ValueKey('extension-card-stats-${item.id}'), mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.star_rounded, size: 15, color: palette.textMuted), const SizedBox(width: 3), Text( CompactCountFormatter.format(item.stars), style: TextStyle(color: palette.textSecondary, fontSize: 12), ), const SizedBox(width: 10), Icon(Icons.download_outlined, size: 15, color: palette.textMuted), const SizedBox(width: 3), Text( CompactCountFormatter.format(item.installCount), style: TextStyle(color: palette.textSecondary, fontSize: 12), ), ], ), ), ), ) else const Spacer(), const SizedBox(width: 8), Row( key: ValueKey('extension-card-actions-${item.id}'), mainAxisSize: MainAxisSize.min, children: [ if (installed == null && item.store != null) shad.GhostButton( key: ValueKey('install-store-extension-${item.store!.id}'), density: shad.ButtonDensity.icon, onPressed: busy ? null : () => onAction( () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), ), child: busy ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) : const Icon(Icons.download), ), if ((item.homepage ?? '').isNotEmpty) shad.GhostButton( density: shad.ButtonDensity.icon, onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), child: const Icon(Icons.home_outlined), ), if ((item.repoUrl ?? '').isNotEmpty) shad.GhostButton( density: shad.ButtonDensity.icon, onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), child: const Icon(Icons.code), ), if (installed?.settings?.isNotEmpty == true) shad.GhostButton( density: shad.ButtonDensity.icon, onPressed: busy ? null : () => onOpenSettings(installed!), child: const Icon(Icons.settings_outlined), ), if (installed != null) shad.GhostButton( key: ValueKey('remove-extension-${installed.identity}'), density: shad.ButtonDensity.icon, onPressed: busy ? null : () async { final confirmed = await _showRemoveExtensionDialog(context, installed.title); if (!confirmed || !context.mounted) return; await onAction( () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), ); }, child: const Icon(Icons.delete_outline), ), ], ), ], ), ], ), ); return GestureDetector( key: ValueKey('open-extension-details-${item.id}'), behavior: HitTestBehavior.opaque, onTap: () { if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item); return; } onOpenDetails(item); }, child: card, ); } } Future _showRemoveExtensionDialog(BuildContext context, String extensionName) async { final overlay = const shad.DialogOverlayHandler().show( context: context, alignment: Alignment.center, barrierDismissable: false, builder: (dialogContext) => shad.AlertDialog( key: const ValueKey('remove-extension-dialog'), title: Text(dialogContext.l10n.removeExtensionTitle), content: Text(dialogContext.l10n.removeExtensionConfirm(extensionName)), actions: [ shad.SecondaryButton( key: const ValueKey('cancel-remove-extension-button'), onPressed: () => shad.closeOverlay(dialogContext, false), child: Text(dialogContext.l10n.cancel), ), shad.DestructiveButton( key: const ValueKey('confirm-remove-extension-button'), onPressed: () => shad.closeOverlay(dialogContext, true), child: Text(dialogContext.l10n.uninstall), ), ], ), ); return await overlay.future ?? false; } class _ExtensionSettingsPanel extends StatelessWidget { const _ExtensionSettingsPanel({ required this.extension, required this.controllers, required this.onClose, required this.onSave, }); final api_extension.Extension extension; final Map controllers; final VoidCallback onClose; final VoidCallback onSave; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final settings = extension.settings ?? const []; return Positioned.fill( child: Row( children: [ Expanded( child: GestureDetector( onTap: onClose, child: ColoredBox(color: const Color(0x66000000), child: const SizedBox.expand()), ), ), Container( width: MediaQuery.sizeOf(context).width < Breakpoints.mobile ? MediaQuery.sizeOf(context).width : 420, decoration: BoxDecoration( color: palette.bg, border: Border(left: BorderSide(color: palette.border)), ), child: SafeArea( child: Column( children: [ SizedBox( height: 56, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18), child: Row( children: [ Expanded( child: Text( extension.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: palette.textPrimary, fontSize: 16, fontWeight: FontWeight.w800), ), ), shad.GhostButton( density: shad.ButtonDensity.icon, onPressed: onClose, child: const Icon(Icons.close), ), ], ), ), ), Expanded( child: ListView.separated( padding: const EdgeInsets.all(18), itemCount: settings.length, separatorBuilder: (_, _) => const SizedBox(height: 14), itemBuilder: (context, index) { final setting = settings[index]; return ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); }, ), ), Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( border: Border(top: BorderSide(color: palette.border)), ), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ shad.SecondaryButton(onPressed: onClose, child: Text(context.l10n.cancel)), const SizedBox(width: 10), AppPrimaryButton(onPressed: onSave, child: Text(context.l10n.save)), ], ), ), ], ), ), ), ], ), ); } } class _ErrorState extends StatelessWidget { const _ErrorState({required this.onRetry}); final VoidCallback onRetry; @override Widget build(BuildContext context) { return AppEmptyState( key: const ValueKey('extensions-error-state'), illustration: AppEmptyIllustrationType.connectionError, illustrationKey: const ValueKey('extensions-error-illustration'), message: context.l10n.unableLoadExtensions, action: AppEmptyStateAction( buttonKey: const ValueKey('extensions-error-retry'), icon: Icons.refresh_rounded, label: context.l10n.retry, hint: context.l10n.emptyStateRetryHint, onPressed: onRetry, ), ); } }