Files
gopeed/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
T

1272 lines
46 KiB
Dart

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/window/app_window_chrome.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_primary_button.dart';
import '../../../../shared/widgets/app_text_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';
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<ExtensionsPage> createState() => _ExtensionsPageState();
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final _searchController = TextEditingController();
final _installController = TextEditingController();
final _listScrollController = ScrollController();
final Map<String, TextEditingController> _settingControllers = {};
ExtensionListItem? _detailItem;
api_extension.Extension? _settingsExtension;
shad.OverlayCompleter<dynamic>? _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;
ref.listen(pendingExtensionInstallProvider, (previous, next) {
if (next == null) return;
ref.read(pendingExtensionInstallProvider.notifier).clear();
unawaited(
_runAction(
() => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode),
),
);
});
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(
error: error,
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,
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<void> _installFromUrl() async {
final url = _installController.text.trim();
if (url.isEmpty) return;
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url));
}
Future<void> _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) {
ref.read(extensionsControllerProvider.notifier).tryOpenDevMode();
if (_installPopover != null) return;
final overlay = const shad.PopoverOverlayHandler().show<void>(
context: anchorContext,
alignment: Alignment.topRight,
anchorAlignment: Alignment.bottomRight,
offset: const Offset(0, 8),
modal: false,
consumeOutsideTaps: false,
builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl),
);
_installPopover = overlay;
unawaited(
overlay.animationFuture.whenComplete(() {
if (identical(_installPopover, overlay)) _installPopover = null;
}),
);
}
Future<void> _installFromFolder() async {
final folder = await FilePicker.getDirectoryPath();
if (folder == null || folder.isEmpty) return;
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true));
}
Future<void> _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 <api_extension.Setting>[]).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<void> _saveExtensionSettings() async {
final extension = _settingsExtension;
if (extension == null) return;
final values = <String, dynamic>{};
for (final setting in extension.settings ?? const <api_extension.Setting>[]) {
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<void> _runAction(Future<void> 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.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<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final ValueChanged<ExtensionListFilter> onFilter;
final ValueChanged<BuildContext> onOpenInstall;
final VoidCallback onDevelopExtension;
final VoidCallback onInstallFolder;
final VoidCallback onRefresh;
final Future<void> Function(Future<void> Function() action) onItemAction;
final ValueChanged<ExtensionListItem> onOpenDetails;
final ValueChanged<api_extension.Extension> 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,
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: 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(
child: Center(
child: Text(
context.l10n.noExtensions,
style: TextStyle(color: palette.textSecondary, fontSize: 14),
),
),
)
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) {
if (item.installed == null) return false;
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
}
}
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.onOpenInstall,
required this.onDevelopExtension,
required this.onInstallFolder,
});
final ExtensionsState state;
final TextEditingController searchController;
final ValueChanged<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final VoidCallback onRefresh;
final ValueChanged<BuildContext> onOpenInstall;
final VoidCallback onDevelopExtension;
final VoidCallback onInstallFolder;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey);
final search = AppTextField(
key: const ValueKey('extension-search-input'),
controller: searchController,
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
onSubmitted: onSearch,
);
final sorting = Row(
mainAxisSize: MainAxisSize.min,
children: [
_Segmented<StoreExtensionSort>(
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 (state.devMode) ...[
_OutlineToolbarIconButton(
key: const ValueKey('load-local-extension-button'),
tooltip: context.l10n.extensionLoadLocal,
icon: Icons.folder_open_outlined,
loading: manualInstallBusy,
onPressed: onInstallFolder,
),
const SizedBox(width: 8),
],
Builder(
builder: (buttonContext) => _OutlineToolbarIconButton(
key: const ValueKey('install-extension-button'),
tooltip: context.l10n.extensionInstallFromUrl,
icon: Icons.add_link,
loading: manualInstallBusy,
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<ExtensionListFilter> onFilter;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: _Segmented<ExtensionListFilter>(
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,
this.loading = false,
});
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
final bool loading;
@override
Widget build(BuildContext context) {
return AppTooltip(
message: tooltip,
child: SizedBox.square(
dimension: 32,
child: shad.IconButton.outline(
size: shad.ButtonSize.xSmall,
onPressed: loading ? null : onPressed,
icon: loading
? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator())
: Icon(icon, size: 17),
),
),
);
}
}
class _InstallPopover extends StatefulWidget {
const _InstallPopover({required this.controller, required this.onInstallUrl});
final TextEditingController controller;
final Future<void> Function() onInstallUrl;
@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<void> _install() async {
if (_installing || widget.controller.text.trim().isEmpty) return;
setState(() => _installing = true);
await widget.onInstallUrl();
if (!mounted) return;
setState(() => _installing = false);
await shad.closeOverlay(context);
}
}
class _Segmented<T> 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<T> 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<void> Function(Future<void> Function() action) onAction;
final ValueChanged<ExtensionListItem> onOpenDetails;
final ValueChanged<api_extension.Extension> 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: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
),
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) ...[
Icon(Icons.star_rounded, size: 15, color: palette.textMuted),
const SizedBox(width: 3),
Text('${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('${item.installCount}', style: TextStyle(color: palette.textSecondary, fontSize: 12)),
],
const Spacer(),
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
onPressed: busy
? null
: () =>
onAction(() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed)),
child: const Icon(Icons.refresh),
),
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),
),
],
),
],
),
);
if (item.store == null) return card;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: 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<bool> _showRemoveExtensionDialog(BuildContext context, String extensionName) async {
final overlay = const shad.DialogOverlayHandler().show<bool>(
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<String, TextEditingController> controllers;
final VoidCallback onClose;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final settings = extension.settings ?? const <api_extension.Setting>[];
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 _ExtensionSettingField extends StatelessWidget {
const _ExtensionSettingField({required this.setting, required this.controller});
final api_extension.Setting setting;
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
setting.title,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
),
if (setting.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)),
],
const SizedBox(height: 8),
if (setting.type == api_extension.SettingType.boolean)
AppTextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint))
else
AppTextField(
controller: controller,
keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text,
),
],
);
}
}
class _ErrorState extends StatelessWidget {
const _ErrorState({required this.error, required this.onRetry});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.extension_off_outlined, size: 32, color: palette.error),
const SizedBox(height: 12),
Text(
context.l10n.unableLoadExtensions,
style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(color: palette.textSecondary),
),
const SizedBox(height: 16),
shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)),
],
),
),
);
}
}