From a2d621ba9b3e4c361ae23035f229b23f21cee393 Mon Sep 17 00:00:00 2001 From: Juan Gilsanz Polo Date: Sat, 24 Feb 2024 01:08:53 +0100 Subject: [PATCH] Added search --- lib/i18n/strings.g.dart | 32 ++++- lib/i18n/strings_en.i18n.json | 9 +- lib/i18n/strings_es.i18n.json | 9 +- lib/providers/app_status_provider.g.dart | 2 +- .../links/provider/add_link.provider.g.dart | 2 +- lib/screens/links/ui/link_item.dart | 98 +++++++++++++ lib/screens/links/ui/links.dart | 95 +------------ lib/screens/search/model/search.model.dart | 11 ++ .../search/provider/search.provider.dart | 50 +++++++ .../search/provider/search.provider.g.dart | 57 ++++++++ .../search/ui/bookmarks_search_list.dart | 70 +++++++++ lib/screens/search/ui/error_search_list.dart | 33 +++++ lib/screens/search/ui/input_search_term.dart | 33 +++++ lib/screens/search/ui/search.dart | 100 ++++++++++++- lib/screens/search/ui/search_no_data.dart | 33 +++++ lib/screens/search/ui/tags_search_list.dart | 84 +++++++++++ lib/services/api_client.dart | 11 +- lib/utils/date_to_string.dart | 18 +++ lib/utils/tab_content_list.dart | 134 ++++++++++++++++++ lib/widgets/sliver_tab_body.dart | 56 ++++++++ 20 files changed, 834 insertions(+), 103 deletions(-) create mode 100644 lib/screens/links/ui/link_item.dart create mode 100644 lib/screens/search/model/search.model.dart create mode 100644 lib/screens/search/provider/search.provider.dart create mode 100644 lib/screens/search/provider/search.provider.g.dart create mode 100644 lib/screens/search/ui/bookmarks_search_list.dart create mode 100644 lib/screens/search/ui/error_search_list.dart create mode 100644 lib/screens/search/ui/input_search_term.dart create mode 100644 lib/screens/search/ui/search_no_data.dart create mode 100644 lib/screens/search/ui/tags_search_list.dart create mode 100644 lib/utils/date_to_string.dart create mode 100644 lib/utils/tab_content_list.dart create mode 100644 lib/widgets/sliver_tab_body.dart diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 9759af6..7c6ecde 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: 192 (96 per locale) +/// Strings: 206 (103 per locale) /// -/// Built on 2024-02-23 at 22:17 UTC +/// Built on 2024-02-24 at 00:06 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -223,6 +223,13 @@ class _StringsSearchEn { // Translations String get search => 'Search'; + String get bookmarks => 'Bookmarks'; + String get tags => 'Tags'; + String get clearSearch => 'Clear search'; + String get inputSearchTerm => 'Input a search term'; + String get cannotSearchError => 'Cannot search content due to an error'; + String get inputtedSearchTermNoResults => 'The inputted search term doesn\'t have any results'; + String created({required Object created}) => 'Created: ${created}'; } // Path: settings @@ -465,6 +472,13 @@ class _StringsSearchEs implements _StringsSearchEn { // Translations @override String get search => 'Buscar'; + @override String get bookmarks => 'Marcadores'; + @override String get tags => 'Etiquetas'; + @override String get clearSearch => 'Limpiar búsqueda'; + @override String get inputSearchTerm => 'Introduce un término de búsqueda'; + @override String get cannotSearchError => 'No se puede buscar el contenido por un error'; + @override String get inputtedSearchTermNoResults => 'El término de búsqueda introducido no tiene resultados'; + @override String created({required Object created}) => 'Creado: ${created}'; } // Path: settings @@ -670,6 +684,13 @@ extension on Translations { case 'links.addLink.leaveEmptyUseWebsiteTitle': return 'Leave empty to use the website\'s title.'; case 'links.addLink.leaveEmptyUseWebsiteDescription': return 'Leave empty to use the website\'s description.'; case 'search.search': return 'Search'; + case 'search.bookmarks': return 'Bookmarks'; + case 'search.tags': return 'Tags'; + case 'search.clearSearch': return 'Clear search'; + case 'search.inputSearchTerm': return 'Input a search term'; + case 'search.cannotSearchError': return 'Cannot search content due to an error'; + case 'search.inputtedSearchTermNoResults': return 'The inputted search term doesn\'t have any results'; + case 'search.created': return ({required Object created}) => 'Created: ${created}'; case 'settings.settings': return 'Settings'; case 'settings.appSettings': return 'App settings'; case 'settings.aboutApp': return 'About the app'; @@ -774,6 +795,13 @@ extension on _StringsEs { case 'links.addLink.leaveEmptyUseWebsiteTitle': return 'Dejar vacío para usar el título del sitio web.'; case 'links.addLink.leaveEmptyUseWebsiteDescription': return 'Dejar vacío para usar la descripción del sitio web.'; case 'search.search': return 'Buscar'; + case 'search.bookmarks': return 'Marcadores'; + case 'search.tags': return 'Etiquetas'; + case 'search.clearSearch': return 'Limpiar búsqueda'; + case 'search.inputSearchTerm': return 'Introduce un término de búsqueda'; + case 'search.cannotSearchError': return 'No se puede buscar el contenido por un error'; + case 'search.inputtedSearchTermNoResults': return 'El término de búsqueda introducido no tiene resultados'; + case 'search.created': return ({required Object created}) => 'Creado: ${created}'; case 'settings.settings': return 'Ajustes'; case 'settings.appSettings': return 'Ajustes de la aplicación'; case 'settings.aboutApp': return 'Sobre la aplicación'; diff --git a/lib/i18n/strings_en.i18n.json b/lib/i18n/strings_en.i18n.json index 3b3409f..b4f741f 100644 --- a/lib/i18n/strings_en.i18n.json +++ b/lib/i18n/strings_en.i18n.json @@ -65,7 +65,14 @@ } }, "search": { - "search": "Search" + "search": "Search", + "bookmarks": "Bookmarks", + "tags": "Tags", + "clearSearch": "Clear search", + "inputSearchTerm": "Input a search term", + "cannotSearchError": "Cannot search content due to an error", + "inputtedSearchTermNoResults": "The inputted search term doesn't have any results", + "created": "Created: $created" }, "settings": { "settings": "Settings", diff --git a/lib/i18n/strings_es.i18n.json b/lib/i18n/strings_es.i18n.json index 5b7a728..6e0168a 100644 --- a/lib/i18n/strings_es.i18n.json +++ b/lib/i18n/strings_es.i18n.json @@ -65,7 +65,14 @@ } }, "search": { - "search": "Buscar" + "search": "Buscar", + "bookmarks": "Marcadores", + "tags": "Etiquetas", + "clearSearch": "Limpiar búsqueda", + "inputSearchTerm": "Introduce un término de búsqueda", + "cannotSearchError": "No se puede buscar el contenido por un error", + "inputtedSearchTermNoResults": "El término de búsqueda introducido no tiene resultados", + "created": "Creado: $created" }, "settings": { "settings": "Ajustes", diff --git a/lib/providers/app_status_provider.g.dart b/lib/providers/app_status_provider.g.dart index 1cfaef1..22feff7 100644 --- a/lib/providers/app_status_provider.g.dart +++ b/lib/providers/app_status_provider.g.dart @@ -21,7 +21,7 @@ final selectedThemeProvider = AutoDisposeProvider.internal( ); typedef SelectedThemeRef = AutoDisposeProviderRef; -String _$appStatusHash() => r'cde4567157dd5a89f842a2818efc199db3585232'; +String _$appStatusHash() => r'7f263e1933715c0ec6305e453af99b9a264156b6'; /// See also [AppStatus]. @ProviderFor(AppStatus) diff --git a/lib/screens/links/provider/add_link.provider.g.dart b/lib/screens/links/provider/add_link.provider.g.dart index d04f5ee..5cec596 100644 --- a/lib/screens/links/provider/add_link.provider.g.dart +++ b/lib/screens/links/provider/add_link.provider.g.dart @@ -304,7 +304,7 @@ final getTagsProvider = AutoDisposeFutureProvider>.internal( ); typedef GetTagsRef = AutoDisposeFutureProviderRef>; -String _$addLinkHash() => r'fd6e2629386254ace7ac7abeff0e566dda69575a'; +String _$addLinkHash() => r'6a5c26a8e562236c7d3806bca869d932097fa6a6'; /// See also [AddLink]. @ProviderFor(AddLink) diff --git a/lib/screens/links/ui/link_item.dart b/lib/screens/links/ui/link_item.dart new file mode 100644 index 0000000..a41072f --- /dev/null +++ b/lib/screens/links/ui/link_item.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:linkdy/models/data/bookmarks.dart'; +import 'package:linkdy/providers/app_status_provider.dart'; +import 'package:linkdy/providers/router_provider.dart'; +import 'package:linkdy/router/paths.dart'; +import 'package:linkdy/utils/date_to_string.dart'; +import 'package:linkdy/utils/open_url.dart'; + +class LinkItem extends ConsumerWidget { + final Bookmark bookmark; + + const LinkItem({ + Key? key, + required this.bookmark, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + String validateStrings(String? string1, String? string2) { + if (string1 != null && string1.isNotEmpty) { + return string1; + } else if (string2 != null && string2.isNotEmpty) { + return string2; + } else { + return ""; + } + } + + return ListTile( + onTap: ref.watch(appStatusProvider).useInAppBrowser == true + ? () => ref.watch(routerProvider).push(RoutesPaths.webview, extra: bookmark) + : () => openUrl(bookmark.url!), + isThreeLine: true, + title: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + validateStrings(bookmark.title, bookmark.websiteTitle), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + validateStrings(bookmark.description, bookmark.websiteDescription), + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + bookmark.tagNames?.map((tag) => "#$tag").join(" ") ?? '', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (bookmark.dateModified != null) ...[ + if (bookmark.tagNames?.isNotEmpty == true) + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Theme.of(context).colorScheme.tertiary.withOpacity(0.3), + ), + Text( + dateToString(bookmark.dateModified!), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/links/ui/links.dart b/lib/screens/links/ui/links.dart index c2ebd86..2c40ab7 100644 --- a/lib/screens/links/ui/links.dart +++ b/lib/screens/links/ui/links.dart @@ -11,6 +11,7 @@ import 'package:linkdy/providers/app_status_provider.dart'; import 'package:linkdy/providers/router_provider.dart'; import 'package:linkdy/router/paths.dart'; import 'package:linkdy/i18n/strings.g.dart'; +import 'package:linkdy/screens/links/ui/link_item.dart'; import 'package:linkdy/utils/open_url.dart'; class Links extends ConsumerWidget { @@ -22,33 +23,6 @@ class Links extends ConsumerWidget { final width = MediaQuery.of(context).size.width; - String validateStrings(String? string1, String? string2) { - if (string1 != null && string1.isNotEmpty) { - return string1; - } else if (string2 != null && string2.isNotEmpty) { - return string2; - } else { - return ""; - } - } - - String dateString(DateTime date) { - String addZeros(int value) { - return value.toString().padLeft(2, '0'); - } - - final today = DateTime.now(); - final yesterday = DateTime.now().subtract(const Duration(days: 1)); - - if (date.day == today.day && date.month == today.month && date.year == today.year) { - return t.links.dates.todayAt(time: "${addZeros(date.hour)}:${addZeros(date.minute)}"); - } else if (date.day == yesterday.day && date.month == yesterday.month && date.year == yesterday.year) { - return t.links.dates.yesterdayAt(time: "${addZeros(date.hour)}:${addZeros(date.minute)}"); - } else { - return "${date.day}/${date.month}/${date.year} - ${addZeros(date.hour)}:${addZeros(date.minute)}"; - } - } - void openAddModal() { showGeneralDialog( context: context, @@ -123,72 +97,7 @@ class Links extends ConsumerWidget { final link = bookmarks.value?.content?.results?[index]; return Column( children: [ - ListTile( - onTap: ref.watch(appStatusProvider).useInAppBrowser == true - ? () => ref.watch(routerProvider).push(RoutesPaths.webview, extra: link) - : () => openUrl(link!.url!), - isThreeLine: true, - title: Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - validateStrings(link?.title, link?.websiteTitle), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - validateStrings(link?.description, link?.websiteDescription), - maxLines: 3, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: Text( - link?.tagNames?.map((tag) => "#$tag").join(" ") ?? '', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - if (link?.dateModified != null) ...[ - if (link?.tagNames?.isNotEmpty == true) - Container( - width: 1, - height: 12, - margin: const EdgeInsets.symmetric(horizontal: 8), - color: Theme.of(context).colorScheme.tertiary.withOpacity(0.3), - ), - Text( - dateString(link!.dateModified!), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ], - ), - ), + LinkItem(bookmark: link!), if (index < bookmarks.value!.content!.results!.length - 1) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/screens/search/model/search.model.dart b/lib/screens/search/model/search.model.dart new file mode 100644 index 0000000..d7ae231 --- /dev/null +++ b/lib/screens/search/model/search.model.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class SearchModel { + final TextEditingController searchController; + String? searchTerm; + + SearchModel({ + required this.searchController, + this.searchTerm = "", + }); +} diff --git a/lib/screens/search/provider/search.provider.dart b/lib/screens/search/provider/search.provider.dart new file mode 100644 index 0000000..c9b058f --- /dev/null +++ b/lib/screens/search/provider/search.provider.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:linkdy/screens/search/model/search.model.dart'; + +import 'package:linkdy/models/api_response.dart'; +import 'package:linkdy/models/data/tags.dart'; +import 'package:linkdy/models/data/bookmarks.dart'; +import 'package:linkdy/providers/api_client_provider.dart'; + +part 'search.provider.g.dart'; + +@riverpod +FutureOr?> searchBookmarks(SearchBookmarksRef ref) async { + if (ref.watch(searchProvider).searchTerm == "") return null; + final result = await ref.read(apiClientProviderProvider)!.fetchBookmarks(q: ref.watch(searchProvider).searchTerm); + return result; +} + +@riverpod +FutureOr?> searchTags(SearchTagsRef ref) async { + if (ref.watch(searchProvider).searchTerm == "") return null; + final result = await ref.read(apiClientProviderProvider)!.fetchTags(q: ref.watch(searchProvider).searchTerm); + return result; +} + +@riverpod +class Search extends _$Search { + @override + SearchModel build() { + return SearchModel( + searchController: TextEditingController(), + ); + } + + void clearSearch() { + state.searchController.clear(); + state.searchTerm = ""; + ref.notifyListeners(); + } + + void notifyOnChange() { + ref.notifyListeners(); + } + + void setSearchTerm(String value) { + state.searchTerm = value; + ref.notifyListeners(); + } +} diff --git a/lib/screens/search/provider/search.provider.g.dart b/lib/screens/search/provider/search.provider.g.dart new file mode 100644 index 0000000..935b356 --- /dev/null +++ b/lib/screens/search/provider/search.provider.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$searchBookmarksHash() => r'5e5ace5cf7231e9c6b919578700fa1f34cf3aca3'; + +/// See also [searchBookmarks]. +@ProviderFor(searchBookmarks) +final searchBookmarksProvider = + AutoDisposeFutureProvider?>.internal( + searchBookmarks, + name: r'searchBookmarksProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchBookmarksHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SearchBookmarksRef + = AutoDisposeFutureProviderRef?>; +String _$searchTagsHash() => r'8470a8d018af64a04b1716b43d1d98dcb54f491d'; + +/// See also [searchTags]. +@ProviderFor(searchTags) +final searchTagsProvider = + AutoDisposeFutureProvider?>.internal( + searchTags, + name: r'searchTagsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$searchTagsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SearchTagsRef = AutoDisposeFutureProviderRef?>; +String _$searchHash() => r'bdb992f3343d912d9422fa5da14ad0d5704f18e7'; + +/// See also [Search]. +@ProviderFor(Search) +final searchProvider = + AutoDisposeNotifierProvider.internal( + Search.new, + name: r'searchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$searchHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Search = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/screens/search/ui/bookmarks_search_list.dart b/lib/screens/search/ui/bookmarks_search_list.dart new file mode 100644 index 0000000..da48b48 --- /dev/null +++ b/lib/screens/search/ui/bookmarks_search_list.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:linkdy/screens/search/ui/error_search_list.dart'; +import 'package:linkdy/screens/search/ui/input_search_term.dart'; +import 'package:linkdy/screens/links/ui/link_item.dart'; +import 'package:linkdy/screens/search/provider/search.provider.dart'; +import 'package:linkdy/screens/search/ui/search_no_data.dart'; +import 'package:linkdy/widgets/sliver_tab_body.dart'; + +class BookmarksSearchList extends ConsumerWidget { + const BookmarksSearchList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bookmarksSearch = ref.watch(searchBookmarksProvider); + + if (ref.watch(searchProvider).searchTerm == "") { + return const SliverTabBody( + child: SliverFillRemaining( + child: InputSearchTerm(), + ), + ); + } + + return bookmarksSearch.when( + data: (data) { + if (data!.successful == false) { + return const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: Icon(Icons.error_rounded), + ), + ), + ); + } else if (data.content!.results!.isEmpty) { + return const SliverTabBody( + child: SliverFillRemaining( + child: SearchNoData(), + ), + ); + } else { + return SliverTabBodyRefresh( + onRefresh: () => ref.refresh(searchBookmarksProvider.future), + child: SliverList.builder( + itemCount: data.content!.results!.length, + itemBuilder: (context, index) => LinkItem( + bookmark: data.content!.results![index], + ), + ), + ); + } + }, + error: (error, stackTrace) => const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: ErrorSearchList(), + ), + ), + ), + loading: () => const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ), + ); + } +} diff --git a/lib/screens/search/ui/error_search_list.dart b/lib/screens/search/ui/error_search_list.dart new file mode 100644 index 0000000..afd8f3d --- /dev/null +++ b/lib/screens/search/ui/error_search_list.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:linkdy/i18n/strings.g.dart'; + +class ErrorSearchList extends StatelessWidget { + const ErrorSearchList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_rounded, + size: 50, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 30), + Text( + t.search.cannotSearchError, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/search/ui/input_search_term.dart b/lib/screens/search/ui/input_search_term.dart new file mode 100644 index 0000000..b427c16 --- /dev/null +++ b/lib/screens/search/ui/input_search_term.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:linkdy/i18n/strings.g.dart'; + +class InputSearchTerm extends StatelessWidget { + const InputSearchTerm({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_rounded, + size: 50, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 30), + Text( + t.search.inputSearchTerm, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/search/ui/search.dart b/lib/screens/search/ui/search.dart index 8bffc1b..c876e1f 100644 --- a/lib/screens/search/ui/search.dart +++ b/lib/screens/search/ui/search.dart @@ -1,10 +1,106 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class Search extends StatelessWidget { +import 'package:linkdy/screens/search/provider/search.provider.dart'; + +import 'package:linkdy/i18n/strings.g.dart'; +import 'package:linkdy/screens/search/ui/bookmarks_search_list.dart'; +import 'package:linkdy/screens/search/ui/tags_search_list.dart'; + +class Search extends ConsumerStatefulWidget { const Search({Key? key}) : super(key: key); + @override + SearchState createState() => SearchState(); +} + +class SearchState extends ConsumerState with TickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + _tabController = TabController(length: 2, vsync: this); + super.initState(); + } + @override Widget build(BuildContext context) { - return Container(); + return Scaffold( + body: DefaultTabController( + length: 2, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: SliverAppBar( + pinned: true, + floating: true, + centerTitle: false, + forceElevated: innerBoxIsScrolled, + toolbarHeight: 70, + title: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: TextFormField( + controller: ref.watch(searchProvider).searchController, + onChanged: (_) => ref.read(searchProvider.notifier).notifyOnChange(), + onFieldSubmitted: ref.read(searchProvider.notifier).setSearchTerm, + decoration: InputDecoration( + hintText: t.search.search, + prefixIcon: const Icon(Icons.search_rounded), + border: InputBorder.none, + filled: true, + fillColor: Colors.grey.withOpacity(0.2), + suffixIcon: ref.watch(searchProvider).searchController.text != "" + ? IconButton( + onPressed: ref.read(searchProvider.notifier).clearSearch, + icon: const Icon( + Icons.close_rounded, + size: 20, + ), + tooltip: t.search.clearSearch, + ) + : null, + ), + textInputAction: TextInputAction.search, + ), + ), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.link_rounded), + const SizedBox(width: 8), + Text(t.search.bookmarks), + ], + ), + ), + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.tag_rounded), + const SizedBox(width: 8), + Text(t.search.tags), + ], + ), + ), + ], + ), + ), + ), + ], + body: TabBarView( + controller: _tabController, + children: const [ + BookmarksSearchList(), + TagsSearchList(), + ], + ), + ), + ), + ); } } diff --git a/lib/screens/search/ui/search_no_data.dart b/lib/screens/search/ui/search_no_data.dart new file mode 100644 index 0000000..d61e562 --- /dev/null +++ b/lib/screens/search/ui/search_no_data.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:linkdy/i18n/strings.g.dart'; + +class SearchNoData extends StatelessWidget { + const SearchNoData({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.clear_rounded, + size: 50, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 30), + Text( + t.search.inputtedSearchTermNoResults, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/search/ui/tags_search_list.dart b/lib/screens/search/ui/tags_search_list.dart new file mode 100644 index 0000000..ded6802 --- /dev/null +++ b/lib/screens/search/ui/tags_search_list.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:linkdy/screens/search/provider/search.provider.dart'; +import 'package:linkdy/screens/search/ui/error_search_list.dart'; +import 'package:linkdy/screens/search/ui/input_search_term.dart'; +import 'package:linkdy/screens/search/ui/search_no_data.dart'; +import 'package:linkdy/widgets/sliver_tab_body.dart'; + +import 'package:linkdy/i18n/strings.g.dart'; +import 'package:linkdy/utils/date_to_string.dart'; + +class TagsSearchList extends ConsumerWidget { + const TagsSearchList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bookmarksSearch = ref.watch(searchTagsProvider); + + if (ref.watch(searchProvider).searchTerm == "") { + return const SliverTabBody( + child: SliverFillRemaining( + child: InputSearchTerm(), + ), + ); + } + + return bookmarksSearch.when( + data: (data) { + if (data!.successful == false) { + return const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: Icon(Icons.error_rounded), + ), + ), + ); + } else if (data.content!.results!.isEmpty) { + return const SliverTabBody( + child: SliverFillRemaining( + child: SearchNoData(), + ), + ); + } else { + return SliverTabBodyRefresh( + onRefresh: () => ref.refresh(searchTagsProvider.future), + child: SliverList.builder( + itemCount: data.content!.results!.length, + itemBuilder: (context, index) => ListTile( + title: Text( + data.content!.results![index].name!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + subtitle: Text( + t.search.created( + created: dateToString(data.content!.results![index].dateAdded!), + ), + ), + ), + ), + ); + } + }, + error: (error, stackTrace) => const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: ErrorSearchList(), + ), + ), + ), + loading: () => const SliverTabBody( + child: SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ), + ); + } +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 9452bda..52e4539 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -76,9 +76,16 @@ class ApiClient { } } - Future> fetchTags() async { + Future> fetchTags({String? q, int? limit, int? offset}) async { try { - final response = await dioInstance.get("/tags/"); + final response = await dioInstance.get( + "/tags/", + queryParameters: { + "q": q, + "limit": limit, + "offset": offset, + }, + ); return ApiResponse( successful: true, content: Tags.fromJson(response.data), diff --git a/lib/utils/date_to_string.dart b/lib/utils/date_to_string.dart new file mode 100644 index 0000000..2ba001e --- /dev/null +++ b/lib/utils/date_to_string.dart @@ -0,0 +1,18 @@ +import 'package:linkdy/i18n/strings.g.dart'; + +String dateToString(DateTime date) { + String addZeros(int value) { + return value.toString().padLeft(2, '0'); + } + + final today = DateTime.now(); + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + + if (date.day == today.day && date.month == today.month && date.year == today.year) { + return t.links.dates.todayAt(time: "${addZeros(date.hour)}:${addZeros(date.minute)}"); + } else if (date.day == yesterday.day && date.month == yesterday.month && date.year == yesterday.year) { + return t.links.dates.yesterdayAt(time: "${addZeros(date.hour)}:${addZeros(date.minute)}"); + } else { + return "${date.day}/${date.month}/${date.year} - ${addZeros(date.hour)}:${addZeros(date.minute)}"; + } +} diff --git a/lib/utils/tab_content_list.dart b/lib/utils/tab_content_list.dart new file mode 100644 index 0000000..64726bd --- /dev/null +++ b/lib/utils/tab_content_list.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:linkdy/constants/enums.dart'; + +class CustomTabContentList extends StatelessWidget { + final Widget Function() loadingGenerator; + final int itemsCount; + final Widget Function(int index) contentWidget; + final Widget noData; + final Widget Function() errorGenerator; + final LoadStatus loadStatus; + final Future Function() onRefresh; + final double? refreshIndicatorOffset; + final bool? fabVisible; + final bool? noSliver; + final EdgeInsets? listPadding; + final double? heightFabHidden; + + const CustomTabContentList({ + super.key, + required this.loadingGenerator, + required this.itemsCount, + required this.contentWidget, + required this.noData, + required this.errorGenerator, + required this.loadStatus, + required this.onRefresh, + this.refreshIndicatorOffset, + this.fabVisible, + this.noSliver, + this.listPadding, + this.heightFabHidden, + }); + + @override + Widget build(BuildContext context) { + switch (loadStatus) { + case LoadStatus.loading: + if (noSliver == true) { + return Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: loadingGenerator()); + } else { + return SafeArea( + top: false, + child: Builder( + builder: (BuildContext context) => CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + SliverFillRemaining( + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: loadingGenerator()), + ), + ], + ), + ), + ); + } + + case LoadStatus.loaded: + if (noSliver == true) { + if (itemsCount > 0) { + return SafeArea( + child: ListView.builder( + padding: listPadding, + itemCount: itemsCount, + itemBuilder: (context, index) => contentWidget(index), + ), + ); + } else { + return SafeArea( + child: noData, + ); + } + } else { + return SafeArea( + top: false, + bottom: false, + child: Builder( + builder: (BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + edgeOffset: refreshIndicatorOffset ?? 70, + child: CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + if (itemsCount > 0) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => contentWidget(index), + childCount: itemsCount, + ), + ), + if (itemsCount == 0) + SliverFillRemaining( + child: noData, + ), + ], + ), + ); + }, + ), + ); + } + + case LoadStatus.error: + if (noSliver == true) { + return Padding(padding: const EdgeInsets.only(top: 95, left: 16, right: 16), child: errorGenerator()); + } else { + return SafeArea( + top: false, + child: Builder( + builder: (BuildContext context) => CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.only(top: 95, left: 16, right: 16), + child: errorGenerator(), + ), + ), + ], + ), + ), + ); + } + + default: + return const SizedBox(); + } + } +} diff --git a/lib/widgets/sliver_tab_body.dart b/lib/widgets/sliver_tab_body.dart new file mode 100644 index 0000000..ccb88dc --- /dev/null +++ b/lib/widgets/sliver_tab_body.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class SliverTabBody extends StatelessWidget { + final Widget child; + + const SliverTabBody({Key? key, required this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Builder( + builder: (BuildContext context) => CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + child, + ], + ), + ), + ); + } +} + +class SliverTabBodyRefresh extends StatelessWidget { + final Widget child; + final Future Function() onRefresh; + + const SliverTabBodyRefresh({ + Key? key, + required this.child, + required this.onRefresh, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Builder( + builder: (BuildContext context) => RefreshIndicator( + displacement: 120, + onRefresh: onRefresh, + child: CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + child, + ], + ), + ), + ), + ); + } +}