tab edit initial

This commit is contained in:
Fabian Freund
2026-01-19 12:31:26 +01:00
parent 1dc609f4e4
commit 629c6da667
8 changed files with 306 additions and 243 deletions
+7
View File
@@ -136,10 +136,16 @@ class SearchRoute extends GoRouteData with $SearchRoute {
final bool launchedFromIntent;
/// When provided, the search screen will load URLs into this existing tab
/// instead of creating a new tab. This also changes the UI to show
/// site-specific bangs instead of the tab type selector.
final String? tabId;
const SearchRoute({
required this.tabType,
this.searchText = SearchRoute.emptySearchText,
this.launchedFromIntent = false,
this.tabId,
});
@override
@@ -150,6 +156,7 @@ class SearchRoute extends GoRouteData with $SearchRoute {
? null
: searchText,
launchedFromIntent: launchedFromIntent,
tabId: tabId,
);
}
}
+2
View File
@@ -637,6 +637,7 @@ mixin $SearchRoute on GoRouteData {
_$boolConverter,
) ??
false,
tabId: state.uri.queryParameters['tab-id'],
);
SearchRoute get _self => this as SearchRoute;
@@ -647,6 +648,7 @@ mixin $SearchRoute on GoRouteData {
queryParams: {
if (_self.launchedFromIntent != false)
'launched-from-intent': _self.launchedFromIntent.toString(),
if (_self.tabId != null) 'tab-id': _self.tabId,
},
);
@@ -27,10 +27,10 @@ final class ViewTabsSheet extends Sheet {
List<Object?> get hashParameters => [null];
}
final class EditUrlSheet extends Sheet {
final class SiteSettingsSheet extends Sheet {
final TabState tabState;
EditUrlSheet({required this.tabState});
SiteSettingsSheet({required this.tabState});
@override
List<Object?> get hashParameters => [tabState];
@@ -591,7 +591,7 @@ class _SheetContainer extends HookConsumerWidget {
onNotification: dismissOnThreshold,
child: _ViewTabsSheet(maxChildSize: stableMaxChildSize),
),
final EditUrlSheet parameter =>
final SiteSettingsSheet parameter =>
NotificationListener<DraggableScrollableNotification>(
onNotification: dismissOnThreshold,
child: _ViewUrlSheet(
@@ -24,7 +24,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
@@ -67,66 +70,91 @@ class AppBarTitle extends HookConsumerWidget {
return Row(
children: [
TabIcon(tabState: tabState),
// Icon tap → opens site settings sheet
GestureDetector(
onTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
child: TabIcon(tabState: tabState),
),
const SizedBox(width: 8),
// Title/URL tap → opens search screen
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Skeletonizer(
enabled: tabState.title.isEmpty,
child: Skeleton.replace(
replacement: const Padding(
padding: EdgeInsets.only(right: 4, top: 1, bottom: 1),
child: Bone.text(),
),
child: TextScroll(
key: ValueKey(tabState.title),
tabState.title,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
child: GestureDetector(
onTap: () async {
// Don't pre-fill for internal URLs
final searchText = tabState.url.scheme == 'about'
? ''
: tabState.url.toString();
await SearchRoute(
tabId: tabState.id,
searchText: searchText.isEmpty
? SearchRoute.emptySearchText
: searchText,
tabType: tabState.isPrivate ? TabType.private : TabType.regular,
).push(context);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Skeletonizer(
enabled: tabState.title.isEmpty,
child: Skeleton.replace(
replacement: const Padding(
padding: EdgeInsets.only(right: 4, top: 1, bottom: 1),
child: Bone.text(),
),
// mode: TextScrollMode.bouncing,
velocity: const Velocity(pixelsPerSecond: Offset(75, 0)),
delayBefore: const Duration(milliseconds: 500),
pauseBetween: const Duration(milliseconds: 5000),
fadedBorder: true,
fadeBorderSide: FadeBorderSide.right,
fadedBorderWidth: 0.05,
intervalSpaces: 4,
numberOfReps: 2,
),
),
),
Row(
children: [
if (isTabTuneledAsync.hasValue &&
isTabTuneledAsync.value == true) ...[
const Icon(MdiIcons.tunnelOutline, size: 14),
const SizedBox(width: 4),
],
icon,
const SizedBox(width: 4),
if (tabState.isPrivate) ...[
Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 14,
),
const SizedBox(width: 4),
],
Expanded(
child: UriBreadcrumb(
uri: tabState.url,
style: theme.textTheme.bodyMedium?.copyWith(
child: TextScroll(
key: ValueKey(tabState.title),
tabState.title,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
// mode: TextScrollMode.bouncing,
velocity: const Velocity(pixelsPerSecond: Offset(75, 0)),
delayBefore: const Duration(milliseconds: 500),
pauseBetween: const Duration(milliseconds: 5000),
fadedBorder: true,
fadeBorderSide: FadeBorderSide.right,
fadedBorderWidth: 0.05,
intervalSpaces: 4,
numberOfReps: 2,
),
),
],
),
],
),
Row(
children: [
if (isTabTuneledAsync.hasValue &&
isTabTuneledAsync.value == true) ...[
const Icon(MdiIcons.tunnelOutline, size: 14),
const SizedBox(width: 4),
],
icon,
const SizedBox(width: 4),
if (tabState.isPrivate) ...[
Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 14,
),
const SizedBox(width: 4),
],
Expanded(
child: UriBreadcrumb(
uri: tabState.url,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
],
),
],
),
),
),
],
@@ -218,20 +218,7 @@ class BrowserTabBar extends HookConsumerWidget {
final toolbarHeight = useMemoized(() => getToolbarHeight());
return GestureDetector(
onTap: () {
if (displayedSheet case EditUrlSheet()) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
return;
}
final tabState = ref.read(selectedTabStateProvider);
if (tabState != null) {
ref
.read(bottomSheetControllerProvider.notifier)
.show(EditUrlSheet(tabState: tabState));
}
},
// Tap handling moved to AppBarTitle for split icon/title behavior
onHorizontalDragStart: (details) {
dragStartPosition.value = details.globalPosition;
},
@@ -22,8 +22,7 @@ import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
@@ -64,64 +63,6 @@ class ViewTabSheetWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final headerKey = useMemoized(() => GlobalKey());
final textFieldKey = useMemoized(() => GlobalKey());
final scrolledTo = useRef(0.0);
final searchTextController = useTextEditingController(
text: initialTabState.url.toString(),
);
Future<void> scroll() async {
if (!context.mounted) return;
final header = headerKey.currentContext?.findRenderObject();
final text = textFieldKey.currentContext?.findRenderObject();
if (header case final RenderBox headerBox) {
if (text case final RenderBox textBox) {
final totalHeight =
headerBox.size.height +
textBox.size.height +
bottomAppBarHeight +
MediaQuery.of(context).viewInsets.bottom;
final relative = (totalHeight / MediaQuery.of(context).size.height)
.clamp(0.0, 1.0);
if (draggableScrollableController.size < relative &&
relative > scrolledTo.value) {
await draggableScrollableController.animateTo(
relative,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
scrolledTo.value = relative;
}
}
}
}
useOnListenableChange(searchTextController, () {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await scroll();
});
});
final availableBangs = ref.watch(
bangListProvider(
domain: initialTabState.url.host,
orderMostFrequentFirst: true,
).select((value) => value.value ?? const []),
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await scroll();
});
return null;
});
final bottomInsets = useRef(0.0);
useEffect(
@@ -164,21 +105,23 @@ class ViewTabSheetWidget extends HookConsumerWidget {
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
child: GestureDetector(
onTap: () async {
if (draggableScrollableController.size > 0.85) {
await draggableScrollableController.animateTo(
(scrolledTo.value > 0.0)
? scrolledTo.value
: initialHeight,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
} else {
await draggableScrollableController.animateTo(
1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
}
// Dismiss sheet and open search screen with tab context
onClose();
// Don't pre-fill for internal URLs
final searchText = initialTabState.url.scheme == 'about'
? ''
: initialTabState.url.toString();
await SearchRoute(
tabId: initialTabState.id,
searchText: searchText.isEmpty
? SearchRoute.emptySearchText
: searchText,
tabType: initialTabState.isPrivate
? TabType.private
: TabType.regular,
).push(context);
},
child: WebsiteTitleTile(initialTabState),
),
@@ -199,20 +142,8 @@ class ViewTabSheetWidget extends HookConsumerWidget {
controller: controller,
physics: const ClampingScrollPhysicsWithoutImplicit(),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SiteSearch(
key: textFieldKey,
domain: initialTabState.url.host,
availableBangs: availableBangs,
controller: searchTextController,
),
),
const Divider(),
// Tracking Protection Section
TrackingProtectionSection(
tabId: initialTabState.id,
),
TrackingProtectionSection(tabId: initialTabState.id),
const Divider(),
// Permissions Section
PermissionsSection(
@@ -221,9 +152,7 @@ class ViewTabSheetWidget extends HookConsumerWidget {
),
const Divider(),
// Clear Site Data Section
ClearSiteDataSection(
url: initialTabState.url,
),
ClearSiteDataSection(url: initialTabState.url),
const SizedBox(height: 16.0),
],
);
@@ -29,6 +29,7 @@ import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
@@ -40,6 +41,8 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class SearchScreen extends HookConsumerWidget {
@@ -47,10 +50,15 @@ class SearchScreen extends HookConsumerWidget {
final TabType tabType;
final bool launchedFromIntent;
/// When provided, URLs will be loaded into this existing tab.
/// When null, a new tab will be created.
final String? tabId;
const SearchScreen({
required this.initialSearchText,
required this.tabType,
this.launchedFromIntent = false,
this.tabId,
});
@override
@@ -67,11 +75,23 @@ class SearchScreen extends HookConsumerWidget {
final selectedTabType = useState(tabType);
final currentTabTabType = ref.watch(selectedTabTypeProvider);
final privateTabMode = switch (selectedTabType.value) {
TabType.regular => false,
TabType.private => true,
TabType.child => currentTabTabType == TabType.private,
};
// When editing an existing tab, get its state
// If tab no longer exists (null), fall back to new tab mode
final existingTabState = tabId != null
? ref.watch(tabStateProvider(tabId))
: null;
// Determine if we're in edit mode (tabId provided AND tab still exists)
final isEditMode = tabId != null && existingTabState != null;
// Derive private mode from existing tab or from selector
final privateTabMode = isEditMode
? existingTabState.isPrivate
: switch (selectedTabType.value) {
TabType.regular => false,
TabType.private => true,
TabType.child => currentTabTabType == TabType.private,
};
final searchTextController = useTextEditingController(
text: initialSearchText,
@@ -105,13 +125,16 @@ class SearchScreen extends HookConsumerWidget {
final activeBang = selectedBang.value ?? defaultSearchBang;
final showBangIcon = useState(false);
ref.listen(selectedBangDataProvider(), (previous, next) {
if (previous != next) {
showBangIcon.value = true;
}
ref.listen(
selectedBangDataProvider(domain: isEditMode ? existingTabState.url.host : null),
(previous, next) {
if (previous != next) {
showBangIcon.value = true;
}
selectedBang.value = next;
});
selectedBang.value = next;
},
);
Future<void> submitSearch(String query) async {
if (activeBang != null && (formKey.currentState?.validate() == true)) {
@@ -123,17 +146,25 @@ class SearchScreen extends HookConsumerWidget {
.triggerBangSearch(activeBang, query);
}
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: searchUri,
private: privateTabMode,
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
);
if (isEditMode) {
// Load into existing tab
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: searchUri);
} else {
// Create new tab
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: searchUri,
private: privateTabMode,
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
);
}
if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
@@ -156,54 +187,70 @@ class SearchScreen extends HookConsumerWidget {
floating: true,
pinned: true,
automaticallyImplyLeading: false,
title: Align(
child: Focus(
canRequestFocus: false,
child: SegmentedButton(
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: TabType.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
const ButtonSegment(
value: TabType.private,
label: Text('Private'),
icon: Icon(MdiIcons.tabUnselected),
),
if (createChildTabsOption)
const ButtonSegment(
value: TabType.child,
label: Text('Child'),
icon: Icon(MdiIcons.fileTree),
title: isEditMode
? Column(
mainAxisSize: MainAxisSize.min,
children: [
_SiteBangsSelector(
tabId: tabId!,
domain: existingTabState.url.host,
),
Text(
'Editing current tab',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
)
: Align(
child: Focus(
canRequestFocus: false,
child: SegmentedButton(
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: TabType.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
const ButtonSegment(
value: TabType.private,
label: Text('Private'),
icon: Icon(MdiIcons.tabUnselected),
),
if (createChildTabsOption)
const ButtonSegment(
value: TabType.child,
label: Text('Child'),
icon: Icon(MdiIcons.fileTree),
),
],
selected: {selectedTabType.value},
onSelectionChanged: (value) {
selectedTabType.value = value.first;
// Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
},
style: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor:
appColors.privateSelectionOverlay,
),
TabType.child =>
(currentTabTabType == TabType.private)
? SegmentedButton.styleFrom(
selectedBackgroundColor:
appColors.privateSelectionOverlay,
)
: null,
},
),
],
selected: {selectedTabType.value},
onSelectionChanged: (value) {
selectedTabType.value = value.first;
// Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
},
style: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor:
appColors.privateSelectionOverlay,
),
TabType.child =>
(currentTabTabType == TabType.private)
? SegmentedButton.styleFrom(
selectedBackgroundColor:
appColors.privateSelectionOverlay,
)
: null,
},
),
),
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: Padding(
@@ -223,7 +270,9 @@ class SearchScreen extends HookConsumerWidget {
if (newUrl == null) {
final bang =
ref.read(selectedBangDataProvider()) ??
ref.read(selectedBangDataProvider(
domain: isEditMode ? existingTabState.url.host : null,
)) ??
await ref.read(
defaultSearchBangDataProvider.future,
);
@@ -240,19 +289,27 @@ class SearchScreen extends HookConsumerWidget {
}
if (newUrl != null) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: newUrl,
private: privateTabMode,
parentId:
(selectedTabType.value ==
TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
);
if (isEditMode) {
// Load into existing tab
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: newUrl);
} else {
// Create new tab
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: newUrl,
private: privateTabMode,
parentId:
(selectedTabType.value ==
TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
);
}
if (context.mounted) {
ref
@@ -296,3 +353,56 @@ class SearchScreen extends HookConsumerWidget {
);
}
}
class _SiteBangsSelector extends HookConsumerWidget {
final String tabId;
final String domain;
const _SiteBangsSelector({
required this.tabId,
required this.domain,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedBang = ref.watch(
selectedBangDataProvider(domain: domain),
);
final availableBangs = ref.watch(
bangListProvider(
domain: domain,
orderMostFrequentFirst: true,
).select((value) => value.value ?? const []),
);
if (availableBangs.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox(
height: 48,
width: double.maxFinite,
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) => UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
availableItems: availableBangs,
selectedItem: selectedBang,
onSelected: (bang) {
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.setTrigger(bang.toKey());
},
onDeleted: (bang) {
if (ref.read(selectedBangTriggerProvider(domain: domain)) ==
bang.toKey()) {
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.clearTrigger();
}
},
),
);
}
}