/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; import 'package:weblibre/features/user/data/database/definitions.drift.dart'; class ContextualToolbarSettingsScreen extends HookConsumerWidget { const ContextualToolbarSettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final configs = ref.watch(effectiveToolbarButtonConfigsProvider); final repository = ref.watch(contextualToolbarConfigRepositoryProvider); return Scaffold( appBar: AppBar( title: const Text('Customize Toolbar'), actions: [ MenuAnchor( builder: (context, controller, child) => IconButton( onPressed: () { if (controller.isOpen) { controller.close(); } else { controller.open(); } }, icon: const Icon(Icons.more_vert), ), menuChildren: [ MenuItemButton( leadingIcon: const Icon(Icons.restore), onPressed: () => _resetToDefaults(ref), child: const Text('Reset to Defaults'), ), ], ), ], ), body: SafeArea( child: CustomScrollView( slivers: [ SliverPersistentHeader( pinned: true, delegate: _ToolbarPreviewDelegate(configs: configs.value), ), SliverToBoxAdapter( child: ListTile( title: Text( 'Button Order', style: Theme.of(context).textTheme.labelLarge, ), ), ), SliverReorderableList( itemCount: configs.value.length, onReorder: (oldIndex, newIndex) => _onReorder(configs.value, oldIndex, newIndex, repository), itemBuilder: (context, index) { final config = configs.value[index]; return _ToolbarButtonConfigTile( key: ValueKey(config.buttonId), index: index, config: config, repository: repository, ); }, ), ], ), ), ); } ({String movedId, int targetIndex}) _resolveToolbarReorder( List configs, int oldIndex, int newIndex, ) { var targetIndex = newIndex; if (targetIndex > oldIndex) { targetIndex -= 1; } targetIndex = targetIndex.clamp(0, configs.length - 1); return (movedId: configs[oldIndex].buttonId, targetIndex: targetIndex); } void _onReorder( List configs, int oldIndex, int newIndex, ContextualToolbarConfigRepository repository, ) { if (oldIndex == newIndex) return; final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex); if (reorder.targetIndex == oldIndex) return; unawaited( _reorderViaDb( repository, configs, oldIndex, reorder.targetIndex, reorder.movedId, ), ); } Future _reorderViaDb( ContextualToolbarConfigRepository repository, List configs, int oldIndex, int targetIndex, String movedId, ) async { final String orderKey; if (targetIndex <= 0) { orderKey = await repository.generateLeadingOrderKey(); } else if (targetIndex >= configs.length - 1) { orderKey = await repository.generateTrailingOrderKey(); } else if (targetIndex < oldIndex) { orderKey = await repository.generateOrderKeyAfterButtonId( configs[targetIndex - 1].buttonId, ) ?? await repository.generateLeadingOrderKey(); } else { orderKey = await repository.generateOrderKeyBeforeButtonId( configs[targetIndex + 1].buttonId, ); } await repository.assignOrderKey(movedId, orderKey: orderKey); } Future _resetToDefaults(WidgetRef ref) async { final repository = ref.read(contextualToolbarConfigRepositoryProvider); await repository.replaceAll(defaultToolbarButtonConfigs.value); } } class _ToolbarButtonConfigTile extends HookConsumerWidget { const _ToolbarButtonConfigTile({ super.key, required this.index, required this.config, required this.repository, }); final int index; final ToolbarButtonConfig config; final ContextualToolbarConfigRepository repository; @override Widget build(BuildContext context, WidgetRef ref) { final def = toolbarButtonRegistryById[config.buttonId]; if (def == null) return const SizedBox.shrink(); final isVisible = config.isVisible; final hasStatefulFallback = def.isPrimaryAvailable != null || def.spec.defaultFallback != null; final fallbackOptions = toolbarButtonRegistry .where( (d) => d.spec.id.name != config.buttonId && d.spec.canBeFallbackTarget, ) .toList(); final longPressActions = def.longPressActions; return Material( color: Colors.transparent, child: ListTile( leading: Icon(def.icon), title: Text(def.label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (hasStatefulFallback) _FallbackPicker( current: ToolbarFallbackChoice.fromStored(config.fallbackId), options: fallbackOptions, onChanged: (newFallback) => repository.assignFallback( config.buttonId, (newFallback ?? ToolbarFallbackNone()).toStoredFallbackId(), ), ), if (longPressActions.isNotEmpty) _LongPressHint( buttonLabel: def.label, icon: def.icon, actions: longPressActions, ), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Switch.adaptive( value: isVisible, onChanged: (v) => repository.assignVisibility(config.buttonId, visible: v), ), ReorderableDragStartListener( index: index, child: const Padding( padding: EdgeInsets.all(8), child: Icon(Icons.drag_handle), ), ), ], ), ), ); } } class _LongPressHint extends StatelessWidget { const _LongPressHint({ required this.buttonLabel, required this.icon, required this.actions, }); final String buttonLabel; final IconData icon; final List actions; @override Widget build(BuildContext context) { return InkWell( borderRadius: BorderRadius.circular(4), onTap: () => _showLongPressDetails(context), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.touch_app, size: 14, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Flexible( child: Text( 'Long press available', style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 4), Icon( Icons.info_outline, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], ), ), ); } Future _showLongPressDetails(BuildContext context) async { await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) { return SafeArea( child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 12), Text( '$buttonLabel Long Press', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 8), Text( 'Press and hold this button to access:', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 12), ...actions.map( (action) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Icon( Icons.touch_app, size: 18, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: Text( action, style: Theme.of(context).textTheme.bodyLarge, ), ), ], ), ), ), const SizedBox(height: 16), ], ), ), ); }, ); } } class _FallbackPicker extends StatelessWidget { const _FallbackPicker({ required this.current, required this.options, required this.onChanged, }); final ToolbarFallbackChoice current; final List options; final ValueChanged onChanged; @override Widget build(BuildContext context) { return DropdownButton( value: current, hint: const Text('No fallback'), isExpanded: true, isDense: true, padding: const EdgeInsets.symmetric(vertical: 2.0), underline: const SizedBox.shrink(), items: [ DropdownMenuItem( value: ToolbarFallbackNone(), child: const Text('No fallback'), ), for (final opt in options) DropdownMenuItem( value: ToolbarFallbackButton(buttonId: opt.spec.id.name), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(opt.icon, size: 16), const SizedBox(width: 8), Text(opt.label), ], ), ), ], onChanged: onChanged, ); } } class _ToolbarPreviewDelegate extends SliverPersistentHeaderDelegate { const _ToolbarPreviewDelegate({required this.configs}); final List configs; static const _previewHeight = BrowserTabBar.contextualToolabarHeight; @override double get minExtent => _previewHeight; @override double get maxExtent => _previewHeight; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return SizedBox( height: maxExtent, child: ColoredBox( color: Theme.of(context).colorScheme.surfaceContainer, child: _ToolbarPreview(configs: configs), ), ); } @override bool shouldRebuild(_ToolbarPreviewDelegate old) => old.configs != configs; } class _ToolbarPreview extends ConsumerWidget { const _ToolbarPreview({required this.configs}); final List configs; @override Widget build(BuildContext context, WidgetRef ref) { final visibleConfigs = configs.where((c) => c.isVisible).toList(); final scope = ContextualToolbarScope( selectedTabId: null, displayedSheet: null, tabState: null, isPreview: true, ); final buttons = visibleConfigs.map((config) { final def = toolbarButtonRegistryById[config.buttonId]; if (def == null) return const SizedBox.shrink(); return def.builder(scope, context, ref); }).toList(); return ContextualToolbarView(buttons: buttons); } }