implement user bangs
This commit is contained in:
+2
-2
@@ -28,11 +28,11 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
|
||||
class BangListScreen extends HookConsumerWidget {
|
||||
class BangCategoryScreen extends HookConsumerWidget {
|
||||
final String? category;
|
||||
final String? subCategory;
|
||||
|
||||
const BangListScreen({this.category, this.subCategory, super.key});
|
||||
const BangCategoryScreen({this.category, this.subCategory, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -0,0 +1,307 @@
|
||||
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/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:weblibre/utils/form_validators.dart';
|
||||
|
||||
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 uri = Uri.parse(urlTextController.text);
|
||||
|
||||
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,
|
||||
format: formatFlags.value.isNotEmpty
|
||||
? formatFlags.value
|
||||
: null,
|
||||
);
|
||||
|
||||
if (initialBang != null &&
|
||||
initialBang!.trigger != bang.trigger) {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.deleteBang(BangGroup.user, 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 showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.deleteBang(BangGroup.user, initialBang!.trigger);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Bangs')),
|
||||
body: 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,76 @@
|
||||
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/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();
|
||||
|
||||
@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(
|
||||
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(BangGroup.user, 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user