diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 5410312..1e5c723 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 182 (91 per locale) +/// Strings: 188 (94 per locale) /// -/// Built on 2024-02-23 at 18:23 UTC +/// Built on 2024-02-23 at 21:28 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -313,6 +313,9 @@ class _StringsLinksAddLinkEn { String get notes => 'Notes'; String get addNotes => 'Add notes'; String get tags => 'Tags'; + String get noTagsAdded => 'No tags added'; + String get tagNoWhitespaces => 'Tag cannot contain whitespaces'; + String get addTag => 'Add tag'; String get others => 'Others'; String get savingLink => 'Saving link...'; String get errorSavingLink => 'An error occured when saving the new link.'; @@ -550,6 +553,9 @@ class _StringsLinksAddLinkEs implements _StringsLinksAddLinkEn { @override String get notes => 'Notas'; @override String get addNotes => 'Añadir notas'; @override String get tags => 'Etiquetas'; + @override String get noTagsAdded => 'No hay etiquetas añadidas'; + @override String get tagNoWhitespaces => 'La etiqueta no puede contener espacios'; + @override String get addTag => 'Añadir etiqueta'; @override String get others => 'Otros'; @override String get savingLink => 'Guardando enlace...'; @override String get errorSavingLink => 'Ocurrió un error al guardar el nuevo enlace.'; @@ -651,6 +657,9 @@ extension on Translations { case 'links.addLink.notes': return 'Notes'; case 'links.addLink.addNotes': return 'Add notes'; case 'links.addLink.tags': return 'Tags'; + case 'links.addLink.noTagsAdded': return 'No tags added'; + case 'links.addLink.tagNoWhitespaces': return 'Tag cannot contain whitespaces'; + case 'links.addLink.addTag': return 'Add tag'; case 'links.addLink.others': return 'Others'; case 'links.addLink.savingLink': return 'Saving link...'; case 'links.addLink.errorSavingLink': return 'An error occured when saving the new link.'; @@ -750,6 +759,9 @@ extension on _StringsEs { case 'links.addLink.notes': return 'Notas'; case 'links.addLink.addNotes': return 'Añadir notas'; case 'links.addLink.tags': return 'Etiquetas'; + case 'links.addLink.noTagsAdded': return 'No hay etiquetas añadidas'; + case 'links.addLink.tagNoWhitespaces': return 'La etiqueta no puede contener espacios'; + case 'links.addLink.addTag': return 'Añadir etiqueta'; case 'links.addLink.others': return 'Otros'; case 'links.addLink.savingLink': return 'Guardando enlace...'; case 'links.addLink.errorSavingLink': return 'Ocurrió un error al guardar el nuevo enlace.'; diff --git a/lib/i18n/strings_en.i18n.json b/lib/i18n/strings_en.i18n.json index c948380..1df0aea 100644 --- a/lib/i18n/strings_en.i18n.json +++ b/lib/i18n/strings_en.i18n.json @@ -54,6 +54,9 @@ "notes": "Notes", "addNotes": "Add notes", "tags": "Tags", + "noTagsAdded": "No tags added", + "tagNoWhitespaces": "Tag cannot contain whitespaces", + "addTag": "Add tag", "others": "Others", "savingLink": "Saving link...", "errorSavingLink": "An error occured when saving the new link.", diff --git a/lib/i18n/strings_es.i18n.json b/lib/i18n/strings_es.i18n.json index ba713cd..58d34d8 100644 --- a/lib/i18n/strings_es.i18n.json +++ b/lib/i18n/strings_es.i18n.json @@ -54,6 +54,9 @@ "notes": "Notas", "addNotes": "Añadir notas", "tags": "Etiquetas", + "noTagsAdded": "No hay etiquetas añadidas", + "tagNoWhitespaces": "La etiqueta no puede contener espacios", + "addTag": "Añadir etiqueta", "others": "Otros", "savingLink": "Guardando enlace...", "errorSavingLink": "Ocurrió un error al guardar el nuevo enlace.", diff --git a/lib/models/data/post_bookmark.dart b/lib/models/data/post_bookmark.dart index 042d9ed..b285c7e 100644 --- a/lib/models/data/post_bookmark.dart +++ b/lib/models/data/post_bookmark.dart @@ -5,7 +5,7 @@ class PostBookmark { final bool isArchived; final bool unread; final bool shared; - final List tagNames; + final String tagNames; const PostBookmark({ required this.url, @@ -21,9 +21,9 @@ class PostBookmark { "url": url, "title": title, "description": description, - "isArchived": isArchived, + "is_archived": isArchived, "unread": unread, "shared": shared, - "tagNames": tagNames, + "tag_names": tagNames, }; } diff --git a/lib/models/data/tags.dart b/lib/models/data/tags.dart new file mode 100644 index 0000000..f8ee83c --- /dev/null +++ b/lib/models/data/tags.dart @@ -0,0 +1,51 @@ +class Tags { + final int? count; + final int? next; + final int? previous; + final List? results; + + Tags({ + this.count, + this.next, + this.previous, + this.results, + }); + + factory Tags.fromJson(Map json) => Tags( + count: json["count"], + next: json["next"], + previous: json["previous"], + results: json["results"] == null ? [] : List.from(json["results"]!.map((x) => Result.fromJson(x))), + ); + + Map toJson() => { + "count": count, + "next": next, + "previous": previous, + "results": results == null ? [] : List.from(results!.map((x) => x.toJson())), + }; +} + +class Result { + final int? id; + final String? name; + final DateTime? dateAdded; + + Result({ + this.id, + this.name, + this.dateAdded, + }); + + factory Result.fromJson(Map json) => Result( + id: json["id"], + name: json["name"], + dateAdded: json["date_added"] == null ? null : DateTime.parse(json["date_added"]), + ); + + Map toJson() => { + "id": id, + "name": name, + "date_added": dateAdded?.toIso8601String(), + }; +} diff --git a/lib/screens/links/model/add_link.model.dart b/lib/screens/links/model/add_link.model.dart index 5ff4cb4..61069d2 100644 --- a/lib/screens/links/model/add_link.model.dart +++ b/lib/screens/links/model/add_link.model.dart @@ -11,7 +11,9 @@ class AddLinkModel { final TextEditingController titleController; final TextEditingController descriptionController; bool markAsUnread; - final List tags; + final TextEditingController tagsController; + String? tagsError; + List tags; final TextEditingController notesController; AddLinkModel({ @@ -22,6 +24,8 @@ class AddLinkModel { required this.titleController, required this.descriptionController, this.markAsUnread = false, + required this.tagsController, + this.tagsError, required this.tags, required this.notesController, }); diff --git a/lib/screens/links/provider/add_link.provider.dart b/lib/screens/links/provider/add_link.provider.dart index 0167d44..838abfc 100644 --- a/lib/screens/links/provider/add_link.provider.dart +++ b/lib/screens/links/provider/add_link.provider.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:linkdy/utils/snackbar.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:linkdy/screens/links/provider/links.provider.dart'; import 'package:linkdy/screens/links/model/add_link.model.dart'; +import 'package:linkdy/models/data/tags.dart'; +import 'package:linkdy/utils/snackbar.dart'; import 'package:linkdy/i18n/strings.g.dart'; import 'package:linkdy/utils/process_modal.dart'; import 'package:linkdy/models/data/post_bookmark.dart'; @@ -30,6 +31,12 @@ FutureOr> addBookmark(AddBookmarkRef ref, PostBookmark new return result; } +@riverpod +FutureOr> getTags(GetTagsRef ref) async { + final result = await ref.watch(apiClientProviderProvider)!.fetchTags(); + return result; +} + @riverpod class AddLink extends _$AddLink { @override @@ -38,6 +45,7 @@ class AddLink extends _$AddLink { urlController: TextEditingController(), titleController: TextEditingController(), descriptionController: TextEditingController(), + tagsController: TextEditingController(), tags: [], notesController: TextEditingController(), ); @@ -86,7 +94,7 @@ class AddLink extends _$AddLink { isArchived: false, unread: state.markAsUnread, shared: false, - tagNames: [], + tagNames: state.tags.join(","), ); final processModal = ProcessModal(); @@ -106,4 +114,23 @@ class AddLink extends _$AddLink { ); } } + + void validateTagInput(String value) { + if (value.contains(" ")) { + state.tagsError = t.links.addLink.tagNoWhitespaces; + } else { + state.tagsError = null; + } + ref.notifyListeners(); + } + + void setTags(List tags) { + state.tags = tags; + ref.notifyListeners(); + } + + void clearTagsController() { + state.tagsController.clear(); + ref.notifyListeners(); + } } diff --git a/lib/screens/links/provider/add_link.provider.g.dart b/lib/screens/links/provider/add_link.provider.g.dart index f396359..d04f5ee 100644 --- a/lib/screens/links/provider/add_link.provider.g.dart +++ b/lib/screens/links/provider/add_link.provider.g.dart @@ -290,7 +290,21 @@ class _AddBookmarkProviderElement PostBookmark get newBookmark => (origin as AddBookmarkProvider).newBookmark; } -String _$addLinkHash() => r'0e9fcd2cca9fa5e195072163a11ae09b7bbe903a'; +String _$getTagsHash() => r'feecc7515f9492838c4a9bccc7031f49713e6ebb'; + +/// See also [getTags]. +@ProviderFor(getTags) +final getTagsProvider = AutoDisposeFutureProvider>.internal( + getTags, + name: r'getTagsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$getTagsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef GetTagsRef = AutoDisposeFutureProviderRef>; +String _$addLinkHash() => r'fd6e2629386254ace7ac7abeff0e566dda69575a'; /// See also [AddLink]. @ProviderFor(AddLink) diff --git a/lib/screens/links/ui/add_link_modal.dart b/lib/screens/links/ui/add_link_modal.dart index b4142ba..89a504f 100644 --- a/lib/screens/links/ui/add_link_modal.dart +++ b/lib/screens/links/ui/add_link_modal.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:easy_autocomplete/easy_autocomplete.dart'; import 'package:linkdy/screens/links/provider/add_link.provider.dart'; -import 'package:linkdy/widgets/autocomplete_text_field.dart'; import 'package:linkdy/widgets/section_label.dart'; import 'package:linkdy/i18n/strings.g.dart'; @@ -49,6 +49,8 @@ class _ModalContent extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final provider = ref.watch(addLinkProvider); + final tags = ref.watch(getTagsProvider); + return ListView( children: [ const SizedBox(height: 16), @@ -185,6 +187,86 @@ class _ModalContent extends ConsumerWidget { vertical: 24, ), ), + if (tags.isLoading == false) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + if (provider.checkBookmark != null) + Row( + children: [ + Expanded( + child: EasyAutocomplete( + controller: provider.tagsController, + onChanged: ref.read(addLinkProvider.notifier).validateTagInput, + suggestions: tags.value?.content?.results?.map((t) => t.name!).toList() ?? [], + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + labelText: t.links.addLink.tags, + errorText: provider.tagsError, + ), + suggestionBuilder: (data) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + data, + style: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + onSubmitted: provider.tagsController.text != "" && provider.tagsError == null + ? (v) { + ref.read(addLinkProvider.notifier).setTags([...provider.tags, v]); + ref.read(addLinkProvider.notifier).clearTagsController(); + } + : null, + ), + ), + const SizedBox(width: 16), + IconButton( + onPressed: provider.tagsController.text != "" && provider.tagsError == null + ? () { + ref + .read(addLinkProvider.notifier) + .setTags([...provider.tags, provider.tagsController.text]); + ref.read(addLinkProvider.notifier).clearTagsController(); + } + : null, + icon: const Icon(Icons.check_rounded), + tooltip: t.links.addLink.addTag, + ), + ], + ), + if (provider.checkBookmark != null) const SizedBox(height: 16), + SizedBox( + height: 40, + child: provider.tags.isNotEmpty + ? ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: provider.tags.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) => InputChip( + label: Text(provider.tags[index]), + onDeleted: () => ref + .read(addLinkProvider.notifier) + .setTags(provider.tags.where((tag) => tag != provider.tags[index]).toList()), + ), + ) + : Center( + child: Text( + t.links.addLink.noTagsAdded, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ], + ), + ), SectionLabel( label: t.links.addLink.others, padding: const EdgeInsets.symmetric( diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 8d6c9cc..9452bda 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -4,6 +4,7 @@ import 'package:linkdy/models/api_response.dart'; import 'package:linkdy/models/data/bookmarks.dart'; import 'package:linkdy/models/data/check_bookmark.dart'; import 'package:linkdy/models/data/post_bookmark.dart'; +import 'package:linkdy/models/data/tags.dart'; import 'package:linkdy/models/server_instance.dart'; class ApiClient { @@ -74,4 +75,16 @@ class ApiClient { return const ApiResponse(successful: false); } } + + Future> fetchTags() async { + try { + final response = await dioInstance.get("/tags/"); + return ApiResponse( + successful: true, + content: Tags.fromJson(response.data), + ); + } catch (e, stackTrace) { + return const ApiResponse(successful: false); + } + } } diff --git a/lib/widgets/autocomplete_text_field.dart b/lib/widgets/autocomplete_text_field.dart deleted file mode 100644 index 255466e..0000000 --- a/lib/widgets/autocomplete_text_field.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -class AutocompleteTextField extends StatefulWidget { - final IconData leadingIcon; - final String label; - - const AutocompleteTextField({ - Key? key, - required this.leadingIcon, - required this.label, - }) : super(key: key); - - @override - State createState() => _AutocompleteTextFieldState(); -} - -class _AutocompleteTextFieldState extends State { - final _controller = TextEditingController(); - final _overlayController = OverlayPortalController(); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - TextFormField( - controller: _controller, - onChanged: (value) { - if (value.isNotEmpty) { - _overlayController.show(); - } else { - _overlayController.hide(); - } - }, - decoration: InputDecoration( - prefixIcon: Icon(widget.leadingIcon), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(10), - ), - ), - labelText: widget.label, - ), - ), - OverlayPortal( - controller: _overlayController, - overlayChildBuilder: (context) => Positioned( - top: 0, - left: 0, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Colors.grey, - ), - ), - ), - ), - ], - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 460ddd3..ccdb571 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.9" + easy_autocomplete: + dependency: "direct main" + description: + name: easy_autocomplete + sha256: e7ded95deb010daec41caf25f553b450fbdaa6769e7a6fdb7b8dce6764318406 + url: "https://pub.dev" + source: hosted + version: "1.6.0" expandable: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e8fabc9..a216f75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: share_plus: ^7.2.2 package_info_plus: ^5.0.1 expandable: ^5.0.1 + easy_autocomplete: ^1.6.0 dev_dependencies: build_runner: ^2.4.8