prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,50 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
/// Dialog to confirm bang deletion.
/// Returns true if user confirms deletion, false if cancelled, null if dismissed.
Future<bool?> showDeleteBangDialog(BuildContext context) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Bang'),
content: const Text('Are you sure you want to delete this Bang?'),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Delete'),
),
],
);
},
);
}
@@ -0,0 +1,127 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
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/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangCategoriesScreen extends HookConsumerWidget {
const BangCategoriesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(bangCategoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Bang Categories'),
actions: [
IconButton(
onPressed: () async {
final trigger = await const BangSearchRoute().push<BangKey?>(
context,
);
if (trigger != null) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(trigger);
}
},
icon: const Icon(Icons.search),
),
],
),
body: SafeArea(
child: categoriesAsync.when(
skipLoadingOnReload: true,
data: (categories) {
return FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
child: HookBuilder(
builder: (context) {
final expanded = useState(<String>{});
return ExpansionPanelList(
expansionCallback: (index, expand) {
final key = categories.keys.elementAt(index);
if (!expanded.value.contains(key)) {
expanded.value = {...expanded.value, key};
} else {
expanded.value = {...expanded.value}..remove(key);
}
},
children: categories.entries
.map(
(category) => ExpansionPanel(
canTapOnHeader: true,
isExpanded: expanded.value.contains(
category.key,
),
headerBuilder: (context, isExpanded) =>
ListTile(title: Text(category.key)),
body: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: category.value
.map(
(subCategory) => ListTile(
title: Text(subCategory),
onTap: () async {
await BangSubCategoryRoute(
category: category.key,
subCategory: subCategory,
).push(context);
},
),
)
.toList(),
),
),
),
)
.toList(),
);
},
),
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bang Categories',
exception: error,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,95 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangCategoryScreen extends HookConsumerWidget {
final String? category;
final String? subCategory;
const BangCategoryScreen({this.category, this.subCategory, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bangsAsync = ref.watch(
bangListProvider(
categoryFilter: category.mapNotNull(
(category) => (category: category, subCategory: subCategory),
),
),
);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(title: Text('$category: $subCategory')),
bangsAsync.when(
skipLoadingOnReload: true,
data: (bangs) {
return SliverList.builder(
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return BangDetails(
bang,
onTap: () {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.toKey());
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).go(context);
},
);
},
);
},
error: (error, stackTrace) => SliverToBoxAdapter(
child: Center(
child: FailureWidget(
title: 'Failed to load Bangs',
exception: error,
),
),
),
loading: () => const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
@@ -0,0 +1,346 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/presentation/dialogs/delete_bang_dialog.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class EditBangScreen extends HookConsumerWidget {
final Bang? initialBang;
const EditBangScreen({super.key, required this.initialBang});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final categories = ref.watch(
bangCategoriesProvider.select((value) => value.value),
);
final nameTextController = useTextEditingController(
text: initialBang?.websiteName,
);
final triggerTextController = useTextEditingController(
text: initialBang?.trigger,
);
final urlTextController = useTextEditingController(
text: initialBang?.urlTemplate,
);
final category = useState(initialBang?.category);
final subCategory = useState(initialBang?.subCategory);
final formatFlags = useState(initialBang?.format);
return Scaffold(
appBar: AppBar(
title: Text(initialBang == null ? 'New Bang' : 'Edit Bang'),
actions: [
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
final existingBang = await ref
.read(bangDataRepositoryProvider.notifier)
.getBang(
BangKey(
group: BangGroup.user,
trigger: triggerTextController.text,
),
);
if ((initialBang == null && existingBang != null) ||
(initialBang != null &&
existingBang != null &&
existingBang.trigger != initialBang!.trigger)) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'A Bang with Trigger "${triggerTextController.text}" does already exist',
);
}
return;
}
final uri = parseValidatedUrl(
urlTextController.text,
eagerParsing: false,
onlyHttpProtocol: true,
);
if (uri == null) {
return;
}
final bang = Bang(
group: BangGroup.user,
trigger: triggerTextController.text,
websiteName: nameTextController.text,
domain: uri.host,
urlTemplate: urlTextController.text,
searxngApi: false,
category: category.value,
subCategory: subCategory.value,
additionalTriggers: initialBang?.additionalTriggers,
snapDomain: initialBang?.snapDomain,
format: formatFlags.value.isNotEmpty
? formatFlags.value
: null,
);
if (initialBang != null &&
initialBang!.trigger != bang.trigger) {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: initialBang!.trigger,
),
);
}
await ref
.read(bangDataRepositoryProvider.notifier)
.upsertBang(bang);
if (context.mounted) {
context.pop();
}
}
},
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
helper: Text(
'The name of the website associated with the bang',
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
TextFormField(
controller: triggerTextController,
decoration: const InputDecoration(
label: Text('Trigger'),
helper: Text(
'The specific trigger word or phrase used to invoke the bang.',
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
TextFormField(
controller: urlTextController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
label: Text('URL'),
helper: Text(
"The URL template to use when the bang is invoked, where `{{{s}}}` is replaced by the user's query.",
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
if (value?.contains('{{{s}}}') != true) {
return 'Must contain the query placeholder {{{s}}}';
}
return validateUrl(
value,
eagerParsing: false,
onlyHttpProtocol: true,
);
},
),
const SizedBox(height: 24),
DropdownMenuFormField(
key: ValueKey(EquatableValue([category.value, categories])),
enableFilter: true,
requestFocusOnTap: true,
label: const Text('Category'),
expandedInsets: EdgeInsets.zero,
initialSelection: category.value,
dropdownMenuEntries: [
...?categories?.keys.map(
(e) => DropdownMenuEntry(value: e, label: e),
),
],
onSelected: (value) {
if (category.value != value) {
category.value = value;
subCategory.value = null;
}
},
),
const SizedBox(height: 16),
DropdownMenuFormField(
key: ValueKey(
EquatableValue([subCategory.value, categories]),
),
enableFilter: true,
requestFocusOnTap: true,
label: const Text('Sub Category'),
expandedInsets: EdgeInsets.zero,
initialSelection: subCategory.value,
dropdownMenuEntries: [
...?categories?[category.value]?.map(
(e) => DropdownMenuEntry(value: e, label: e),
),
],
onSelected: (value) {
if (subCategory.value != value) {
subCategory.value = value;
}
},
),
const SizedBox(height: 16),
Text('Flags', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
CheckboxListTile(
value:
formatFlags.value?.contains(BangFormat.openBasePath) ??
false,
title: const Text('Open Base Path'),
subtitle: const Text(
'When the bang is invoked with no query, opens the base path of the URL (/) instead of any path given in the template (g., /search)',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.openBasePath,
}
: {...?formatFlags.value}
..remove(BangFormat.openBasePath);
}
},
),
CheckboxListTile(
value:
formatFlags.value?.contains(
BangFormat.urlEncodePlaceholder,
) ??
false,
title: const Text('URL Encode Placeholder'),
subtitle: const Text(
'URL encode the search terms. Some sites do not work with this, so it can be disabled by omitting this.',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.urlEncodePlaceholder,
}
: {...?formatFlags.value}
..remove(BangFormat.urlEncodePlaceholder);
}
},
),
CheckboxListTile(
value:
formatFlags.value?.contains(
BangFormat.urlEncodeSpaceToPlus,
) ??
false,
title: const Text('URL Encode Space to Plus'),
subtitle: const Text(
'URL encodes spaces as +, instead of %20. Some sites only work correctly with one or the other.',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.urlEncodeSpaceToPlus,
}
: {...?formatFlags.value}
..remove(BangFormat.urlEncodeSpaceToPlus);
}
},
),
if (initialBang != null)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteBangDialog(context);
if (result == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: initialBang!.trigger,
),
);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,64 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
class BangMenuScreen extends HookConsumerWidget {
const BangMenuScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bangs')),
body: SafeArea(
child: ListView(
children: [
ListTile(
leading: const Icon(MdiIcons.accountAlert),
title: const Text('Manage User Bangs'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const UserBangsRoute().push(context);
},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search Bangs'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const BangSearchRoute().push(context);
},
),
ListTile(
leading: const Icon(MdiIcons.fileTree),
title: const Text('Browse Categories'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const BangCategoriesRoute().push(context);
},
),
],
),
),
);
}
}
@@ -0,0 +1,108 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangSearchScreen extends HookConsumerWidget {
final String? initialSearchText;
const BangSearchScreen({super.key, this.initialSearchText});
@override
Widget build(BuildContext context, WidgetRef ref) {
final resultsAsync = ref.watch(bangSearchProvider);
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
final focusNode = useFocusNode();
final textEditingController = useTextEditingController(
text: initialSearchText,
);
useOnListenableChange(textEditingController, () {
unawaited(
ref
.read(bangSearchProvider.notifier)
.search(textEditingController.text),
);
});
return Scaffold(
appBar: AppBar(
title: TextField(
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: focusNode,
controller: textEditingController,
autofocus: true,
autocorrect: false,
decoration: const InputDecoration.collapsed(hintText: 'Search'),
),
actions: [
IconButton(
onPressed: () {
if (textEditingController.text.isEmpty) {
context.pop();
} else {
textEditingController.clear();
focusNode.requestFocus();
}
},
icon: const Icon(Icons.clear),
),
],
),
body: SafeArea(
child: resultsAsync.when(
skipLoadingOnReload: true,
data: (bangs) => FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView.builder(
controller: controller,
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return BangDetails(
bang,
onTap: () {
context.pop(bang.toKey());
},
);
},
);
},
),
error: (error, stackTrace) => Center(
child: FailureWidget(title: 'Bang Search failed', exception: error),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,102 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class UserBangs extends HookConsumerWidget {
static const _userGroupFilter = [BangGroup.user];
const UserBangs({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bangsAsync = ref.watch(bangListProvider(groups: _userGroupFilter));
return Scaffold(
appBar: AppBar(title: const Text('User Bangs')),
body: bangsAsync.when(
skipLoadingOnReload: true,
data: (bangs) {
return ListView.builder(
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: (context) async {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: bang.trigger,
),
);
},
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onErrorContainer,
icon: Icons.delete,
label: 'Delete',
),
],
),
child: BangDetails(
bang,
onTap: () async {
await EditUserBangRoute(
initialBang: jsonEncode(bang.toJson()),
).push(context);
},
),
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(title: 'Failed to load Bangs', exception: error),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
await const NewUserBangRoute().push(context);
},
),
);
}
}
@@ -0,0 +1,131 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class BangDetails extends HookConsumerWidget {
final BangData bangData;
final void Function()? onTap;
const BangDetails(this.bangData, {this.onTap, super.key});
String? _categoryString(BangData bang) {
if (bang.category == null) {
return null;
} else if (bang.subCategory == null) {
return bang.category;
} else {
return '${bang.category} / ${bang.subCategory}';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
UrlIcon([bangData.getDefaultUrl()], iconSize: 34.0),
const SizedBox(width: 12.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bangData.websiteName.trim(),
style: theme.textTheme.titleMedium,
),
if (bangData.category != null)
Text(
_categoryString(bangData)!,
style: theme.textTheme.titleSmall,
),
],
),
),
],
),
const SizedBox(height: 8.0),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FilledButton.tonalIcon(
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
),
onPressed: () async {
final url = Uri.parse(bangData.getDefaultUrl().origin);
final tabMode = TabMode.fromTabType(
ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url, tabMode: tabMode, selectTab: true);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
label: Text(bangData.domain),
icon: const Icon(Icons.open_in_new),
),
const SizedBox(width: 8),
Expanded(
child: Tooltip(
message:
'Triggers: ${bangData.trigger}${bangData.additionalTriggers.mapNotNull((triggers) => ', ${triggers.map((trigger) => '!$trigger').join(', ')}') ?? ''}',
child: Text(
'!${bangData.trigger}',
style: theme.textTheme.titleSmall,
textAlign: TextAlign.right,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
),
);
}
}