From 89e9dbb281d402542e884dfbc5bc2f413c2d79e3 Mon Sep 17 00:00:00 2001 From: NaniDeagle Date: Sun, 19 Nov 2023 21:43:10 +0300 Subject: [PATCH] A bunch of changes - update to flutter 3.16 - update deps (dart_vlc is now taken from main git) - bump targetSdkVersion to 34 - show init state during app start on special screen - new booru handler to view Downloads - rework how item filtering works and used (fixes lags during fast scroll and loading of new pages) - added DB indexing (may cause long first start after the update for some) - reworked "tab with this tag exists" indication - added ability to filter snatched items and items with AI related tags - downloads drawer [WIP] - moved from WillPopScope to PopScope - reworked tab selector [a bit incomplete, needs another bug and design pass] + rework history viewer - fixed scaling bugs with notes (error in calc when fully zoomed, sample images didn't scale position properly, takes image dimensions directly from the loaded image through photo_view) - scroll to search in tagview on focus - attempt to make double tap zoom to tap position (works poorly, still needs work, see photo_view fork) - added double-tap-drag-zoom - old tag searches from booru now properly cancel when starting a new one, which fixes race condition when old results could override newer ones - fixed videos unpausing after restate when they shouldn't - thumbnails scrollbar is now interactive (fixed in inner_drawer fork, it had a container which blocked interaction) - other small fixes --- .fvm/fvm_config.json | 2 +- analysis_options.yaml | 2 - android/app/build.gradle | 4 +- android/build.gradle | 2 +- lib/main.dart | 43 +- lib/src/boorus/booru_on_rails_handler.dart | 2 +- lib/src/boorus/booru_type.dart | 5 +- lib/src/boorus/downloads_handler.dart | 68 + lib/src/boorus/e621_handler.dart | 2 +- lib/src/boorus/favourites_handler.dart | 23 +- lib/src/boorus/gelbooru_handler.dart | 3 +- lib/src/boorus/hydrus_handler.dart | 2 +- lib/src/boorus/ink_bunny_handler.dart | 4 +- lib/src/boorus/mergebooru_handler.dart | 5 +- lib/src/boorus/philomena_handler.dart | 2 +- lib/src/boorus/rainbooru_handler.dart | 4 +- lib/src/boorus/sankaku_handler.dart | 2 +- lib/src/boorus/szurubooru_handler.dart | 2 +- lib/src/boorus/worldxyz_handler.dart | 6 +- lib/src/data/booru_item.dart | 19 +- lib/src/data/tag_type.dart | 6 + lib/src/handlers/booru_handler.dart | 65 +- lib/src/handlers/booru_handler_factory.dart | 4 + lib/src/handlers/database_handler.dart | 70 +- lib/src/handlers/loli_sync_handler.dart | 2 +- lib/src/handlers/search_handler.dart | 77 +- lib/src/handlers/settings_handler.dart | 187 ++- lib/src/handlers/snatch_handler.dart | 7 +- lib/src/handlers/tag_handler.dart | 32 +- lib/src/handlers/theme_handler.dart | 21 +- lib/src/handlers/viewer_handler.dart | 6 +- lib/src/pages/desktop_home_page.dart | 10 +- lib/src/pages/gallery_view_page.dart | 3 +- lib/src/pages/init_home_page.dart | 59 + lib/src/pages/loli_sync_page.dart | 18 +- lib/src/pages/loli_sync_progress_page.dart | 24 +- lib/src/pages/mobile_home_page.dart | 410 ++++- .../pages/settings/backup_restore_page.dart | 29 +- lib/src/pages/settings/booru_edit_page.dart | 34 +- lib/src/pages/settings/booru_page.dart | 29 +- lib/src/pages/settings/database_page.dart | 119 +- lib/src/pages/settings/debug_page.dart | 31 +- lib/src/pages/settings/dir_picker_page.dart | 20 +- lib/src/pages/settings/gallery_page.dart | 19 +- lib/src/pages/settings/logger_page.dart | 13 +- lib/src/pages/settings/save_cache_page.dart | 17 +- lib/src/pages/settings/settings_template.dart | 15 +- lib/src/pages/settings/tags_filters_page.dart | 31 +- lib/src/pages/settings/theme_page.dart | 19 +- .../pages/settings/user_interface_page.dart | 68 +- lib/src/pages/settings_page.dart | 19 +- lib/src/pages/snatcher_page.dart | 16 +- lib/src/services/get_perms.dart | 3 +- lib/src/utils/extensions.dart | 23 +- lib/src/utils/html_parse.dart | 3 +- lib/src/utils/logger.dart | 30 +- lib/src/widgets/common/bordered_text.dart | 2 +- lib/src/widgets/common/kaomoji.dart | 51 + lib/src/widgets/common/marquee_text.dart | 112 +- lib/src/widgets/common/media_loading.dart | 8 +- lib/src/widgets/common/settings_widgets.dart | 74 +- lib/src/widgets/common/text_expander.dart | 66 - .../desktop/desktop_image_listener.dart | 2 +- lib/src/widgets/desktop/desktop_tabs.dart | 15 +- lib/src/widgets/dialogs/comments_dialog.dart | 2 +- lib/src/widgets/gallery/hideable_appbar.dart | 19 +- lib/src/widgets/gallery/notes_renderer.dart | 54 +- lib/src/widgets/gallery/tag_view.dart | 89 +- lib/src/widgets/history/history.dart | 21 +- .../widgets/image/custom_network_image.dart | 5 +- lib/src/widgets/image/favicon.dart | 19 +- lib/src/widgets/image/image_viewer.dart | 204 ++- lib/src/widgets/preview/grid_builder.dart | 2 +- lib/src/widgets/preview/shimmer_builder.dart | 2 +- .../widgets/preview/staggered_builder.dart | 6 +- lib/src/widgets/preview/waterfall_view.dart | 48 +- lib/src/widgets/root/active_title.dart | 5 +- lib/src/widgets/root/main_appbar.dart | 83 +- lib/src/widgets/root/scroll_physics.dart | 4 +- lib/src/widgets/search/tag_chip.dart | 12 +- lib/src/widgets/search/tag_search_box.dart | 52 +- lib/src/widgets/tabs/tab_booru_selector.dart | 12 +- lib/src/widgets/tabs/tab_filters_dialog.dart | 130 ++ lib/src/widgets/tabs/tab_manager_dialog.dart | 799 --------- lib/src/widgets/tabs/tab_move_dialog.dart | 193 ++- lib/src/widgets/tabs/tab_row.dart | 142 +- lib/src/widgets/tabs/tab_selector.dart | 1496 +++++++++++++++-- .../widgets/tags_filters/tf_list_item.dart | 10 +- .../tags_filters/tf_settings_list.dart | 22 + lib/src/widgets/tags_manager/tm_dialog.dart | 14 +- .../widgets/tags_manager/tm_list_item.dart | 29 +- lib/src/widgets/thumbnail/thumbnail.dart | 407 +++-- .../widgets/thumbnail/thumbnail_build.dart | 208 +-- .../thumbnail/thumbnail_card_build.dart | 34 +- .../widgets/video/guess_extension_viewer.dart | 1 - lib/src/widgets/video/loli_controls.dart | 4 +- .../video/unknown_viewer_placeholder.dart | 1 - lib/src/widgets/video/video_viewer.dart | 24 +- .../widgets/video/video_viewer_desktop.dart | 29 +- .../video/video_viewer_placeholder.dart | 1 - lib/src/widgets/webview/webview_page.dart | 68 +- linux/flutter/generated_plugin_registrant.cc | 8 - linux/flutter/generated_plugins.cmake | 2 - pubspec.yaml | 108 +- windows/flutter/CMakeLists.txt | 7 +- .../flutter/generated_plugin_registrant.cc | 9 - windows/flutter/generated_plugins.cmake | 3 - 107 files changed, 4043 insertions(+), 2416 deletions(-) create mode 100644 lib/src/boorus/downloads_handler.dart create mode 100644 lib/src/pages/init_home_page.dart create mode 100644 lib/src/widgets/common/kaomoji.dart delete mode 100644 lib/src/widgets/common/text_expander.dart create mode 100644 lib/src/widgets/tabs/tab_filters_dialog.dart delete mode 100644 lib/src/widgets/tabs/tab_manager_dialog.dart diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 2f49d155..f1f9ceed 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.10.6", + "flutterSdkVersion": "3.16.0", "flavors": {} } \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index db4e847e..8967d4cf 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -78,14 +78,12 @@ linter: implementation_imports: true implicit_reopen: true invalid_case_patterns: true - iterable_contains_unrelated_type: true join_return_with_assignment: true leading_newlines_in_multiline_strings: true library_annotations: true library_names: true library_prefixes: true library_private_types_in_public_api: true - list_remove_unrelated_type: true literal_only_boolean_expressions: true missing_whitespace_between_adjacent_strings: true no_adjacent_strings_in_list: true diff --git a/android/app/build.gradle b/android/app/build.gradle index 2b65b18f..8930254d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -50,7 +50,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -64,7 +64,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId packageName minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/build.gradle b/android/build.gradle index 63b1aac2..0425fc73 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.9.0' repositories { google() mavenCentral() diff --git a/lib/main.dart b/lib/main.dart index 100530d4..0ff89356 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ import 'package:lolisnatcher/src/handlers/tag_handler.dart'; import 'package:lolisnatcher/src/handlers/theme_handler.dart'; import 'package:lolisnatcher/src/handlers/viewer_handler.dart'; import 'package:lolisnatcher/src/pages/desktop_home_page.dart'; +import 'package:lolisnatcher/src/pages/init_home_page.dart'; import 'package:lolisnatcher/src/pages/mobile_home_page.dart'; import 'package:lolisnatcher/src/pages/settings/booru_edit_page.dart'; import 'package:lolisnatcher/src/services/image_writer.dart'; @@ -39,7 +40,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); if (Platform.isWindows || Platform.isLinux) { - await DartVLC.initialize(); + DartVLC.initialize(); // Init db stuff sqfliteFfiInit(); @@ -149,12 +150,13 @@ class _MainAppState extends State { Future initHandlers() async { // should init earlier than tabs so tags color properly on first render of search box // TODO but this possibly could lead to bad preformance on start if tag storage is too big? - await Future.wait([ - tagHandler.initialize(), - searchHandler.restoreTabs(), - ]); - settingsHandler.alice.setNavigatorKey(navigationHandler.navigatorKey); + await settingsHandler.postInit(() async { + settingsHandler.postInitMessage.value = 'Loading tags...'; + await tagHandler.initialize(); + settingsHandler.postInitMessage.value = 'Restoring tabs...'; + await searchHandler.restoreTabs(); + }); } Future setMaxFPS() async { @@ -399,13 +401,28 @@ class _HomeState extends State with WidgetsBindingObserver { } } - return Obx(() { - if (settingsHandler.appMode.value.isMobile) { - return const MobileHome(); - } else { - return const DesktopHome(); - } - }); + return ColoredBox( + color: Theme.of(context).colorScheme.background, + child: Obx(() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Builder( + key: ValueKey('init:${settingsHandler.isPostInit.value}-mobile:${settingsHandler.appMode.value.isMobile}'), + builder: (context) { + if (settingsHandler.isPostInit.value == false) { + return const InitHomePage(); + } + + if (settingsHandler.appMode.value.isMobile) { + return const MobileHome(); + } else { + return const DesktopHome(); + } + }, + ), + ); + }), + ); // with lockscreen: // return Obx(() { diff --git a/lib/src/boorus/booru_on_rails_handler.dart b/lib/src/boorus/booru_on_rails_handler.dart index 8cf580ae..35ba2653 100644 --- a/lib/src/boorus/booru_on_rails_handler.dart +++ b/lib/src/boorus/booru_on_rails_handler.dart @@ -112,7 +112,7 @@ class BooruOnRailsHandler extends BooruHandler { ['-fwslash-', '/'], ['-bwslash-', r'\'], ['-dot-', '.'], - ['-plus-', '+'] + ['-plus-', '+'], ]; @override diff --git a/lib/src/boorus/booru_type.dart b/lib/src/boorus/booru_type.dart index fd283ea7..3dc0df15 100644 --- a/lib/src/boorus/booru_type.dart +++ b/lib/src/boorus/booru_type.dart @@ -2,7 +2,7 @@ enum BooruType { AutoDetect, - // + // AGNPH, BooruOnRails, Danbooru, @@ -27,10 +27,12 @@ enum BooruType { // [Special types] GelbooruAlike, Merge, + Downloads, Favourites; static List get dropDownValues { return [...values] + ..remove(BooruType.Downloads) ..remove(BooruType.Favourites) ..remove(BooruType.Merge) ..remove(BooruType.GelbooruAlike); @@ -38,6 +40,7 @@ enum BooruType { static List get detectable { return [...values] + ..remove(BooruType.Downloads) ..remove(BooruType.Favourites) ..remove(BooruType.Merge) ..remove(BooruType.AutoDetect) diff --git a/lib/src/boorus/downloads_handler.dart b/lib/src/boorus/downloads_handler.dart new file mode 100644 index 00000000..8de8403f --- /dev/null +++ b/lib/src/boorus/downloads_handler.dart @@ -0,0 +1,68 @@ +import 'package:dio/dio.dart'; + +import 'package:lolisnatcher/src/handlers/booru_handler.dart'; +import 'package:lolisnatcher/src/handlers/settings_handler.dart'; +import 'package:lolisnatcher/src/utils/logger.dart'; + +class DownloadsHandler extends BooruHandler { + DownloadsHandler(super.booru, super.limit); + + @override + String validateTags(String tags) { + return tags; + } + + @override + Future search(String tags, int? pageNumCustom, {bool withCaptchaCheck = true}) async { + // set custom page number + if (pageNumCustom != null) { + pageNum = pageNumCustom; + } + + // validate tags + tags = validateTags(tags.trim()); + + // if tags are different than previous tags, reset fetched + if (prevTags != tags) { + fetched.value = []; + totalCount.value = 0; + } + + // get amount of items before fetching + final int length = fetched.length; + + final newItems = await SettingsHandler.instance.dbHandler.searchDB( + tags, + (pageNum * limit).toString(), + limit.toString(), + 'DESC', + 'Favourites', + isDownloads: true, + ); + + await afterParseResponse(newItems); + prevTags = tags; + + if (fetched.isEmpty || fetched.length == length) { + Logger.Inst().log('dbhandler dbLocked', 'DownloadsHandler', 'search', LogTypes.booruHandlerInfo); + locked = true; + } + + return fetched; + } + + @override + Future> tagSearch(String input, {CancelToken? cancelToken}) async { + final List tags = await SettingsHandler.instance.dbHandler.getTags(input, limit); + return tags; + } + + @override + Future searchCount(String input) async { + totalCount.value = await SettingsHandler.instance.dbHandler.searchDBCount( + input, + isDownloads: true, + ); + return; + } +} diff --git a/lib/src/boorus/e621_handler.dart b/lib/src/boorus/e621_handler.dart index 9e590b6e..a46acf46 100644 --- a/lib/src/boorus/e621_handler.dart +++ b/lib/src/boorus/e621_handler.dart @@ -67,7 +67,7 @@ class e621Handler extends BooruHandler { ...current['tags']['artist'], ...current['tags']['meta'], ...current['tags']['general'], - ...current['tags']['species'] + ...current['tags']['species'], ], postURL: makePostURL(current['id'].toString()), fileExt: current['file']['ext'], diff --git a/lib/src/boorus/favourites_handler.dart b/lib/src/boorus/favourites_handler.dart index 2a358c74..48d9f88c 100644 --- a/lib/src/boorus/favourites_handler.dart +++ b/lib/src/boorus/favourites_handler.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; + import 'package:lolisnatcher/src/handlers/booru_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/utils/logger.dart'; @@ -18,7 +20,7 @@ class FavouritesHandler extends BooruHandler { } // validate tags - tags = validateTags(tags); + tags = validateTags(tags.trim()); // if tags are different than previous tags, reset fetched if (prevTags != tags) { @@ -29,15 +31,16 @@ class FavouritesHandler extends BooruHandler { // get amount of items before fetching final int length = fetched.length; - fetched.addAll( - await SettingsHandler.instance.dbHandler.searchDB( - tags, - (pageNum * limit).toString(), - limit.toString(), - 'DESC', - 'Favourites', - ), + final newItems = await SettingsHandler.instance.dbHandler.searchDB( + tags, + (pageNum * limit).toString(), + limit.toString(), + 'DESC', + 'Favourites', ); + + await afterParseResponse(newItems); + prevTags = tags; if (fetched.isEmpty || fetched.length == length) { @@ -49,7 +52,7 @@ class FavouritesHandler extends BooruHandler { } @override - Future> tagSearch(String input) async { + Future> tagSearch(String input, {CancelToken? cancelToken}) async { final List tags = await SettingsHandler.instance.dbHandler.getTags(input, limit); return tags; } diff --git a/lib/src/boorus/gelbooru_handler.dart b/lib/src/boorus/gelbooru_handler.dart index 910cb8db..5af5e46c 100644 --- a/lib/src/boorus/gelbooru_handler.dart +++ b/lib/src/boorus/gelbooru_handler.dart @@ -32,7 +32,7 @@ class GelbooruHandler extends BooruHandler { Map getHeaders() { return { ...super.getHeaders(), - 'Cookie': 'fringeBenefits=yup;' // unlocks restricted content (but it's probably not necessary) + 'Cookie': 'fringeBenefits=yup;', // unlocks restricted content (but it's probably not necessary) }; } @@ -118,6 +118,7 @@ class GelbooruHandler extends BooruHandler { Future afterParseResponse(List newItems) async { final int lengthBefore = fetched.length; fetched.addAll(newItems); + filterFetched(); await populateTagHandler(newItems); // difference from default afterParse unawaited(setMultipleTrackedValues(lengthBefore, fetched.length)); } diff --git a/lib/src/boorus/hydrus_handler.dart b/lib/src/boorus/hydrus_handler.dart index 5913977d..6b668406 100644 --- a/lib/src/boorus/hydrus_handler.dart +++ b/lib/src/boorus/hydrus_handler.dart @@ -15,7 +15,7 @@ import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; class HydrusHandler extends BooruHandler { HydrusHandler(super.booru, super.limit); - var _fileIDs; + dynamic _fileIDs; @override Map getHeaders() { diff --git a/lib/src/boorus/ink_bunny_handler.dart b/lib/src/boorus/ink_bunny_handler.dart index 5078e14d..952cb9aa 100644 --- a/lib/src/boorus/ink_bunny_handler.dart +++ b/lib/src/boorus/ink_bunny_handler.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:math'; +import 'package:dio/dio.dart'; + import 'package:lolisnatcher/src/data/booru_item.dart'; import 'package:lolisnatcher/src/handlers/booru_handler.dart'; import 'package:lolisnatcher/src/utils/logger.dart'; @@ -219,7 +221,7 @@ class InkBunnyHandler extends BooruHandler { } @override - Future> tagSearch(String input) async { + Future> tagSearch(String input, {CancelToken? cancelToken}) async { final List searchTags = []; final String url = makeTagURL(input); try { diff --git a/lib/src/boorus/mergebooru_handler.dart b/lib/src/boorus/mergebooru_handler.dart index 9a3dc01a..5961d002 100644 --- a/lib/src/boorus/mergebooru_handler.dart +++ b/lib/src/boorus/mergebooru_handler.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; import 'package:lolisnatcher/src/boorus/booru_type.dart'; import 'package:lolisnatcher/src/data/booru.dart'; @@ -146,8 +147,8 @@ class MergebooruHandler extends BooruHandler { } @override - Future> tagSearch(String input) async { - return booruHandlers[0].tagSearch(input); + Future> tagSearch(String input, {CancelToken? cancelToken}) async { + return booruHandlers[0].tagSearch(input, cancelToken: cancelToken); } @override diff --git a/lib/src/boorus/philomena_handler.dart b/lib/src/boorus/philomena_handler.dart index 1a31001e..60c74856 100644 --- a/lib/src/boorus/philomena_handler.dart +++ b/lib/src/boorus/philomena_handler.dart @@ -107,7 +107,7 @@ class PhilomenaHandler extends BooruHandler { ['-fwslash-', '/'], ['-bwslash-', r'\'], ['-dot-', '.'], - ['-plus-', '+'] + ['-plus-', '+'], ]; String tag = responseItem['slug'].toString(); diff --git a/lib/src/boorus/rainbooru_handler.dart b/lib/src/boorus/rainbooru_handler.dart index 9181762b..a14144ca 100644 --- a/lib/src/boorus/rainbooru_handler.dart +++ b/lib/src/boorus/rainbooru_handler.dart @@ -28,7 +28,7 @@ class RainbooruHandler extends BooruHandler { @override Future parseItemFromResponse(dynamic responseItem, int index) async { String thumbURL = ''; - final urlElem = responseItem.firstChild!; + final urlElem = responseItem.firstChild; thumbURL += urlElem.firstChild!.attributes['src']!; final String url = makePostURL(urlElem.attributes['href']!.split('img/')[1]); final responseInner = await DioNetwork.get(url, headers: getHeaders()); @@ -97,7 +97,7 @@ class RainbooruHandler extends BooruHandler { ['-fwslash-', '/'], ['-bwslash-', r'\'], ['-dot-', '.'], - ['-plus-', '+'] + ['-plus-', '+'], ]; String tag = responseItem['slug'].toString(); diff --git a/lib/src/boorus/sankaku_handler.dart b/lib/src/boorus/sankaku_handler.dart index 02cf3aad..748c14db 100644 --- a/lib/src/boorus/sankaku_handler.dart +++ b/lib/src/boorus/sankaku_handler.dart @@ -156,7 +156,7 @@ class SankakuHandler extends BooruHandler { // 'User-Agent': 'SCChannelApp/4.0', 'User-Agent': Constants.defaultBrowserUserAgent, 'Referer': 'https://sankaku.app/', - 'Origin': 'https://sankaku.app' + 'Origin': 'https://sankaku.app', }; } diff --git a/lib/src/boorus/szurubooru_handler.dart b/lib/src/boorus/szurubooru_handler.dart index 1f7e89d1..252bdd10 100644 --- a/lib/src/boorus/szurubooru_handler.dart +++ b/lib/src/boorus/szurubooru_handler.dart @@ -64,7 +64,7 @@ class SzurubooruHandler extends BooruHandler { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': Tools.browserUserAgent, - if (booru.apiKey?.isNotEmpty == true) 'Authorization': "Token ${base64Encode(utf8.encode("${booru.userID}:${booru.apiKey}"))}" + if (booru.apiKey?.isNotEmpty == true) 'Authorization': "Token ${base64Encode(utf8.encode("${booru.userID}:${booru.apiKey}"))}", }; } diff --git a/lib/src/boorus/worldxyz_handler.dart b/lib/src/boorus/worldxyz_handler.dart index 1815d524..01827588 100644 --- a/lib/src/boorus/worldxyz_handler.dart +++ b/lib/src/boorus/worldxyz_handler.dart @@ -94,7 +94,11 @@ class WorldXyzHandler extends BooruHandler { } @override - Future fetchTagSuggestions(Uri uri, String input) { + Future fetchTagSuggestions( + Uri uri, + String input, { + CancelToken? cancelToken, + }) { return DioNetwork.post( uri.toString(), headers: { diff --git a/lib/src/data/booru_item.dart b/lib/src/data/booru_item.dart index 701bc676..debfa606 100644 --- a/lib/src/data/booru_item.dart +++ b/lib/src/data/booru_item.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:lolisnatcher/src/data/note_item.dart'; +import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/utils/tools.dart'; class BooruItem { @@ -62,7 +63,7 @@ class BooruItem { late Rx mediaType; RxnString possibleExt = RxnString(null); RxnBool isSnatched = RxnBool(null), isFavourite = RxnBool(null); - RxBool isHated = false.obs, isLoved = false.obs, isNoScale = false.obs; + RxBool isNoScale = false.obs; String? fileExt, serverId, rating, score, uploaderName, description, md5String, postDate, postDateFormat; String fileNameExtras; @@ -76,6 +77,22 @@ class BooruItem { return fileAspectRatio != null && fileAspectRatio! < 0.3; } + bool get isHated { + return SettingsHandler.instance.containsHated(tagsList); + } + + bool get isLoved { + return SettingsHandler.instance.containsLoved(tagsList); + } + + bool get isSound { + return SettingsHandler.instance.containsSound(tagsList); + } + + bool get isAI { + return SettingsHandler.instance.containsAI(tagsList); + } + Map toJson() { return { 'postURL': postURL, diff --git a/lib/src/data/tag_type.dart b/lib/src/data/tag_type.dart index b176c758..8db6f87b 100644 --- a/lib/src/data/tag_type.dart +++ b/lib/src/data/tag_type.dart @@ -8,6 +8,12 @@ enum TagType { species, none; + bool get isArtist => this == TagType.artist; + bool get isCharacter => this == TagType.character; + bool get isSpecies => this == TagType.species; + bool get isMeta => this == TagType.meta; + bool get isNone => this == TagType.none; + static TagType fromString(String string) { switch (string) { case 'artist': diff --git a/lib/src/handlers/booru_handler.dart b/lib/src/handlers/booru_handler.dart index c8971212..78765109 100644 --- a/lib/src/handlers/booru_handler.dart +++ b/lib/src/handlers/booru_handler.dart @@ -47,20 +47,40 @@ abstract class BooruHandler { }; getx.RxList fetched = getx.RxList([]); - List get filteredFetched => fetched.where((el) { - final SettingsHandler settingsHandler = SettingsHandler.instance; + getx.RxList filteredFetched = getx.RxList([]); - if (settingsHandler.filterHated && el.isHated.value) { - return false; - } + /// Filters the list of fetched items and stores them in filteredFetched + /// + /// Should always be called after fetched changed (so don't forget to add it in custom afterParseResponse or search methods) + /// (See gelbooru of favourites handlers for example) + void filterFetched() { + final SettingsHandler settingsHandler = SettingsHandler.instance; - final bool filterFavourites = settingsHandler.filterFavourites && booru.type != BooruType.Favourites; - if (filterFavourites && el.isFavourite.value == true) { - return false; - } + final List filteredItems = []; + for (final item in fetched) { + if (settingsHandler.filterHated && item.isHated) { + continue; + } - return true; - }).toList(); + if (settingsHandler.filterAi && item.isAI) { + continue; + } + + final bool filterFavourites = settingsHandler.filterFavourites && booru.type != BooruType.Favourites; + if (filterFavourites && item.isFavourite.value == true) { + continue; + } + + final bool filterSnatched = settingsHandler.filterSnatched && booru.type != BooruType.Downloads; + if (filterSnatched && item.isSnatched.value == true) { + continue; + } + + filteredItems.add(item); + } + + filteredFetched.value = filteredItems; + } String get className => runtimeType.toString(); @@ -141,8 +161,6 @@ abstract class BooruHandler { errorString = e.toString(); } } - - // print('Fetched: ${filteredFetched.length}'); return fetched; } @@ -182,9 +200,6 @@ abstract class BooruHandler { try { final BooruItem? item = await parseItemFromResponse(post, i); if (item != null) { - final List> hatedAndLovedTags = SettingsHandler.instance.parseTagsList(item.tagsList); - item.isHated.value = hatedAndLovedTags[0].isNotEmpty; - item.isLoved.value = hatedAndLovedTags[1].isNotEmpty; newItems.add(item); } } catch (e) { @@ -213,6 +228,7 @@ abstract class BooruHandler { Future afterParseResponse(List newItems) async { final int lengthBefore = fetched.length; fetched.addAll(newItems); + filterFetched(); unawaited(setMultipleTrackedValues(lengthBefore, fetched.length)); // TODO // notifyAboutFailed(); @@ -232,7 +248,7 @@ abstract class BooruHandler { //////////////////////////////////////////////////////////////////////// // TODO rename to getTagSuggestions - Future> tagSearch(String input) async { + Future> tagSearch(String input, {CancelToken? cancelToken}) async { final List tags = []; final String url = makeTagURL(input); @@ -251,7 +267,7 @@ abstract class BooruHandler { Response response; const int limit = 10; try { - response = await fetchTagSuggestions(uri, input); + response = await fetchTagSuggestions(uri, input, cancelToken: cancelToken); if (response.statusCode == 200) { Logger.Inst().log('fetchTagSuggestions response: ${response.data}', className, 'tagSearch', null); final rawTags = await parseTagSuggestionsList(response); @@ -281,14 +297,14 @@ abstract class BooruHandler { return tags; } - Future> fetchTagSuggestions(Uri uri, String input) async { + Future> fetchTagSuggestions(Uri uri, String input, {CancelToken? cancelToken}) async { final String cookies = await getCookies() ?? ''; final Map headers = { ...getHeaders(), if (cookies.isNotEmpty) 'Cookie': cookies, }; - return DioNetwork.get(uri.toString(), headers: headers); + return DioNetwork.get(uri.toString(), headers: headers, cancelToken: cancelToken); } /// [SHOULD BE OVERRIDDEN] @@ -583,9 +599,6 @@ abstract class BooruHandler { fetched[fetchedIndex].isSnatched.value = values[0]; fetched[fetchedIndex].isFavourite.value = values[1]; } - // List> tagLists = settingsHandler.parseTagsList(fetched[fetchedIndex].tagsList); - // fetched[fetchedIndex].isHated.value = tagLists[0].isNotEmpty; - // fetched[fetchedIndex].isLoved.value = tagLists[1].length > 0; return; } @@ -614,12 +627,6 @@ abstract class BooruHandler { valuesList.asMap().forEach((index, values) { fetched[fetchedIndexes[index]].isSnatched.value = values[0]; fetched[fetchedIndexes[index]].isFavourite.value = values[1]; - - // TODO probably leads to worse performance on page loads, change to isolate or async maybe? - // TODO causes freezes on page load, currently moved directly to where item is created, but could cause performance issues there too? - // List> tagLists = settingsHandler.parseTagsList(fetched[fetchedIndexes[index]].tagsList); - // fetched[fetchedIndexes[index]].isHated.value = tagLists[0].isNotEmpty; - // fetched[fetchedIndex].isLoved.value = tagLists[1].length > 0; }); } diff --git a/lib/src/handlers/booru_handler_factory.dart b/lib/src/handlers/booru_handler_factory.dart index 49243ea8..5b1f39a3 100644 --- a/lib/src/handlers/booru_handler_factory.dart +++ b/lib/src/handlers/booru_handler_factory.dart @@ -2,6 +2,7 @@ import 'package:lolisnatcher/src/boorus/agnph_handler.dart'; import 'package:lolisnatcher/src/boorus/booru_on_rails_handler.dart'; import 'package:lolisnatcher/src/boorus/booru_type.dart'; import 'package:lolisnatcher/src/boorus/danbooru_handler.dart'; +import 'package:lolisnatcher/src/boorus/downloads_handler.dart'; import 'package:lolisnatcher/src/boorus/e621_handler.dart'; import 'package:lolisnatcher/src/boorus/empty_handler.dart'; import 'package:lolisnatcher/src/boorus/favourites_handler.dart'; @@ -93,6 +94,9 @@ class BooruHandlerFactory { pageNum = 0; booruHandler = BooruOnRailsHandler(booru, limit); break; + case BooruType.Downloads: + booruHandler = DownloadsHandler(booru, limit); + break; case BooruType.Favourites: booruHandler = FavouritesHandler(booru, limit); break; diff --git a/lib/src/handlers/database_handler.dart b/lib/src/handlers/database_handler.dart index 97e0a721..87cde7e9 100644 --- a/lib/src/handlers/database_handler.dart +++ b/lib/src/handlers/database_handler.dart @@ -23,7 +23,7 @@ class DBHandler { Database? db; /// Connects to the database file and create the database if the tables dont exist - Future dbConnect(String path, bool indexesEnabled) async { + Future dbConnect(String path) async { // await Sqflite.devSetDebugModeOn(true); if (Platform.isAndroid || Platform.isIOS) { db = await openDatabase('${path}store.db', version: 1, singleInstance: false); @@ -31,11 +31,6 @@ class DBHandler { db = await databaseFactory.openDatabase('${path}store.db'); } await updateTable(); - if (indexesEnabled) { - await createIndexes(); - } else { - await dropIndexes(); - } await deleteUntracked(); return true; } @@ -124,16 +119,18 @@ class DBHandler { String? itemID = await getItemID(item.postURL); String resultStr = ''; if (itemID == null || itemID.isEmpty) { - final result = - await db?.rawInsert('INSERT INTO BooruItem(thumbnailURL, sampleURL, fileURL, postURL, mediaType, isSnatched, isFavourite) VALUES(?,?,?,?,?,?,?)', [ - item.thumbnailURL, - item.sampleURL, - item.fileURL, - item.postURL, - item.mediaType.toJson(), - Tools.boolToInt(item.isSnatched.value == true), - Tools.boolToInt(item.isFavourite.value == true) - ]); + final result = await db?.rawInsert( + 'INSERT INTO BooruItem(thumbnailURL, sampleURL, fileURL, postURL, mediaType, isSnatched, isFavourite) VALUES(?,?,?,?,?,?,?)', + [ + item.thumbnailURL, + item.sampleURL, + item.fileURL, + item.postURL, + item.mediaType.toJson(), + Tools.boolToInt(item.isSnatched.value == true), + Tools.boolToInt(item.isFavourite.value == true), + ], + ); itemID = result?.toString(); await updateTags(item.tagsList, itemID); resultStr = 'Inserted'; @@ -165,16 +162,18 @@ class DBHandler { String? itemID = (itemIDs.isNotEmpty && itemIndex != -1) ? itemIDs[itemIndex] : null; if (itemID == null || itemID.isEmpty) { - final result = - await db?.rawInsert('INSERT INTO BooruItem(thumbnailURL, sampleURL, fileURL, postURL, mediaType, isSnatched, isFavourite) VALUES(?,?,?,?,?,?,?)', [ - item.thumbnailURL, - item.sampleURL, - item.fileURL, - item.postURL, - item.mediaType.toJson(), - Tools.boolToInt(item.isSnatched.value == true), - Tools.boolToInt(item.isFavourite.value == true) - ]); + final result = await db?.rawInsert( + 'INSERT INTO BooruItem(thumbnailURL, sampleURL, fileURL, postURL, mediaType, isSnatched, isFavourite) VALUES(?,?,?,?,?,?,?)', + [ + item.thumbnailURL, + item.sampleURL, + item.fileURL, + item.postURL, + item.mediaType.toJson(), + Tools.boolToInt(item.isSnatched.value == true), + Tools.boolToInt(item.isFavourite.value == true), + ], + ); itemID = result?.toString(); await updateTags(item.tagsList, itemID); saved++; @@ -257,6 +256,7 @@ class DBHandler { String order, String mode, { List customConditions = const [], + bool isDownloads = false, }) async { // TODO multiple tags in search can lead to wrong results List searchTags = [], excludeTags = []; @@ -285,6 +285,8 @@ class DBHandler { // benchmark reqest time // final DateTime start = DateTime.now(); + final filterMode = isDownloads ? 'bi.isSnatched = 1' : 'bi.isFavourite = 1'; + if (searchTags.isNotEmpty || excludeTags.isNotEmpty) { final String searchPart = searchTags.isNotEmpty ? "t.name IN (${List.generate(searchTags.length, (_) => '?').join(',')}) " : ''; @@ -301,8 +303,8 @@ class DBHandler { ' JOIN Tag AS t ON it.tagID = t.id ' " WHERE t.name IN (${List.generate(excludeTags.length, (_) => '?').join(',')}) " ') AS ei ON bi.id = ei.id ' - 'WHERE ei.id IS NULL AND bi.isFavourite = 1 $andStr1 $siteQuery $andStr2 $searchPart $customConditionsStr ' - : 'WHERE bi.isFavourite = 1 $andStr1 $siteQuery $andStr2 $searchPart $customConditionsStr '; + 'WHERE ei.id IS NULL AND $filterMode $andStr1 $siteQuery $andStr2 $searchPart $customConditionsStr ' + : 'WHERE $filterMode $andStr1 $siteQuery $andStr2 $searchPart $customConditionsStr '; final String havingPart = searchTags.isNotEmpty ? 'HAVING COUNT(DISTINCT t.id) = ${searchTags.length} ' : ''; @@ -325,7 +327,7 @@ class DBHandler { result = await db?.rawQuery( 'SELECT bi.id as dbid, bi.thumbnailURL, bi.sampleURL, bi.fileURL, bi.postURL, bi.mediaType, bi.isSnatched, bi.isFavourite ' 'FROM BooruItem AS bi ' - 'WHERE $siteQuery $andStr1 bi.isFavourite = 1 ' + 'WHERE $siteQuery $andStr1 $filterMode ' 'GROUP BY bi.id ' 'ORDER BY bi.id $order ' 'LIMIT $limit ' @@ -391,7 +393,7 @@ class DBHandler { } /// Gets amount of BooruItems from the database - Future searchDBCount(String searchTagsString) async { + Future searchDBCount(String searchTagsString, {bool isDownloads = false}) async { List searchTags = [], excludeTags = []; List? result; @@ -414,6 +416,8 @@ class DBHandler { excludeTags = []; } + final filterMode = isDownloads ? 'bi.isSnatched = 1' : 'bi.isFavourite = 1'; + if (searchTags.isNotEmpty || excludeTags.isNotEmpty) { final String searchPart = searchTags.isNotEmpty ? "t.name IN (${List.generate(searchTags.length, (_) => '?').join(',')}) " : ''; @@ -428,8 +432,8 @@ class DBHandler { ' JOIN Tag AS t ON it.tagID = t.id ' " WHERE t.name IN (${List.generate(excludeTags.length, (_) => '?').join(',')}) " ') AS ei ON bi.id = ei.id ' - 'WHERE ei.id IS NULL AND bi.isFavourite = 1 $andStr1 $siteQuery $andStr2 $searchPart ' - : 'WHERE bi.isFavourite = 1 $andStr1 $siteQuery $andStr2 $searchPart '; + 'WHERE ei.id IS NULL AND $filterMode $andStr1 $siteQuery $andStr2 $searchPart ' + : 'WHERE $filterMode $andStr1 $siteQuery $andStr2 $searchPart '; final String havingPart = searchTags.isNotEmpty ? 'HAVING COUNT(DISTINCT t.id) = ${searchTags.length} ' : ''; @@ -448,7 +452,7 @@ class DBHandler { result = await db?.rawQuery('SELECT COUNT(*) as count ' 'FROM BooruItem AS bi ' - 'WHERE $siteQuery $andStr1 bi.isFavourite = 1 ' + 'WHERE $siteQuery $andStr1 $filterMode ' 'GROUP BY bi.id;'); } diff --git a/lib/src/handlers/loli_sync_handler.dart b/lib/src/handlers/loli_sync_handler.dart index 1afe035b..81c9db0a 100644 --- a/lib/src/handlers/loli_sync_handler.dart +++ b/lib/src/handlers/loli_sync_handler.dart @@ -518,7 +518,7 @@ class LoliSync { case 'Booru': yield 'Sync Starting $address'; yield 'Preparing booru data'; - final List booruList = settingsHandler.booruList.where((e) => e.type != BooruType.Favourites).toList(); + final List booruList = settingsHandler.booruList.where((e) => e.type != BooruType.Favourites && e.type != BooruType.Downloads).toList(); final int booruCount = booruList.length; if (booruCount > 0) { for (int i = 0; i < booruCount; i++) { diff --git a/lib/src/handlers/search_handler.dart b/lib/src/handlers/search_handler.dart index 4e5c99d2..a21f818c 100644 --- a/lib/src/handlers/search_handler.dart +++ b/lib/src/handlers/search_handler.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -24,6 +25,18 @@ Uuid uuid = const Uuid(); EventChannel? volumeKeyChannel = Platform.isAndroid ? const EventChannel('com.noaisu.loliSnatcher/volume') : null; +// special strings used to separate parts of tab backup string +const String tabDivider = '|||', listDivider = '~~~'; +List> decodeBackupString(String input) { + final List> result = []; + final List splitInput = input.split(listDivider); + for (final String str in splitInput) { + final List booruAndTags = str.split(tabDivider); + result.add(booruAndTags); + } + return result; +} + class SearchHandler extends GetxController { SearchHandler(this.rootRestate); // alternative way to get instance of the controller @@ -128,9 +141,6 @@ class SearchHandler extends GetxController { } // move tab - if (fromIndex < toIndex) { - toIndex -= 1; - } final SearchTab tab = list[fromIndex]; list.removeAt(fromIndex); list.insert(toIndex, tab); @@ -303,13 +313,20 @@ class SearchHandler extends GetxController { } HasTabWithTagResult hasTabWithTag(String tag) { - for (final SearchTab tab in list) { - if (tab.tags.toLowerCase().trim() == tag.toLowerCase().trim()) { + final tabsWithOnlyTag = list.where((tab) => tab.tags.toLowerCase().trim() == tag.toLowerCase().trim()); + if (tabsWithOnlyTag.isNotEmpty) { + if (tabsWithOnlyTag.any((tab) => tab.selectedBooru.value == currentBooru)) { return HasTabWithTagResult.onlyTag; - } else if (tab.tags.toLowerCase().trim().split(' ').contains(tag.toLowerCase().trim())) { - return HasTabWithTagResult.containsTag; + } else { + return HasTabWithTagResult.onlyTagDifferentBooru; } } + + final tabsContainingTag = list.where((tab) => tab.tags.toLowerCase().trim().split(' ').contains(tag.toLowerCase().trim())); + if (tabsContainingTag.isNotEmpty) { + return HasTabWithTagResult.containsTag; + } + return HasTabWithTagResult.noTag; } @@ -329,6 +346,9 @@ class SearchHandler extends GetxController { BooruHandler get currentBooruHandler => currentTab.booruHandler; Booru get currentBooru => currentTab.selectedBooru.value; List get currentFetched => currentBooruHandler.filteredFetched; + void filterCurrentFetched() { + currentBooruHandler.filterFetched(); + } RxInt viewedIndex = (-1).obs; Rx viewedItem = BooruItem( @@ -368,7 +388,16 @@ class SearchHandler extends GetxController { ServiceHandler.vibrate(); item.isFavourite.value = item.isFavourite.value == true ? false : true; - await SettingsHandler.instance.dbHandler.updateBooruItem(item, BooruUpdateMode.local); + await SettingsHandler.instance.dbHandler.updateBooruItem( + item, + BooruUpdateMode.local, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + // update filtered items list in case user has favourites filter enabled + await Future.delayed(const Duration(milliseconds: 200)); + filterCurrentFetched(); + }); } return item.isFavourite.value; } @@ -570,7 +599,7 @@ class SearchHandler extends GetxController { StreamSubscription? _rootVolumeListener; // hack to allow global restates to force refresh of everything (mainly used when saving settings when exiting settings page) - Function rootRestate; + VoidCallback rootRestate; @override void onInit() { @@ -601,21 +630,9 @@ class SearchHandler extends GetxController { DateTime lastBackupTime = DateTime.now(); // TODO rework to use json structure instead - // special strings used to separate parts of tab backup string - final String tabDivider = '|||', listDivider = '~~~'; // example of backup string: "booruName1|||tags1|||tab~~~booruName2|||tags2|||selected~~~booruName3|||tags3|||tab" // Restores tabs from a string saved in DB - List> decodeBackupString(String input) { - final List> result = []; - final List splitInput = input.split(listDivider); - for (final String str in splitInput) { - final List booruAndTags = str.split(tabDivider); - result.add(booruAndTags); - } - return result; - } - Future restoreTabs() async { final SettingsHandler settingsHandler = SettingsHandler.instance; final List result = await settingsHandler.dbHandler.getTabRestore(); @@ -626,7 +643,8 @@ class SearchHandler extends GetxController { int newIndex = 0; if (result.length == 2) { // split list into tabs - final List> splitInput = decodeBackupString(result[1]); + final List> splitInput = + await compute(decodeBackupString, result[1]); // decodeBackupString(result[1]) // await compute(decodeBackupString, result[1]) // print('restoreTabs: ${splitInput}'); for (final List booruAndTags in splitInput) { // check for parsing errors @@ -692,6 +710,7 @@ class SearchHandler extends GetxController { ), sideColor: foundBrokenItem ? Colors.yellow : Colors.green, leadingIcon: foundBrokenItem ? Icons.warning_amber : Icons.settings_backup_restore, + duration: Duration(seconds: brokenItems.isEmpty ? 4 : 10), ); list.value = restoredGlobals; @@ -872,29 +891,23 @@ class SearchTab { tagsList: [], postURL: '', ).obs; - RxList selected = RxList.from([]); + RxList selected = RxList.from([]); @override String toString() { return 'tags: $tags selectedBooru: $selectedBooru booruHandler: $booruHandler'; } - - List getSelected() { - final List selectedItems = []; - for (int i = 0; i < selected.length; i++) { - selectedItems.add(booruHandler.filteredFetched.elementAt(selected[i])); - } - return selectedItems; - } } enum HasTabWithTagResult { onlyTag, + onlyTagDifferentBooru, containsTag, noTag; bool get isOnlyTag => this == HasTabWithTagResult.onlyTag; + bool get isOnlyTagDifferentBooru => this == HasTabWithTagResult.onlyTagDifferentBooru; bool get isContainsTag => this == HasTabWithTagResult.containsTag; bool get isNoTag => this == HasTabWithTagResult.noTag; - bool get hasTag => this == HasTabWithTagResult.containsTag || this == HasTabWithTagResult.onlyTag; + bool get hasTag => this == HasTabWithTagResult.onlyTag || this == HasTabWithTagResult.onlyTagDifferentBooru || this == HasTabWithTagResult.containsTag; } diff --git a/lib/src/handlers/settings_handler.dart b/lib/src/handlers/settings_handler.dart index 25fc571d..33c41946 100644 --- a/lib/src/handlers/settings_handler.dart +++ b/lib/src/handlers/settings_handler.dart @@ -36,7 +36,8 @@ class SettingsHandler extends GetxController { late Alice alice; // service vars - RxBool isInit = false.obs; + RxBool isInit = false.obs, isPostInit = false.obs; + RxString postInitMessage = ''.obs; String cachePath = ''; String path = ''; String boorusPath = ''; @@ -110,7 +111,7 @@ class SettingsHandler extends GetxController { ['info', 'Display Info'], ['share', 'Share'], ['open', 'Open in Browser'], - ['reloadnoscale', 'Reload w/out scaling'] + ['reloadnoscale', 'Reload w/out scaling'], ]; List> buttonOrder = [ ['autoscroll', 'AutoScroll'], @@ -119,7 +120,7 @@ class SettingsHandler extends GetxController { ['info', 'Display Info'], ['share', 'Share'], ['open', 'Open in Browser'], - ['reloadnoscale', 'Reload w/out scaling'] + ['reloadnoscale', 'Reload w/out scaling'], ]; bool jsonWrite = false; @@ -133,6 +134,8 @@ class SettingsHandler extends GetxController { bool searchHistoryEnabled = true; bool filterHated = false; bool filterFavourites = false; + bool filterSnatched = false; + bool filterAi = false; bool useVolumeButtonsForScroll = false; bool shitDevice = false; bool disableVideo = false; @@ -202,7 +205,7 @@ class SettingsHandler extends GetxController { 'showImageStats', 'isDebug', 'showURLOnThumb', - 'desktopListsDrag' + 'desktopListsDrag', ]; // default values and possible options map for validation // TODO build settings widgets from this map, need to add Label/Description/other options required for the input element @@ -402,6 +405,14 @@ class SettingsHandler extends GetxController { 'type': 'bool', 'default': false, }, + 'filterSnatched': { + 'type': 'bool', + 'default': false, + }, + 'filterAi': { + 'type': 'bool', + 'default': false, + }, 'useVolumeButtonsForScroll': { 'type': 'bool', 'default': false, @@ -457,7 +468,7 @@ class SettingsHandler extends GetxController { ['info', 'Display Info'], ['share', 'Share'], ['open', 'Open in Browser'], - ['reloadnoscale', 'Reload w/out scaling'] + ['reloadnoscale', 'Reload w/out scaling'], ], }, 'cacheDuration': { @@ -498,7 +509,7 @@ class SettingsHandler extends GetxController { ThemeItem(name: 'Red', primary: Colors.red[700], accent: Colors.red[800]), ThemeItem(name: 'Green', primary: Colors.green, accent: Colors.green[700]), ThemeItem(name: 'Custom', primary: null, accent: null), - ] + ], }, 'themeMode': { 'type': 'themeMode', @@ -724,15 +735,43 @@ class SettingsHandler extends GetxController { } else { await saveSettings(restate: true); } + return true; + } - if (!Tools.isTestMode) { - if (dbEnabled) { - await dbHandler.dbConnect(path, indexesEnabled); - } else { - dbHandler = DBHandler(); + Future loadDatabase() async { + try { + if (!Tools.isTestMode) { + if (dbEnabled) { + await dbHandler.dbConnect(path); + } else { + dbHandler = DBHandler(); + } } + return true; + } catch (err) { + Logger.Inst().log('loadDatabase error: $err', 'SettingsHandler', 'loadDatabase', LogTypes.exception); + return false; + } + } + + Future indexDatabase() async { + try { + if (!Tools.isTestMode) { + if (dbEnabled) { + if (indexesEnabled) { + postInitMessage.value = 'Indexing database...\nThis may take a while'; + await dbHandler.createIndexes(); + } else { + postInitMessage.value = 'Dropping indexes...\nThis may take a while'; + await dbHandler.dropIndexes(); + } + } + } + return true; + } catch (err) { + Logger.Inst().log('indexDatabase error: $err', 'SettingsHandler', 'indexDatabase', LogTypes.exception); + return false; } - return true; } Future checkForSettings() { @@ -802,6 +841,10 @@ class SettingsHandler extends GetxController { return filterHated; case 'filterFavourites': return filterFavourites; + case 'filterSnatched': + return filterSnatched; + case 'filterAi': + return filterAi; case 'useVolumeButtonsForScroll': return useVolumeButtonsForScroll; case 'volumeButtonsScrollSpeed': @@ -963,6 +1006,12 @@ class SettingsHandler extends GetxController { case 'filterFavourites': filterFavourites = validatedValue; break; + case 'filterSnatched': + filterSnatched = validatedValue; + break; + case 'filterAi': + filterAi = validatedValue; + break; case 'useVolumeButtonsForScroll': useVolumeButtonsForScroll = validatedValue; break; @@ -1101,6 +1150,8 @@ class SettingsHandler extends GetxController { 'searchHistoryEnabled': validateValue('searchHistoryEnabled', null, toJSON: true), 'filterHated': validateValue('filterHated', null, toJSON: true), 'filterFavourites': validateValue('filterFavourites', null, toJSON: true), + 'filterSnatched': validateValue('filterSnatched', null, toJSON: true), + 'filterAi': validateValue('filterAi', null, toJSON: true), 'useVolumeButtonsForScroll': validateValue('useVolumeButtonsForScroll', null, toJSON: true), 'volumeButtonsScrollSpeed': validateValue('volumeButtonsScrollSpeed', null, toJSON: true), 'mousewheelScrollSpeed': validateValue('mousewheelScrollSpeed', null, toJSON: true), @@ -1268,7 +1319,9 @@ class SettingsHandler extends GetxController { await writer.close(); if (restate) { - SearchHandler.instance.rootRestate(); // force global state update to redraw stuff + final searchHandler = SearchHandler.instance; + searchHandler.filterCurrentFetched(); // refilter fetched because user could have changed the filtering settings + searchHandler.rootRestate(); // force global state update to redraw stuff } return true; } @@ -1293,7 +1346,7 @@ class SettingsHandler extends GetxController { // print(files[i].toString()); final File booruFile = files[i] as File; final Booru booruFromFile = Booru.fromJSON(await booruFile.readAsString()); - final bool isAllowed = booruFromFile.type != BooruType.Favourites; + final bool isAllowed = booruFromFile.type != BooruType.Favourites && booruFromFile.type != BooruType.Downloads; if (isAllowed) { tempList.add(booruFromFile); } else { @@ -1309,6 +1362,7 @@ class SettingsHandler extends GetxController { if (dbEnabled && tempList.isNotEmpty) { tempList.add(Booru('Favourites', BooruType.Favourites, '', '', '')); + tempList.add(Booru('Downloads', BooruType.Downloads, '', '', '')); } } catch (e) { Logger.Inst().log('Failed to load boorus $e', 'SettingsHandler', 'loadBoorus', LogTypes.exception); @@ -1353,6 +1407,15 @@ class SettingsHandler extends GetxController { sorted.remove(tmp); sorted.add(tmp); } + + final int dlsIndex = sorted.indexWhere((el) => el.type == BooruType.Downloads); + if (dlsIndex != -1) { + // move downloads to the end + final Booru tmp = sorted.elementAt(dlsIndex); + sorted.remove(tmp); + sorted.add(tmp); + } + booruList.value = sorted; } @@ -1388,12 +1451,29 @@ class SettingsHandler extends GetxController { return true; } - List> parseTagsList(List itemTags, {bool isCapped = true}) { + // TODO add more tags? + static const List soundTags = [ + 'sound', + 'sound_edit', + 'has_audio', + 'voice_acted', + ]; + static const List aiTags = [ + 'ai_generated', + 'ai-generated', + 'ai_created', + 'ai-created', + 'novelai', + 'stable_diffusion', + 'stable-diffusion', + ]; + + TagsListData parseTagsList(List itemTags, {bool isCapped = true}) { final List cleanItemTags = cleanTagsList(itemTags); List hatedInItem = hatedTags.where(cleanItemTags.contains).toList(); List lovedInItem = lovedTags.where(cleanItemTags.contains).toList(); - final List soundInItem = ['sound', 'sound_edit', 'has_audio', 'voice_acted'].where(cleanItemTags.contains).toList(); - // TODO add more sound tags? + final List soundInItem = soundTags.where(cleanItemTags.contains).toList(); + final List aiInItem = aiTags.where(cleanItemTags.contains).toList(); if (isCapped) { if (hatedInItem.length > 5) { @@ -1404,7 +1484,23 @@ class SettingsHandler extends GetxController { } } - return [hatedInItem, lovedInItem, soundInItem]; + return TagsListData(hatedInItem, lovedInItem, soundInItem, aiInItem); + } + + bool containsHated(List itemTags) { + return hatedTags.where(itemTags.contains).isNotEmpty; + } + + bool containsLoved(List itemTags) { + return lovedTags.where(itemTags.contains).isNotEmpty; + } + + bool containsSound(List itemTags) { + return soundTags.where(itemTags.contains).isNotEmpty; + } + + bool containsAI(List itemTags) { + return aiTags.where(itemTags.contains).isNotEmpty; } void addTagToList(String type, String tag) { @@ -1467,7 +1563,7 @@ class SettingsHandler extends GetxController { true, // is update available in store [LEGACY], after 2.2.0 hits the store - left this in update.json as true for backwards compatibility with pre-2.2 'is_important': false, // is update important => force open dialog on start 'store_package': 'com.noaisu.play.loliSnatcher', // custom app package name, to allow to redirect store users to new app if it will be needed - 'github_url': 'https://github.com/NO-ob/LoliSnatcher_Droid/releases/latest' + 'github_url': 'https://github.com/NO-ob/LoliSnatcher_Droid/releases/latest', }; // fake update json for tests // String fakeUpdate = '123'; // broken string @@ -1627,9 +1723,6 @@ class SettingsHandler extends GetxController { await getPerms(); await loadSettings(); - if (booruList.isEmpty) { - await loadBoorus(); - } if (allowSelfSignedCerts) { HttpOverrides.global = MyHttpOverrides(); } @@ -1662,6 +1755,42 @@ class SettingsHandler extends GetxController { isInit.value = true; return; } + + Future postInit(AsyncCallback externalAction) async { + if (isPostInit.value == true) { + return; + } + + try { + postInitMessage.value = 'Loading Database...'; + await loadDatabase(); + await indexDatabase(); + if (booruList.isEmpty) { + postInitMessage.value = 'Loading Boorus...'; + await loadBoorus(); + } + await externalAction(); + } catch (e) { + postInitMessage.value = 'Error!'; + Logger.Inst().log(e.toString(), 'SettingsHandler', 'postInit', LogTypes.settingsError); + FlashElements.showSnackbar( + title: const Text( + 'Post Initialization Error!', + style: TextStyle(fontSize: 20), + ), + content: Text( + e.toString(), + ), + sideColor: Colors.red, + leadingIcon: Icons.error, + leadingIconColor: Colors.red, + ); + } + + isPostInit.value = true; + postInitMessage.value = ''; + return; + } } class EnvironmentConfig { @@ -1670,3 +1799,17 @@ class EnvironmentConfig { defaultValue: false, ); } + +class TagsListData { + const TagsListData([ + this.hatedTags = const [], + this.lovedTags = const [], + this.soundTags = const [], + this.aiTags = const [], + ]); + + final List hatedTags; + final List lovedTags; + final List soundTags; + final List aiTags; +} diff --git a/lib/src/handlers/snatch_handler.dart b/lib/src/handlers/snatch_handler.dart index 554dfd1e..679147ce 100644 --- a/lib/src/handlers/snatch_handler.dart +++ b/lib/src/handlers/snatch_handler.dart @@ -22,6 +22,7 @@ class SnatchHandler extends GetxController { RxBool active = false.obs; RxString status = ''.obs; RxInt queueProgress = 0.obs; + Rx current = Rx(null); RxInt received = 0.obs; RxInt total = 0.obs; @@ -52,6 +53,7 @@ class SnatchHandler extends GetxController { Future snatch(SnatchItem item) async { active.value = true; status.value = queuedList.isNotEmpty ? '0/${item.booruItems.length}/${queuedList.length}' : '0/${item.booruItems.length}'; + current.value = item; // writeMultipleFake(item.booruItems, item.booru, item.cooldown).listen( ImageWriter() @@ -103,12 +105,13 @@ class SnatchHandler extends GetxController { } }, onDone: () { - if (queuedList.isNotEmpty) { + if (queuedList.isNotEmpty && active.value) { snatch(queuedList.removeLast()); } else { active.value = false; status.value = ''; queueProgress.value = 0; + current.value = null; received.value = 0; total.value = 0; } @@ -117,7 +120,7 @@ class SnatchHandler extends GetxController { } void trySnatch() { - if (!active.value) { + if (!active.value && current.value == null) { if (queuedList.isNotEmpty) { snatch(queuedList.removeLast()); } else if (queuedList.isEmpty) { diff --git a/lib/src/handlers/tag_handler.dart b/lib/src/handlers/tag_handler.dart index 02bf6110..4bc01fc1 100644 --- a/lib/src/handlers/tag_handler.dart +++ b/lib/src/handlers/tag_handler.dart @@ -59,7 +59,12 @@ class TagHandler extends GetxController { return tag ?? Tag(tagString, tagType: TagType.none); } - Future putTag(Tag tag, {bool useDB = true, bool preferTypeIfNone = false}) async { + Future putTag( + Tag tag, { + required bool dbEnabled, + bool useDB = true, + bool preferTypeIfNone = false, + }) async { // TODO sanitize tagString? if (tag.fullString.isEmpty) { return; @@ -78,7 +83,7 @@ class TagHandler extends GetxController { } _tagMap[tag.fullString] = tag; - if (SettingsHandler.instance.dbEnabled && useDB) { + if (dbEnabled && useDB) { await SettingsHandler.instance.dbHandler.updateTagsFromObjects([tag]); } return; @@ -94,6 +99,8 @@ class TagHandler extends GetxController { Future getTagTypes(UntypedCollection untyped) async { if (SettingsHandler.instance.tagTypeFetchEnabled) { + final bool dbEnabled = SettingsHandler.instance.dbEnabled; + Logger.Inst().log('Snatching tags: ${untyped.tags}', 'TagHandler', 'getTagTypes', LogTypes.tagHandlerInfo); tagFetchActive.value = true; final List temp = BooruHandlerFactory().getBooruHandler([untyped.booru], null); @@ -116,7 +123,7 @@ class TagHandler extends GetxController { if (workingTags.isNotEmpty) { final List newTags = await booruHandler.genTagObjects(workingTags); for (final Tag tag in newTags) { - await putTag(tag); + await putTag(tag, dbEnabled: dbEnabled); //TODO write tag to database tagCounter++; @@ -132,12 +139,14 @@ class TagHandler extends GetxController { /// Stores given tags list with given type, if tag is already in the tag map - update it's type, but only if the type was "none" Future addTagsWithType(List tags, TagType type) async { + final dbEnabled = SettingsHandler.instance.dbEnabled; + for (final String tag in tags) { if (!hasTagAndNotStale(tag)) { - await putTag(Tag(tag, tagType: type)); + await putTag(Tag(tag, tagType: type), dbEnabled: dbEnabled); } else if (type != TagType.none) { if (getTag(tag).tagType == TagType.none) { - await putTag(Tag(tag, tagType: type)); + await putTag(Tag(tag, tagType: type), dbEnabled: dbEnabled); } } } @@ -159,10 +168,11 @@ class TagHandler extends GetxController { Future loadTags() async { try { - if (SettingsHandler.instance.dbEnabled) { + final bool dbEnabled = SettingsHandler.instance.dbEnabled; + if (dbEnabled) { final List tags = await SettingsHandler.instance.dbHandler.getAllTags(); for (final Tag tag in tags) { - await putTag(tag, useDB: false); + await putTag(tag, useDB: false, dbEnabled: dbEnabled); } } else { if (await checkForTagsFile()) { @@ -190,11 +200,17 @@ class TagHandler extends GetxController { Future loadFromJSON(String jsonString, {bool preferTagTypeIfNone = false}) async { try { + final bool dbEnabled = SettingsHandler.instance.dbEnabled; + final List jsonList = jsonDecode(jsonString); for (final Map rawTag in jsonList) { try { final Tag tagObject = Tag.fromJson(rawTag); - await putTag(tagObject, preferTypeIfNone: preferTagTypeIfNone); + await putTag( + tagObject, + preferTypeIfNone: preferTagTypeIfNone, + dbEnabled: dbEnabled, + ); } catch (e) { Logger.Inst().log( 'Error parsing tag: $rawTag', diff --git a/lib/src/handlers/theme_handler.dart b/lib/src/handlers/theme_handler.dart index 96537761..dc1543f5 100644 --- a/lib/src/handlers/theme_handler.dart +++ b/lib/src/handlers/theme_handler.dart @@ -58,7 +58,7 @@ class ThemeHandler { ThemeData lightTheme() { final ColorScheme lightColorScheme = colorScheme(); - return ThemeData.light().copyWith( + return ThemeData.light(useMaterial3: useMaterial3).copyWith( brightness: Brightness.light, appBarTheme: appBarTheme(lightColorScheme), @@ -67,7 +67,6 @@ class ThemeHandler { textSelectionTheme: textSelectionTheme(lightColorScheme), elevatedButtonTheme: elevatedButtonTheme(lightColorScheme), - useMaterial3: useMaterial3, splashFactory: InkSparkle.splashFactory, // androidOverscrollIndicator: AndroidOverscrollIndicator.stretch, @@ -76,8 +75,8 @@ class ThemeHandler { applyElevationOverlayColor: true, buttonTheme: buttonTheme(lightColorScheme), - cardColor: lightColorScheme.background, - // dividerColor: lightColorScheme.onBackground, + cardColor: Color.lerp(lightColorScheme.background, Colors.black, 0.04), + dividerColor: lightColorScheme.onSurface.withOpacity(0.12), dialogBackgroundColor: lightColorScheme.background, floatingActionButtonTheme: floatingActionButtonTheme(lightColorScheme), iconTheme: iconTheme(lightColorScheme), @@ -102,7 +101,7 @@ class ThemeHandler { ThemeData darkTheme() { final ColorScheme darkColorScheme = colorScheme(); - return ThemeData.dark().copyWith( + return ThemeData.dark(useMaterial3: useMaterial3).copyWith( brightness: Brightness.dark, appBarTheme: appBarTheme(darkColorScheme), @@ -114,7 +113,6 @@ class ThemeHandler { textSelectionTheme: textSelectionTheme(darkColorScheme), elevatedButtonTheme: elevatedButtonTheme(darkColorScheme), - useMaterial3: useMaterial3, splashFactory: InkSparkle.splashFactory, // androidOverscrollIndicator: AndroidOverscrollIndicator.stretch, @@ -124,7 +122,7 @@ class ThemeHandler { applyElevationOverlayColor: true, buttonTheme: buttonTheme(darkColorScheme), cardColor: darkColorScheme.background, - // // dividerColor: darkColorScheme.onBackground, + dividerColor: darkColorScheme.onSurface.withOpacity(0.12), dialogBackgroundColor: darkColorScheme.background, floatingActionButtonTheme: floatingActionButtonTheme(darkColorScheme), iconTheme: iconTheme(darkColorScheme), @@ -161,9 +159,12 @@ class ThemeHandler { return ColorScheme.fromSeed( seedColor: theme.accent!, primary: theme.primary, + onPrimary: primaryIsDark ? Colors.white : Colors.black, secondary: theme.accent, - brightness: isDark ? Brightness.dark : Brightness.light, + onSecondary: accentIsDark ? Colors.white : Colors.black, error: Colors.redAccent, + onError: Colors.white, + brightness: isDark ? Brightness.dark : Brightness.light, ); } @@ -194,8 +195,8 @@ class ThemeHandler { style: ElevatedButton.styleFrom( backgroundColor: colorScheme.secondary, foregroundColor: accentIsDark ? Colors.white : Colors.black, - disabledForegroundColor: isDark ? Colors.white : Colors.black.withOpacity(0.38), - disabledBackgroundColor: isDark ? Colors.white : Colors.black.withOpacity(0.12), + disabledForegroundColor: Colors.black, + disabledBackgroundColor: Colors.grey, textStyle: TextStyle( color: accentIsDark ? Colors.white : Colors.black, fontSize: 16, diff --git a/lib/src/handlers/viewer_handler.dart b/lib/src/handlers/viewer_handler.dart index e4725efc..1106843f 100644 --- a/lib/src/handlers/viewer_handler.dart +++ b/lib/src/handlers/viewer_handler.dart @@ -65,7 +65,7 @@ class ViewerHandler extends GetxController { RxBool displayAppbar = true.obs; // is gallery toolbar visible RxBool isZoomed = false.obs; // is current item zoomed in RxBool isLoaded = false.obs; // is current item loaded - Rx viewState = Rx(null); // current view state + Rx viewState = Rx(null); // current view controller value RxBool isFullscreen = false.obs; // is in fullscreen (on mobile for videos through VideoViewer) RxBool isDesktopFullscreen = false.obs; // is in fullscreen mode in DesktopHome @@ -78,7 +78,7 @@ class ViewerHandler extends GetxController { isLoaded.value = false; isFullscreen.value = false; isDesktopFullscreen.value = false; - viewState.value = const PhotoViewControllerValue(position: Offset.zero, scale: null, rotation: 0, rotationFocusPoint: null); + viewState.value = null; } // Get zoom state of new current item @@ -149,7 +149,7 @@ class ViewerHandler extends GetxController { state?.doubleTapZoom?.call(); } - void setViewState(Key? key, PhotoViewControllerValue value) { + void setViewValue(Key? key, PhotoViewControllerValue value) { if (key == null || currentKey.value != key) { return; } diff --git a/lib/src/pages/desktop_home_page.dart b/lib/src/pages/desktop_home_page.dart index 31498d14..1e490ec6 100644 --- a/lib/src/pages/desktop_home_page.dart +++ b/lib/src/pages/desktop_home_page.dart @@ -9,6 +9,7 @@ import 'package:lolisnatcher/src/pages/settings_page.dart'; import 'package:lolisnatcher/src/pages/snatcher_page.dart'; import 'package:lolisnatcher/src/services/get_perms.dart'; import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; +import 'package:lolisnatcher/src/widgets/common/kaomoji.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; import 'package:lolisnatcher/src/widgets/desktop/desktop_image_listener.dart'; import 'package:lolisnatcher/src/widgets/desktop/resizable_split_view.dart'; @@ -87,7 +88,7 @@ class DesktopHome extends StatelessWidget { page: () => const SettingsPage(), ), Obx(() { - if (searchHandler.list.isNotEmpty) { + if (searchHandler.list.isNotEmpty && searchHandler.currentTab.selected.isNotEmpty) { return Stack( alignment: Alignment.center, children: [ @@ -100,7 +101,7 @@ class DesktopHome extends StatelessWidget { // call a function to save the currently viewed image when the save button is pressed if (searchHandler.currentTab.selected.isNotEmpty) { snatchHandler.queue( - searchHandler.currentTab.getSelected(), + searchHandler.currentTab.selected, searchHandler.currentBooru, settingsHandler.snatchCooldown, false, @@ -110,7 +111,10 @@ class DesktopHome extends StatelessWidget { FlashElements.showSnackbar( context: context, title: const Text('No items selected', style: TextStyle(fontSize: 20)), - overrideLeadingIconWidget: const Text(' (」°ロ°)」 ', style: TextStyle(fontSize: 18)), + overrideLeadingIconWidget: const Kaomoji( + type: KaomojiType.angryHandsUp, + style: TextStyle(fontSize: 18), + ), ); } }, diff --git a/lib/src/pages/gallery_view_page.dart b/lib/src/pages/gallery_view_page.dart index b8b9a0c3..3fee9d0a 100644 --- a/lib/src/pages/gallery_view_page.dart +++ b/lib/src/pages/gallery_view_page.dart @@ -59,7 +59,6 @@ class _GalleryViewPageState extends State { // enable volume buttons if opened page is a video AND appbar is visible final BooruItem item = searchHandler.currentFetched[widget.initialIndex]; final bool isVideo = item.mediaType.value.isVideo; - // bool isHated = item.isHated.value; final bool isVolumeAllowed = !settingsHandler.useVolumeButtonsForScroll || (isVideo && viewerHandler.displayAppbar.value); ServiceHandler.setVolumeButtons(isVolumeAllowed); setVolumeListener(); @@ -111,7 +110,7 @@ class _GalleryViewPageState extends State { DismissDirection.up: 0.2, DismissDirection.down: 0.2, DismissDirection.startToEnd: 0.3, - DismissDirection.endToStart: 0.3 + DismissDirection.endToStart: 0.3, }, // Amount of swiped away which triggers dismiss onDismissed: (_) => Navigator.of(context).pop(), child: Center( diff --git a/lib/src/pages/init_home_page.dart b/lib/src/pages/init_home_page.dart new file mode 100644 index 00000000..a369dedc --- /dev/null +++ b/lib/src/pages/init_home_page.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:get/get.dart'; + +import 'package:lolisnatcher/src/handlers/settings_handler.dart'; + +class InitHomePage extends StatefulWidget { + const InitHomePage({super.key}); + + @override + State createState() => _InitHomePageState(); +} + +class _InitHomePageState extends State { + final SettingsHandler settingsHandler = SettingsHandler.instance; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('LoliSnatcher'), + leading: const Icon(null), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + const Expanded( + flex: 3, + child: Center( + child: SizedBox( + height: 50, + width: 50, + child: CircularProgressIndicator(strokeWidth: 8), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 100, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 50), + child: Obx( + () => Text( + key: ValueKey(settingsHandler.postInitMessage.value), + settingsHandler.postInitMessage.value, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ), + const Spacer(flex: 2), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/loli_sync_page.dart b/lib/src/pages/loli_sync_page.dart index 34de77bd..d7a9f4b4 100644 --- a/lib/src/pages/loli_sync_page.dart +++ b/lib/src/pages/loli_sync_page.dart @@ -59,13 +59,18 @@ class _LoliSyncPageState extends State { String selectedInterface = 'Auto'; String? selectedAddress; - Future _onWillPop() async { - testSync.killSync(); + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + testSync.killSync(); settingsHandler.lastSyncIp = ipController.text; settingsHandler.lastSyncPort = portController.text; final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } @override @@ -255,7 +260,7 @@ class _LoliSyncPageState extends State { Text('If you want to sync from the beginning leave this field blank'), Text(''), Text('Example: You have X amount of favs, set this field to 100, sync will start from item #100 and go until it reaches X'), - Text('Order of favs: From oldest (0) to newest (X)') + Text('Order of favs: From oldest (0) to newest (X)'), ], ); }, @@ -534,8 +539,9 @@ class _LoliSyncPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( diff --git a/lib/src/pages/loli_sync_progress_page.dart b/lib/src/pages/loli_sync_progress_page.dart index 30736989..b537dc1a 100644 --- a/lib/src/pages/loli_sync_progress_page.dart +++ b/lib/src/pages/loli_sync_progress_page.dart @@ -95,8 +95,8 @@ class _LoliSyncProgressPageState extends State { setState(() {}); } - Future _onWillPop() async { - final bool? shouldPop = await showDialog( + Future showPopDialog() async { + return showDialog( context: context, builder: (context) { return SettingsDialog( @@ -125,13 +125,29 @@ class _LoliSyncProgressPageState extends State { ); }, ); + } + + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + + final bool? shouldPop = await showPopDialog(); + if (shouldPop ?? false) { + Navigator.of(context).pop(); + } + } + + Future _onWillPop() async { + final bool? shouldPop = await showPopDialog(); return shouldPop ?? false; } @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( diff --git a/lib/src/pages/mobile_home_page.dart b/lib/src/pages/mobile_home_page.dart index 026433e7..9112916e 100644 --- a/lib/src/pages/mobile_home_page.dart +++ b/lib/src/pages/mobile_home_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -5,12 +7,18 @@ import 'package:flutter_inner_drawer/inner_drawer.dart'; import 'package:get/get.dart'; import 'package:logger_flutter_fork/logger_flutter_fork.dart'; +import 'package:lolisnatcher/src/boorus/booru_type.dart'; +import 'package:lolisnatcher/src/data/booru.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/service_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; +import 'package:lolisnatcher/src/handlers/snatch_handler.dart'; import 'package:lolisnatcher/src/pages/settings_page.dart'; import 'package:lolisnatcher/src/pages/snatcher_page.dart'; +import 'package:lolisnatcher/src/services/get_perms.dart'; +import 'package:lolisnatcher/src/utils/extensions.dart'; import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; +import 'package:lolisnatcher/src/widgets/common/kaomoji.dart'; import 'package:lolisnatcher/src/widgets/common/mascot_image.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; import 'package:lolisnatcher/src/widgets/preview/media_previews.dart'; @@ -20,6 +28,7 @@ import 'package:lolisnatcher/src/widgets/search/tag_search_button.dart'; import 'package:lolisnatcher/src/widgets/tabs/tab_booru_selector.dart'; import 'package:lolisnatcher/src/widgets/tabs/tab_buttons.dart'; import 'package:lolisnatcher/src/widgets/tabs/tab_selector.dart'; +import 'package:lolisnatcher/src/widgets/thumbnail/thumbnail_build.dart'; class MobileHome extends StatefulWidget { const MobileHome({super.key}); @@ -31,6 +40,7 @@ class MobileHome extends StatefulWidget { class _MobileHomeState extends State { final SettingsHandler settingsHandler = SettingsHandler.instance; final SearchHandler searchHandler = SearchHandler.instance; + final SnatchHandler snatchHandler = SnatchHandler.instance; final GlobalKey mainScaffoldKey = GlobalKey(); @@ -50,7 +60,23 @@ class _MobileHomeState extends State { searchHandler.mainDrawerKey = GlobalKey(); } - Future _onBackPressed(BuildContext context) async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + + final result = await _onBackPressed(); + if (result) { + if (Platform.isAndroid) { + // will close the app completely + await SystemNavigator.pop(); + } else { + Navigator.of(context).pop(); + } + } + } + + Future _onBackPressed() async { if (isDrawerOpened) { // close the drawer if it's opened _toggleDrawer(null); @@ -99,7 +125,10 @@ class _MobileHomeState extends State { onLongPress: _onMenuLongTap, onSecondaryTap: _onMenuLongTap, child: IconButton( - icon: Icon(Icons.menu, color: Theme.of(context).appBarTheme.iconTheme?.color), + icon: Icon( + Icons.menu, + color: Theme.of(context).appBarTheme.iconTheme?.color, + ), onPressed: () { _toggleDrawer(direction); @@ -112,6 +141,54 @@ class _MobileHomeState extends State { ); } + Widget snatcherButton(InnerDrawerDirection direction) { + return Obx(() { + if (searchHandler.list.isNotEmpty) { + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + IconButton( + icon: Icon( + Icons.save, + color: Theme.of(context).appBarTheme.iconTheme?.color, + ), + onPressed: () async { + _toggleDrawer(direction); + }, + ), + if (searchHandler.currentTab.selected.isNotEmpty) + Positioned( + right: -0, + top: 8, + child: IgnorePointer( + child: Container( + width: 20, + height: 20, + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(15), + ), + child: Center( + child: FittedBox( + child: Text( + searchHandler.currentTab.selected.length.toFormattedString(), + style: TextStyle(color: Theme.of(context).colorScheme.onSecondary), + ), + ), + ), + ), + ), + ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }); + } + @override Widget build(BuildContext context) { // print('!!! main build !!!'); @@ -156,8 +233,8 @@ class _MobileHomeState extends State { } }, // return true (open) or false (close) - leftChild: const MainDrawer(), - rightChild: const MainDrawer(), + leftChild: settingsHandler.handSide.value.isLeft ? const MainDrawer() : DownloadsDrawer(toggleDrawer: () => _toggleDrawer(null)), + rightChild: settingsHandler.handSide.value.isRight ? const MainDrawer() : DownloadsDrawer(toggleDrawer: () => _toggleDrawer(null)), // Note: use "automaticallyImplyLeading: false" if you do not personalize "leading" of Bar scaffold: Scaffold( @@ -169,18 +246,17 @@ class _MobileHomeState extends State { body: SafeArea( top: false, bottom: false, - child: WillPopScope( - onWillPop: () { - return _onBackPressed(context); - }, + child: PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Stack( alignment: Alignment.topCenter, children: [ const MediaPreviews(), Obx( () => MainAppBar( - leading: settingsHandler.handSide.value.isLeft ? menuButton(InnerDrawerDirection.start) : null, - trailing: settingsHandler.handSide.value.isRight ? menuButton(InnerDrawerDirection.end) : null, + leading: settingsHandler.handSide.value.isLeft ? menuButton(InnerDrawerDirection.start) : snatcherButton(InnerDrawerDirection.start), + trailing: settingsHandler.handSide.value.isRight ? menuButton(InnerDrawerDirection.end) : snatcherButton(InnerDrawerDirection.end), ), ), ], @@ -259,19 +335,6 @@ class MainDrawer extends StatelessWidget { } }), // - Obx(() { - if (settingsHandler.booruList.isNotEmpty && searchHandler.list.isNotEmpty) { - return SettingsButton( - name: 'Snatcher', - icon: const Icon(Icons.download_sharp), - page: () => const SnatcherPage(), - drawTopBorder: true, - ); - } else { - return const SizedBox.shrink(); - } - }), - // Obx(() { if (settingsHandler.isDebug.value) { return SettingsButton( @@ -361,6 +424,307 @@ class MainDrawer extends StatelessWidget { } } +class DownloadsDrawer extends StatelessWidget { + const DownloadsDrawer({ + required this.toggleDrawer, + super.key, + }); + + final void Function() toggleDrawer; + + Future onStartSnatching(BuildContext context, bool isLongTap) async { + final SnatchHandler snatchHandler = SnatchHandler.instance; + final SettingsHandler settingsHandler = SettingsHandler.instance; + final SearchHandler searchHandler = SearchHandler.instance; + + final bool permsRes = await getPerms(); + if (!permsRes) { + FlashElements.showSnackbar( + context: context, + title: const Text('Please provide Storage permissions', style: TextStyle(fontSize: 20)), + leadingIcon: Icons.warning, + sideColor: Colors.red, + leadingIconColor: Colors.red, + ); + return; + } + // call a function to save the currently viewed image when the save button is pressed + if (searchHandler.currentTab.selected.isNotEmpty) { + snatchHandler.queue( + [...searchHandler.currentTab.selected], + searchHandler.currentBooru, + settingsHandler.snatchCooldown, + isLongTap, + ); + await Future.delayed(const Duration(milliseconds: 100)); + searchHandler.currentTab.selected.value = []; + } else { + FlashElements.showSnackbar( + context: context, + title: const Text('No items selected', style: TextStyle(fontSize: 20)), + overrideLeadingIconWidget: const Kaomoji( + type: KaomojiType.angryHandsUp, + style: TextStyle(fontSize: 18), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final SnatchHandler snatchHandler = SnatchHandler.instance; + final SettingsHandler settingsHandler = SettingsHandler.instance; + final SearchHandler searchHandler = SearchHandler.instance; + + // TODO better design? + // TODO rework snatchhandler? drop the queue and just use a single list? + + // print('build downloads drawer'); + + return ColoredBox( + color: Theme.of(context).colorScheme.background, + child: SafeArea( + child: Drawer( + child: Column( + children: [ + Obx(() { + if (snatchHandler.queuedList.isEmpty && snatchHandler.current.value == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: ElevatedButton( + onPressed: (snatchHandler.active.value == false && snatchHandler.current.value != null) + ? null + : () { + if (snatchHandler.active.value) { + snatchHandler.active.value = false; + } else { + snatchHandler.trySnatch(); + } + }, + child: Text( + snatchHandler.active.value + ? 'Pause (from next queue)' + : snatchHandler.current.value != null + ? 'Wait' + : 'Continue', + ), + ), + ); + }), + Expanded( + child: Obx(() { + // final queuedLengths = snatchHandler.queuedList.map((e) => e.booruItems.length); + // final int queuedItemsLength = queuedLengths.isEmpty ? 0 : queuedLengths.reduce((value, e) => value + e); + final int queuesAmount = snatchHandler.queuedList.length; + final int activeLength = + snatchHandler.current.value != null ? snatchHandler.current.value!.booruItems.length - snatchHandler.queueProgress.value : 0; + + if (activeLength == 0 && queuesAmount == 0) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Kaomoji( + type: KaomojiType.shrug, + style: TextStyle(fontSize: 40), + ), + Text( + 'No items queued', + style: TextStyle(fontSize: 20), + ), + ], + ), + ); + } + + return ListView.builder( + // controller: ScrollController(), + itemCount: activeLength + queuesAmount, + itemBuilder: (BuildContext context, int index) { + if (index < activeLength) { + final item = snatchHandler.current.value!.booruItems[snatchHandler.queueProgress.value + index]; + return Stack( + children: [ + if (index == 0) + Positioned.fill( + bottom: 0, + left: 0, + child: Obx(() { + if (snatchHandler.total.value == 0) { + return const SizedBox.shrink(); + } + + return LinearProgressIndicator( + value: snatchHandler.received.value / snatchHandler.total.value, + color: Theme.of(context).progressIndicatorTheme.color?.withOpacity(0.5), + ); + }), + ), + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: 100, + height: 150, + child: ThumbnailBuild( + item: item, + // isStandalone: true, + // ignoreColumnsCount: true, + ), + ), + ), + Text( + '${snatchHandler.queueProgress.value + index + 1}/${snatchHandler.current.value!.booruItems.length}', + style: const TextStyle(fontSize: 16), + ), + if (index == 0) ...[ + const Spacer(), + Obx( + () => Text( + snatchHandler.total.value == 0 + ? '...%' + : '${((snatchHandler.received.value / snatchHandler.total.value) * 100.0).toStringAsFixed(2)}%', + style: const TextStyle(fontSize: 16), + ), + ), + ], + ], + ), + ], + ); + } else { + final int queueIndex = (queuesAmount - 1) + (index - activeLength - (activeLength == 0 ? 0 : 1)); + final queue = snatchHandler.queuedList[queueIndex]; + final firstItem = queue.booruItems.first; + final lastItem = queue.booruItems.last; + + return Row( + children: [ + Stack( + children: [ + if (firstItem != lastItem) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: SizedBox( + width: 100, + height: 134, + child: ThumbnailBuild( + item: lastItem, + // isStandalone: true, + // ignoreColumnsCount: true, + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: 100, + height: 150, + child: ThumbnailBuild( + item: firstItem, + // isStandalone: true, + // ignoreColumnsCount: true, + ), + ), + ), + ], + ), + Text( + 'Queue #$queueIndex (${queue.booruItems.length})', + style: const TextStyle(fontSize: 16), + ), + ], + ); + } + }, + ); + }), + ), + // + Obx(() { + if (settingsHandler.booruList.isNotEmpty && searchHandler.list.isNotEmpty) { + return Container( + margin: const EdgeInsets.only(top: 16), + child: Column( + children: [ + Obx(() { + final selected = searchHandler.currentTab.selected; + if (selected.isNotEmpty) { + return Column( + children: [ + SettingsButton( + name: 'Snatch selected items (${searchHandler.currentTab.selected.length.toFormattedString()})', + icon: const Icon(Icons.download_sharp), + action: () => onStartSnatching(context, false), + onLongPress: () => onStartSnatching(context, true), + drawTopBorder: true, + drawBottomBorder: false, + ), + SettingsButton( + name: 'Clear selected items', + icon: const Icon(Icons.delete_forever), + action: () => searchHandler.currentTab.selected.clear(), + drawTopBorder: true, + drawBottomBorder: false, + ), + ], + ); + } else { + return SettingsButton( + name: 'Select all items', + icon: const Icon(Icons.select_all), + action: () => searchHandler.currentTab.selected.addAll(searchHandler.currentFetched), + drawTopBorder: true, + drawBottomBorder: false, + ); + } + }), + SettingsButton( + name: 'Snatcher', + icon: const Icon(Icons.download_sharp), + page: () => const SnatcherPage(), + drawTopBorder: true, + ), + SettingsButton( + name: 'Snatching History', + icon: const Icon(Icons.file_download_outlined), + action: () { + final Booru? downloadsBooru = settingsHandler.booruList.firstWhereOrNull((booru) => booru.type == BooruType.Downloads); + final bool hasDownloads = downloadsBooru != null; + + if (!hasDownloads) { + return; + } + + searchHandler.addTabByString( + '', + switchToNew: true, + customBooru: downloadsBooru, + ); + toggleDrawer(); + }, + ), + ], + ), + ); + } else { + return const SizedBox.shrink(); + } + }), + ], + ), + ), + ), + ); + } +} + class MergeBooruToggle extends StatelessWidget { const MergeBooruToggle({super.key}); diff --git a/lib/src/pages/settings/backup_restore_page.dart b/lib/src/pages/settings/backup_restore_page.dart index 922679ba..109c3a4d 100644 --- a/lib/src/pages/settings/backup_restore_page.dart +++ b/lib/src/pages/settings/backup_restore_page.dart @@ -73,16 +73,23 @@ class _BackupRestorePageState extends State { } //called when page is closed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } @override Widget build(BuildContext context) { if (!Platform.isAndroid) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( @@ -97,7 +104,7 @@ class _BackupRestorePageState extends State { child: const Text( "This feature is only available on Android, on Desktop builds you can just copy/paste files from/to app's data folder, respective to your system", ), - ) + ), ], ), ), @@ -105,8 +112,9 @@ class _BackupRestorePageState extends State { ); } - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( @@ -249,7 +257,7 @@ class _BackupRestorePageState extends State { } settingsHandler.dbHandler = DBHandler(); - await settingsHandler.dbHandler.dbConnect(newFile.path, settingsHandler.indexesEnabled); + await settingsHandler.dbHandler.dbConnect(newFile.path); // showSnackbar(context, 'Database restored from backup! App will restart in a few seconds!', false); await Future.delayed(const Duration(seconds: 3)); @@ -267,7 +275,8 @@ class _BackupRestorePageState extends State { name: 'Backup Boorus', action: () async { try { - final List booruList = settingsHandler.booruList.where((e) => e.type != BooruType.Favourites).toList(); + final List booruList = + settingsHandler.booruList.where((e) => e.type != BooruType.Favourites && e.type != BooruType.Downloads).toList(); if (await ServiceHandler.existsFileFromSAFDirectory(backupPath, 'boorus.json')) { final bool res = await detectedDuplicateFile('boorus.json'); if (!res) { @@ -306,7 +315,7 @@ class _BackupRestorePageState extends State { final Booru booru = Booru.fromMap(json[i]); final bool alreadyExists = settingsHandler.booruList.indexWhere((el) => el.baseURL == booru.baseURL && el.name == booru.name) != -1; - final bool isAllowed = booru.type != BooruType.Favourites; + final bool isAllowed = booru.type != BooruType.Favourites && booru.type != BooruType.Downloads; if (!alreadyExists && isAllowed) { final File booruFile = File('${configBoorusDir.path}${booru.name}.json'); final writer = booruFile.openWrite(); diff --git a/lib/src/pages/settings/booru_edit_page.dart b/lib/src/pages/settings/booru_edit_page.dart index 969a213b..93b06421 100644 --- a/lib/src/pages/settings/booru_edit_page.dart +++ b/lib/src/pages/settings/booru_edit_page.dart @@ -22,15 +22,13 @@ import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; import 'package:lolisnatcher/src/widgets/webview/webview_page.dart'; -/// This is the booru editor page. class BooruEdit extends StatefulWidget { - BooruEdit( + const BooruEdit( this.booru, { super.key, }); - Booru booru; - BooruType? booruType; + final Booru booru; @override State createState() => _BooruEditState(); @@ -47,6 +45,7 @@ class _BooruEditState extends State { final booruUserIDController = TextEditingController(); final booruDefTagsController = TextEditingController(); + BooruType? booruType; BooruType selectedBooruType = BooruType.AutoDetect; // TODO make standalone / move to handlers themselves @@ -262,6 +261,9 @@ class _BooruEditState extends State { if (!booruURLController.text.contains('http://') && !booruURLController.text.contains('https://')) { booruURLController.text = 'https://${booruURLController.text}'; } + if (booruURLController.text.endsWith('/')) { + booruURLController.text = booruURLController.text.substring(0, booruURLController.text.length - 1); + } // autofill favicon if not specified if (booruFaviconController.text == '') { booruFaviconController.text = '${booruURLController.text}/favicon.ico'; @@ -279,7 +281,7 @@ class _BooruEditState extends State { if (booruAPIKeyController.text == '') { testBooru = Booru( booruNameController.text, - widget.booruType, + booruType, booruFaviconController.text, booruURLController.text, booruDefTagsController.text, @@ -287,7 +289,7 @@ class _BooruEditState extends State { } else { testBooru = Booru.withKey( booruNameController.text, - widget.booruType, + booruType, booruFaviconController.text, booruURLController.text, booruDefTagsController.text, @@ -298,18 +300,18 @@ class _BooruEditState extends State { isTesting = true; setState(() {}); final List testResults = await booruTest(testBooru, selectedBooruType); - final BooruType? booruType = testResults[0]; + final BooruType? testBooruType = testResults[0]; final String errorString = testResults[1].isNotEmpty ? 'Error text: "${testResults[1]}"' : ''; // If a booru type is returned set the widget state - if (booruType != null) { - widget.booruType = booruType; - selectedBooruType = booruType; + if (testBooruType != null) { + booruType = testBooruType; + selectedBooruType = testBooruType; // Alert user about the results of the test FlashElements.showSnackbar( context: context, title: Text( - 'Booru Type is ${booruType.alias}', + 'Booru Type is ${testBooruType.alias}', style: const TextStyle(fontSize: 20), ), content: const Text( @@ -343,15 +345,15 @@ class _BooruEditState extends State { /// allowing the user to save the booru config otherwise an empty container is returned Widget saveButton() { return SettingsButton( - name: "Save Booru${widget.booruType == null ? ' (Run Test First)' : ''}", + name: "Save Booru${booruType == null ? ' (Run Test First)' : ''}", icon: Icon( Icons.save, - color: widget.booruType == null ? Colors.red : Colors.green, + color: booruType == null ? Colors.red : Colors.green, ), action: () async { sanitizeBooruName(); - if (widget.booruType == null) { + if (booruType == null) { FlashElements.showSnackbar( context: context, title: const Text('Run Test First!', style: TextStyle(fontSize: 20)), @@ -366,14 +368,14 @@ class _BooruEditState extends State { final Booru newBooru = (booruAPIKeyController.text == '' && booruUserIDController.text == '') ? Booru( booruNameController.text, - widget.booruType, + booruType, booruFaviconController.text, booruURLController.text, booruDefTagsController.text, ) : Booru.withKey( booruNameController.text, - widget.booruType, + booruType, booruFaviconController.text, booruURLController.text, booruDefTagsController.text, diff --git a/lib/src/pages/settings/booru_page.dart b/lib/src/pages/settings/booru_page.dart index 4ddcd545..cd6b1171 100644 --- a/lib/src/pages/settings/booru_page.dart +++ b/lib/src/pages/settings/booru_page.dart @@ -75,7 +75,11 @@ class _BooruPageState extends State { } //called when page is clsoed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.defTags = defaultTagsController.text; if (int.parse(limitController.text) > 100) { limitController.text = '100'; @@ -93,13 +97,15 @@ class _BooruPageState extends State { } else if (res == false && initPrefBooru != null) { settingsHandler.prefBooru = initPrefBooru?.name ?? ''; } else if (res == null) { - return false; + return; } } settingsHandler.limit = int.parse(limitController.text); final bool result = await settingsHandler.saveSettings(restate: false); await settingsHandler.sortBooruList(); - return result; + if (result) { + Navigator.of(context).pop(); + } } Widget addButton() { @@ -148,7 +154,7 @@ class _BooruPageState extends State { name: 'Share Selected Booru', icon: const Icon(Icons.share), action: () { - if (selectedBooru?.type == BooruType.Favourites) { + if (selectedBooru?.type == BooruType.Favourites || selectedBooru?.type == BooruType.Downloads) { return; } @@ -218,10 +224,12 @@ class _BooruPageState extends State { return SettingsButton( name: 'Edit Selected Booru', icon: const Icon(Icons.edit), - // do nothing if no selected or selected "Favourites" + // do nothing if no selected or selected "Favourites/Dowloads" // TODO update all tabs with old booru with a new one // TODO if you open edit after already editing - it will open old instance + possible exception due to old data - page: (selectedBooru != null && selectedBooru?.type != BooruType.Favourites) ? () => BooruEdit(selectedBooru!) : null, + page: (selectedBooru != null && selectedBooru?.type != BooruType.Favourites && selectedBooru?.type != BooruType.Downloads) + ? () => BooruEdit(selectedBooru!) + : null, ); } @@ -230,7 +238,7 @@ class _BooruPageState extends State { name: 'Delete Selected Booru', icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error), action: () { - // do nothing if no selected or selected "Favourites" or there are tabs with it + // do nothing if no selected or selected "Favourites/Downloads" or there are tabs with it if (selectedBooru == null) { FlashElements.showSnackbar( context: context, @@ -241,7 +249,7 @@ class _BooruPageState extends State { ); return; } - if (selectedBooru?.type == BooruType.Favourites) { + if (selectedBooru?.type == BooruType.Favourites || selectedBooru?.type == BooruType.Downloads) { FlashElements.showSnackbar( context: context, title: const Text("Can't delete this Booru!", style: TextStyle(fontSize: 20)), @@ -438,8 +446,9 @@ class _BooruPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( diff --git a/lib/src/pages/settings/database_page.dart b/lib/src/pages/settings/database_page.dart index 36e7b515..72b65035 100644 --- a/lib/src/pages/settings/database_page.dart +++ b/lib/src/pages/settings/database_page.dart @@ -54,7 +54,11 @@ class _DatabasePageState extends State { } //called when page is closed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + if (isUpdating) { FlashElements.showSnackbar( title: const Text("Can't leave the page right now!", style: TextStyle(fontSize: 20)), @@ -63,7 +67,7 @@ class _DatabasePageState extends State { leadingIconColor: Colors.yellow, sideColor: Colors.yellow, ); - return false; + return; } if (changingIndexes) { @@ -74,7 +78,7 @@ class _DatabasePageState extends State { leadingIconColor: Colors.yellow, sideColor: Colors.yellow, ); - return false; + return; } // Set settingshandler values here @@ -83,7 +87,9 @@ class _DatabasePageState extends State { settingsHandler.searchHistoryEnabled = searchHistoryEnabled; settingsHandler.tagTypeFetchEnabled = tagTypeFetchEnabled; final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } List getSankakuBoorus() { @@ -252,8 +258,9 @@ class _DatabasePageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -294,47 +301,79 @@ class _DatabasePageState extends State { children: [ IgnorePointer( ignoring: changingIndexes, - child: SettingsToggle( - value: indexesEnabled, - onChanged: changeIndexes, - title: 'Enable Indexes', - trailingIcon: IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const SettingsDialog( - title: Text('Indexes'), - contentItems: [ - Text('Indexes help make searching database faster,'), - Text('but they take up more space on disk (possibly doubling the size of the database)'), - Text("Don't leave the page while indexes are being changed to avoid database corruption"), - ], + child: Column( + children: [ + SettingsToggle( + value: indexesEnabled, + onChanged: changeIndexes, + title: 'Enable Indexes', + trailingIcon: IconButton( + icon: const Icon(Icons.help_outline), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const SettingsDialog( + title: Text('Indexes'), + contentItems: [ + Text('Indexes help make searching database faster,'), + Text('but they take up more space on disk (possibly doubling the size of the database)'), + Text("Don't leave the page while indexes are being changed to avoid database corruption"), + ], + ); + }, ); }, - ); - }, - ), + ), + ), + if (settingsHandler.isDebug.value) ...[ + SettingsButton( + name: 'Create Indexes [Debug]', + icon: const Icon(Icons.create_new_folder_rounded), + action: () async { + changingIndexes = true; + setState(() {}); + await settingsHandler.dbHandler.createIndexes(); + changingIndexes = false; + setState(() {}); + }, + ), + SettingsButton( + name: 'Drop Indexes [Debug]', + icon: const Icon(Icons.delete_forever), + action: () async { + changingIndexes = true; + setState(() {}); + await settingsHandler.dbHandler.dropIndexes(); + changingIndexes = false; + setState(() {}); + }, + ), + const SettingsButton(name: '', enabled: false), + ], + ], ), ), - if (changingIndexes) ...[ + if (changingIndexes) Positioned.fill( - child: ColoredBox( - color: Colors.black.withOpacity(0.5), - ), - ), - const Positioned.fill( - child: Align( - alignment: Alignment.center, - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(), - ), + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: Colors.black.withOpacity(0.5), + ), + ), + const Align( + alignment: Alignment.center, + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ), + ), + ], ), ), - ], ], ), SettingsToggle( diff --git a/lib/src/pages/settings/debug_page.dart b/lib/src/pages/settings/debug_page.dart index 1b2dce79..cf465a79 100644 --- a/lib/src/pages/settings/debug_page.dart +++ b/lib/src/pages/settings/debug_page.dart @@ -16,10 +16,10 @@ import 'package:lolisnatcher/src/handlers/service_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/pages/settings/logger_page.dart'; import 'package:lolisnatcher/src/utils/http_overrides.dart'; +import 'package:lolisnatcher/src/widgets/common/assault_indicator.dart'; import 'package:lolisnatcher/src/widgets/common/cancel_button.dart'; import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; -import 'package:lolisnatcher/src/widgets/common/text_expander.dart'; import 'package:lolisnatcher/src/widgets/tags_manager/tm_dialog.dart'; import 'package:lolisnatcher/src/widgets/webview/webview_page.dart'; @@ -54,7 +54,11 @@ class _DebugPageState extends State { } //called when page is closed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.allowSelfSignedCerts = allowSelfSignedCerts; final bool result = await settingsHandler.saveSettings(restate: false); if (allowSelfSignedCerts) { @@ -62,13 +66,17 @@ class _DebugPageState extends State { } else { HttpOverrides.global = null; } - return result; + + if (result) { + Navigator.of(context).pop(); + } } @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -174,12 +182,10 @@ class _DebugPageState extends State { }, ), - if (kDebugMode) const SettingsButton(name: '', enabled: false), - - if (kDebugMode) - TextExpander( - title: 'Vibration', - bodyList: [ + if (kDebugMode && (Platform.isAndroid || Platform.isIOS)) ...[ + const SettingsButton(name: 'Vibration', enabled: false), + Column( + children: [ const SettingsButton( name: 'Vibration tests', ), @@ -299,8 +305,7 @@ class _DebugPageState extends State { ), ], ), - - const SettingsButton(name: '', enabled: false), + ], SettingsButton(name: 'Res: ${MediaQuery.of(context).size.width.toPrecision(4)}x${MediaQuery.of(context).size.height.toPrecision(4)}'), SettingsButton(name: 'Pixel Ratio: ${MediaQuery.of(context).devicePixelRatio.toPrecision(4)}'), diff --git a/lib/src/pages/settings/dir_picker_page.dart b/lib/src/pages/settings/dir_picker_page.dart index ef83d99f..a3fb2fc6 100644 --- a/lib/src/pages/settings/dir_picker_page.dart +++ b/lib/src/pages/settings/dir_picker_page.dart @@ -30,9 +30,13 @@ class _DirPickerState extends State { path = widget.path; } - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + if (path == '/storage') { - final shouldPop = await showDialog( + final bool? shouldPop = await showDialog( context: context, builder: (context) { return SettingsDialog( @@ -57,7 +61,10 @@ class _DirPickerState extends State { ); }, ); - return shouldPop; + + if (shouldPop ?? false) { + Navigator.of(context).pop(); + } } else { if (path.lastIndexOf('/') > -1) { setState(() { @@ -65,7 +72,7 @@ class _DirPickerState extends State { print(path); }); } - return false; + return; } } @@ -142,8 +149,9 @@ class _DirPickerState extends State { if (path != widget.path) { title = path.substring(path.lastIndexOf('/') + 1); } - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( diff --git a/lib/src/pages/settings/gallery_page.dart b/lib/src/pages/settings/gallery_page.dart index 825d2fe5..aaab319a 100644 --- a/lib/src/pages/settings/gallery_page.dart +++ b/lib/src/pages/settings/gallery_page.dart @@ -56,7 +56,11 @@ class _GalleryPageState extends State { } //called when page is clsoed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.autoHideImageBar = autoHideImageBar; settingsHandler.galleryMode = galleryMode; settingsHandler.galleryBarPosition = galleryBarPosition; @@ -85,7 +89,9 @@ class _GalleryPageState extends State { settingsHandler.preloadCount = int.parse(preloadController.text); // Set settingshandler values here final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } @override @@ -96,8 +102,9 @@ class _GalleryPageState extends State { final bool hasHydrus = settingsHandler.hasHydrus; - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -197,7 +204,7 @@ class _GalleryPageState extends State { '[Note]: If File is saved in cache, it will be loaded from there. Otherwise it will be loaded again from network which can take some time.', ), const Text(''), - const Text('[Tip]: You can open Share Actions Menu by long pressing Share button.') + const Text('[Tip]: You can open Share Actions Menu by long pressing Share button.'), ], ); }, @@ -305,7 +312,7 @@ class _GalleryPageState extends State { buttonOrder!.insert(newIndex, item); }); }, - ) + ), ], ), ), diff --git a/lib/src/pages/settings/logger_page.dart b/lib/src/pages/settings/logger_page.dart index d56c3192..cf6f7142 100644 --- a/lib/src/pages/settings/logger_page.dart +++ b/lib/src/pages/settings/logger_page.dart @@ -31,17 +31,22 @@ class _LoggerPageState extends State { } //called when page is closed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.enabledLogTypes.value = enabledLogTypes; - return true; + Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final bool allLogTypesEnabled = enabledLogTypes.toSet().toList().length == LogTypes.values.length; - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( diff --git a/lib/src/pages/settings/save_cache_page.dart b/lib/src/pages/settings/save_cache_page.dart index dd6a33f0..92e9e167 100644 --- a/lib/src/pages/settings/save_cache_page.dart +++ b/lib/src/pages/settings/save_cache_page.dart @@ -113,7 +113,11 @@ class _SaveCachePageState extends State { } //called when page is closed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.snatchCooldown = int.parse(snatchCooldownController.text); settingsHandler.jsonWrite = jsonWrite; settingsHandler.mediaCache = mediaCache; @@ -125,7 +129,9 @@ class _SaveCachePageState extends State { settingsHandler.downloadNotifications = downloadNotifications; settingsHandler.customUserAgent = userAgentController.text; final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } void setPath(String path) { @@ -184,8 +190,9 @@ class _SaveCachePageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -339,7 +346,7 @@ class _SaveCachePageState extends State { Text(''), Text("[Note]: Videos will cache only if 'Cache Media' is enabled."), Text(''), - Text('[Warning]: On desktop builds Stream mode can work incorrectly for some Boorus.') + Text('[Warning]: On desktop builds Stream mode can work incorrectly for some Boorus.'), ], ); }, diff --git a/lib/src/pages/settings/settings_template.dart b/lib/src/pages/settings/settings_template.dart index 0228bc8c..f7d2e089 100644 --- a/lib/src/pages/settings/settings_template.dart +++ b/lib/src/pages/settings/settings_template.dart @@ -17,16 +17,23 @@ class _SettingsTemplateState extends State { } //called when page is clsoed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + // Set settingshandler values here final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( diff --git a/lib/src/pages/settings/tags_filters_page.dart b/lib/src/pages/settings/tags_filters_page.dart index 88a43bee..763b8dba 100644 --- a/lib/src/pages/settings/tags_filters_page.dart +++ b/lib/src/pages/settings/tags_filters_page.dart @@ -25,7 +25,7 @@ class _TagsFiltersPageState extends State with SingleTickerProv List hatedList = []; List lovedList = []; - bool filterHated = false, filterFavourites = false; + bool filterHated = false, filterFavourites = false, filterSnatched = false, filterAi = false; @override void initState() { @@ -36,6 +36,8 @@ class _TagsFiltersPageState extends State with SingleTickerProv lovedList.sort(sortTags); filterHated = settingsHandler.filterHated; filterFavourites = settingsHandler.filterFavourites; + filterSnatched = settingsHandler.filterSnatched; + filterAi = settingsHandler.filterAi; tabController = TabController(vsync: this, length: 3)..addListener(updateState); } @@ -53,13 +55,21 @@ class _TagsFiltersPageState extends State with SingleTickerProv super.dispose(); } - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.hatedTags = settingsHandler.cleanTagsList(hatedList); settingsHandler.lovedTags = settingsHandler.cleanTagsList(lovedList); settingsHandler.filterHated = filterHated; settingsHandler.filterFavourites = filterFavourites; + settingsHandler.filterSnatched = filterSnatched; + settingsHandler.filterAi = filterAi; final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } List getTagsList(String type) { @@ -150,8 +160,9 @@ class _TagsFiltersPageState extends State with SingleTickerProv @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -231,6 +242,16 @@ class _TagsFiltersPageState extends State with SingleTickerProv filterFavourites = newValue; updateState(); }, + filterSnatched: filterSnatched, + onFilterSnatchedChanged: (bool newValue) { + filterSnatched = newValue; + updateState(); + }, + filterAi: filterAi, + onFilterAiChanged: (bool newValue) { + filterAi = newValue; + updateState(); + }, ), ], ), diff --git a/lib/src/pages/settings/theme_page.dart b/lib/src/pages/settings/theme_page.dart index a0f0f5d1..b12c18f8 100644 --- a/lib/src/pages/settings/theme_page.dart +++ b/lib/src/pages/settings/theme_page.dart @@ -69,7 +69,11 @@ class _ThemePageState extends State { } //called when page is closed or to debounce theme change, sets settingshandler variables and then writes settings to disk - Future _onWillPop({bool withRestate = false}) async { + Future _onPopInvoked(bool didPop, {bool? withRestate}) async { + if (didPop) { + return; + } + settingsHandler.theme.value = theme; settingsHandler.themeMode.value = themeMode; settingsHandler.useMaterial3.value = useMaterial3; @@ -92,8 +96,10 @@ class _ThemePageState extends State { } else { settingsHandler.drawerMascotPathOverride = mascotPathOverride; } - final bool result = await settingsHandler.saveSettings(restate: withRestate); - return result; + final bool result = await settingsHandler.saveSettings(restate: withRestate ?? false); + if (result && withRestate == null) { + Navigator.of(context).pop(); + } } Future updateTheme({bool withRestate = false}) async { @@ -104,7 +110,7 @@ class _ThemePageState extends State { Debounce.debounce( tag: 'theme_change', callback: () async { - await _onWillPop(withRestate: withRestate); + await _onPopInvoked(false, withRestate: withRestate); setState(() {}); }, duration: const Duration(milliseconds: 500), @@ -174,8 +180,9 @@ class _ThemePageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( diff --git a/lib/src/pages/settings/user_interface_page.dart b/lib/src/pages/settings/user_interface_page.dart index eba8ca32..67e287a2 100644 --- a/lib/src/pages/settings/user_interface_page.dart +++ b/lib/src/pages/settings/user_interface_page.dart @@ -39,7 +39,11 @@ class _UserInterfacePageState extends State { } //called when page is clsoed, sets settingshandler variables and then writes settings to disk - Future _onWillPop() async { + Future _onPopInvoked(bool didPop) async { + if (didPop) { + return; + } + settingsHandler.appMode.value = appMode; settingsHandler.handSide.value = handSide; settingsHandler.previewMode = previewMode; @@ -54,13 +58,16 @@ class _UserInterfacePageState extends State { settingsHandler.portraitColumns = int.parse(columnsPortraitController.text); settingsHandler.mousewheelScrollSpeed = double.parse(mouseSpeedController.text); final bool result = await settingsHandler.saveSettings(restate: false); - return result; + if (result) { + Navigator.of(context).pop(); + } } @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: _onPopInvoked, child: Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -122,7 +129,7 @@ class _UserInterfacePageState extends State { contentItems: [ Text('Moves some parts of the UI to the selected side of the screen'), Text('Currently only changes the position of the main drawer button'), - Text('[This is a WIP feature, will include more changes in the future versions]') + Text('[This is a WIP feature, will include more changes in the future versions]'), ], ); }, @@ -201,7 +208,7 @@ class _UserInterfacePageState extends State { Text(' - Sample - Medium resolution, app will also load a Thumbnail quality as a placeholder while higher quality loads'), Text(' - Thumbnail - Low resolution'), Text(' '), - Text('[Note]: Sample quality can noticeably degrade performance, especially if you have too many columns in preview grid') + Text('[Note]: Sample quality can noticeably degrade performance, especially if you have too many columns in preview grid'), ], ); }, @@ -219,30 +226,31 @@ class _UserInterfacePageState extends State { }, title: 'Preview Display', ), - SettingsTextInput( - controller: mouseSpeedController, - title: 'Mouse Wheel Scroll Modifer', - hintText: 'Scroll modifier', - inputType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - resetText: () => settingsHandler.map['mousewheelScrollSpeed']!['default']!.toString(), - numberButtons: true, - numberStep: 0.5, - numberMin: 0.1, - numberMax: 20, - validator: (String? value) { - final double? parse = double.tryParse(value ?? ''); - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } else if (parse == null) { - return 'Please enter a valid numeric value'; - } else if (parse > 20.0) { - return 'Please enter a value between 0.1 and 20.0'; - } else { - return null; - } - }, - ), + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) + SettingsTextInput( + controller: mouseSpeedController, + title: 'Mouse Wheel Scroll Modifer', + hintText: 'Scroll modifier', + inputType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + resetText: () => settingsHandler.map['mousewheelScrollSpeed']!['default']!.toString(), + numberButtons: true, + numberStep: 0.5, + numberMin: 0.1, + numberMax: 20, + validator: (String? value) { + final double? parse = double.tryParse(value ?? ''); + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } else if (parse == null) { + return 'Please enter a valid numeric value'; + } else if (parse > 20.0) { + return 'Please enter a value between 0.1 and 20.0'; + } else { + return null; + } + }, + ), ], ), ), diff --git a/lib/src/pages/settings_page.dart b/lib/src/pages/settings_page.dart index 148adc80..6693b0a4 100644 --- a/lib/src/pages/settings_page.dart +++ b/lib/src/pages/settings_page.dart @@ -28,6 +28,20 @@ import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); + Future _onPopInvoked(BuildContext context, bool didPop) async { + if (didPop) { + return; + } + + final SettingsHandler settingsHandler = SettingsHandler.instance; + final bool result = await settingsHandler.saveSettings(restate: true); + await settingsHandler.loadSettings(); + // await settingsHandler.getBooru(); + if (result) { + Navigator.of(context).pop(); + } + } + Future _onWillPop() async { final SettingsHandler settingsHandler = SettingsHandler.instance; final bool result = await settingsHandler.saveSettings(restate: true); @@ -40,8 +54,9 @@ class SettingsPage extends StatelessWidget { Widget build(BuildContext context) { final SettingsHandler settingsHandler = SettingsHandler.instance; - return WillPopScope( - onWillPop: _onWillPop, + return PopScope( + canPop: false, + onPopInvoked: (didPop) async => _onPopInvoked(context, didPop), child: Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( diff --git a/lib/src/pages/snatcher_page.dart b/lib/src/pages/snatcher_page.dart index fa956743..7e975584 100644 --- a/lib/src/pages/snatcher_page.dart +++ b/lib/src/pages/snatcher_page.dart @@ -6,7 +6,6 @@ import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/handlers/snatch_handler.dart'; import 'package:lolisnatcher/src/services/get_perms.dart'; -import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; /// This is the page which allows the user to batch download images @@ -34,7 +33,7 @@ class _SnatcherPageState extends State { getPerms(); //If the user has searched tags on the main window they will be loaded into the tags field snatcherTagsController.text = searchHandler.currentTab.tags; - snatcherAmountController.text = 10.toString(); + snatcherAmountController.text = settingsHandler.limit.toString(); selectedBooru = searchHandler.currentBooru; snatcherSleepController.text = settingsHandler.snatchCooldown.toString(); } @@ -97,19 +96,6 @@ class _SnatcherPageState extends State { if (snatcherSleepController.text.isEmpty) { snatcherSleepController.text = 0.toString(); } - if (selectedBooru == null) { - FlashElements.showSnackbar( - context: context, - title: const Text( - 'No Booru Selected!', - style: TextStyle(fontSize: 18), - ), - leadingIcon: Icons.error_outline, - leadingIconColor: Colors.red, - sideColor: Colors.red, - ); - return; - } snatchHandler.searchSnatch( snatcherTagsController.text, diff --git a/lib/src/services/get_perms.dart b/lib/src/services/get_perms.dart index 500bbb11..e7c4e4dc 100644 --- a/lib/src/services/get_perms.dart +++ b/lib/src/services/get_perms.dart @@ -11,9 +11,10 @@ import 'package:lolisnatcher/src/handlers/service_handler.dart'; /// it is called before every operation which would require writing to storage which is why its in its own function /// /// The dialog will not show if the user has already accepted perms or android sdk is below 33 -Future getPerms() async { +Future getPerms() async { if ((Platform.isAndroid && await ServiceHandler.getAndroidSDKVersion() < 33) || Platform.isIOS) { return Permission.storage.request().isGranted; } + return true; // print(Platform.environment['HOME']); } diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 3dc9d7c7..eb1efaa0 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; extension UIExtras on Widget { - Widget withBorder({Color? color, double? strokeWidth, BorderRadius? borderRadius}) => Container( + Widget withBorder({ + Color? color, + double? strokeWidth, + BorderRadius? borderRadius, + }) => + Container( decoration: BoxDecoration( border: Border.all(color: color ?? Colors.black, width: strokeWidth ?? 1), borderRadius: borderRadius, @@ -37,7 +42,7 @@ extension StringExtras on String { String toPascalCase() => toTitleCase().replaceAll(' ', ''); - bool stringToBool() => this == 'true' || this == '1'; + bool toBool() => this == 'true' || this == '1'; } extension IntExtras on int { @@ -47,11 +52,23 @@ extension IntExtras on int { int clamp(int min, int max) => (min > this ? min : (max < this ? max : this)); - bool intToBool() => this == 1; + bool toBool() => this == 1; + + String toFormattedString() => formatNumber(this); } extension DoubleExtras on double { double clamp(double min, double max) => (min > this ? min : (max < this ? max : this)); String toStringAsFixed(int digits) => toStringAsFixed(digits); + + String toFormattedString() => formatNumber(this); +} + +String formatNumber(num number) { + final String formattedPart = number.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match match) => '${match[1]} ', + ); + return formattedPart.trim(); } diff --git a/lib/src/utils/html_parse.dart b/lib/src/utils/html_parse.dart index 35c0abae..1dbbe8c2 100644 --- a/lib/src/utils/html_parse.dart +++ b/lib/src/utils/html_parse.dart @@ -28,6 +28,7 @@ InlineSpan _parseRecursive(dynamic node, TextStyle style, bool styleChanged, boo // TODO the method always remove whitespace at leading, // but it should check the tail of the previous span +// ignore: unused_element String _fixWhitespaceInText(String text) { final sb = StringBuffer(); int pre = ' '.codeUnitAt(0); @@ -119,7 +120,7 @@ InlineSpan _parseElement(dom.Element element, TextStyle style, bool styleChanged } break; default: - print('Unhandled tag: $tag'); + // print('Unhandled tag: $tag'); break; } diff --git a/lib/src/utils/logger.dart b/lib/src/utils/logger.dart index 29d47e16..4660117b 100644 --- a/lib/src/utils/logger.dart +++ b/lib/src/utils/logger.dart @@ -12,6 +12,7 @@ import 'package:lolisnatcher/src/utils/tools.dart'; class Logger { static Logger? _loggerInstance; + // ignore: non_constant_identifier_names static Logger Inst() { _loggerInstance ??= Logger(); return _loggerInstance!; @@ -487,37 +488,26 @@ enum LogTypes { LogLib.Level get logLevel { switch (this) { case LogTypes.booruHandlerFetchFailed: - return LogLib.Level.error; - case LogTypes.booruHandlerInfo: - return LogLib.Level.info; case LogTypes.booruHandlerParseFailed: + case LogTypes.exception: + case LogTypes.imageLoadingError: + case LogTypes.networkError: + case LogTypes.settingsError: return LogLib.Level.error; + // + case LogTypes.booruHandlerInfo: case LogTypes.booruHandlerRawFetched: - return LogLib.Level.info; case LogTypes.booruHandlerSearchURL: - return LogLib.Level.info; case LogTypes.booruHandlerTagInfo: - return LogLib.Level.info; case LogTypes.booruItemLoad: - return LogLib.Level.info; - case LogTypes.exception: - return LogLib.Level.error; case LogTypes.imageInfo: - return LogLib.Level.info; - case LogTypes.imageLoadingError: - return LogLib.Level.error; case LogTypes.loliSyncInfo: - return LogLib.Level.info; - case LogTypes.networkError: - return LogLib.Level.error; - case LogTypes.settingsError: - return LogLib.Level.error; case LogTypes.settingsLoad: - return LogLib.Level.info; case LogTypes.tagHandlerInfo: return LogLib.Level.info; - default: - return LogLib.Level.wtf; + // + // default: + // return LogLib.Level.wtf; } } } diff --git a/lib/src/widgets/common/bordered_text.dart b/lib/src/widgets/common/bordered_text.dart index c3980705..5f017c7d 100644 --- a/lib/src/widgets/common/bordered_text.dart +++ b/lib/src/widgets/common/bordered_text.dart @@ -80,7 +80,7 @@ class BorderedText extends StatelessWidget { strutStyle: child.strutStyle, textAlign: child.textAlign, textDirection: child.textDirection, - textScaleFactor: child.textScaleFactor, + textScaler: child.textScaler, ), child, ], diff --git a/lib/src/widgets/common/kaomoji.dart b/lib/src/widgets/common/kaomoji.dart new file mode 100644 index 00000000..ceaba667 --- /dev/null +++ b/lib/src/widgets/common/kaomoji.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +// TODO add more, maybe make themed lists (happy, angry...) and randomly pick one on first build? + +class Kaomoji extends StatelessWidget { + const Kaomoji({ + required this.type, + this.style, + this.richText = false, + super.key, + }); + + final KaomojiType type; + final TextStyle? style; + final bool richText; + + @override + Widget build(BuildContext context) { + if (richText) { + return RichText( + text: TextSpan( + style: style, + children: [ + TextSpan( + text: ' ${type.kaomoji} ', + ), + ], + ), + ); + } else { + return Text( + ' ${type.kaomoji} ', + style: style, + ); + } + } +} + +enum KaomojiType { + shrug, + angryHandsUp; + + String get kaomoji { + switch (this) { + case KaomojiType.shrug: + return r'¯\_(ツ)_/¯'; + case KaomojiType.angryHandsUp: + return '(」°ロ°)」'; + } + } +} diff --git a/lib/src/widgets/common/marquee_text.dart b/lib/src/widgets/common/marquee_text.dart index 058d5d4b..c75c5c68 100644 --- a/lib/src/widgets/common/marquee_text.dart +++ b/lib/src/widgets/common/marquee_text.dart @@ -8,11 +8,7 @@ import 'package:fast_marquee/fast_marquee.dart'; class MarqueeText extends StatelessWidget { const MarqueeText({ required this.text, - required this.fontSize, - this.fontWeight = FontWeight.normal, - this.fontStyle = FontStyle.normal, - this.addedHeight = 6, - this.color, + this.style, this.velocity = 45.0, this.curve = Curves.linear, this.blankSpace = 50.0, @@ -20,12 +16,16 @@ class MarqueeText extends StatelessWidget { this.startAfter = const Duration(milliseconds: 1000), this.pauseAfterRound = const Duration(milliseconds: 1500), this.isExpanded = true, + this.reverse = false, + this.allowDownscale = true, + this.fadingEdgeStartFraction = 0, + this.fadingEdgeEndFraction = 0.15, super.key, }) : textSpan = null; const MarqueeText.rich({ required this.textSpan, - this.addedHeight = 6, + required TextStyle this.style, this.velocity = 45.0, this.curve = Curves.linear, this.blankSpace = 50.0, @@ -33,20 +33,16 @@ class MarqueeText extends StatelessWidget { this.startAfter = const Duration(milliseconds: 1000), this.pauseAfterRound = const Duration(milliseconds: 1500), this.isExpanded = true, + this.reverse = false, + this.allowDownscale = true, + this.fadingEdgeStartFraction = 0, + this.fadingEdgeEndFraction = 0.15, super.key, - }) : text = null, - fontSize = 0, - fontWeight = FontWeight.normal, - fontStyle = FontStyle.normal, - color = null; + }) : text = null; final String? text; + final TextStyle? style; final TextSpan? textSpan; - final double fontSize; - final FontWeight fontWeight; - final FontStyle fontStyle; - final double addedHeight; - final Color? color; final double velocity; final Curve curve; final double blankSpace; @@ -54,12 +50,32 @@ class MarqueeText extends StatelessWidget { final Duration startAfter; final Duration pauseAfterRound; final bool isExpanded; + final bool reverse; + final bool allowDownscale; + final double fadingEdgeStartFraction; + final double fadingEdgeEndFraction; @override Widget build(BuildContext context) { - return isExpanded ? Expanded(child: innerBox(context)) : innerBox(context); + if (isExpanded) { + return Expanded(child: innerBox(context)); + } + + return innerBox(context); + } + + TextStyle get defaultStyle { + return const TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.normal, + ); } + double get fontSize => style?.fontSize ?? defaultStyle.fontSize!; + double get lineHeight => fontSize * 1.2; + Widget marquee(BuildContext context) { // This one can detect when text overflows by itself, but I'll leave AutoSize to resize text a bit when nearing overflow return Marquee( @@ -68,17 +84,18 @@ class MarqueeText extends StatelessWidget { curve: curve, velocity: velocity, startPadding: startPadding, - fadingEdgeStartFraction: 0, - fadingEdgeEndFraction: 0.15, + fadingEdgeStartFraction: fadingEdgeStartFraction, + fadingEdgeEndFraction: fadingEdgeEndFraction, + reverse: reverse, showFadingOnlyWhenScrolling: false, startAfter: startAfter, pauseAfterRound: pauseAfterRound, - style: TextStyle( - fontSize: fontSize, - color: color ?? Theme.of(context).colorScheme.onBackground, - fontWeight: fontWeight, - fontStyle: fontStyle, - ), + style: style?.copyWith( + color: style?.color ?? Theme.of(context).colorScheme.onBackground, + ) ?? + defaultStyle.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), ); } @@ -89,8 +106,9 @@ class MarqueeText extends StatelessWidget { curve: curve, velocity: velocity, startPadding: startPadding, - fadingEdgeStartFraction: 0, - fadingEdgeEndFraction: 0.15, + fadingEdgeStartFraction: fadingEdgeStartFraction, + fadingEdgeEndFraction: fadingEdgeEndFraction, + reverse: reverse, showFadingOnlyWhenScrolling: false, startAfter: startAfter, pauseAfterRound: pauseAfterRound, @@ -98,28 +116,46 @@ class MarqueeText extends StatelessWidget { } Widget innerBox(BuildContext context) { + // allow text to shrink a bit, so that strings can exceed a few symbols in length before starting to scroll + const double stepGranularity = 0.1; + double minFontSize = double.parse((fontSize * 0.85).toStringAsFixed(1)); + // make sure that minFontSize is dividable by stepGranularity + minFontSize = (minFontSize / stepGranularity).ceil() * stepGranularity; + if (textSpan != null) { return Container( alignment: Alignment.centerLeft, - child: marqueeRich(context), + child: AutoSizeText.rich( + textSpan!, + minFontSize: allowDownscale ? minFontSize : fontSize, + maxFontSize: fontSize, + maxLines: 1, + stepGranularity: stepGranularity, + style: style?.copyWith( + color: style?.color ?? Theme.of(context).colorScheme.onBackground, + ) ?? + defaultStyle.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + overflowReplacement: marqueeRich(context), + ), ); } return Container( alignment: Alignment.centerLeft, - height: (fontSize + addedHeight) * MediaQuery.of(context).textScaleFactor, // +X to not trigger overflow on short strings child: AutoSizeText( text!, - minFontSize: - (fontSize * 0.8).ceilToDouble(), // allow text to shrink a bit, so that strings can exceed a few symbols in length before starting to scroll + minFontSize: allowDownscale ? minFontSize : fontSize, maxFontSize: fontSize, - maxLines: 2, - style: TextStyle( - fontSize: fontSize, - color: color ?? Theme.of(context).colorScheme.onBackground, - fontWeight: fontWeight, - fontStyle: fontStyle, - ), + maxLines: 1, + stepGranularity: stepGranularity, + style: style?.copyWith( + color: style?.color ?? Theme.of(context).colorScheme.onBackground, + ) ?? + defaultStyle.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), overflowReplacement: marquee(context), ), ); diff --git a/lib/src/widgets/common/media_loading.dart b/lib/src/widgets/common/media_loading.dart index 0f9361ba..1666e33e 100644 --- a/lib/src/widgets/common/media_loading.dart +++ b/lib/src/widgets/common/media_loading.dart @@ -176,7 +176,9 @@ class _MediaLoadingState extends State { if (settingsHandler.shitDevice) { if (settingsHandler.loadingGif) { - return const Center(child: Image(image: AssetImage('assets/images/loading.gif'))); + return const Center( + child: Image(image: AssetImage('assets/images/loading.gif')), + ); } else { return const Center( child: CircularProgressIndicator(), @@ -234,7 +236,7 @@ class _MediaLoadingState extends State { final int sinceStartSeconds = (sinceStart / 1000).floor(); final String sinceStartText = (!widget.isDone && percentDone < 1) ? 'Started ${sinceStartSeconds}s ago' : ''; - final bool isMovedBelow = settingsHandler.previewMode == 'Sample' && !widget.item.isHated.value; + final bool isMovedBelow = settingsHandler.previewMode == 'Sample' && !widget.item.isHated; // print('$percentDone | $percentDoneText'); @@ -259,7 +261,7 @@ class _MediaLoadingState extends State { backgroundColor: MaterialStateProperty.all(Colors.black54), ), label: LoadingText( - text: (widget.isTooBig || widget.item.isHated.value) ? 'Load Anyway' : 'Restart Loading', + text: (widget.isTooBig || widget.item.isHated) ? 'Load Anyway' : 'Restart Loading', fontSize: 16, color: Colors.blue, ), diff --git a/lib/src/widgets/common/settings_widgets.dart b/lib/src/widgets/common/settings_widgets.dart index 84bdcd70..0de144f8 100644 --- a/lib/src/widgets/common/settings_widgets.dart +++ b/lib/src/widgets/common/settings_widgets.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:get/get.dart'; + import 'package:lolisnatcher/src/boorus/booru_type.dart'; import 'package:lolisnatcher/src/data/booru.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; @@ -34,9 +36,9 @@ class SettingsButton extends StatelessWidget { final String name; final Widget? icon; final Widget? subtitle; - final Widget Function()? page; - final void Function()? action; - final void Function()? onLongPress; + final WidgetCallback? page; + final VoidCallback? action; + final VoidCallback? onLongPress; final Widget? trailingIcon; final bool drawTopBorder; final bool drawBottomBorder; @@ -197,10 +199,7 @@ class SettingsToggle extends StatelessWidget { return ListTile( title: Row( children: [ - MarqueeText( - text: title, - fontSize: 16, - ), + MarqueeText(text: title), trailingIcon ?? const SizedBox(width: 8), ], ), @@ -233,6 +232,7 @@ class SettingsDropdown extends StatelessWidget { this.drawBottomBorder = true, this.trailingIcon, this.itemBuilder, + this.selectedItemBuilder, this.itemTitleBuilder, super.key, }); @@ -246,6 +246,7 @@ class SettingsDropdown extends StatelessWidget { final bool drawBottomBorder; final Widget? trailingIcon; final Widget Function(T)? itemBuilder; + final DropdownMenuItem Function(T)? selectedItemBuilder; final String Function(T)? itemTitleBuilder; String getTitle(T value) { @@ -276,14 +277,15 @@ class SettingsDropdown extends StatelessWidget { dropdownColor: Theme.of(context).colorScheme.surface, selectedItemBuilder: (BuildContext context) { return items.map>((T item) { - return DropdownMenuItem( - value: item, - child: Row( - children: [ - getItemWidget(item), - ], - ), - ); + return (selectedItemBuilder?.call(item) ?? + DropdownMenuItem( + value: item, + child: Row( + children: [ + getItemWidget(item), + ], + ), + )) as DropdownMenuItem; }).toList(); }, items: items.map>((T item) { @@ -329,6 +331,7 @@ class SettingsBooruDropdown extends StatelessWidget { required this.title, this.drawTopBorder = false, this.drawBottomBorder = true, + this.nullable = false, this.trailingIcon, super.key, }); @@ -338,29 +341,45 @@ class SettingsBooruDropdown extends StatelessWidget { final String title; final bool drawTopBorder; final bool drawBottomBorder; + final bool nullable; final Widget? trailingIcon; + Widget itemBuilder(Booru? booru) { + return Row( + children: [ + if (booru == null) + Icon(nullable ? Icons.question_mark : null, size: 18) + else if (booru.type == BooruType.Downloads) + const Icon(Icons.file_download_outlined, size: 18) + else if (booru.type == BooruType.Favourites) + const Icon(Icons.favorite, color: Colors.red, size: 18) + else + Favicon(booru), + Text(" ${booru?.name ?? (nullable ? 'Select a Booru' : '')}".trim()), + ], + ); + } + @override Widget build(BuildContext context) { final List boorus = SettingsHandler.instance.booruList; return SettingsDropdown( value: value, - items: boorus, + items: [ + if (nullable) null, + ...boorus, + ], onChanged: onChanged, title: title, drawTopBorder: drawTopBorder, drawBottomBorder: drawBottomBorder, trailingIcon: trailingIcon, - itemBuilder: (Booru? booru) { - return Row( - children: [ - if (booru == null) - const Icon(null) - else - booru.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(booru), - Text(" ${booru?.name ?? ''}".trim()), - ], + itemBuilder: itemBuilder, + selectedItemBuilder: (Booru? booru) { + return DropdownMenuItem( + value: booru, + child: itemBuilder(booru), ); }, ); @@ -371,6 +390,7 @@ class SettingsTextInput extends StatefulWidget { const SettingsTextInput({ required this.controller, required this.title, + this.focusNode, this.inputType = TextInputType.text, this.inputFormatters, this.validator, @@ -394,6 +414,7 @@ class SettingsTextInput extends StatefulWidget { final TextEditingController controller; final String title; + final FocusNode? focusNode; final TextInputType inputType; final List? inputFormatters; final String? Function(String?)? validator; @@ -419,11 +440,12 @@ class SettingsTextInput extends StatefulWidget { class _SettingsTextInputState extends State { bool isFocused = false; - final FocusNode _focusNode = FocusNode(); + late final FocusNode _focusNode; @override void initState() { super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(focusListener); } diff --git a/lib/src/widgets/common/text_expander.dart b/lib/src/widgets/common/text_expander.dart deleted file mode 100644 index d52ab804..00000000 --- a/lib/src/widgets/common/text_expander.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; - -class TextExpander extends StatefulWidget { - const TextExpander({ - required this.title, - required this.bodyList, - super.key, - }); - - final String title; - final List bodyList; - - @override - State createState() => _TextExpanderState(); -} - -class _TextExpanderState extends State { - bool showText = false; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5), - child: Row( - children: [ - Text( - widget.title, - textScaleFactor: 1.2, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - icon: Icon( - showText ? Icons.remove : Icons.add, - color: Theme.of(context).colorScheme.secondary, - ), - onPressed: () { - setState(() { - showText = !showText; - }); - }, - ), - ], - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: showText - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.bodyList, - ) - : const Center(child: SizedBox.shrink()), - ), - Divider( - height: 10, - thickness: 1, - indent: 5, - endIndent: 5, - color: Theme.of(context).dividerColor, - ), - ], - ); - } -} diff --git a/lib/src/widgets/desktop/desktop_image_listener.dart b/lib/src/widgets/desktop/desktop_image_listener.dart index 33846157..632d1464 100644 --- a/lib/src/widgets/desktop/desktop_image_listener.dart +++ b/lib/src/widgets/desktop/desktop_image_listener.dart @@ -222,7 +222,7 @@ class _DesktopImageListenerState extends State { ), ], ), - ) + ), ], ); } diff --git a/lib/src/widgets/desktop/desktop_tabs.dart b/lib/src/widgets/desktop/desktop_tabs.dart index 16035e08..7198fca9 100644 --- a/lib/src/widgets/desktop/desktop_tabs.dart +++ b/lib/src/widgets/desktop/desktop_tabs.dart @@ -78,15 +78,22 @@ class _DesktopTabsState extends State { child: Row( children: [ if (isNotEmptyBooru) - tab.selectedBooru.value.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(tab.selectedBooru.value) + if (tab.selectedBooru.value.type == BooruType.Downloads) + const Icon(Icons.file_download_outlined, size: 18) + else if (tab.selectedBooru.value.type == BooruType.Favourites) + const Icon(Icons.favorite, color: Colors.red, size: 18) + else + Favicon(tab.selectedBooru.value) else const Icon(CupertinoIcons.question, size: 18), const SizedBox(width: 3), MarqueeText( key: ValueKey(tagText), text: tagText, - fontSize: 16, - color: tab.tags == '' ? Colors.grey : null, + style: TextStyle( + fontSize: 16, + color: tab.tags == '' ? Colors.grey : null, + ), ), const SizedBox(width: 3), IconButton( @@ -161,7 +168,7 @@ class _DesktopTabsState extends State { ); }).toList(); }, - ) + ), ], ); }), diff --git a/lib/src/widgets/dialogs/comments_dialog.dart b/lib/src/widgets/dialogs/comments_dialog.dart index e667587a..32485397 100644 --- a/lib/src/widgets/dialogs/comments_dialog.dart +++ b/lib/src/widgets/dialogs/comments_dialog.dart @@ -241,7 +241,7 @@ class _CommentsDialogState extends State { ), ), ], - ) + ), ], ), const SizedBox(height: 8), diff --git a/lib/src/widgets/gallery/hideable_appbar.dart b/lib/src/widgets/gallery/hideable_appbar.dart index af9e950f..07ad662d 100644 --- a/lib/src/widgets/gallery/hideable_appbar.dart +++ b/lib/src/widgets/gallery/hideable_appbar.dart @@ -735,9 +735,7 @@ class _HideableAppBarState extends State { }, leading: const Icon(Icons.file_present), title: const Text('Hydrus'), - ) - else - Container() + ), ], ), const SizedBox(height: 15), @@ -785,21 +783,18 @@ class _HideableAppBarState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { + return PopScope( + onPopInvoked: (bool didPop) { // clear currently loading item from cache to avoid creating broken files // TODO move sharing download routine to somewhere in global context? shareCancelToken?.cancel(); if (sharedItem != null) { - unawaited( - imageWriter.deleteFileFromCache( - sharedItem!.fileURL, - 'media', - fileNameExtras: sharedItem!.fileNameExtras, - ), + imageWriter.deleteFileFromCache( + sharedItem!.fileURL, + 'media', + fileNameExtras: sharedItem!.fileNameExtras, ); } - return true; }, child: SafeArea( // to fix height bug when bar on top diff --git a/lib/src/widgets/gallery/notes_renderer.dart b/lib/src/widgets/gallery/notes_renderer.dart index 63fbb595..1d15b101 100644 --- a/lib/src/widgets/gallery/notes_renderer.dart +++ b/lib/src/widgets/gallery/notes_renderer.dart @@ -6,7 +6,6 @@ import 'package:get/get.dart'; import 'package:preload_page_view/preload_page_view.dart'; import 'package:lolisnatcher/src/data/booru_item.dart'; -import 'package:lolisnatcher/src/handlers/navigation_handler.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/handlers/viewer_handler.dart'; @@ -28,7 +27,6 @@ class _NotesRendererState extends State { final SearchHandler searchHandler = SearchHandler.instance; final SettingsHandler settingsHandler = SettingsHandler.instance; final ViewerHandler viewerHandler = ViewerHandler.instance; - final NavigationHandler navigationHandler = NavigationHandler.instance; late BooruItem item; late double screenWidth, @@ -37,16 +35,14 @@ class _NotesRendererState extends State { imageWidth, imageHeight, imageRatio, - ratioDiff, - widthLimit, - viewScale, screenToImageRatio, offsetX, offsetY, viewOffsetX, viewOffsetY, - pageOffset; - bool loading = false; + pageOffset, + resizeScale; + bool loading = false, shouldScale = false; StreamSubscription? itemListener; StreamSubscription? viewStateListener; @@ -55,6 +51,8 @@ class _NotesRendererState extends State { void initState() { super.initState(); + shouldScale = settingsHandler.galleryMode == 'Sample' || !settingsHandler.disableImageScaling; + screenWidth = WidgetsBinding.instance.platformDispatcher.views.first.physicalSize.width; screenHeight = WidgetsBinding.instance.platformDispatcher.views.first.physicalSize.height; screenRatio = screenWidth / screenHeight; @@ -69,8 +67,7 @@ class _NotesRendererState extends State { loadNotes(); }); - viewStateListener = viewerHandler.viewState.listen((viewState) { - // TODO when second double tap zoom scale is entered - no state sent? + viewStateListener = viewerHandler.viewState.listen((_) { triggerCalculations(); }); @@ -132,28 +129,17 @@ class _NotesRendererState extends State { void doCalculations() { // do the calculations depending on the current item here - imageWidth = item.fileWidth ?? 100; - imageHeight = item.fileHeight ?? 100; + imageWidth = viewerHandler.viewState.value?.scaleBoundaries?.childSize.width ?? item.fileWidth ?? screenWidth; + imageHeight = viewerHandler.viewState.value?.scaleBoundaries?.childSize.height ?? item.fileHeight ?? screenHeight; imageRatio = imageWidth / imageHeight; - ratioDiff = 1; - widthLimit = 0; - if (settingsHandler.disableImageScaling || item.isNoScale.value || !item.mediaType.value.isImageOrAnimation) { - // do nothing - } else { - final Size screenSize = WidgetsBinding.instance.platformDispatcher.views.first.physicalSize; - final double pixelRatio = WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio; - // image size can change if scaling is allowed and it's size is too big - widthLimit = screenSize.width * pixelRatio * 2; - if (imageWidth > widthLimit) { - ratioDiff = widthLimit / imageWidth; - imageWidth = widthLimit; - imageHeight = imageHeight * ratioDiff; - } + resizeScale = 1; + if (shouldScale && item.fileWidth != null && item.fileHeight != null && imageWidth != 0 && imageHeight != 0) { + resizeScale = imageWidth / item.fileWidth!; } - viewScale = viewerHandler.viewState.value?.scale ?? 1; - screenToImageRatio = viewScale == 1 ? (screenRatio > imageRatio ? (screenWidth / imageWidth) : (screenHeight / imageHeight)) : viewScale; + final viewScale = viewerHandler.viewState.value?.scale; + screenToImageRatio = viewScale ?? (screenRatio > imageRatio ? (screenWidth / imageWidth) : (screenHeight / imageHeight)); final double page = widget.pageController?.hasClients == true ? (widget.pageController!.page ?? 0) : 0; pageOffset = ((page * 10000).toInt() % 10000) / 10000; @@ -210,14 +196,13 @@ class _NotesRendererState extends State { ), ) else - // TODO change to animated transform? ...item.notes.map( (note) => NoteBuild( text: note.content, - left: (note.posX * screenToImageRatio * ratioDiff) + offsetX + viewOffsetX, - top: (note.posY * screenToImageRatio * ratioDiff) + offsetY + viewOffsetY, - width: note.width * screenToImageRatio * ratioDiff, - height: note.height * screenToImageRatio * ratioDiff, + left: (note.posX * resizeScale * screenToImageRatio) + offsetX + viewOffsetX, + top: (note.posY * resizeScale * screenToImageRatio) + offsetY + viewOffsetY, + width: note.width * resizeScale * screenToImageRatio, + height: note.height * resizeScale * screenToImageRatio, ), ), ], @@ -263,7 +248,8 @@ class _NoteBuildState extends State { // return const SizedBox.shrink(); // } - return Positioned( + return AnimatedPositioned( + duration: const Duration(milliseconds: 10), left: widget.left, top: widget.top, child: TransparentPointer( @@ -365,7 +351,7 @@ class NotesDialog extends StatelessWidget { false, ), ), - subtitle: Text('X:${note.posX}/${item.fileWidth!.round()}, Y:${note.posY}/${item.fileHeight!.round()}'), + subtitle: Text('X:${note.posX}, Y:${note.posY}'), shape: Border( bottom: BorderSide( color: Colors.grey.shade600, diff --git a/lib/src/widgets/gallery/tag_view.dart b/lib/src/widgets/gallery/tag_view.dart index 5387f030..283208ef 100644 --- a/lib/src/widgets/gallery/tag_view.dart +++ b/lib/src/widgets/gallery/tag_view.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; @@ -37,7 +38,7 @@ class _TagViewState extends State { final ViewerHandler viewerHandler = ViewerHandler.instance; final TagHandler tagHandler = TagHandler.instance; - List> hatedAndLovedTags = []; + TagsListData tagsData = const TagsListData(); ScrollController scrollController = ScrollController(); late BooruItem item; @@ -45,12 +46,16 @@ class _TagViewState extends State { bool? sortTags; late StreamSubscription itemSubscription; final TextEditingController searchController = TextEditingController(); + final FocusNode searchFocusNode = FocusNode(); + final GlobalKey searchKey = GlobalKey(debugLabel: 'tagsSearchKey'); @override void initState() { super.initState(); searchHandler.searchTextController.addListener(onMainSearchTextChanged); + searchFocusNode.addListener(searchFocusListener); + item = searchHandler.viewedItem.value; // copy tags to avoid changing the original array tags = [...item.tagsList]; @@ -66,12 +71,13 @@ class _TagViewState extends State { @override void dispose() { searchHandler.searchTextController.removeListener(onMainSearchTextChanged); + searchFocusNode.removeListener(searchFocusListener); itemSubscription.cancel(); super.dispose(); } void parseTags() { - hatedAndLovedTags = settingsHandler.parseTagsList(tags, isCapped: false); + tagsData = settingsHandler.parseTagsList(tags, isCapped: false); } List filterTags(List tagsToFilter) { @@ -136,12 +142,23 @@ class _TagViewState extends State { setState(() {}); } + void searchFocusListener() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (searchFocusNode.hasFocus) { + Scrollable.ensureVisible( + searchKey.currentContext!, + alignment: 0.1, + duration: const Duration(milliseconds: 300), + ); + } + }); + } + Widget infoBuild() { final String fileName = Tools.getFileName(item.fileURL); final String fileUrl = item.fileURL; final String fileRes = (item.fileWidth != null && item.fileHeight != null) ? '${item.fileWidth?.toInt() ?? ''}x${item.fileHeight?.toInt() ?? ''}' : ''; final String fileSize = item.fileSize != null ? Tools.formatBytes(item.fileSize!, 2) : ''; - final String hasNotes = item.hasNotes != null ? item.hasNotes.toString() : ''; final String itemId = item.serverId ?? ''; final String rating = item.rating ?? ''; final String score = item.score ?? ''; @@ -180,7 +197,6 @@ class _TagViewState extends State { infoText('Resolution', fileRes), infoText('Size', fileSize), infoText('MD5', md5), - infoText('Has Notes', hasNotes, canCopy: false), infoText('Posted', formattedDate, canCopy: false), commentsButton(), notesButton(), @@ -195,7 +211,9 @@ class _TagViewState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: SettingsTextInput( + key: searchKey, controller: searchController, + focusNode: searchFocusNode, title: 'Search tags', onlyInput: true, clearable: true, @@ -438,8 +456,10 @@ class _TagViewState extends State { title: MarqueeText( key: ValueKey(tag), text: tag, - fontSize: 18, - fontWeight: FontWeight.w700, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), isExpanded: false, ), ), @@ -604,21 +624,18 @@ class _TagViewState extends State { tagsItemBuilder, addAutomaticKeepAlives: false, // add empty items to allow a bit of overscroll for easier reachability - childCount: filteredTags.length + 6, + childCount: filteredTags.length, ), ); } Widget tagsItemBuilder(BuildContext context, int index) { - if (index >= filteredTags.length) { - return const SizedBox(height: 50); - } - final String currentTag = filteredTags[index]; - final bool isHated = hatedAndLovedTags[0].contains(currentTag); - final bool isLoved = hatedAndLovedTags[1].contains(currentTag); - final bool isSound = hatedAndLovedTags[2].contains(currentTag); + final bool isHated = tagsData.hatedTags.contains(currentTag); + final bool isLoved = tagsData.lovedTags.contains(currentTag); + final bool isSound = tagsData.soundTags.contains(currentTag); + final bool isAi = tagsData.aiTags.contains(currentTag); final bool isInSearch = searchHandler.searchTextController.text .toLowerCase() .split(' ') @@ -627,6 +644,9 @@ class _TagViewState extends State { final HasTabWithTagResult hasTabWithTag = searchHandler.hasTabWithTag(currentTag); final List tagIconAndColor = []; + if (isAi) { + tagIconAndColor.add(TagInfoIcon(FontAwesomeIcons.robot, Theme.of(context).colorScheme.onBackground)); + } if (isSound) { tagIconAndColor.add(TagInfoIcon(Icons.volume_up_rounded, Theme.of(context).colorScheme.onBackground)); } @@ -655,24 +675,40 @@ class _TagViewState extends State { width: 6, height: 50, decoration: BoxDecoration( - border: Border(left: BorderSide(width: 6, color: tagHandler.getTag(currentTag).getColour())), + border: Border( + left: BorderSide( + width: 6, + color: tagHandler.getTag(currentTag).getColour(), + ), + ), ), ), const SizedBox(width: 8), MarqueeText( key: ValueKey(currentTag), text: tagHandler.getTag(currentTag).fullString, - fontSize: 14, - fontWeight: FontWeight.w700, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), isExpanded: true, ), if (tagIconAndColor.isNotEmpty) ...[ ...tagIconAndColor.map( - (t) => Icon( - t.icon, - color: t.color, - size: 20, - ), + (t) => t.icon == FontAwesomeIcons.robot + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FaIcon( + t.icon, + color: t.color, + size: 18, + ), + ) + : Icon( + t.icon, + color: t.color, + size: 20, + ), ), const SizedBox(width: 5), ], @@ -739,7 +775,9 @@ class _TagViewState extends State { child: Icon( Icons.circle, size: 6, - color: hasTabWithTag.isOnlyTag ? Theme.of(context).colorScheme.onBackground : Colors.blue, + color: hasTabWithTag.isOnlyTag + ? Theme.of(context).colorScheme.onBackground + : (hasTabWithTag.isOnlyTagDifferentBooru ? Colors.yellow : Colors.blue), ), ), ], @@ -817,6 +855,11 @@ class _TagViewState extends State { slivers: [ infoBuild(), tagsBuild(), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).viewInsets.bottom, + ), + ), ], ), ), diff --git a/lib/src/widgets/history/history.dart b/lib/src/widgets/history/history.dart index 4eb86535..2e35ab04 100644 --- a/lib/src/widgets/history/history.dart +++ b/lib/src/widgets/history/history.dart @@ -341,7 +341,9 @@ class _HistoryListState extends State { onTap: isActive ? () => showHistoryEntryActions(buildEntry(index, false, true), currentEntry, booru) : null, minLeadingWidth: 20, leading: booru != null - ? (booru.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(booru)) + ? (booru.type == BooruType.Downloads + ? const Icon(Icons.file_download_outlined, size: 18) + : (booru.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(booru))) : const Icon(CupertinoIcons.question, size: 18), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -354,12 +356,17 @@ class _HistoryListState extends State { if (showCheckbox) checkbox, ], ), - title: MarqueeText( - key: ValueKey(currentEntry.searchText), - text: currentEntry.searchText, - fontSize: 16, - fontWeight: FontWeight.bold, - isExpanded: false, + title: SizedBox( + height: 16, + child: MarqueeText( + key: ValueKey(currentEntry.searchText), + text: currentEntry.searchText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + isExpanded: false, + ), ), subtitle: Text(booru?.name ?? 'Unknown booru (${currentEntry.booruName}-${currentEntry.booruType})'), ), diff --git a/lib/src/widgets/image/custom_network_image.dart b/lib/src/widgets/image/custom_network_image.dart index 24f8eafb..1e4037e7 100644 --- a/lib/src/widgets/image/custom_network_image.dart +++ b/lib/src/widgets/image/custom_network_image.dart @@ -61,7 +61,10 @@ class CustomNetworkImage extends ImageProvider chunkEvents = StreamController(); return MultiFrameImageStreamCompleter( diff --git a/lib/src/widgets/image/favicon.dart b/lib/src/widgets/image/favicon.dart index e8c4d3bf..441efad7 100644 --- a/lib/src/widgets/image/favicon.dart +++ b/lib/src/widgets/image/favicon.dart @@ -149,9 +149,14 @@ class _FaviconState extends State { Widget build(BuildContext context) { // print('Favicon build ${widget.faviconURL}'); - return SizedBox( + return Container( width: iconSize, height: iconSize, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(iconSize / 5), + ), + clipBehavior: Clip.hardEdge, child: Stack( alignment: Alignment.center, children: [ @@ -186,19 +191,21 @@ class _FaviconState extends State { else const SizedBox.shrink(), ], - AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: (isLoaded || isFailed) ? const SizedBox.shrink() - : ShimmerCard( - isLoading: !isLoaded && !isFailed, - child: !isLoaded && !isFailed ? null : Container(), + : ClipRRect( + borderRadius: BorderRadius.circular(iconSize / 5), + child: ShimmerCard( + isLoading: !isLoaded && !isFailed, + child: !isLoaded && !isFailed ? null : Container(), + ), ), ), // Image( - // image: NetworkImage(widget.faviconURL), + // image: NetworkImage(widget.booru.faviconURL!), // width: iconSize, // height: iconSize, // errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { diff --git a/lib/src/widgets/image/image_viewer.dart b/lib/src/widgets/image/image_viewer.dart index ac310630..362a1dcf 100644 --- a/lib/src/widgets/image/image_viewer.dart +++ b/lib/src/widgets/image/image_viewer.dart @@ -39,7 +39,7 @@ class ImageViewerState extends State { PhotoViewController viewController = PhotoViewController(); final RxInt total = 0.obs, received = 0.obs, startedAt = 0.obs; - bool isStopped = false, isFromCache = false, isLoaded = false, isViewed = false, isZoomed = false; + bool isStopped = false, isFromCache = false, isLoaded = false, isViewed = false, isZoomed = false, firstBuild = true; int isTooBig = 0; // 0 = not too big, 1 = too big, 2 = too big, but allow downloading List stopReason = []; @@ -134,8 +134,6 @@ class ImageViewerState extends State { // debug output viewController.outputStateStream.listen(onViewStateChanged); scaleController.outputScaleStateStream.listen(onScaleStateChanged); - - initViewer(false); } Future initViewer(bool ignoreTagsCheck) async { @@ -152,10 +150,12 @@ class ImageViewerState extends State { initViewer(false); }); - if (widget.booruItem.isHated.value && !ignoreTagsCheck) { - final List> hatedAndLovedTags = settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true); - if (hatedAndLovedTags[0].isNotEmpty) { - killLoading(['Contains Hated tags:', ...hatedAndLovedTags[0]]); + if (widget.booruItem.isHated && !ignoreTagsCheck) { + if (widget.booruItem.isHated) { + killLoading([ + 'Contains Hated tags:', + ...settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true).hatedTags, + ]); return; } } @@ -191,6 +191,14 @@ class ImageViewerState extends State { updateState(); } + void calcWidthLimit(BoxConstraints constraints) { + final int? prevWidthLimit = widthLimit; + widthLimit = settingsHandler.disableImageScaling ? null : (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio * 2).round(); + if (prevWidthLimit != widthLimit) { + updateState(postFrame: true); + } + } + Future getImageProvider() async { ImageProvider provider; cancelToken = CancelToken(); @@ -256,8 +264,18 @@ class ImageViewerState extends State { super.dispose(); } - void updateState() { - if (mounted) setState(() {}); + void updateState({bool postFrame = false}) { + if (postFrame) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() {}); + } + }); + } else { + if (mounted) { + setState(() {}); + } + } } void disposables() { @@ -298,7 +316,7 @@ class ImageViewerState extends State { void onViewStateChanged(PhotoViewControllerValue viewState) { // print(viewState); - viewerHandler.setViewState(widget.key, viewState); + viewerHandler.setViewValue(widget.key, viewState); } void resetZoom() { @@ -331,85 +349,97 @@ class ImageViewerState extends State { Widget build(BuildContext context) { // print('!!! Build media ${searchHandler.getItemIndex(widget.booruItem)} $isViewed !!!'); - return Hero( - tag: 'imageHero${isViewed ? '' : '-ignore-'}${searchHandler.getItemIndex(widget.booruItem)}#${widget.booruItem.fileURL}', - // without this every text element will have broken styles on first frames - child: Material( - color: Colors.black, - child: Stack( - alignment: Alignment.center, - children: [ - // TODO find a way to detect when main image is fully rendered to dispose this widget to free up memory - Thumbnail( - item: widget.booruItem, - isStandalone: false, - ignoreColumnsCount: true, - ), - // - MediaLoading( - item: widget.booruItem, - hasProgress: true, - isFromCache: isFromCache, - isDone: isLoaded, - isTooBig: isTooBig > 0, - isStopped: isStopped, - stopReasons: stopReason, - isViewed: isViewed, - total: total, - received: received, - startedAt: startedAt, - startAction: () { - if (isTooBig == 1) { - isTooBig = 2; - } - initViewer(true); - }, - stopAction: () { - killLoading(['Stopped by User']); - }, - ), - // - AnimatedSwitcher( - duration: Duration(milliseconds: settingsHandler.appMode.value.isDesktop ? 50 : 300), - child: mainProvider != null - ? Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - scrollZoomImage(pointerSignal.scrollDelta.dy); - } - }, - child: AnimatedOpacity( - opacity: isLoaded ? 1 : 0, - duration: Duration(milliseconds: settingsHandler.appMode.value.isDesktop ? 50 : 300), - child: PhotoView( - imageProvider: mainProvider, - gaplessPlayback: true, - loadingBuilder: (context, event) { - return const SizedBox.shrink(); - }, - errorBuilder: (context, error, stackTrace) { - WidgetsBinding.instance.addPostFrameCallback((_) { - onError(error); - }); - return const SizedBox.shrink(); + const double fullOpacity = 1; + + return LayoutBuilder( + builder: (context, constraints) { + calcWidthLimit(constraints); + if (firstBuild) { + firstBuild = false; + initViewer(false); + } + + return Hero( + tag: 'imageHero${isViewed ? '' : '-ignore-'}${searchHandler.getItemIndex(widget.booruItem)}#${widget.booruItem.fileURL}', + // without this every text element will have broken styles on first frames + child: Material( + color: Colors.black, + child: Stack( + alignment: Alignment.center, + children: [ + // TODO find a way to detect when main image is fully rendered to dispose this widget to free up memory + Thumbnail( + item: widget.booruItem, + isStandalone: false, + ), + // + MediaLoading( + item: widget.booruItem, + hasProgress: true, + isFromCache: isFromCache, + isDone: isLoaded, + isTooBig: isTooBig > 0, + isStopped: isStopped, + stopReasons: stopReason, + isViewed: isViewed, + total: total, + received: received, + startedAt: startedAt, + startAction: () { + if (isTooBig == 1) { + isTooBig = 2; + } + initViewer(true); + }, + stopAction: () { + killLoading(['Stopped by User']); + }, + ), + // + AnimatedSwitcher( + duration: Duration(milliseconds: settingsHandler.appMode.value.isDesktop ? 50 : 300), + child: mainProvider != null + ? Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + scrollZoomImage(pointerSignal.scrollDelta.dy); + } }, - // TODO FilterQuality.high somehow leads to a worse looking image on desktop - filterQuality: widget.booruItem.isLong ? FilterQuality.medium : FilterQuality.medium, - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 8, - initialScale: PhotoViewComputedScale.contained, - enableRotation: false, - basePosition: Alignment.center, - controller: viewController, - scaleStateController: scaleController, - ), - ), - ) - : const SizedBox.shrink(), + child: AnimatedOpacity( + opacity: isLoaded ? fullOpacity : 0, + duration: Duration(milliseconds: settingsHandler.appMode.value.isDesktop ? 50 : 300), + child: PhotoView( + imageProvider: mainProvider, + gaplessPlayback: true, + loadingBuilder: (context, event) { + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onError(error); + }); + return const SizedBox.shrink(); + }, + // TODO FilterQuality.high somehow leads to a worse looking image on desktop + filterQuality: widget.booruItem.isLong ? FilterQuality.medium : FilterQuality.medium, + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 8, + initialScale: PhotoViewComputedScale.contained, + enableRotation: false, + basePosition: Alignment.center, + controller: viewController, + scaleStateController: scaleController, + enableTapDragZoom: true, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/lib/src/widgets/preview/grid_builder.dart b/lib/src/widgets/preview/grid_builder.dart index eb96bf89..eec3d980 100644 --- a/lib/src/widgets/preview/grid_builder.dart +++ b/lib/src/widgets/preview/grid_builder.dart @@ -39,7 +39,7 @@ class GridBuilder extends StatelessWidget { cacheExtent: 200, shrinkWrap: false, itemCount: searchHandler.currentFetched.length, - padding: EdgeInsets.fromLTRB(2, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 2, 80), + padding: EdgeInsets.fromLTRB(10, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 10, 80), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columnCount, childAspectRatio: settingsHandler.previewDisplay == 'Square' ? 1 : 9 / 16, diff --git a/lib/src/widgets/preview/shimmer_builder.dart b/lib/src/widgets/preview/shimmer_builder.dart index 57eb801f..f0c70908 100644 --- a/lib/src/widgets/preview/shimmer_builder.dart +++ b/lib/src/widgets/preview/shimmer_builder.dart @@ -40,7 +40,7 @@ class ShimmerList extends StatelessWidget { addAutomaticKeepAlives: false, cacheExtent: 200, shrinkWrap: false, - padding: EdgeInsets.fromLTRB(2, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 2, 80), + padding: EdgeInsets.fromLTRB(10, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 10, 80), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: columnCount, childAspectRatio: displayType == 'Square' ? 1 : 9 / 16), children: List.generate( previewCount, diff --git a/lib/src/widgets/preview/staggered_builder.dart b/lib/src/widgets/preview/staggered_builder.dart index 17664ab9..d3de22e2 100644 --- a/lib/src/widgets/preview/staggered_builder.dart +++ b/lib/src/widgets/preview/staggered_builder.dart @@ -36,8 +36,8 @@ class StaggeredBuilder extends StatelessWidget { return LayoutBuilder( builder: (ctx, constraints) { - final double itemMaxWidth = constraints.maxWidth / columnCount; //MediaQuery.of(context).size.width / columnCount; - final double itemMaxHeight = itemMaxWidth * (16 / 9); //MediaQuery.of(context).size.height * 0.6; + final double itemMaxWidth = (constraints.maxWidth - 20) / columnCount; + final double itemMaxHeight = itemMaxWidth * (16 / 9); return Obx(() { return WaterfallFlow.builder( controller: searchHandler.gridScrollController, @@ -46,7 +46,7 @@ class StaggeredBuilder extends StatelessWidget { addAutomaticKeepAlives: false, cacheExtent: 200, itemCount: searchHandler.currentFetched.length, - padding: EdgeInsets.fromLTRB(2, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 2, 80), + padding: EdgeInsets.fromLTRB(10, 2 + (isDesktop ? 0 : (kToolbarHeight + MediaQuery.of(context).padding.top)), 10, 80), gridDelegate: SliverWaterfallFlowDelegateWithFixedCrossAxisCount( crossAxisCount: columnCount, mainAxisSpacing: 4, diff --git a/lib/src/widgets/preview/waterfall_view.dart b/lib/src/widgets/preview/waterfall_view.dart index 1a77d7c8..c315da96 100644 --- a/lib/src/widgets/preview/waterfall_view.dart +++ b/lib/src/widgets/preview/waterfall_view.dart @@ -143,30 +143,24 @@ class _WaterfallViewState extends State { super.dispose(); } - Future jumpTo(int newIndex) async { - if (!searchHandler.gridScrollController.hasClients) { - return; - } - if (newIndex == -1) { + void jumpTo(int newIndex) { + if (!searchHandler.gridScrollController.hasClients || newIndex == -1 || (!viewerHandler.inViewer.value && isMobile)) { return; } - if (!viewerHandler.inViewer.value && isMobile) { - return; - // await Future.delayed(Duration(milliseconds: 500)); - } - - if (newIndex == 0) { - // viewedIndex == 0 when tab is first created, so we should scroll to top on 0th item (not to the item itself, because there is padding on top of it) to avoid bugs with appbar - searchHandler.gridScrollController.jumpTo(0); - } else { - // scroll to viewed item - await searchHandler.gridScrollController.scrollToIndex( - newIndex, - duration: Duration(milliseconds: isMobile ? 10 : 100), - preferPosition: AutoScrollPosition.begin, - ); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (newIndex == 0) { + // viewedIndex == 0 when tab is first created, so we should scroll to top on 0th item (not to the item itself, because there is padding on top of it) to avoid bugs with appbar + searchHandler.gridScrollController.jumpTo(0); + } else { + // scroll to viewed item + searchHandler.gridScrollController.scrollToIndex( + newIndex, + duration: Duration(milliseconds: isMobile ? 10 : 100), + preferPosition: AutoScrollPosition.begin, + ); + } + }); } void afterSearch() { @@ -195,8 +189,7 @@ class _WaterfallViewState extends State { kbFocusNode.unfocus(); viewerHandler.inViewer.value = true; - await Navigator.push( - context, + await Navigator.of(context).push( PageRouteBuilder( pageBuilder: (context, anim1, anim2) => // Opacity(opacity: 0.5, child: GalleryViewPage(index)), @@ -220,10 +213,10 @@ class _WaterfallViewState extends State { Future onLongPress(int index, BooruItem item) async { ServiceHandler.vibrate(duration: 5); - if (searchHandler.currentTab.selected.contains(index)) { - searchHandler.currentTab.selected.remove(index); + if (searchHandler.currentTab.selected.contains(item)) { + searchHandler.currentTab.selected.remove(item); } else { - searchHandler.currentTab.selected.add(index); + searchHandler.currentTab.selected.add(item); } } @@ -316,6 +309,9 @@ class _WaterfallViewState extends State { NotificationListener( child: Scrollbar( controller: searchHandler.gridScrollController, + interactive: true, + thickness: 8, + thumbVisibility: true, child: RefreshIndicator( triggerMode: RefreshIndicatorTriggerMode.anywhere, displacement: 80, diff --git a/lib/src/widgets/root/active_title.dart b/lib/src/widgets/root/active_title.dart index 397977f2..49d5adaa 100644 --- a/lib/src/widgets/root/active_title.dart +++ b/lib/src/widgets/root/active_title.dart @@ -28,7 +28,10 @@ class ActiveTitle extends StatelessWidget { if (searchHandler.list.isEmpty) { return const Text('LoliSnatcher'); } else { - return const TabSelectorHeader(); + return TabSelector( + withBorder: false, + color: Theme.of(context).appBarTheme.foregroundColor, + ); } } }); diff --git a/lib/src/widgets/root/main_appbar.dart b/lib/src/widgets/root/main_appbar.dart index 299e5779..4ca220e9 100644 --- a/lib/src/widgets/root/main_appbar.dart +++ b/lib/src/widgets/root/main_appbar.dart @@ -3,15 +3,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/handlers/snatch_handler.dart'; import 'package:lolisnatcher/src/handlers/viewer_handler.dart'; -import 'package:lolisnatcher/src/services/get_perms.dart'; -import 'package:lolisnatcher/src/utils/tools.dart'; -import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; import 'package:lolisnatcher/src/widgets/root/active_title.dart'; class MainAppBar extends StatefulWidget implements PreferredSizeWidget { @@ -112,81 +107,6 @@ class _MainAppBarState extends State { // }); } - Widget devButton() { - return IconButton( - icon: Icon(Icons.timelapse, color: Theme.of(context).appBarTheme.iconTheme?.color), - onPressed: () { - sinceLastBackup(); - Tools.forceClearMemoryCache(withLive: true); - }, - ); - } - - Widget saveButton() { - return Obx(() { - if (searchHandler.list.isNotEmpty) { - return Stack( - alignment: Alignment.center, - children: [ - IconButton( - icon: Icon(Icons.save, color: Theme.of(context).appBarTheme.iconTheme?.color), - onPressed: () { - getPerms(); - // call a function to save the currently viewed image when the save button is pressed - if (searchHandler.currentTab.selected.isNotEmpty) { - snatchHandler.queue( - searchHandler.currentTab.getSelected(), - searchHandler.currentBooru, - settingsHandler.snatchCooldown, - false, - ); - searchHandler.currentTab.selected.value = []; - } else { - FlashElements.showSnackbar( - context: context, - title: const Text('No items selected', style: TextStyle(fontSize: 20)), - overrideLeadingIconWidget: const Text(' (」°ロ°)」 ', style: TextStyle(fontSize: 18)), - ); - } - }, - ), - if (searchHandler.currentTab.selected.isNotEmpty) - Positioned( - right: 2, - bottom: 5, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondary, - border: Border.all(color: Theme.of(context).colorScheme.secondary, width: 1), - borderRadius: BorderRadius.circular(15), - ), - child: Center( - child: FittedBox( - child: Text( - '${searchHandler.currentTab.selected.length}', - style: TextStyle(color: Theme.of(context).colorScheme.onSecondary), - ), - ), - ), - ), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }); - } - - void sinceLastBackup() { - FlashElements.showSnackbar( - title: Text('Since last backup: ${searchHandler.lastBackupTime.difference(DateTime.now()).inSeconds * -1} seconds'), - duration: const Duration(seconds: 3), - ); - } - @override Widget build(BuildContext context) { final double barHeight = _scrollOffset * (widget.defaultHeight + MediaQuery.of(context).padding.top); @@ -203,9 +123,8 @@ class _MainAppBarState extends State { title: const ActiveTitle(), actions: [ // lockButton(), - // devButton(), - saveButton(), widget.trailing ?? const SizedBox.shrink(), + const SizedBox(width: 8), ], ), ); diff --git a/lib/src/widgets/root/scroll_physics.dart b/lib/src/widgets/root/scroll_physics.dart index e78053fd..2952cb3a 100644 --- a/lib/src/widgets/root/scroll_physics.dart +++ b/lib/src/widgets/root/scroll_physics.dart @@ -10,7 +10,7 @@ class CustomScrollBehavior extends MaterialScrollBehavior { switch (getPlatform(context)) { case TargetPlatform.iOS: case TargetPlatform.macOS: - // case TargetPlatform.android: + // case TargetPlatform.android: return const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()); case TargetPlatform.fuchsia: case TargetPlatform.android: @@ -24,7 +24,7 @@ class CustomScrollBehavior extends MaterialScrollBehavior { // Override behavior methods and getters like dragDevices @override Set get dragDevices => { - ...PointerDeviceKind.values + ...PointerDeviceKind.values, // PointerDeviceKind.touch, // PointerDeviceKind.mouse, }; diff --git a/lib/src/widgets/search/tag_chip.dart b/lib/src/widgets/search/tag_chip.dart index 716f0a4d..596075e4 100644 --- a/lib/src/widgets/search/tag_chip.dart +++ b/lib/src/widgets/search/tag_chip.dart @@ -4,10 +4,14 @@ import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/tag_handler.dart'; class TagChip extends StatelessWidget { - TagChip({required this.gestureDetector, super.key, this.tagString = ''}); + TagChip({ + required this.tagString, + this.trailing, + super.key, + }); final String tagString; - final GestureDetector gestureDetector; + final Widget? trailing; final SearchHandler searchHandler = SearchHandler.instance; final TagHandler tagHandler = TagHandler.instance; @@ -54,9 +58,9 @@ class TagChip extends StatelessWidget { color: chipColour, borderRadius: BorderRadius.circular(16), ), - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(10, 3, 0, 3), @@ -73,7 +77,7 @@ class TagChip extends StatelessWidget { ], ), ), - gestureDetector, + if (trailing != null) trailing! else const SizedBox(width: 10), ], ), ); diff --git a/lib/src/widgets/search/tag_search_box.dart b/lib/src/widgets/search/tag_search_box.dart index 2e49d992..d9c9be2e 100644 --- a/lib/src/widgets/search/tag_search_box.dart +++ b/lib/src/widgets/search/tag_search_box.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:keyboard_actions/keyboard_actions.dart'; @@ -19,7 +20,6 @@ import 'package:lolisnatcher/src/widgets/search/tag_chip.dart'; // TODO // - make the search box wider? use the same OverlayEntry method? https://stackoverflow.com/questions/60884031/draw-outside-listview-bounds-in-flutter -// - debounce searches [In progress: needs rewrite of tagSearch to dio to use requests cancelling] // - parse tag type from search if possible class TagSearchBox extends StatefulWidget { @@ -54,6 +54,8 @@ class _TagSearchBoxState extends State { RxList> databaseResults = RxList([]); RxList> modifiersResults = RxList([]); + CancelToken? cancelToken; + @override void initState() { super.initState(); @@ -136,6 +138,7 @@ class _TagSearchBoxState extends State { searchHandler.searchBoxFocus.removeListener(onFocusChange); searchHandler.searchTextController.removeListener(onTextChanged); + cancelToken?.cancel(); Debounce.cancel('tag_search_box'); suggestionsScrollController.dispose(); @@ -272,20 +275,23 @@ class _TagSearchBoxState extends State { continue; } tags.add( - TagChip( - tagString: stringContent, - gestureDetector: GestureDetector( - onTap: () { - splitInput.removeAt(i); - searchHandler.searchTextController.text = splitInput.join(' '); - tagStuff(); - combinedSearch(); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - child: Icon(Icons.cancel, size: 24, color: Colors.white.withOpacity(0.9)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: TagChip( + tagString: stringContent, + trailing: GestureDetector( + onTap: () { + splitInput.removeAt(i); + searchHandler.searchTextController.text = splitInput.join(' '); + tagStuff(); + combinedSearch(); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + child: Icon(Icons.cancel, size: 24, color: Colors.white.withOpacity(0.9)), + ), ), ), ), @@ -352,15 +358,17 @@ class _TagSearchBoxState extends State { Future searchBooru() async { booruResults.value = [ - [' ', 'loading'] + [' ', 'loading'], ]; // TODO cancel previous search when new starts List getFromBooru = []; + cancelToken?.cancel(); + cancelToken = CancelToken(); if (multiIndex != -1) { final MergebooruHandler handler = searchHandler.currentBooruHandler as MergebooruHandler; - getFromBooru = await handler.booruHandlers[multiIndex].tagSearch(lastTag); + getFromBooru = await handler.booruHandlers[multiIndex].tagSearch(lastTag, cancelToken: cancelToken); } else { - getFromBooru = await searchHandler.currentBooruHandler.tagSearch(lastTag); + getFromBooru = await searchHandler.currentBooruHandler.tagSearch(lastTag, cancelToken: cancelToken); } booruResults.value = getFromBooru.map((tag) { @@ -371,7 +379,7 @@ class _TagSearchBoxState extends State { Future searchHistory() async { historyResults.value = [ - [' ', 'loading'] + [' ', 'loading'], ]; historyResults.value = lastTag.isNotEmpty ? (await settingsHandler.dbHandler.getSearchHistoryByInput(lastTag, 2)).map((tag) { @@ -384,7 +392,7 @@ class _TagSearchBoxState extends State { Future searchDatabase() async { databaseResults.value = [ - [' ', 'loading'] + [' ', 'loading'], ]; databaseResults.value = lastTag.isNotEmpty ? (await settingsHandler.dbHandler.getTags(lastTag, 2)).map((tag) { @@ -405,7 +413,7 @@ class _TagSearchBoxState extends State { void combinedSearch() { // drop previous list even if new search didn't start yet booruResults.value = [ - [' ', 'loading'] + [' ', 'loading'], ]; Debounce.debounce( tag: 'tag_search_box', @@ -452,7 +460,6 @@ class _TagSearchBoxState extends State { leading: null, title: const MarqueeText( text: 'No Suggestions!', - fontSize: 16, isExpanded: false, ), onTap: () { @@ -513,7 +520,6 @@ class _TagSearchBoxState extends State { title: MarqueeText( key: ValueKey(tag), text: tag, - fontSize: 16, isExpanded: false, ), onTap: () { diff --git a/lib/src/widgets/tabs/tab_booru_selector.dart b/lib/src/widgets/tabs/tab_booru_selector.dart index 13952527..07a94d75 100644 --- a/lib/src/widgets/tabs/tab_booru_selector.dart +++ b/lib/src/widgets/tabs/tab_booru_selector.dart @@ -116,7 +116,7 @@ class TabBooruSelector extends StatelessWidget { return DropdownButtonFormField( isExpanded: true, value: selectedBooru, - icon: const Icon(Icons.arrow_drop_down), + icon: const Icon(null, size: 0), itemHeight: kMinInteractiveDimension, decoration: InputDecoration( labelText: 'Booru', @@ -187,12 +187,18 @@ class TabBooruSelectorItem extends StatelessWidget { return Row( children: [ //Booru Icon - if (withFavicon) booru.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(booru), + if (withFavicon) ...[ + if (booru.type == BooruType.Downloads) + const Icon(Icons.file_download_outlined, size: 18) + else if (booru.type == BooruType.Favourites) + const Icon(Icons.favorite, color: Colors.red, size: 18) + else + Favicon(booru), + ], //Booru name MarqueeText( key: ValueKey(name), text: name, - fontSize: 16, ), // Text(name), ], diff --git a/lib/src/widgets/tabs/tab_filters_dialog.dart b/lib/src/widgets/tabs/tab_filters_dialog.dart new file mode 100644 index 00000000..c4036c9b --- /dev/null +++ b/lib/src/widgets/tabs/tab_filters_dialog.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import 'package:lolisnatcher/src/data/booru.dart'; +import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; + +class TabManagerFiltersDialog extends StatefulWidget { + const TabManagerFiltersDialog({ + required this.loadedFilter, + required this.loadedFilterChanged, + required this.booruFilter, + required this.booruFilterChanged, + required this.duplicateFilter, + required this.duplicateFilterChanged, + required this.emptyFilter, + required this.emptyFilterChanged, + super.key, + }); + + final bool? loadedFilter; + final ValueChanged loadedFilterChanged; + final Booru? booruFilter; + final ValueChanged booruFilterChanged; + final bool duplicateFilter; + final ValueChanged duplicateFilterChanged; + final bool emptyFilter; + final ValueChanged emptyFilterChanged; + + @override + State createState() => _TabManagerFiltersDialogState(); +} + +class _TabManagerFiltersDialogState extends State { + bool? loadedFilter; + Booru? booruFilter; + bool duplicateFilter = false, emptyFilter = false; + + @override + void initState() { + super.initState(); + + loadedFilter = widget.loadedFilter; + booruFilter = widget.booruFilter; + duplicateFilter = widget.duplicateFilter; + emptyFilter = widget.emptyFilter; + } + + @override + Widget build(BuildContext context) { + return SettingsBottomSheet( + title: const Text('Tab Filters'), + contentPadding: const EdgeInsets.all(16), + buttonPadding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + contentItems: [ + SettingsBooruDropdown( + title: 'Booru', + value: booruFilter, + drawBottomBorder: false, + nullable: true, + onChanged: (Booru? newValue) { + booruFilter = newValue; + setState(() {}); + }, + ), + SettingsDropdown( + title: 'Loaded', + value: loadedFilter, + drawBottomBorder: false, + onChanged: (bool? newValue) { + loadedFilter = newValue; + setState(() {}); + }, + items: const [ + null, + true, + false, + ], + itemBuilder: (item) => item == null ? const Text('All') : Text(item ? 'Loaded' : 'Unloaded'), + itemTitleBuilder: (item) => item == null + ? 'All' + : item + ? 'Loaded' + : 'Unloaded', + ), + SettingsToggle( + title: 'Duplicates', + value: duplicateFilter, + onChanged: (bool newValue) { + duplicateFilter = newValue; + setState(() {}); + }, + ), + SettingsToggle( + title: 'Empty tags', + value: emptyFilter, + onChanged: (bool newValue) { + emptyFilter = newValue; + setState(() {}); + }, + ), + ], + actionButtons: [ + SizedBox( + height: 40, + child: ElevatedButton.icon( + label: const Text('Clear'), + icon: const Icon(Icons.delete), + onPressed: () { + Navigator.of(context).pop('clear'); + }, + ), + ), + const SizedBox(width: 10), + SizedBox( + height: 40, + child: ElevatedButton.icon( + label: const Text('Apply'), + icon: const Icon(Icons.check), + onPressed: () { + widget.loadedFilterChanged(loadedFilter); + widget.booruFilterChanged(booruFilter); + widget.duplicateFilterChanged(duplicateFilter); + widget.emptyFilterChanged(emptyFilter); + Navigator.of(context).pop('apply'); + }, + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/tabs/tab_manager_dialog.dart b/lib/src/widgets/tabs/tab_manager_dialog.dart deleted file mode 100644 index 505659e9..00000000 --- a/lib/src/widgets/tabs/tab_manager_dialog.dart +++ /dev/null @@ -1,799 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:scroll_to_index/scroll_to_index.dart'; - -import 'package:lolisnatcher/src/boorus/booru_type.dart'; -import 'package:lolisnatcher/src/data/booru.dart'; -import 'package:lolisnatcher/src/handlers/search_handler.dart'; -import 'package:lolisnatcher/src/utils/tools.dart'; -import 'package:lolisnatcher/src/widgets/common/cancel_button.dart'; -import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; -import 'package:lolisnatcher/src/widgets/common/marquee_text.dart'; -import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; -import 'package:lolisnatcher/src/widgets/desktop/desktop_scroll_wrap.dart'; -import 'package:lolisnatcher/src/widgets/image/favicon.dart'; -import 'package:lolisnatcher/src/widgets/tabs/tab_move_dialog.dart'; - -class TabManagerDialog extends StatefulWidget { - const TabManagerDialog({super.key}); - - @override - State createState() => _TabManagerDialogState(); -} - -class _TabManagerDialogState extends State { - final SearchHandler searchHandler = SearchHandler.instance; - - List tabs = [], filteredTabs = [], selectedTabs = []; - late final AutoScrollController scrollController; - final TextEditingController filterController = TextEditingController(); - bool? sortTabs; - bool? loadedFilter; - Booru? booruFilter; - bool duplicateFilter = false; - bool firstBuild = true; - - bool get isFilterActive => filteredTabs.length != searchHandler.total || sortTabs != null; - - @override - void initState() { - super.initState(); - getTabs(); - - scrollController = AutoScrollController(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - await jumpToCurrent(); - firstBuild = false; - setState(() {}); - WidgetsBinding.instance.addPostFrameCallback((_) { - jumpToCurrent(); - }); - }); - } - - void getTabs() { - tabs = searchHandler.list; - filteredTabs = tabs; - filterTabs(); - } - - Future jumpToCurrent() async { - if (scrollController.hasClients) { - final int filteredIndex = filteredTabs.indexOf(searchHandler.currentTab); - if (filteredIndex == -1) { - return; - } - - // jump close to selected tab - scrollController.jumpTo( - filteredIndex * (scrollController.position.maxScrollExtent / filteredTabs.length), - ); - await Future.delayed(const Duration(milliseconds: 50)); - // then correct the position (otherwise duration is ignored and it scrolls slower than intended) - await scrollController.scrollToIndex(filteredIndex, duration: const Duration(milliseconds: 50), preferPosition: AutoScrollPosition.begin); - } - } - - void filterTabs() { - filteredTabs = [...tabs]; - - if (booruFilter != null) { - filteredTabs = filteredTabs.where((t) => t.selectedBooru.value == booruFilter).toList(); - } - - if (loadedFilter != null) { - filteredTabs = - filteredTabs.where((t) => loadedFilter == true ? t.booruHandler.filteredFetched.isNotEmpty : t.booruHandler.filteredFetched.isEmpty).toList(); - } - - if (duplicateFilter) { - // tabs where booru and tags are the same - filteredTabs = filteredTabs.where((tab) { - final List sameBooru = filteredTabs.where((t) => t.selectedBooru.value == tab.selectedBooru.value).toList(); - final List sameTags = sameBooru.where((t) => t.tags == tab.tags).toList(); - return sameTags.length > 1; - }).toList(); - } - - if (filterController.text.isNotEmpty) { - filteredTabs = filteredTabs.where((t) { - final String filterText = filterController.text.toLowerCase().trim(); - return t.tags.toLowerCase().contains(filterText); - }).toList(); - } - - if (sortTabs != null) { - filteredTabs.sort( - (a, b) => sortTabs == true ? a.tags.toLowerCase().compareTo(b.tags.toLowerCase()) : b.tags.toLowerCase().compareTo(a.tags.toLowerCase()), - ); - } - - setState(() {}); - } - - void showTabEntryActions(Widget row, SearchTab data, int index) { - showDialog( - context: context, - builder: (context) { - final int tabIndex = searchHandler.list.indexOf(data); - - return SettingsDialog( - contentItems: [ - SizedBox(width: double.maxFinite, child: row), - // - const SizedBox(height: 20), - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide(color: Theme.of(context).colorScheme.secondary), - ), - onTap: () async { - if (tabIndex != -1) { - searchHandler.changeTabIndex(tabIndex); - } - Navigator.of(context).pop(true); - Navigator.of(context).pop(true); - }, - leading: const Icon(Icons.menu_open), - title: const Text('Open'), - ), - // - const SizedBox(height: 10), - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide(color: Theme.of(context).colorScheme.secondary), - ), - onTap: () async { - await Clipboard.setData(ClipboardData(text: data.tags)); - FlashElements.showSnackbar( - context: context, - duration: const Duration(seconds: 2), - title: const Text('Copied to clipboard!', style: TextStyle(fontSize: 20)), - content: Text(data.tags, style: const TextStyle(fontSize: 16)), - leadingIcon: Icons.copy, - sideColor: Colors.green, - ); - Navigator.of(context).pop(true); - }, - leading: const Icon(Icons.copy), - title: const Text('Copy'), - ), - // - const SizedBox(height: 10), - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide(color: Theme.of(context).colorScheme.secondary), - ), - onTap: () async { - await showDialog( - context: context, - builder: (BuildContext context) => TabMoveDialog( - row: buildEntry(index, false, true), - index: tabIndex, - ), - ); - getTabs(); - }, - leading: const Icon(Icons.move_down_sharp), - title: const Text('Move'), - ), - // - const SizedBox(height: 10), - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide(color: Theme.of(context).colorScheme.secondary), - ), - onTap: () { - if (tabIndex != -1) { - searchHandler.removeTabAt(tabIndex: tabIndex); - } - getTabs(); - Navigator.of(context).pop(true); - }, - leading: const Icon(Icons.delete_forever, color: Colors.red), - title: const Text('Remove'), - ), - ], - ); - }, - ); - } - - Widget listBuild() { - if (filteredTabs.isEmpty) { - return const Center(child: Text('No tabs found')); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Material( - child: SizedBox( - width: double.maxFinite, - child: Scrollbar( - controller: scrollController, - child: DesktopScrollWrap( - controller: scrollController, - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - searchHandler.moveTab(oldIndex, newIndex); - getTabs(); - }, - buildDefaultDragHandles: !isFilterActive, - padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), - scrollController: scrollController, - physics: getListPhysics(), // const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - shrinkWrap: false, - itemCount: filteredTabs.length, - scrollDirection: Axis.vertical, - itemBuilder: listEntryBuild, - ), - ), - ), - ), - ), - ); - } - - Widget listEntryBuild(BuildContext context, int index) { - return AutoScrollTag( - highlightColor: Colors.red, - key: ValueKey(index), - controller: scrollController, - index: index, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: firstBuild ? const SizedBox(height: 72, width: double.maxFinite, child: Card()) : buildEntry(index, true, true), - ), - ); - } - - Widget buildEntry(int index, bool isActive, bool fromFiltered) { - final SearchTab tab = fromFiltered ? filteredTabs[index] : searchHandler.list[index]; - final bool isNotEmptyBooru = tab.selectedBooru.value.faviconURL != null; - final bool isCurrent = searchHandler.currentTab == tab; - - final bool showCheckbox = isActive && !isCurrent; - final bool isSelected = selectedTabs.contains(tab); - - // print(value.tags); - final int totalCount = tab.booruHandler.totalCount.value; - final String totalCountText = (totalCount > 0) ? ' ($totalCount)' : ''; - final String tagText = "${tab.tags == "" ? "[No Tags]" : tab.tags}$totalCountText"; - - final bool hasItems = tab.booruHandler.filteredFetched.isNotEmpty; - - final String? givenIndexText = isFilterActive ? '${index + 1}' : null; - final String tabIndexText = '${searchHandler.list.indexOf(tab) + 1}'; - - final Widget checkbox = Checkbox( - value: isSelected, - onChanged: (bool? newValue) { - if (isSelected) { - selectedTabs.removeWhere((item) => item == tab); - } else { - selectedTabs.add(tab); - } - setState(() {}); - }, - ); - - final Widget favicon = isNotEmptyBooru - ? (tab.selectedBooru.value.type == BooruType.Favourites ? const Icon(Icons.favorite, color: Colors.red, size: 18) : Favicon(tab.selectedBooru.value)) - : const Icon(CupertinoIcons.question, size: 18); - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide( - color: isCurrent ? Theme.of(context).colorScheme.secondary : Colors.grey, - style: BorderStyle.solid, - ), - ), - onTap: isActive ? () => showTabEntryActions(buildEntry(index, false, true), tab, index) : null, - minLeadingWidth: 20, - leading: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - favicon, - Text( - tabIndexText, - style: const TextStyle(fontSize: 10), - ), - if (givenIndexText != null && givenIndexText != '0') - Text( - givenIndexText, - style: const TextStyle(fontSize: 10), - ), - ], - ), - trailing: showCheckbox ? checkbox : null, - title: MarqueeText( - key: ValueKey(tagText), - text: tagText, - fontSize: 16, - fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, - color: tab.tags == '' ? Colors.grey : null, - isExpanded: false, - ), - subtitle: Text(isNotEmptyBooru ? tab.selectedBooru.value.name! : ''), - ), - ); - } - - int get filtersCount { - int count = 0; - if (loadedFilter != null) { - count++; - } - if (booruFilter != null) { - count++; - } - if (duplicateFilter) { - count++; - } - return count; - } - - Future openFiltersDialog() async { - final String? result = await SettingsPageOpen( - context: context, - asBottomSheet: true, - page: () => TabManagerFiltersDialog( - loadedFilter: loadedFilter, - loadedFilterChanged: (bool? newValue) { - loadedFilter = newValue; - }, - booruFilter: booruFilter, - booruFilterChanged: (Booru? newValue) { - booruFilter = newValue; - }, - duplicateFilter: duplicateFilter, - duplicateFilterChanged: (bool newValue) { - duplicateFilter = newValue; - }, - ), - ).open(); - - if (result == 'apply') { - filterTabs(); - } else if (result == 'clear') { - loadedFilter = null; - booruFilter = null; - duplicateFilter = false; - filterTabs(); - } - } - - Widget filterBuild() { - final String filterText = "Filter Tabs (${!isFilterActive ? tabs.length : '${filteredTabs.length}/${tabs.length}'})"; - - return Container( - margin: const EdgeInsets.fromLTRB(10, 2, 10, 10), - width: double.infinity, - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: SettingsTextInput( - onlyInput: true, - controller: filterController, - onChanged: (String? input) { - filterTabs(); - }, - title: filterText, - hintText: 'Input tags to filter tabs', - inputType: TextInputType.text, - clearable: true, - drawBottomBorder: false, - margin: const EdgeInsets.fromLTRB(2, 8, 2, 5), - ), - ), - const SizedBox(width: 10), - Stack( - children: [ - IconButton( - iconSize: 24, - onPressed: openFiltersDialog, - icon: const Icon(CupertinoIcons.slider_horizontal_3), - ), - if (filtersCount > 0) - Positioned( - top: 0, - right: 0, - child: GestureDetector( - onTap: openFiltersDialog, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(10), - ), - constraints: const BoxConstraints(minWidth: 20, minHeight: 20), - child: Text( - filtersCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ], - ), - ], - ), - ); - } - - List filterActions() { - return [ - IconButton( - icon: const Icon(Icons.subdirectory_arrow_left_outlined), - tooltip: 'Scroll to current tab', - onPressed: () async { - WidgetsBinding.instance.addPostFrameCallback((_) async { - firstBuild = true; - await jumpToCurrent(); - firstBuild = false; - setState(() {}); - WidgetsBinding.instance.addPostFrameCallback((_) { - jumpToCurrent(); - }); - }); - }, - ), - Transform( - alignment: Alignment.center, - transform: sortTabs == true ? Matrix4.rotationX(pi) : Matrix4.rotationX(0), - child: IconButton( - icon: Icon((sortTabs == true || sortTabs == false) ? Icons.sort : Icons.sort_by_alpha), - tooltip: 'Sort tabs', - onPressed: () { - if (sortTabs == true) { - sortTabs = false; - } else if (sortTabs == false) { - sortTabs = null; - } else { - sortTabs = true; - } - filterTabs(); - }, - ), - ), - IconButton( - icon: const Icon(Icons.help_center_outlined), - tooltip: 'Help', - onPressed: () { - showDialog( - context: context, - builder: (context) { - return SettingsDialog( - title: const Text('Tabs Manager'), - contentItems: [ - const Row( - children: [ - Icon(Icons.subdirectory_arrow_left_outlined), - SizedBox(width: 10), - Text('Scroll to current tab'), - ], - ), - const Divider(), - const Row( - children: [ - Icon(CupertinoIcons.slider_horizontal_3), - SizedBox(width: 10), - Expanded(child: Text('Filter tabs by booru, loaded state, duplicates, etc.')), - ], - ), - const Divider(), - const Row( - children: [ - Icon(Icons.sort_by_alpha), - SizedBox(width: 10), - Text('Default tabs order'), - ], - ), - Row( - children: [ - Transform( - alignment: Alignment.center, - transform: Matrix4.rotationX(pi), - child: const Icon(Icons.sort), - ), - const SizedBox(width: 10), - const Text('Sort alphabetically'), - ], - ), - Row( - children: [ - Transform( - alignment: Alignment.center, - transform: Matrix4.rotationX(0), - child: const Icon(Icons.sort), - ), - const SizedBox(width: 10), - const Text('Sort alphabetically (reversed)'), - ], - ), - const Divider(), - const Row( - children: [ - Icon(Icons.expand), - SizedBox(width: 10), - Text('Long press on a tab to move it'), - ], - ), - const Divider(), - const Text('Numbers under the favicon:'), - const Text('First number - tab index in default list order'), - const Text('Second number - tab index in current list order, appears when filtering/sorting is active'), - const Divider(), - const Text('Special filters:'), - const Text('"loaded" - show tabs which have loaded items'), - const Text('"unloaded" - show tabs which are not loaded and/or have zero items'), - RichText( - text: const TextSpan( - children: [ - TextSpan(text: 'Unloaded tabs have '), - TextSpan( - text: 'italic', - style: TextStyle(fontStyle: FontStyle.italic), - ), - TextSpan(text: ' text'), - ], - ), - ), - ], - ); - }, - ); - }, - ), - ]; - } - - Widget selectedActionsBuild() { - if (selectedTabs.isEmpty) { - if (filteredTabs.isNotEmpty) { - return Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(10), - child: ElevatedButton.icon( - icon: const Icon(Icons.select_all), - label: const Text('Select all'), - onPressed: () { - selectedTabs = filteredTabs.where((element) => element != searchHandler.currentTab).toList(); - setState(() {}); - }, - ), - ), - ), - ], - ); - } else { - return const SizedBox(); - } - } - - return Container( - margin: const EdgeInsets.all(10), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.delete_forever), - label: Text("Delete ${selectedTabs.length} ${Tools.pluralize('tab', selectedTabs.length)}"), - onPressed: () { - if (selectedTabs.isEmpty) { - return; - } - - // sort selected tabs in order of appearance in the list instead of order of selection - selectedTabs.sort((a, b) => searchHandler.list.indexOf(a).compareTo(searchHandler.list.indexOf(b))); - - final Widget deleteDialog = SettingsDialog( - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Delete Tabs'), - Text( - 'Are you sure you want to delete ${selectedTabs.length} ${Tools.pluralize('tab', selectedTabs.length)}?', - style: const TextStyle(fontSize: 16), - ), - ], - ), - scrollable: false, - content: Container( - height: MediaQuery.of(context).size.height * 0.75, - width: double.maxFinite, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - ), - clipBehavior: Clip.hardEdge, - child: ListView.builder( - clipBehavior: Clip.hardEdge, - shrinkWrap: true, - itemCount: selectedTabs.length, - itemBuilder: (_, index) { - final int itemIndex = searchHandler.list.indexOf(selectedTabs[index]); - - return buildEntry(itemIndex, false, false); - }, - ), - ), - actionButtons: [ - const CancelButton(), - ElevatedButton.icon( - label: const Text('Delete'), - icon: const Icon(Icons.delete_forever), - onPressed: () { - for (int i = 0; i < selectedTabs.length; i++) { - final int index = searchHandler.list.indexOf(selectedTabs[i]); - searchHandler.removeTabAt(tabIndex: index); - } - selectedTabs.clear(); - filterTabs(); - Navigator.of(context).pop(); - }, - ), - ], - ); - - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => deleteDialog, - ); - }, - ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.border_clear), - label: const Text('Clear selection'), - onPressed: () { - selectedTabs.clear(); - setState(() {}); - }, - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return SettingsPageDialog( - title: const Text('Tabs'), - content: Column( - children: [ - filterBuild(), - Expanded(child: listBuild()), - selectedActionsBuild(), - ], - ), - actions: filterActions(), - ); - } -} - -class TabManagerFiltersDialog extends StatefulWidget { - const TabManagerFiltersDialog({ - required this.loadedFilter, - required this.loadedFilterChanged, - required this.booruFilter, - required this.booruFilterChanged, - required this.duplicateFilter, - required this.duplicateFilterChanged, - super.key, - }); - - final bool? loadedFilter; - final ValueChanged loadedFilterChanged; - final Booru? booruFilter; - final ValueChanged booruFilterChanged; - final bool duplicateFilter; - final ValueChanged duplicateFilterChanged; - - @override - State createState() => _TabManagerFiltersDialogState(); -} - -class _TabManagerFiltersDialogState extends State { - bool? loadedFilter; - Booru? booruFilter; - bool duplicateFilter = false; - - @override - void initState() { - super.initState(); - - loadedFilter = widget.loadedFilter; - booruFilter = widget.booruFilter; - duplicateFilter = widget.duplicateFilter; - } - - @override - Widget build(BuildContext context) { - return SettingsBottomSheet( - title: const Text('Tab Filters'), - contentPadding: const EdgeInsets.all(16), - contentItems: [ - SettingsBooruDropdown( - title: 'Booru', - value: booruFilter, - drawBottomBorder: false, - onChanged: (Booru? newValue) { - booruFilter = newValue; - setState(() {}); - }, - ), - SettingsDropdown( - title: 'Loaded', - value: loadedFilter, - drawBottomBorder: false, - onChanged: (bool? newValue) { - loadedFilter = newValue; - setState(() {}); - }, - items: const [ - null, - true, - false, - ], - itemBuilder: (item) => item == null ? const Text('All') : Text(item ? 'Loaded' : 'Unloaded'), - itemTitleBuilder: (item) => item == null - ? 'All' - : item - ? 'Loaded' - : 'Unloaded', - ), - SettingsToggle( - title: 'Duplicates', - value: duplicateFilter, - onChanged: (bool newValue) { - duplicateFilter = newValue; - setState(() {}); - }, - ), - ], - actionButtons: [ - ElevatedButton.icon( - label: const Text('Clear'), - icon: const Icon(Icons.delete), - onPressed: () { - Navigator.of(context).pop('clear'); - }, - ), - const SizedBox(width: 10), - ElevatedButton.icon( - label: const Text('Apply'), - icon: const Icon(Icons.check), - onPressed: () { - widget.loadedFilterChanged(loadedFilter); - widget.booruFilterChanged(booruFilter); - widget.duplicateFilterChanged(duplicateFilter); - Navigator.of(context).pop('apply'); - }, - ), - ], - ); - } -} diff --git a/lib/src/widgets/tabs/tab_move_dialog.dart b/lib/src/widgets/tabs/tab_move_dialog.dart index 2eb7b3eb..339581b6 100644 --- a/lib/src/widgets/tabs/tab_move_dialog.dart +++ b/lib/src/widgets/tabs/tab_move_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; +import 'package:lolisnatcher/src/widgets/tabs/tab_selector.dart'; class TabMoveDialog extends StatefulWidget { const TabMoveDialog({ @@ -58,7 +59,7 @@ class _TabMoveDialogState extends State { side: BorderSide(color: Theme.of(context).colorScheme.secondary), ), onTap: () async { - searchHandler.moveTab(widget.index, searchHandler.total); + searchHandler.moveTab(widget.index, searchHandler.total - 1); Navigator.of(context).pop(true); Navigator.of(context).pop(true); }, @@ -102,7 +103,7 @@ class _TabMoveDialogState extends State { ), ); } else { - if (enteredIndex < 0 || enteredIndex > searchHandler.total) { + if (enteredIndex < 1 || enteredIndex > searchHandler.total) { return await FlashElements.showSnackbar( title: const Text('Invalid Tab Number'), content: const Column( @@ -134,6 +135,7 @@ class _TabMoveDialogState extends State { TabMovePreview( index: widget.index, indexController: indexController, + setState: setState, ), ], ); @@ -144,11 +146,13 @@ class TabMovePreview extends StatelessWidget { const TabMovePreview({ required this.index, required this.indexController, + required this.setState, super.key, }); final int index; final TextEditingController indexController; + final Function(VoidCallback) setState; @override Widget build(BuildContext context) { @@ -157,104 +161,107 @@ class TabMovePreview extends StatelessWidget { int enteredIndex = int.tryParse(indexController.text) ?? index; if (enteredIndex < 1) { - enteredIndex = index; + enteredIndex = 1; } else if (enteredIndex > searchHandler.total) { - enteredIndex = index; + enteredIndex = searchHandler.total; } - final int prevTabIndex = enteredIndex - 2; - final SearchTab? prevTab = searchHandler.getTabByIndex(prevTabIndex); + // to make calculations easier + enteredIndex -= 1; - final int nextTabIndex = enteredIndex; - final SearchTab? nextTab = searchHandler.getTabByIndex(nextTabIndex); + const double dotsSize = 20; + const dotsWidget = Text('...', style: TextStyle(fontSize: dotsSize, height: 1)); + const dotsHeightPlaceholder = SizedBox(height: dotsSize); + const tabHeightPlaceholder = SizedBox(height: 80); - final SearchTab currentTab = searchHandler.getTabByIndex(index)!; - final SearchTab firstTab = searchHandler.getTabByIndex(0)!; - final SearchTab lastTab = searchHandler.getTabByIndex(searchHandler.total - 1)!; - - final bool showFirst = enteredIndex > 2; - final bool showFirstDots = showFirst && - (enteredIndex > 1) && - (enteredIndex - 1 > 2); // is first tab shown and entered number is bigger than 2 and possible prev tab number is bigger than 2 - final bool showLast = enteredIndex < searchHandler.total - 1; - final bool showLastDots = showLast && - (enteredIndex < searchHandler.total) && - (enteredIndex + 1 < - searchHandler.total - 1); // is last tab shown and entered number is smaller than total and possible next tab number is smaller than total - 1 + final List children = []; + // first tab - show only when input is not on first + if (enteredIndex != 0) { + children.add( + TabManagerItem( + tab: searchHandler.getTabByIndex(index == 0 ? 1 : 0)!, + index: 0, + onTap: () { + indexController.text = 1.toString(); + setState(() {}); + }, + ), + ); + } else { + children.add(tabHeightPlaceholder); + } + // show dots if more than 2 tabs away from first + if (enteredIndex > 2) { + children.add(dotsWidget); + } else { + children.add(dotsHeightPlaceholder); + } + // show prev tab if more than 1 tab away from first + if (enteredIndex > 1) { + children.add( + TabManagerItem( + tab: searchHandler.getTabByIndex(index < enteredIndex ? enteredIndex : enteredIndex - 1)!, + index: enteredIndex - 1, + onTap: () { + indexController.text = enteredIndex.toString(); + setState(() {}); + }, + ), + ); + } else { + children.add(tabHeightPlaceholder); + } + // current tab at entered position + children.add( + TabManagerItem( + tab: searchHandler.getTabByIndex(index)!, + index: enteredIndex, + isCurrent: true, + onTap: () { + // do nothing + }, + ), + ); + // show next tab if more than 1 tab away from last + if (enteredIndex < searchHandler.total - 2) { + children.add( + TabManagerItem( + tab: searchHandler.getTabByIndex(index <= enteredIndex ? enteredIndex + 1 : enteredIndex)!, + index: enteredIndex + 1, + onTap: () { + indexController.text = (enteredIndex + 2).toString(); + setState(() {}); + }, + ), + ); + } else { + children.add(tabHeightPlaceholder); + } + // show dots if more than 2 tabs away from last + if (enteredIndex < searchHandler.total - 3) { + children.add(dotsWidget); + } else { + children.add(dotsHeightPlaceholder); + } + // last tab - show only when input is not on last + if (enteredIndex < searchHandler.total - 1) { + children.add( + TabManagerItem( + tab: searchHandler.getTabByIndex(searchHandler.total - (index + 1 == searchHandler.total ? 2 : 1))!, + index: searchHandler.total - 1, + onTap: () { + indexController.text = searchHandler.total.toString(); + setState(() {}); + }, + ), + ); + } else { + children.add(tabHeightPlaceholder); + } return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showFirst) - Container( - margin: const EdgeInsets.only(left: 10, bottom: 10), - child: ElevatedButton( - child: Text('#1 - ${firstTab.tags}'), - onPressed: () { - indexController.text = '1'; - }, - ), - ), - if (showFirstDots) - Container( - margin: const EdgeInsets.only(left: 10, bottom: 5), - child: const Text('...'), - ), - // - if (prevTab != null) - Container( - margin: const EdgeInsets.only(left: 10, bottom: 10), - child: ElevatedButton( - child: Text('#${prevTabIndex + 1} - ${prevTab.tags}'), - onPressed: () { - indexController.text = (prevTabIndex + 1).toString(); - }, - ), - ), - // - Container( - margin: const EdgeInsets.only(left: 10, bottom: 10), - child: ElevatedButton( - style: Theme.of(context).elevatedButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all(Colors.transparent), - side: MaterialStateProperty.all(BorderSide(color: Theme.of(context).colorScheme.secondary, width: 2)), - ), - child: Text( - '#$enteredIndex - ${currentTab.tags}', - ), - onPressed: () { - indexController.text = (index + 1).toString(); - }, - ), - ), - // - if (nextTab != null) - Container( - margin: const EdgeInsets.only(left: 10, bottom: 10), - child: ElevatedButton( - child: Text('#${nextTabIndex + 1} - ${nextTab.tags}'), - onPressed: () { - indexController.text = (nextTabIndex + 1).toString(); - }, - ), - ), - // - if (showLastDots) - Container( - margin: const EdgeInsets.only(left: 10, bottom: 5), - child: const Text('...'), - ), - if (showLast) - Container( - margin: const EdgeInsets.only(left: 10), - child: ElevatedButton( - child: Text('#${searchHandler.total} - ${lastTab.tags}'), - onPressed: () { - indexController.text = searchHandler.total.toString(); - }, - ), - ), - ], + crossAxisAlignment: CrossAxisAlignment.center, + children: children, ); } } diff --git a/lib/src/widgets/tabs/tab_row.dart b/lib/src/widgets/tabs/tab_row.dart index c99fce14..7fc05e85 100644 --- a/lib/src/widgets/tabs/tab_row.dart +++ b/lib/src/widgets/tabs/tab_row.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:lolisnatcher/src/boorus/booru_type.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; +import 'package:lolisnatcher/src/handlers/tag_handler.dart'; import 'package:lolisnatcher/src/widgets/common/marquee_text.dart'; import 'package:lolisnatcher/src/widgets/image/favicon.dart'; @@ -14,6 +15,8 @@ class TabRow extends StatelessWidget { this.color, this.fontWeight, this.withFavicon = true, + this.withColoredTags = true, + this.filterText, super.key, }); @@ -21,38 +24,141 @@ class TabRow extends StatelessWidget { final Color? color; final FontWeight? fontWeight; final bool withFavicon; + final bool withColoredTags; + final String? filterText; @override Widget build(BuildContext context) { return Obx(() { // print(tab.tags); - final int totalCount = tab.booruHandler.totalCount.value; - final String totalCountText = (totalCount > 0) ? ' ($totalCount)' : ''; - final String multiText = (tab.secondaryBoorus?.isNotEmpty ?? false) ? ' [M]' : ''; - final String tagText = "${tab.tags == "" ? "[No Tags]" : tab.tags}$totalCountText$multiText".trim(); + final String rawTagsStr = tab.tags; + final String tagText = (rawTagsStr.trim().isEmpty ? '[No Tags]' : rawTagsStr).trim(); final bool hasItems = tab.booruHandler.filteredFetched.isNotEmpty; final bool isNotEmptyBooru = tab.selectedBooru.value.faviconURL != null; - return SizedBox( - width: double.maxFinite, - child: Row( - children: [ - if (withFavicon) - isNotEmptyBooru - ? (tab.selectedBooru.value.type == BooruType.Favourites - ? const Icon(Icons.favorite, color: Colors.red, size: 18) - : Favicon(tab.selectedBooru.value, color: color)) - : const Icon(CupertinoIcons.question, size: 18), - const SizedBox(width: 3), - MarqueeText( - key: ValueKey(tagText), - text: tagText, + Widget marquee = MarqueeText( + key: ValueKey(tagText), + text: tagText, + style: TextStyle( + fontSize: 16, + fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, + fontWeight: fontWeight ?? FontWeight.normal, + color: color ?? (tab.tags == '' ? Colors.grey : null) ?? Theme.of(context).colorScheme.onBackground, + ), + ); + + if (tab.tags.trim().isNotEmpty) { + if (filterText?.isNotEmpty == true) { + final List spans = []; + final List split = tagText.split(filterText!); + + for (int i = 0; i < split.length; i++) { + final spanStyle = TextStyle( + fontSize: 16, + fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, + fontWeight: fontWeight ?? FontWeight.normal, + color: color ?? (tab.tags == '' ? Colors.grey : null) ?? Theme.of(context).colorScheme.onBackground, + ); + + spans.add( + TextSpan( + text: split[i], + style: spanStyle, + ), + ); + if (i < split.length - 1) { + spans.add( + TextSpan( + text: filterText, + style: spanStyle.copyWith(backgroundColor: Colors.green), + ), + ); + } + } + + marquee = MarqueeText.rich( + key: ValueKey(tagText), + textSpan: TextSpan( + children: spans, + ), + style: TextStyle( + fontSize: 16, + fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, + fontWeight: fontWeight ?? FontWeight.normal, + color: color ?? (tab.tags == '' ? Colors.grey : null) ?? Theme.of(context).colorScheme.onBackground, + ), + ); + } else if (withColoredTags) { + final List spans = []; + final List split = tagText.trim().split(' '); + + for (int i = 0; i < split.length; i++) { + final tag = split[i].trim(); + + final tagData = TagHandler.instance.getTag(tag); + + final bool isColored = !tagData.tagType.isNone; + + final spanStyle = TextStyle( + fontSize: 16, + fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, + fontWeight: fontWeight ?? FontWeight.normal, + color: color ?? (tab.tags == '' ? Colors.grey : null) ?? Theme.of(context).colorScheme.onBackground, + backgroundColor: isColored ? tagData.tagType.getColour().withOpacity(0.66) : null, + ); + + spans.add( + TextSpan( + // add non-breaking space to the end of italics to hide text overflowing the bgColor, + text: '$tag${(hasItems || !isColored) ? '' : '\u{00A0}'}', + style: spanStyle, + ), + ); + if (i < split.length - 1) { + spans.add( + TextSpan( + text: ' ', + style: spanStyle.copyWith( + backgroundColor: Colors.transparent, + ), + ), + ); + } + } + + marquee = MarqueeText.rich( + key: ValueKey(tagText), + textSpan: TextSpan( + children: spans, + ), + style: TextStyle( fontSize: 16, fontStyle: hasItems ? FontStyle.normal : FontStyle.italic, fontWeight: fontWeight ?? FontWeight.normal, color: color ?? (tab.tags == '' ? Colors.grey : null), ), + ); + } + } + + return SizedBox( + width: double.maxFinite, + child: Row( + children: [ + if (withFavicon) ...[ + if (isNotEmptyBooru) ...[ + if (tab.selectedBooru.value.type == BooruType.Downloads) + const Icon(Icons.file_download_outlined, size: 18) + else if (tab.selectedBooru.value.type == BooruType.Favourites) + const Icon(Icons.favorite, color: Colors.red, size: 18) + else + Favicon(tab.selectedBooru.value, color: color), + ] else + const Icon(CupertinoIcons.question, size: 18), + const SizedBox(width: 3), + ], + marquee, ], ), ); diff --git a/lib/src/widgets/tabs/tab_selector.dart b/lib/src/widgets/tabs/tab_selector.dart index 851cc959..568fd4ec 100644 --- a/lib/src/widgets/tabs/tab_selector.dart +++ b/lib/src/widgets/tabs/tab_selector.dart @@ -1,163 +1,1429 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:get/get.dart'; +import 'package:lolisnatcher/src/data/booru.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; +import 'package:lolisnatcher/src/utils/extensions.dart'; +import 'package:lolisnatcher/src/utils/tools.dart'; +import 'package:lolisnatcher/src/widgets/common/cancel_button.dart'; +import 'package:lolisnatcher/src/widgets/common/flash_elements.dart'; +import 'package:lolisnatcher/src/widgets/common/kaomoji.dart'; +import 'package:lolisnatcher/src/widgets/common/marquee_text.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; -import 'package:lolisnatcher/src/widgets/tabs/tab_manager_dialog.dart'; +import 'package:lolisnatcher/src/widgets/desktop/desktop_scroll_wrap.dart'; +import 'package:lolisnatcher/src/widgets/preview/shimmer_builder.dart'; +import 'package:lolisnatcher/src/widgets/tabs/tab_filters_dialog.dart'; +import 'package:lolisnatcher/src/widgets/tabs/tab_move_dialog.dart'; import 'package:lolisnatcher/src/widgets/tabs/tab_row.dart'; +// TODO improve scrolling performance when tapping scroll to top/bottom/current buttons and freezing after input in search when there are a lot of tabs (+ add debounce?) + class TabSelector extends StatelessWidget { - const TabSelector({super.key}); + const TabSelector({ + this.withBorder = true, + this.color, + super.key, + }); + + final bool withBorder; + final Color? color; @override Widget build(BuildContext context) { + const double radius = 10; + + final SearchHandler searchHandler = SearchHandler.instance; final SettingsHandler settingsHandler = SettingsHandler.instance; + return Obx(() { + if (searchHandler.list.isEmpty) { + return const SizedBox.shrink(); + } - final bool isDesktop = settingsHandler.appMode.value.isDesktop; - final EdgeInsetsGeometry padding = isDesktop ? const EdgeInsets.fromLTRB(2, 5, 2, 2) : const EdgeInsets.fromLTRB(5, 8, 5, 8); - final EdgeInsetsGeometry contentPadding = EdgeInsets.symmetric(horizontal: 12, vertical: isDesktop ? 2 : 8); + final currentTab = searchHandler.currentTab; + final totalTabs = searchHandler.total; + final currentTabIndex = searchHandler.currentIndex; - return TabSelectorRender( - isDesktop: isDesktop, - padding: padding, - contentPadding: contentPadding, - ); + final theme = Theme.of(context); + final inputDecoration = theme.inputDecorationTheme; + + final bool isDesktop = settingsHandler.appMode.value.isDesktop; + final EdgeInsetsGeometry margin = + isDesktop ? const EdgeInsets.fromLTRB(2, 5, 2, 2) : (withBorder ? const EdgeInsets.fromLTRB(5, 8, 5, 8) : const EdgeInsets.fromLTRB(0, 16, 0, 0)); + final EdgeInsetsGeometry contentPadding = EdgeInsets.symmetric(horizontal: 12, vertical: isDesktop ? 2 : 12); + + return Padding( + padding: margin, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: withBorder ? const BorderRadius.all(Radius.circular(radius)) : null, + onTap: () { + SettingsPageOpen( + context: context, + page: () => const TabManagerPage(), + ).open(); + }, + child: InputDecorator( + decoration: InputDecoration( + label: Obx(() { + final totalCount = currentTab.booruHandler.totalCount.value; + + return RichText( + text: TextSpan( + style: inputDecoration.labelStyle?.copyWith( + color: color ?? inputDecoration.labelStyle?.color, + ), + children: [ + TextSpan( + text: 'Tab | ${(currentTabIndex + 1).toFormattedString()}/${totalTabs.toFormattedString()}', + ), + if (totalCount > 0) ...[ + const TextSpan(text: ' | '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + Icons.image, + size: inputDecoration.labelStyle?.fontSize ?? 12, + color: color ?? inputDecoration.labelStyle?.color, + ), + ), + ), + TextSpan( + text: totalCount.toFormattedString(), + ), + ], + ], + ), + ); + }), + labelStyle: inputDecoration.labelStyle?.copyWith( + color: color ?? inputDecoration.labelStyle?.color, + ), + contentPadding: contentPadding, + border: inputDecoration.border?.copyWith( + borderSide: BorderSide( + color: withBorder ? (inputDecoration.border?.borderSide.color ?? Colors.transparent) : Colors.transparent, + width: 1, + ), + ), + enabledBorder: inputDecoration.enabledBorder?.copyWith( + borderSide: BorderSide( + color: withBorder ? (inputDecoration.enabledBorder?.borderSide.color ?? Colors.transparent) : Colors.transparent, + width: 1, + ), + ), + focusedBorder: inputDecoration.focusedBorder?.copyWith( + borderSide: BorderSide( + color: withBorder ? (inputDecoration.focusedBorder?.borderSide.color ?? Colors.transparent) : Colors.transparent, + width: 2, + ), + ), + ), + child: TabRow( + tab: currentTab, + color: color, + ), + ), + ), + ), + ); + }); } } -class TabSelectorHeader extends StatelessWidget { - const TabSelectorHeader({super.key}); +class TabManagerPage extends StatefulWidget { + const TabManagerPage({super.key}); @override - Widget build(BuildContext context) { - final SettingsHandler settingsHandler = SettingsHandler.instance; + State createState() => _TabManagerPageState(); +} - final bool isDesktop = settingsHandler.appMode.value.isDesktop; - const EdgeInsetsGeometry padding = EdgeInsets.fromLTRB(5, 10, 2, 0); - const EdgeInsetsGeometry contentPadding = EdgeInsets.symmetric(horizontal: 4, vertical: 8); - const Color borderColor = Colors.transparent; - final Color? textColor = Theme.of(context).appBarTheme.titleTextStyle?.color; - - return TabSelectorRender( - isDesktop: isDesktop, - padding: padding, - contentPadding: contentPadding, - borderColor: borderColor, - textColor: textColor, +class _TabManagerPageState extends State { + final SearchHandler searchHandler = SearchHandler.instance; + final SettingsHandler settingsHandler = SettingsHandler.instance; + + List tabs = [], filteredTabs = [], selectedTabs = []; + late final ScrollController scrollController; + + final TextEditingController filterController = TextEditingController(); + bool? sortTabs, loadedFilter; + Booru? booruFilter; + bool duplicateFilter = false, emptyFilter = false; + bool selectMode = false; + + bool showPlaceholders = false, firstRender = true; + + static const double tabHeight = 72 + 8; + + int get totalTabs => searchHandler.total; + int get totalFilteredTabs => filteredTabs.length; + bool get isFilterActive => totalFilteredTabs != totalTabs || filterController.text.isNotEmpty || filtersCount > 0; + int get currentTabIndex => filteredTabs.indexOf(searchHandler.currentTab); + + int get filtersCount { + int count = 0; + if (loadedFilter != null) { + count++; + } + if (booruFilter != null) { + count++; + } + if (duplicateFilter) { + count++; + } + if (emptyFilter) { + count++; + } + return count; + } + + @override + void initState() { + super.initState(); + getTabs(); + + scrollController = ScrollController( + initialScrollOffset: currentTabIndex * tabHeight, ); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await jumpToCurrent(); + firstRender = false; + setState(() {}); + }); } -} -class TabSelectorRender extends StatelessWidget { - const TabSelectorRender({ - required this.isDesktop, - required this.padding, - required this.contentPadding, - this.borderColor, - this.textColor, - super.key, - }); + void getTabs() { + tabs = searchHandler.list; + filteredTabs = tabs; + filterTabs(); + + setState(() {}); + } + + Future jumpToCurrent({bool animated = false}) async { + if (scrollController.hasClients) { + if (currentTabIndex == -1) { + return; + } + + // final double viewport = scrollController.position.viewportDimension; + final double maxScroll = scrollController.position.maxScrollExtent; + final double itemOffset = currentTabIndex * tabHeight; + double scrollOffset = 0; + if (itemOffset > maxScroll) { + scrollOffset = maxScroll; + } else { + scrollOffset = itemOffset; + } + + if (animated) { + await scrollController.animateTo( + scrollOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + scrollController.jumpTo(scrollOffset); + } + } + } + + void scrollToCurrent() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + showPlaceholders = true; + setState(() {}); + await jumpToCurrent(animated: true); + showPlaceholders = false; + setState(() {}); + }); + } + + void jumpToTop() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + firstRender = true; + setState(() {}); + await Future.delayed(const Duration(milliseconds: 20)); + scrollController.jumpTo(0); + firstRender = false; + setState(() {}); + }); + } - final bool isDesktop; - final EdgeInsetsGeometry padding; - final EdgeInsetsGeometry contentPadding; - final Color? borderColor; - final Color? textColor; + void scrollToTop() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + showPlaceholders = true; + setState(() {}); + await Future.delayed(const Duration(milliseconds: 20)); + await scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + showPlaceholders = false; + setState(() {}); + }); + } + + void scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + showPlaceholders = true; + setState(() {}); + await Future.delayed(const Duration(milliseconds: 20)); + await scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + showPlaceholders = false; + setState(() {}); + }); + } + + void filterTabs() { + filteredTabs = [...tabs]; + + if (booruFilter != null) { + filteredTabs = filteredTabs.where((t) => t.selectedBooru.value == booruFilter).toList(); + } + + if (loadedFilter != null) { + filteredTabs = + filteredTabs.where((t) => loadedFilter == true ? t.booruHandler.filteredFetched.isNotEmpty : t.booruHandler.filteredFetched.isEmpty).toList(); + } + + if (duplicateFilter) { + // tabs where booru and tags are the same + filteredTabs = filteredTabs.where((tab) { + final List sameBooru = filteredTabs.where((t) => t.selectedBooru.value == tab.selectedBooru.value).toList(); + final List sameTags = sameBooru.where((t) => t.tags == tab.tags).toList(); + return sameTags.length > 1; + }).toList(); + } - Future openTabsDialog(BuildContext context) { - return SettingsPageOpen( + if (emptyFilter) { + filteredTabs = filteredTabs.where((tab) => tab.tags.trim().isEmpty).toList(); + } + + if (filterController.text.isNotEmpty) { + filteredTabs = filteredTabs.where((t) { + final String filterText = filterController.text.toLowerCase().trim(); + return t.tags.toLowerCase().contains(filterText); + }).toList(); + } + + if (sortTabs != null) { + filteredTabs.sort( + (a, b) => sortTabs == true ? a.tags.toLowerCase().compareTo(b.tags.toLowerCase()) : b.tags.toLowerCase().compareTo(a.tags.toLowerCase()), + ); + } + } + + Future openFiltersDialog() async { + final String? result = await SettingsPageOpen( context: context, - page: () => const TabManagerDialog(), + asBottomSheet: true, + page: () => TabManagerFiltersDialog( + loadedFilter: loadedFilter, + loadedFilterChanged: (bool? newValue) { + loadedFilter = newValue; + }, + booruFilter: booruFilter, + booruFilterChanged: (Booru? newValue) { + booruFilter = newValue; + }, + duplicateFilter: duplicateFilter, + duplicateFilterChanged: (bool newValue) { + duplicateFilter = newValue; + }, + emptyFilter: emptyFilter, + emptyFilterChanged: (bool newValue) { + emptyFilter = newValue; + }, + ), ).open(); - } - @override - Widget build(BuildContext context) { - final SearchHandler searchHandler = SearchHandler.instance; + final int tabsBeforeFilters = totalFilteredTabs; + if (result == 'apply') { + // + } else if (result == 'clear') { + loadedFilter = null; + booruFilter = null; + duplicateFilter = false; + emptyFilter = false; + } + + if (result != null) { + showPlaceholders = true; + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) async { + getTabs(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (filteredTabs.contains(searchHandler.currentTab)) { + await jumpToCurrent(); + } else { + scrollToTop(); + } + showPlaceholders = false; + setState(() {}); - // print('tabbox build'); + if (totalFilteredTabs > tabsBeforeFilters) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await jumpToCurrent(); + }); + } + }); + }); + } + } + Widget filterBuild() { return Container( - padding: padding, - child: Obx(() { - final List list = searchHandler.list; - final int index = searchHandler.currentIndex; - - if (list.isEmpty) { - return const SizedBox(); - } - - return GestureDetector( - onLongPress: () => openTabsDialog(context), - onSecondaryTap: () => openTabsDialog(context), - child: DropdownButtonFormField( - isExpanded: true, - value: list[index], - icon: Icon(Icons.arrow_drop_down, color: textColor), - itemHeight: kMinInteractiveDimension, - decoration: InputDecoration( - labelText: 'Tab | ${searchHandler.currentIndex + 1}/${searchHandler.total}', - labelStyle: Theme.of(context).inputDecorationTheme.labelStyle?.copyWith( - color: textColor ?? Theme.of(context).inputDecorationTheme.labelStyle?.color, + margin: const EdgeInsets.only(right: 10), + width: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: SettingsTextInput( + title: 'Search Tabs', + controller: filterController, + inputType: TextInputType.text, + clearable: true, + onlyInput: true, + drawBottomBorder: false, + margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + // margin: const EdgeInsets.fromLTRB(2, 8, 2, 5), + onChanged: (_) => getTabs(), + ), + ), + const SizedBox(width: 4), + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + iconSize: 30, + onPressed: openFiltersDialog, + icon: const Icon(Icons.filter_alt), + ), + if (filtersCount > 0) + Positioned( + top: -4, + right: -4, + child: GestureDetector( + onTap: openFiltersDialog, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(100), + ), + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + child: Center( + child: Text( + filtersCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ), ), - contentPadding: contentPadding, - border: Theme.of(context).inputDecorationTheme.border?.copyWith( - borderSide: BorderSide( - color: borderColor ?? Theme.of(context).inputDecorationTheme.border?.borderSide.color ?? Colors.transparent, - width: 1, + ), + ], + ), + ], + ), + ); + } + + Widget proxyDecorator(Widget child, int index, Animation animation) { + return child; + } + + Widget itemBuilder(BuildContext context, int index) { + final SearchTab tab = filteredTabs[index]; + + // if (mode.isViewer && firstRender) { + // return const SizedBox(height: tabHeight); + // } + + // print('itemBuilder $index'); + + final bool isCurrent = tab == searchHandler.currentTab; + final bool isSelected = selectedTabs.contains(tab); + + return ReorderableDelayedDragStartListener( + key: ValueKey('item-$index-${tab.id}'), + index: index, + enabled: !selectMode && !isFilterActive && sortTabs == null, + child: Dismissible( + key: ValueKey('dismiss-item-$index-${tab.id}'), + direction: selectMode ? DismissDirection.none : DismissDirection.horizontal, + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + return true; + } else { + return false; + } + }, + onDismissed: (direction) { + if (direction == DismissDirection.endToStart) { + selectedTabs.remove(tab); + searchHandler.removeTabAt(tabIndex: searchHandler.list.indexOf(tab)); + getTabs(); + } + }, + background: Container(), + secondaryBackground: selectMode + ? const SizedBox.shrink() + : Container( + alignment: Alignment.centerRight, + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.close, + color: Colors.white, + ), + ), + child: TabManagerItem( + tab: tab, + index: index, + isFiltered: isFilterActive || sortTabs != null, + originalIndex: (isFilterActive || sortTabs != null) ? searchHandler.list.indexOf(tab) : null, + isCurrent: isCurrent, + showPlaceholders: showPlaceholders, + firstRender: firstRender, + filterText: filterController.text, + onTap: selectMode + ? () { + if (isSelected || isCurrent) { + selectedTabs.removeWhere((item) => item == tab); + } else { + selectedTabs.add(tab); + } + setState(() {}); + } + : () { + searchHandler.changeTabIndex( + searchHandler.list.indexOf(tab), + ); + Navigator.of(context).pop(); + }, + optionsWidgetBuilder: selectMode + ? (_, onTap) { + if (isCurrent) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Checkbox( + value: isSelected, + onChanged: (bool? newValue) { + if (isSelected) { + selectedTabs.removeWhere((item) => item == tab); + } else { + selectedTabs.add(tab); + } + setState(() {}); + }, ), + ); + } + : null, + onOptionsTap: () { + if (!selectMode) { + showOptionsDialog(index); + } + }, + onCloseTap: selectMode + ? null + : () { + selectedTabs.remove(tab); + searchHandler.removeTabAt(tabIndex: searchHandler.list.indexOf(tab)); + getTabs(); + }, + ), + ), + ); + } + + void showOptionsDialog(int index) { + final SearchTab tab = filteredTabs[index]; + final int originalIndex = searchHandler.list.indexOf(tab); + + final Widget optionsDialog = SettingsDialog( + scrollable: false, + contentItems: [ + TabManagerItem( + tab: tab, + index: index, + isFiltered: isFilterActive, + originalIndex: isFilterActive ? originalIndex : null, + ), + const SizedBox(height: 20), + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide(color: Theme.of(context).colorScheme.secondary), + ), + onTap: () async { + await Clipboard.setData(ClipboardData(text: tab.tags)); + FlashElements.showSnackbar( + context: context, + duration: const Duration(seconds: 2), + title: const Text('Copied to clipboard!', style: TextStyle(fontSize: 20)), + content: Text(tab.tags, style: const TextStyle(fontSize: 16)), + leadingIcon: Icons.copy, + sideColor: Colors.green, + ); + Navigator.of(context).pop(true); + }, + leading: const Icon(Icons.copy), + title: const Text('Copy'), + ), + const SizedBox(height: 10), + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide(color: Theme.of(context).colorScheme.secondary), + ), + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) => TabMoveDialog( + row: TabManagerItem( + tab: tab, + index: searchHandler.list.indexOf(tab), + isFiltered: isFilterActive, + originalIndex: isFilterActive ? originalIndex : null, + ), + index: searchHandler.list.indexOf(tab), + ), + ); + getTabs(); + }, + leading: const Icon(Icons.move_down_sharp), + title: const Text('Move'), + ), + const SizedBox(height: 10), + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide(color: Theme.of(context).colorScheme.secondary), + ), + onTap: () async { + selectedTabs.remove(tab); + searchHandler.removeTabAt(tabIndex: searchHandler.list.indexOf(tab)); + getTabs(); + }, + leading: const Icon(Icons.close, color: Colors.red), + title: const Text('Remove'), + ), + const SizedBox(height: 10), + // TODO more stuff? + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) => optionsDialog, + ); + } + + void showDeleteDialog() { + if (selectedTabs.isEmpty) { + return; + } + + // sort selected tabs in order of appearance in the list instead of order of selection + selectedTabs.sort((a, b) => searchHandler.list.indexOf(a).compareTo(searchHandler.list.indexOf(b))); + + final Widget deleteDialog = SettingsDialog( + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Delete Tabs'), + Text( + 'Are you sure you want to delete ${selectedTabs.length} ${Tools.pluralize('tab', selectedTabs.length)}?', + style: const TextStyle(fontSize: 16), + ), + ], + ), + scrollable: false, + content: Container( + height: MediaQuery.of(context).size.height * 0.75, + width: double.maxFinite, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.hardEdge, + child: ListView.builder( + clipBehavior: Clip.hardEdge, + shrinkWrap: true, + itemCount: selectedTabs.length, + itemBuilder: (_, index) { + final int itemIndex = searchHandler.list.indexOf(selectedTabs[index]); + + return TabManagerItem( + tab: selectedTabs[index], + index: index, + isFiltered: true, + originalIndex: itemIndex, + ); + }, + ), + ), + actionButtons: [ + const SizedBox( + height: 40, + child: CancelButton(), + ), + SizedBox( + height: 40, + child: ElevatedButton.icon( + label: const Text('Delete'), + icon: const Icon(Icons.delete_forever), + onPressed: () { + for (int i = 0; i < selectedTabs.length; i++) { + final int index = searchHandler.list.indexOf(selectedTabs[i]); + searchHandler.removeTabAt(tabIndex: index); + } + selectedTabs.clear(); + getTabs(); + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => deleteDialog, + ); + } + + void showHelpDialog() { + showDialog( + context: context, + builder: (context) { + return SettingsDialog( + title: const Text('Tabs Manager'), + contentItems: [ + const Text('Scrolling:'), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.subdirectory_arrow_left_outlined), + SizedBox(width: 10), + Expanded(child: Text('Scroll to current tab')), + ], + ), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.arrow_circle_up), + SizedBox(width: 10), + Expanded(child: Text('Scroll to top')), + ], + ), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.arrow_circle_down), + SizedBox(width: 10), + Expanded(child: Text('Scroll to bottom')), + ], + ), + const Divider(), + const Row( + children: [ + Icon(Icons.filter_alt), + SizedBox(width: 10), + Expanded(child: Text('Filter tabs by booru, loaded state, duplicates, etc.')), + ], + ), + const Divider(), + const Text('Sorting:'), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.sort_by_alpha), + SizedBox(width: 10), + Expanded(child: Text('Default tabs order')), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Transform( + alignment: Alignment.center, + transform: Matrix4.rotationX(pi), + child: const Icon(Icons.sort), + ), + const SizedBox(width: 10), + const Expanded(child: Text('Sort alphabetically')), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Transform( + alignment: Alignment.center, + transform: Matrix4.rotationX(0), + child: const Icon(Icons.sort), + ), + const SizedBox(width: 10), + const Expanded(child: Text('Sort alphabetically (reversed)')), + ], + ), + const SizedBox(height: 6), + const Text('Long press on the sort button to save the current tabs order'), + const Divider(), + const Text('Select:'), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.select_all), + SizedBox(width: 10), + Expanded(child: Text('Toggle select mode')), + ], + ), + const SizedBox(height: 12), + const Text('On the bottom of the page: '), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.select_all), + Text(' / '), + Icon(Icons.border_clear), + SizedBox(width: 10), + Expanded(child: Text('Select/deselect all tabs')), + ], + ), + const SizedBox(height: 6), + const Row( + children: [ + Icon(Icons.delete), + SizedBox(width: 10), + Expanded(child: Text('Delete selected tabs')), + ], + ), + const Divider(), + const Row( + children: [ + Icon(Icons.expand), + SizedBox(width: 10), + Text('Long press on a tab to move it'), + ], + ), + const Divider(), + const Text('Numbers in the bottom right of the tab:'), + // TODO + const Text('First number - tab index in default list order'), + const Text('Second number - tab index in current list order, appears when filtering/sorting is active'), + const Divider(), + const Text('Special filters:'), + const Text('"loaded" - show tabs which have loaded items'), + const Text('"unloaded" - show tabs which are not loaded and/or have zero items'), + RichText( + text: const TextSpan( + children: [ + TextSpan(text: 'Unloaded tabs have '), + TextSpan( + text: 'italic', + style: TextStyle(fontStyle: FontStyle.italic), ), - enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder?.copyWith( - borderSide: BorderSide( - color: borderColor ?? Theme.of(context).inputDecorationTheme.enabledBorder?.borderSide.color ?? Colors.transparent, - width: 1, + TextSpan(text: ' text'), + ], + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: RichText( + text: TextSpan( + style: Theme.of(context).appBarTheme.titleTextStyle, + children: [ + const TextSpan(text: 'Tabs | '), + if (sortTabs != null) ...[ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Transform( + alignment: Alignment.center, + transform: sortTabs == true ? Matrix4.rotationX(pi) : Matrix4.rotationX(0), + child: const Icon(Icons.sort, size: 18), + ), + ), + ], + if (isFilterActive) ...[ + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon(Icons.filter_alt, size: 18), + ), + TextSpan(text: '${totalFilteredTabs.toFormattedString()}/'), + ], + TextSpan(text: totalTabs.toFormattedString()), + ], + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + tooltip: 'Select mode', + onPressed: () { + setState(() { + selectMode = !selectMode; + selectedTabs.clear(); + }); + }, + ), + const SizedBox(width: 8), + Transform( + alignment: Alignment.center, + transform: sortTabs == true ? Matrix4.rotationX(pi) : Matrix4.rotationX(0), + child: GestureDetector( + onLongPress: () async { + if (!isFilterActive) { + final currentTab = searchHandler.currentTab; + + final res = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return SettingsDialog( + title: Text(sortTabs != null ? 'Sort tabs' : 'Shuffle tabs'), + contentItems: sortTabs != null + ? [ + const Text('Save current tabs sorting?'), + Text(sortTabs == true ? 'Alphabetically' : 'Alphabetically (reversed)'), + ] + : [ + const Text('Shuffle tabs randomly?'), + ], + actionButtons: [ + const SizedBox( + height: 40, + child: CancelButton(), + ), + SizedBox( + height: 40, + child: ElevatedButton.icon( + label: Text(sortTabs != null ? 'Sort' : 'Shuffle'), + icon: Transform( + alignment: Alignment.center, + transform: sortTabs == true ? Matrix4.rotationX(pi) : Matrix4.rotationX(0), + child: Icon(sortTabs != null ? Icons.sort : Icons.sort_by_alpha), + ), + onPressed: () { + Navigator.of(context).pop('allow'); + }, + ), + ), + ], + ); + }, + ); + + if (res != 'allow') { + return; + } + + if (sortTabs == null) { + // randomly shuffle all filtered tabs + filteredTabs.shuffle(); + + FlashElements.showSnackbar( + context: context, + duration: const Duration(seconds: 2), + title: const Text('Tab randomly shuffled!', style: TextStyle(fontSize: 20)), + leadingIcon: Icons.sort_by_alpha, + sideColor: Colors.green, + ); + } else { + FlashElements.showSnackbar( + context: context, + duration: const Duration(seconds: 2), + title: const Text('Tab order saved!', style: TextStyle(fontSize: 20)), + leadingIcon: Icons.sort, + sideColor: Colors.green, + ); + } + + searchHandler.list.value = [...filteredTabs]; + + final int newIndex = searchHandler.list.indexOf(currentTab); + searchHandler.changeTabIndex(newIndex); + + getTabs(); + } + }, + child: IconButton( + icon: Icon((sortTabs == true || sortTabs == false) ? Icons.sort : Icons.sort_by_alpha), + tooltip: 'Sort tabs', + onPressed: () { + if (sortTabs == true) { + // reverse + sortTabs = false; + } else if (sortTabs == false) { + // default + sortTabs = null; + } else { + // alphabetically + sortTabs = true; + } + getTabs(); + }, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.help_center_outlined), + tooltip: 'Help', + onPressed: showHelpDialog, + ), + const SizedBox(width: 8), + ], + ), + body: Column( + children: [ + filterBuild(), + Expanded( + child: Stack( + children: [ + ShimmerWrap( + child: Scrollbar( + controller: scrollController, + thickness: 8, + interactive: true, + child: DesktopScrollWrap( + controller: scrollController, + child: ReorderableListView.builder( + scrollController: scrollController, + onReorder: (oldIndex, newIndex) { + if (oldIndex == newIndex) { + return; + } else if (oldIndex < newIndex) { + newIndex -= 1; + } + + searchHandler.moveTab(oldIndex, newIndex); + getTabs(); + }, + physics: getListPhysics(), + buildDefaultDragHandles: false, + proxyDecorator: proxyDecorator, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + itemCount: totalFilteredTabs, + itemBuilder: itemBuilder, + ), ), ), - focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder?.copyWith( - borderSide: BorderSide( - color: borderColor ?? Theme.of(context).inputDecorationTheme.focusedBorder?.borderSide.color ?? Colors.transparent, - width: 2, + ), + if (totalFilteredTabs == 0) + const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Kaomoji( + type: KaomojiType.shrug, + style: TextStyle(fontSize: 40), + ), + Text( + 'No tabs found', + style: TextStyle(fontSize: 20), + ), + ], ), ), + ], ), - borderRadius: BorderRadius.circular(8), - onChanged: (SearchTab? newValue) { - if (newValue != null) { - searchHandler.changeTabIndex(list.indexOf(newValue)); - } - }, - selectedItemBuilder: (BuildContext context) { - return list.map>((SearchTab value) { - final bool isCurrent = list.indexOf(value) == index; - - return DropdownMenuItem( - value: value, - child: TabRow(key: ValueKey(value), tab: value, color: textColor, withFavicon: isCurrent), - ); - }).toList(); - }, - items: list.map>((SearchTab value) { - final bool isCurrent = list.indexOf(value) == index; - - return DropdownMenuItem( - value: value, - child: Container( - padding: isDesktop ? const EdgeInsets.all(5) : const EdgeInsets.fromLTRB(5, 10, 5, 10), - decoration: isCurrent - ? BoxDecoration( - border: Border.all(color: Theme.of(context).colorScheme.secondary, width: 1), - borderRadius: BorderRadius.circular(5), - ) - : null, - child: TabRow(key: ValueKey(value), tab: value), + ), + Builder( + builder: (context) { + const double iconSize = 28; + const double btnHeight = 50; + + final toTopBtn = SizedBox( + height: btnHeight, + child: ElevatedButton( + onPressed: scrollToTop, + child: const Icon( + Icons.arrow_circle_up_rounded, + size: iconSize, + ), ), ); - }).toList(), + + final filteredTabsMinusCurrent = [...filteredTabs]..remove(searchHandler.currentTab); + final selectedAll = selectedTabs.length == filteredTabsMinusCurrent.length; + + final selectAllBtn = SizedBox( + height: btnHeight, + child: ElevatedButton( + onPressed: () { + if (selectedAll) { + selectedTabs.clear(); + } else { + selectedTabs = [...filteredTabs]; + selectedTabs.remove(searchHandler.currentTab); + } + setState(() {}); + }, + child: Icon( + selectedAll ? Icons.border_clear : Icons.select_all, + size: iconSize, + ), + ), + ); + + final toCurrentBtn = SizedBox( + height: btnHeight, + child: ElevatedButton( + onPressed: currentTabIndex != -1 ? scrollToCurrent : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.subdirectory_arrow_left_outlined, + size: iconSize, + ), + const SizedBox(width: 4), + Text( + (searchHandler.currentIndex + 1).toFormattedString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: currentTabIndex == -1 ? Colors.transparent : null, + ), + ), + ], + ), + ), + ); + + final bool hasSelected = selectedTabs.isNotEmpty; + final deleteSelectedBtn = SizedBox( + height: btnHeight, + child: ElevatedButton( + onPressed: hasSelected ? showDeleteDialog : null, + child: Row( + children: [ + const Icon( + Icons.delete, + size: iconSize, + ), + const SizedBox(width: 4), + Stack( + children: [ + Text( + selectedTabs.length.toFormattedString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const Text( + '00', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.transparent), + ), + ], + ), + ], + ), + ), + ); + + final toBottomBtn = SizedBox( + height: btnHeight, + child: ElevatedButton( + onPressed: scrollToBottom, + child: const Icon( + Icons.arrow_circle_down_rounded, + size: iconSize, + ), + ), + ); + + return Container( + margin: EdgeInsets.fromLTRB( + 10, + 10, + 10, + 10 + MediaQuery.of(context).padding.bottom, + ), + width: double.infinity, + height: btnHeight, + child: Row( + children: [ + if (settingsHandler.handSide.value.isLeft) ...[ + if (selectMode) ...[ + selectAllBtn, + const SizedBox(width: 6), + deleteSelectedBtn, + const SizedBox(width: 6), + ] else ...[ + toBottomBtn, + const SizedBox(width: 6), + toCurrentBtn, + const SizedBox(width: 6), + toTopBtn, + const SizedBox(width: 6), + ], + ], + Expanded( + child: SizedBox( + height: btnHeight, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon( + Icons.close, + size: iconSize, + ), + label: const AutoSizeText( + 'Close', + maxLines: 1, + overflowReplacement: SizedBox.shrink(), + ), + ), + ), + ), + if (settingsHandler.handSide.value.isRight) ...[ + if (selectMode) ...[ + const SizedBox(width: 6), + deleteSelectedBtn, + const SizedBox(width: 6), + selectAllBtn, + ] else ...[ + const SizedBox(width: 6), + toTopBtn, + const SizedBox(width: 6), + toCurrentBtn, + const SizedBox(width: 6), + toBottomBtn, + ], + ], + ], + ), + ); + }, ), + ], + ), + ); + } +} + +class TabManagerItem extends StatelessWidget { + const TabManagerItem({ + required this.tab, + this.index, + this.isCurrent = false, + this.showPlaceholders = false, + this.firstRender = false, + this.isFiltered = false, + this.originalIndex, + this.onTap, + this.optionsWidgetBuilder, + this.onOptionsTap, + this.onCloseTap, + this.filterText, + super.key, + }) : assert( + !isFiltered || (index != null && originalIndex != null), + 'originalIndex must be provided if isFiltered is true', + ); + + final SearchTab tab; + final int? index; + final bool isCurrent; + final bool showPlaceholders; + final bool firstRender; + final bool isFiltered; + final int? originalIndex; + final VoidCallback? onTap; + final Widget Function(BuildContext, VoidCallback?)? optionsWidgetBuilder; + final VoidCallback? onOptionsTap; + final VoidCallback? onCloseTap; + final String? filterText; + + @override + Widget build(BuildContext context) { + // print('tab selector item build $index $showPlaceholders'); + + final BorderRadius radius = BorderRadius.circular(10); + + final subtitleStyle = Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).textTheme.bodySmall!.color, ); - }), + + if (firstRender) { + return const SizedBox(height: 80); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 72, + width: double.maxFinite, + child: Material( + color: Theme.of(context).cardColor, + shape: RoundedRectangleBorder( + borderRadius: radius, + side: isCurrent + ? BorderSide( + color: Theme.of(context).colorScheme.secondary, + width: 2, + ) + : BorderSide.none, + ), + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: showPlaceholders + ? Container( + decoration: BoxDecoration( + borderRadius: radius, + ), + clipBehavior: Clip.hardEdge, + child: const ShimmerCard(), + ) + : Padding( + padding: const EdgeInsets.only( + left: 12, + right: 12, + top: 2, + bottom: 6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + flex: 2, + child: Row( + children: [ + Expanded( + child: TabRow( + tab: tab, + filterText: filterText, + ), + ), + if (onOptionsTap != null) ...[ + const SizedBox(width: 4), + optionsWidgetBuilder?.call(context, onOptionsTap) ?? + IconButton( + onPressed: onOptionsTap, + icon: const Icon(CupertinoIcons.slider_horizontal_3), + ), + ], + if (onCloseTap != null) ...[ + if (onOptionsTap == null) const SizedBox(width: 4) else const SizedBox(width: 8), + IconButton( + onPressed: onCloseTap, + icon: const Icon( + Icons.close, + ), + ), + ], + ], + ), + ), + Expanded( + flex: 1, + child: Obx( + () { + final int totalCount = tab.booruHandler.totalCount.value; + + return Row( + children: [ + Expanded( + child: SizedBox( + height: subtitleStyle.fontSize, + child: Builder( + builder: (context) { + final List booruNames = [ + tab.booruHandler.booru.name ?? '', + if (tab.secondaryBoorus != null) + for (final booru in tab.secondaryBoorus!) booru.name ?? '', + ]; + final String booruNamesStr = booruNames.join(', '); + + return MarqueeText( + text: booruNamesStr.trim(), + style: subtitleStyle, + allowDownscale: false, + isExpanded: false, + ); + }, + ), + ), + ), + const SizedBox(width: 4), + if (totalCount > 0) ...[ + Icon( + Icons.image, + size: 16, + color: subtitleStyle.color, + ), + const SizedBox(width: 2), + Text( + '${totalCount.toFormattedString()} | ', + style: subtitleStyle, + ), + ], + if (index != null) + Text( + '#${(index! + 1).toFormattedString()}${originalIndex != null ? '|${(originalIndex! + 1).toFormattedString()}' : ''}', + style: subtitleStyle, + ), + const SizedBox(width: 8), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ), ); } } diff --git a/lib/src/widgets/tags_filters/tf_list_item.dart b/lib/src/widgets/tags_filters/tf_list_item.dart index 06f773a3..f57855b5 100644 --- a/lib/src/widgets/tags_filters/tf_list_item.dart +++ b/lib/src/widgets/tags_filters/tf_list_item.dart @@ -26,10 +26,12 @@ class TagsFiltersListItem extends StatelessWidget { ), onTap: () => onTap?.call(tag), leading: overrideIcon ?? const Icon(CupertinoIcons.tag), - title: MarqueeText( - text: tag, - fontSize: 16, - isExpanded: false, + title: SizedBox( + height: 16, + child: MarqueeText( + text: tag, + isExpanded: false, + ), ), ), ); diff --git a/lib/src/widgets/tags_filters/tf_settings_list.dart b/lib/src/widgets/tags_filters/tf_settings_list.dart index 4a9bc7ff..31475ff0 100644 --- a/lib/src/widgets/tags_filters/tf_settings_list.dart +++ b/lib/src/widgets/tags_filters/tf_settings_list.dart @@ -1,6 +1,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; class TagsFiltersSettingsList extends StatelessWidget { @@ -10,6 +12,10 @@ class TagsFiltersSettingsList extends StatelessWidget { required this.onFilterHatedChanged, required this.filterFavourites, required this.onFilterFavouritesChanged, + required this.filterSnatched, + required this.onFilterSnatchedChanged, + required this.filterAi, + required this.onFilterAiChanged, super.key, }); @@ -18,6 +24,10 @@ class TagsFiltersSettingsList extends StatelessWidget { final Function(bool) onFilterHatedChanged; final bool filterFavourites; final Function(bool) onFilterFavouritesChanged; + final bool filterSnatched; + final Function(bool) onFilterSnatchedChanged; + final bool filterAi; + final Function(bool) onFilterAiChanged; @override Widget build(BuildContext context) { @@ -38,6 +48,18 @@ class TagsFiltersSettingsList extends StatelessWidget { onChanged: onFilterFavouritesChanged, trailingIcon: const Icon(Icons.favorite, color: Colors.red), ), + SettingsToggle( + title: 'Remove Snatched Items', + value: filterSnatched, + onChanged: onFilterSnatchedChanged, + trailingIcon: const Icon(Icons.file_download_outlined), + ), + SettingsToggle( + title: 'Remove AI Items', + value: filterAi, + onChanged: onFilterAiChanged, + trailingIcon: const FaIcon(FontAwesomeIcons.robot, size: 20), + ), ], ); } diff --git a/lib/src/widgets/tags_manager/tm_dialog.dart b/lib/src/widgets/tags_manager/tm_dialog.dart index 91e482ff..1a4dd59c 100644 --- a/lib/src/widgets/tags_manager/tm_dialog.dart +++ b/lib/src/widgets/tags_manager/tm_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lolisnatcher/src/data/tag.dart'; import 'package:lolisnatcher/src/data/tag_type.dart'; +import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/handlers/tag_handler.dart'; import 'package:lolisnatcher/src/widgets/common/settings_widgets.dart'; import 'package:lolisnatcher/src/widgets/tags_manager/tm_add_dialog.dart'; @@ -19,6 +20,7 @@ class TagsManagerDialog extends StatefulWidget { } class _TagsManagerDialogState extends State { + final SettingsHandler settingsHandler = SettingsHandler.instance; final TagHandler tagHandler = TagHandler.instance; List tags = [], filteredTags = [], selected = []; @@ -78,6 +80,8 @@ class _TagsManagerDialogState extends State { } void showItemActions(Widget row, Tag item) { + final bool dbEnabled = settingsHandler.dbEnabled; + showDialog( context: context, builder: (context) { @@ -91,23 +95,23 @@ class _TagsManagerDialogState extends State { onChangedType: (TagType? newValue) { if (newValue != null && item.tagType != newValue) { item.tagType = newValue; - tagHandler.putTag(item); + tagHandler.putTag(item, dbEnabled: dbEnabled); filterTags(); } }, onSetStale: () { item.updatedAt = 100; - tagHandler.putTag(item); + tagHandler.putTag(item, dbEnabled: dbEnabled); filterTags(); }, onResetStale: () { item.updatedAt = DateTime.now().millisecondsSinceEpoch; - tagHandler.putTag(item); + tagHandler.putTag(item, dbEnabled: dbEnabled); filterTags(); }, onSetUnstaleable: () { item.updatedAt = DateTime.now().millisecondsSinceEpoch * 10; - tagHandler.putTag(item); + tagHandler.putTag(item, dbEnabled: dbEnabled); filterTags(); }, ); @@ -124,7 +128,7 @@ class _TagsManagerDialogState extends State { ); if (tag != null && !tagHandler.hasTag(tag.fullString)) { - await tagHandler.putTag(tag); + await tagHandler.putTag(tag, dbEnabled: settingsHandler.dbEnabled); tags.add(tag); filterTags(); } diff --git a/lib/src/widgets/tags_manager/tm_list_item.dart b/lib/src/widgets/tags_manager/tm_list_item.dart index e85c6b69..88395c46 100644 --- a/lib/src/widgets/tags_manager/tm_list_item.dart +++ b/lib/src/widgets/tags_manager/tm_list_item.dart @@ -39,22 +39,23 @@ class TagsManagerListItem extends StatelessWidget { borderRadius: BorderRadius.circular(5), ), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onTap != null) - Checkbox( + trailing: onTap != null + ? Checkbox( value: isSelected, onChanged: onSelect, - ), - ], - ), - title: MarqueeText( - key: ValueKey(tag.fullString), - text: tag.fullString, - fontSize: 16, - fontWeight: FontWeight.bold, - isExpanded: false, + ) + : null, + title: SizedBox( + height: 16, + child: MarqueeText( + key: ValueKey(tag.fullString), + text: tag.fullString, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + isExpanded: false, + ), ), subtitle: Text('${tag.tagType} $staleText'.trim()), ), diff --git a/lib/src/widgets/thumbnail/thumbnail.dart b/lib/src/widgets/thumbnail/thumbnail.dart index 32fbc848..2b362271 100644 --- a/lib/src/widgets/thumbnail/thumbnail.dart +++ b/lib/src/widgets/thumbnail/thumbnail.dart @@ -8,7 +8,6 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:lolisnatcher/src/data/booru_item.dart'; -import 'package:lolisnatcher/src/handlers/navigation_handler.dart'; import 'package:lolisnatcher/src/handlers/search_handler.dart'; import 'package:lolisnatcher/src/handlers/settings_handler.dart'; import 'package:lolisnatcher/src/utils/debouncer.dart'; @@ -22,7 +21,6 @@ class Thumbnail extends StatefulWidget { const Thumbnail({ required this.item, required this.isStandalone, - this.ignoreColumnsCount = false, super.key, }); @@ -30,7 +28,6 @@ class Thumbnail extends StatefulWidget { /// set to true when used in gallery preview to enable hero animation final bool isStandalone; - final bool ignoreColumnsCount; @override State createState() => _ThumbnailState(); @@ -44,21 +41,20 @@ class _ThumbnailState extends State { int restartedCount = 0; bool? isFromCache; // isFailed - loading error, isVisible - controls fade in - bool isFailed = false, isLoaded = false, isLoadedExtra = false, failedRendering = false; + bool isFailed = false, isLoaded = false, isLoadedExtra = false, failedRendering = false, firstBuild = true; String? errorCode; CancelToken? cancelToken; bool? isThumbQuality; late String thumbURL; late String thumbFolder; + double? thumbWidth, thumbHeight; ImageProvider? mainProvider; ImageProvider? extraProvider; ImageStreamListener? mainImageListener, extraImageListener; ImageStream? mainImageStream, extraImageStream; - StreamSubscription? hateListener; - @override void didUpdateWidget(Thumbnail oldWidget) { // force redraw on tab change @@ -70,14 +66,6 @@ class _ThumbnailState extends State { super.didUpdateWidget(oldWidget); } - int columnsCount() { - if (widget.ignoreColumnsCount) { - return 1; - } - - return settingsHandler.currentColumnCount(context); - } - Future getImageProvider(bool isMain) async { // if(widget.item.isHated.value) { // // pixelate hated images @@ -104,48 +92,6 @@ class _ThumbnailState extends State { }, ); - double? thumbWidth; - double? thumbHeight; - if (mounted) { - // mediaquery will throw an exception if we try to read it after disposing => check if mounted - final MediaQueryData mQuery = NavigationHandler.instance.navigatorKey.currentContext!.mediaQuery; - final double widthLimit = (mQuery.size.width / columnsCount()) * mQuery.devicePixelRatio * 1; - double thumbRatio = 1; - final bool hasSizeData = widget.item.fileHeight != null && widget.item.fileWidth != null; - - if (widget.isStandalone) { - thumbWidth = widthLimit; - } else { - switch (settingsHandler.previewDisplay) { - case 'Rectangle': - case 'Staggered': - // thumbRatio = 16 / 9; - if (hasSizeData) { - // if api gives size data - thumbRatio = widget.item.fileAspectRatio!; - if (thumbRatio < 1) { - // vertical image - resize to width - thumbWidth = widthLimit; - } else { - // horizontal image - resize to height - thumbHeight = widthLimit * thumbRatio; - } - } else { - thumbWidth = widthLimit; - } - break; - - case 'Square': - default: - // otherwise resize to widthLimit - thumbWidth = widthLimit; - break; - } - } - } - - // debugPrint('ThumbWidth: $thumbWidth'); - // return empty image if no size rectrictions were calculated (propably happens because widget is not mounted) if (settingsHandler.disableImageScaling || (thumbWidth == null && thumbHeight == null)) { return provider; @@ -159,6 +105,50 @@ class _ThumbnailState extends State { ); } + void calcThumbWidth(BoxConstraints constraints) { + final double? prevThumbWidth = thumbWidth, prevThumbHeight = thumbHeight; + + final double widthLimit = constraints.maxWidth * MediaQuery.of(context).devicePixelRatio * 1; + double thumbRatio = 1; + final bool hasSizeData = widget.item.fileHeight != null && widget.item.fileWidth != null; + + if (widget.isStandalone) { + thumbWidth = widthLimit; + } else { + switch (settingsHandler.previewDisplay) { + case 'Rectangle': + case 'Staggered': + // thumbRatio = 16 / 9; + if (hasSizeData) { + // if api gives size data + thumbRatio = widget.item.fileAspectRatio!; + if (thumbRatio < 1) { + // vertical image - resize to width + thumbWidth = widthLimit; + } else { + // horizontal image - resize to height + thumbHeight = widthLimit * thumbRatio; + } + } else { + thumbWidth = widthLimit; + } + break; + + case 'Square': + default: + // otherwise resize to widthLimit + thumbWidth = widthLimit; + break; + } + } + + // print('thumbWidth: $thumbWidth thumbHeight: $thumbHeight'); + + if (prevThumbHeight != thumbHeight || prevThumbWidth != thumbWidth) { + updateState(postFrame: true); + } + } + void onBytesAdded(int receivedNew, int? totalNew) { received.value = receivedNew; total.value = totalNew ?? 0; @@ -187,32 +177,19 @@ class _ThumbnailState extends State { } else { errorCode = null; } - if (delayed) { - // onError can happen while widget restates, which will cause an exception, this will delay the restate until the other one is done - WidgetsBinding.instance.addPostFrameCallback((_) { - updateState(); - }); - } else { - updateState(); - } - // this.mounted prevents exceptions when using staggered view + // onError can happen while widget restates, which will cause an exception, this will delay the restate until the other one is done + updateState(postFrame: delayed); } // print('Dio request cancelled: $thumbURL $error'); } } - @override - void initState() { - super.initState(); - selectThumbProvider(); - } - void selectThumbProvider() { startedAt.value = DateTime.now().millisecondsSinceEpoch; // if scaling is disabled - allow gifs as thumbnails, but only if they are not hated (resize image doesnt work with gifs) - final bool isGifSampleNotAllowed = widget.item.mediaType.value.isAnimation && - ((settingsHandler.disableImageScaling && settingsHandler.gifsAsThumbnails) ? widget.item.isHated.value : true); + final bool isGifSampleNotAllowed = + widget.item.mediaType.value.isAnimation && ((settingsHandler.disableImageScaling && settingsHandler.gifsAsThumbnails) ? widget.item.isHated : true); isThumbQuality = settingsHandler.previewMode == 'Thumbnail' || (isGifSampleNotAllowed || widget.item.mediaType.value.isVideo || widget.item.mediaType.value.isNeedsExtraRequest) || @@ -220,15 +197,6 @@ class _ThumbnailState extends State { thumbURL = isThumbQuality == true ? widget.item.thumbnailURL : widget.item.sampleURL; thumbFolder = isThumbQuality == true ? 'thumbnails' : 'samples'; - // restart loading if item was marked as hated - hateListener = widget.item.isHated.listen((bool value) { - if (value == true) { - WidgetsBinding.instance.addPostFrameCallback((_) { - restartLoading(); - }); - } - }); - // delay loading a little to improve performance when scrolling fast, ignore delay if it's a standalone widget (i.e. not in a list) Debounce.debounce( tag: 'thumbnail_start_${searchHandler.currentTab.id}#${searchHandler.getItemIndex(widget.item)}', @@ -239,7 +207,7 @@ class _ThumbnailState extends State { } Future startDownloading() async { - final bool useExtra = isThumbQuality == false && !widget.item.isHated.value; + final bool useExtra = isThumbQuality == false && !widget.item.isHated; mainProvider = await getImageProvider(true); mainImageStream?.removeListener(mainImageListener!); @@ -306,8 +274,6 @@ class _ThumbnailState extends State { isFailed = false; errorCode = null; - hateListener?.cancel(); - updateState(); selectThumbProvider(); @@ -326,8 +292,18 @@ class _ThumbnailState extends State { } } - void updateState() { - if (mounted) setState(() {}); + void updateState({bool postFrame = false}) { + if (postFrame) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() {}); + } + }); + } else { + if (mounted) { + setState(() {}); + } + } } @override @@ -361,131 +337,140 @@ class _ThumbnailState extends State { Debounce.cancel('thumbnail_reload_${searchHandler.currentTab.id}#${searchHandler.getItemIndex(widget.item)}'); } - Widget renderImages(BuildContext context) { - final double screenWidth = MediaQuery.of(context).size.width; - final double iconSize = (screenWidth / columnsCount()) * 0.75; - - final bool showShimmer = !(isLoaded || isLoadedExtra) && !isFailed; - final bool useExtra = isThumbQuality == false && !widget.item.isHated.value; - - const double fullOpacity = 1; - - return Stack( - alignment: Alignment.center, - children: [ - if (useExtra) // fetch small low quality thumbnail while loading a sample - AnimatedOpacity( - // fade in image - opacity: !widget.isStandalone ? fullOpacity : (isLoadedExtra ? fullOpacity : 0), - duration: const Duration(milliseconds: 200), - child: AnimatedSwitcher( - duration: Duration(milliseconds: widget.isStandalone ? 100 : 0), - child: extraProvider != null - ? ImageFiltered( - enabled: widget.item.isHated.value, - imageFilter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - tileMode: TileMode.decal, - ), - child: Image( - image: extraProvider!, - fit: widget.isStandalone ? BoxFit.cover : BoxFit.contain, - isAntiAlias: true, - width: double.infinity, - height: double.infinity, - errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - if (widget.isStandalone) { - return Icon(Icons.broken_image, size: 30, color: Colors.yellow.withOpacity(0.5)); - } else { - return const SizedBox.shrink(); - } - }, - ), - ) - : const SizedBox.expand(), + @override + Widget build(BuildContext context) { + final Widget imageStack = LayoutBuilder( + builder: (context, constraints) { + calcThumbWidth(constraints); + if (firstBuild) { + firstBuild = false; + selectThumbProvider(); + } + + final double iconSize = constraints.maxWidth * 0.75; + + final bool showShimmer = !(isLoaded || isLoadedExtra) && !isFailed; + final bool useExtra = isThumbQuality == false && !widget.item.isHated; + + const double fullOpacity = 1; + + return Stack( + alignment: Alignment.center, + children: [ + if (useExtra) // fetch small low quality thumbnail while loading a sample + AnimatedOpacity( + // fade in image + opacity: !widget.isStandalone ? fullOpacity : (isLoadedExtra ? fullOpacity : 0), + duration: const Duration(milliseconds: 200), + child: AnimatedSwitcher( + duration: Duration(milliseconds: widget.isStandalone ? 100 : 0), + child: extraProvider != null + ? ImageFiltered( + enabled: widget.item.isHated, + imageFilter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + tileMode: TileMode.decal, + ), + child: Image( + image: extraProvider!, + fit: widget.isStandalone ? BoxFit.cover : BoxFit.contain, + isAntiAlias: true, + width: double.infinity, + height: double.infinity, + errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { + if (widget.isStandalone) { + return Icon(Icons.broken_image, size: 30, color: Colors.yellow.withOpacity(0.5)); + } else { + return const SizedBox.shrink(); + } + }, + ), + ) + : const SizedBox.expand(), + ), + ), + AnimatedOpacity( + // fade in image + opacity: !widget.isStandalone ? fullOpacity : (isLoaded ? fullOpacity : 0), + duration: const Duration(milliseconds: 300), + child: AnimatedSwitcher( + duration: Duration(milliseconds: widget.isStandalone ? 200 : 0), + child: mainProvider != null + ? ImageFiltered( + enabled: widget.item.isHated, + imageFilter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + tileMode: TileMode.decal, + ), + child: Image( + image: mainProvider!, + fit: widget.isStandalone ? BoxFit.cover : BoxFit.contain, + isAntiAlias: true, + filterQuality: FilterQuality.medium, + width: double.infinity, + height: double.infinity, + errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { + if (widget.isStandalone) { + return Icon(Icons.broken_image, size: 30, color: Colors.white.withOpacity(0.5)); + } else { + return const SizedBox.shrink(); + } + }, + ), + ) + : const SizedBox.expand(), + ), ), - ), - AnimatedOpacity( - // fade in image - opacity: !widget.isStandalone ? fullOpacity : (isLoaded ? fullOpacity : 0), - duration: const Duration(milliseconds: 300), - child: AnimatedSwitcher( - duration: Duration(milliseconds: widget.isStandalone ? 200 : 0), - child: mainProvider != null - ? ImageFiltered( - enabled: widget.item.isHated.value, - imageFilter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - tileMode: TileMode.decal, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: (isLoaded || isLoadedExtra) + ? const SizedBox.shrink() + : ShimmerCard( + isLoading: showShimmer, + child: showShimmer ? null : Container(), ), - child: Image( - image: mainProvider!, - fit: widget.isStandalone ? BoxFit.cover : BoxFit.contain, - isAntiAlias: true, - filterQuality: FilterQuality.medium, - width: double.infinity, - height: double.infinity, - errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - if (widget.isStandalone) { - return Icon(Icons.broken_image, size: 30, color: Colors.white.withOpacity(0.5)); - } else { - return const SizedBox.shrink(); - } - }, - ), - ) - : const SizedBox.expand(), - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: (isLoaded || isLoadedExtra) - ? const SizedBox.shrink() - : ShimmerCard( - isLoading: showShimmer, - child: showShimmer ? null : Container(), - ), - ), - if (widget.isStandalone) - ThumbnailLoading( - item: widget.item, - hasProgress: true, - isFromCache: isFromCache, - isDone: isLoaded && !isFailed, - isFailed: isFailed, - total: total, - received: received, - startedAt: startedAt, - restartAction: () { - restartedCount = 0; - restartLoading(); - }, - errorCode: errorCode, - ), - if (widget.item.isHated.value) - Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(iconSize * 0.1), ), - width: iconSize, - height: iconSize, - child: const Icon(CupertinoIcons.eye_slash, color: Colors.white), - ), - if (settingsHandler.showURLOnThumb) - ColoredBox( - color: Colors.black, - child: Text(thumbURL), - ), - ], + if (widget.isStandalone) + ThumbnailLoading( + item: widget.item, + hasProgress: true, + isFromCache: isFromCache, + isDone: isLoaded && !isFailed, + isFailed: isFailed, + total: total, + received: received, + startedAt: startedAt, + restartAction: () { + restartedCount = 0; + restartLoading(); + }, + errorCode: errorCode, + ), + if (widget.item.isHated) + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(iconSize * 0.1), + ), + width: iconSize, + height: iconSize, + child: const Icon(CupertinoIcons.eye_slash, color: Colors.white), + ), + if (settingsHandler.showURLOnThumb) + ColoredBox( + color: Colors.black, + child: Text(thumbURL), + ), + ], + ); + }, ); - } - @override - Widget build(BuildContext context) { + // print('building thumb ${searchHandler.getItemIndex(widget.item)}'); + if (widget.isStandalone) { return Hero( tag: 'imageHero${searchHandler.getItemIndex(widget.item)}#${widget.item.fileURL}', @@ -494,11 +479,13 @@ class _ThumbnailState extends State { // background of the image gallery return child; }, - child: renderImages(context), + child: imageStack, ); } else { - // print('building thumb ${searchHandler.getItemIndex(widget.item)}'); - return ColoredBox(color: Colors.black, child: renderImages(context)); + return ColoredBox( + color: Colors.black, + child: imageStack, + ); } } } diff --git a/lib/src/widgets/thumbnail/thumbnail_build.dart b/lib/src/widgets/thumbnail/thumbnail_build.dart index 9f08326c..a8724408 100644 --- a/lib/src/widgets/thumbnail/thumbnail_build.dart +++ b/lib/src/widgets/thumbnail/thumbnail_build.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:lolisnatcher/src/data/booru_item.dart'; @@ -23,19 +24,15 @@ class ThumbnailBuild extends StatelessWidget { final SettingsHandler settingsHandler = SettingsHandler.instance; final IconData itemIcon = Tools.getFileIcon(item.possibleExt.value ?? item.mediaType.toJson()); - final List> parsedTags = settingsHandler.parseTagsList( + final tagsData = settingsHandler.parseTagsList( item.tagsList, isCapped: false, ); - final bool isHated = parsedTags[0].isNotEmpty; - final bool isLoved = parsedTags[1].isNotEmpty; - final bool isSound = parsedTags[2].isNotEmpty; + // final bool isHated = tagsData.hatedTags.isNotEmpty; + final bool isLoved = tagsData.lovedTags.isNotEmpty; + final bool isSound = tagsData.soundTags.isNotEmpty; + final bool isAi = tagsData.aiTags.isNotEmpty; final bool hasNotes = item.hasNotes == true; - final bool hasComments = item.hasComments == true; - - // reset the isHated and isLoved values since we already re-check them on every render - item.isHated.value = isHated; - item.isLoved.value = isLoved; // print('ThumbnailBuild $index'); @@ -49,7 +46,8 @@ class ThumbnailBuild extends StatelessWidget { isStandalone: true, ), // Image( - // image: ResizeImage(NetworkImage(item.thumbnailURL), width: (MediaQuery.of(context).size.width * MediaQuery.of(context).devicePixelRatio / 3).round()), + // image: ResizeImage(NetworkImage(item.thumbnailURL), + // width: (MediaQuery.of(context).size.width * MediaQuery.of(context).devicePixelRatio / 3).round()), // fit: BoxFit.cover, // isAntiAlias: true, // filterQuality: FilterQuality.medium, @@ -64,109 +62,117 @@ class ThumbnailBuild extends StatelessWidget { children: [ Obx(() { final selected = searchHandler.currentTab.selected; - if (selected.isNotEmpty) { - final itemIndex = searchHandler.getItemIndex(item); - return Checkbox( - value: selected.contains(itemIndex), - onChanged: (bool? value) { - if (value != null) { - if (value) { - searchHandler.currentTab.selected.add(itemIndex); - } else { - searchHandler.currentTab.selected.remove(itemIndex); - } - } - }, + Widget checkboxWidget = const SizedBox.shrink(); + if (selected.isNotEmpty) { + final isSelected = selected.contains(item); + final int selectedIndex = selected.indexOf(item); + + checkboxWidget = Container( + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.66), + borderRadius: const BorderRadius.only(topRight: Radius.circular(5)), + ), + child: Row( + children: [ + Checkbox( + value: isSelected, + onChanged: (bool? value) { + if (value != null) { + if (value) { + searchHandler.currentTab.selected.add(item); + } else { + searchHandler.currentTab.selected.remove(item); + } + } + }, + ), + if (isSelected) + Text( + (selectedIndex + 1).toString(), + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ], + ), ); - } else { - return const SizedBox(); } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: checkboxWidget, + ); }), // - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.66), - borderRadius: const BorderRadius.only(topLeft: Radius.circular(5)), - ), - child: Obx( - () => Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Text(' ${(index + 1)} ', style: TextStyle(fontSize: 10, color: Colors.white)), - - if (item.isFavourite.value == null) const Text('.'), - - AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: (item.isFavourite.value == true || isLoved) ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: AnimatedSwitcher( + Flexible( + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.66), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(5)), + ), + child: Obx( + () => Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 1.5, + runSpacing: 2, + children: [ + AnimatedCrossFade( duration: const Duration(milliseconds: 200), - child: Icon( - item.isFavourite.value == true ? Icons.favorite : Icons.star, - color: item.isFavourite.value == true ? Colors.red : Colors.grey, - key: ValueKey(item.isFavourite.value == true ? Colors.red : Colors.grey), - size: 14, + crossFadeState: (item.isFavourite.value == true || isLoved) ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: (settingsHandler.dbEnabled && item.isFavourite.value == null) + ? const SizedBox( + height: 14, + width: 14, + child: Center( + child: Text( + '.', + style: TextStyle(fontSize: 14, height: 1), + ), + ), + ) + : Icon( + item.isFavourite.value == true ? Icons.favorite : Icons.star, + color: item.isFavourite.value == true ? Colors.red : Colors.grey, + key: ValueKey(item.isFavourite.value == true ? Colors.red : Colors.grey), + size: 14, + ), ), + secondChild: const SizedBox(), ), - secondChild: const SizedBox(), - ), - - if (item.isSnatched.value == true) - const Icon( - Icons.save_alt, - color: Colors.white, - size: 14, - ), - - if (isSound) - const Icon( - Icons.volume_up_rounded, - color: Colors.white, - size: 14, - ), - - if (hasNotes) - const Icon( - Icons.note_add, - color: Colors.white, - size: 14, - ), - - Icon( - itemIcon, - color: Colors.white, - size: 14, - ), - - if (settingsHandler.isDebug.value) - Container( - color: Colors.grey, - width: 1, - height: 10, - margin: const EdgeInsets.symmetric(horizontal: 2), - ), - - if (settingsHandler.isDebug.value) + if (item.isSnatched.value == true) + const Icon( + Icons.save_alt, + color: Colors.white, + size: 14, + ), + if (isAi) + const FaIcon( + FontAwesomeIcons.robot, + color: Colors.white, + size: 13, + ), + if (hasNotes) + const Icon( + Icons.note_add, + color: Colors.white, + size: 14, + ), + if (isSound) + const Icon( + Icons.volume_up_rounded, + color: Colors.white, + size: 14, + ), Icon( - Icons.crop_original, - color: (item.sampleURL == item.thumbnailURL - ? Colors.red - : item.sampleURL == item.fileURL - ? Colors.green - : Colors.white) - .withOpacity(0.66), - size: 14, - ), - - if (settingsHandler.isDebug.value && hasComments) - const Icon( - Icons.comment, + itemIcon, color: Colors.white, size: 14, ), - ], + ], + ), ), ), ), diff --git a/lib/src/widgets/thumbnail/thumbnail_card_build.dart b/lib/src/widgets/thumbnail/thumbnail_card_build.dart index 064e99f8..cecf33b1 100644 --- a/lib/src/widgets/thumbnail/thumbnail_card_build.dart +++ b/lib/src/widgets/thumbnail/thumbnail_card_build.dart @@ -34,21 +34,21 @@ class ThumbnailCardBuild extends StatelessWidget { final SearchHandler searchHandler = SearchHandler.instance; // print('ThumbnailCardBuild: $index'); - return Obx(() { - final bool isSelected = searchHandler.currentTab.selected.contains(index); - final bool isCurrent = settingsHandler.appMode.value.isDesktop && (searchHandler.viewedIndex.value == index); - // print('ThumbnailCardBuild obx: $index'); + return AutoScrollTag( + highlightColor: Colors.red, + key: ValueKey(index), + controller: searchHandler.gridScrollController, + index: index, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(4), + child: Obx(() { + // print('ThumbnailCardBuild obx: $index'); + final bool isSelected = searchHandler.currentTab.selected.contains(item); + final bool isCurrent = settingsHandler.appMode.value.isDesktop && (searchHandler.viewedIndex.value == index); - return AutoScrollTag( - highlightColor: Colors.red, - key: ValueKey(index), - controller: searchHandler.gridScrollController, - index: index, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(4), - child: Ink( + return Ink( decoration: (isCurrent || isSelected) ? BoxDecoration( borderRadius: BorderRadius.circular(4), @@ -79,9 +79,9 @@ class ThumbnailCardBuild extends StatelessWidget { // TODO make inkwell ripple work with thumbnail (currently can't just use stack because thumbnail must be clickable too (i.e. checkbox)) child: ThumbnailBuild(item: item), ), - ), - ), - ); - }); + ); + }), + ), + ); } } diff --git a/lib/src/widgets/video/guess_extension_viewer.dart b/lib/src/widgets/video/guess_extension_viewer.dart index 0f50bd6e..16eba3eb 100644 --- a/lib/src/widgets/video/guess_extension_viewer.dart +++ b/lib/src/widgets/video/guess_extension_viewer.dart @@ -102,7 +102,6 @@ class _GuessExtensionViewerState extends State { Thumbnail( item: widget.item, isStandalone: false, - ignoreColumnsCount: true, ), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), diff --git a/lib/src/widgets/video/loli_controls.dart b/lib/src/widgets/video/loli_controls.dart index f65e643e..0aabce64 100644 --- a/lib/src/widgets/video/loli_controls.dart +++ b/lib/src/widgets/video/loli_controls.dart @@ -193,7 +193,7 @@ class _LoliControlsState extends State with SingleTickerProviderSt ), ], ), - ) + ), ], ), ); @@ -816,7 +816,7 @@ class _PlaybackSpeedDialog extends StatelessWidget { ); }, itemCount: _speeds.length, - ) + ), ], ); } diff --git a/lib/src/widgets/video/unknown_viewer_placeholder.dart b/lib/src/widgets/video/unknown_viewer_placeholder.dart index 0414ecd4..3c89fd5d 100644 --- a/lib/src/widgets/video/unknown_viewer_placeholder.dart +++ b/lib/src/widgets/video/unknown_viewer_placeholder.dart @@ -25,7 +25,6 @@ class UnknownViewerPlaceholder extends StatelessWidget { Thumbnail( item: item, isStandalone: false, - ignoreColumnsCount: true, ), LayoutBuilder( builder: (BuildContext layoutContext, BoxConstraints constraints) { diff --git a/lib/src/widgets/video/video_viewer.dart b/lib/src/widgets/video/video_viewer.dart index 8a9b55e9..2ad5e01d 100644 --- a/lib/src/widgets/video/video_viewer.dart +++ b/lib/src/widgets/video/video_viewer.dart @@ -52,7 +52,7 @@ class VideoViewerState extends State { final RxInt total = 0.obs, received = 0.obs, startedAt = 0.obs; int lastViewedIndex = -1; int isTooBig = 0; // 0 = not too big, 1 = too big, 2 = too big, but allow downloading - bool isFromCache = false, isStopped = false, isViewed = false, isZoomed = false; + bool isFromCache = false, isStopped = false, isViewed = false, isZoomed = false, didAutoplay = false; List stopReason = []; StreamSubscription? indexListener; @@ -217,6 +217,7 @@ class VideoViewerState extends State { if (settingsHandler.appMode.value.isMobile ? isCurrentIndex : isCurrentItem) { isViewed = true; } else { + didAutoplay = false; isViewed = false; } @@ -241,9 +242,9 @@ class VideoViewerState extends State { } void initVideo(bool ignoreTagsCheck) { - if (widget.booruItem.isHated.value && !ignoreTagsCheck) { - final List> hatedAndLovedTags = settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true); - killLoading(['Contains Hated tags:', ...hatedAndLovedTags[0]]); + if (widget.booruItem.isHated && !ignoreTagsCheck) { + final tagsData = settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true); + killLoading(['Contains Hated tags:', ...tagsData.hatedTags]); } else { downloadVideo(); } @@ -317,7 +318,7 @@ class VideoViewerState extends State { void onViewStateChanged(PhotoViewControllerValue viewState) { // print(viewState); - viewerHandler.setViewState(widget.key, viewState); + viewerHandler.setViewValue(widget.key, viewState); } void resetZoom() { @@ -507,9 +508,12 @@ class VideoViewerState extends State { if (needsRestart) { videoController!.seekTo(Duration.zero); } - if (settingsHandler.autoPlayEnabled) { - // autoplay if viewed and setting is enabled - videoController!.play(); + if (!didAutoplay) { + if (settingsHandler.autoPlayEnabled) { + // autoplay if viewed and setting is enabled + videoController!.play(); + } + didAutoplay = true; } if (viewerHandler.videoAutoMute) { videoController!.setVolume(0); @@ -543,7 +547,6 @@ class VideoViewerState extends State { : Thumbnail( item: widget.booruItem, isStandalone: false, - ignoreColumnsCount: true, ), ), AnimatedSwitcher( @@ -595,6 +598,7 @@ class VideoViewerState extends State { basePosition: Alignment.center, controller: viewController, scaleStateController: scaleController, + enableTapDragZoom: true, child: Chewie(controller: chewieController!), ), ChewieControllerProvider( @@ -604,7 +608,7 @@ class VideoViewerState extends State { child: LoliControls(), ), ), - ) + ), ], ), ) diff --git a/lib/src/widgets/video/video_viewer_desktop.dart b/lib/src/widgets/video/video_viewer_desktop.dart index 6592ab0c..c68b9ff8 100644 --- a/lib/src/widgets/video/video_viewer_desktop.dart +++ b/lib/src/widgets/video/video_viewer_desktop.dart @@ -41,7 +41,7 @@ class VideoViewerDesktopState extends State { final RxInt _total = 0.obs, _received = 0.obs, _startedAt = 0.obs; int _lastViewedIndex = -1; int isTooBig = 0; // 0 = not too big, 1 = too big, 2 = too big, but allow downloading - bool isFromCache = false, isStopped = false, firstViewFix = false, isViewed = false, isZoomed = false, isLoaded = false; + bool isFromCache = false, isStopped = false, firstViewFix = false, isViewed = false, isZoomed = false, isLoaded = false, didAutoplay = false; List stopReason = []; StreamSubscription? indexListener; @@ -226,6 +226,7 @@ class VideoViewerDesktopState extends State { if (settingsHandler.appMode.value.isMobile ? isCurrentIndex : isCurrentItem) { isViewed = true; } else { + didAutoplay = false; isViewed = false; } @@ -248,9 +249,9 @@ class VideoViewerDesktopState extends State { } void initVideo(bool ignoreTagsCheck) { - if (widget.booruItem.isHated.value && !ignoreTagsCheck) { - final List> hatedAndLovedTags = settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true); - killLoading(['Contains Hated tags:', ...hatedAndLovedTags[0]]); + if (widget.booruItem.isHated && !ignoreTagsCheck) { + final tagsData = settingsHandler.parseTagsList(widget.booruItem.tagsList, isCapped: true); + killLoading(['Contains Hated tags:', ...tagsData.hatedTags]); } else { _downloadVideo(); } @@ -321,7 +322,7 @@ class VideoViewerDesktopState extends State { void onViewStateChanged(PhotoViewControllerValue viewState) { // print(viewState); - viewerHandler.setViewState(widget.key, viewState); + viewerHandler.setViewValue(widget.key, viewState); } void resetZoom() { @@ -362,7 +363,6 @@ class VideoViewerDesktopState extends State { // print('uri: ${widget.booruItem.fileURL}'); media = Media.network( widget.booruItem.fileURL, - extras: await Tools.getFileCustomHeaders(searchHandler.currentBooru, checkForReferer: true), startTime: const Duration(milliseconds: 50), ); } @@ -388,7 +388,6 @@ class VideoViewerDesktopState extends State { // print('uri: ${widget.booruItem.fileURL}'); media = Media.network( Uri.encodeFull(widget.booruItem.fileURL), - extras: await Tools.getFileCustomHeaders(searchHandler.currentBooru, checkForReferer: true), startTime: const Duration(milliseconds: 50), ); } @@ -452,12 +451,14 @@ class VideoViewerDesktopState extends State { firstViewFix = true; } - // TODO managed to fix videos starting, but needs more fixing to make sure everything is okay - if (settingsHandler.autoPlayEnabled) { - // autoplay if viewed and setting is enabled - videoController!.play(); - } else { - videoController!.pause(); + if (!didAutoplay) { + if (settingsHandler.autoPlayEnabled) { + // autoplay if viewed and setting is enabled + videoController!.play(); + } else { + videoController!.pause(); + } + didAutoplay = true; } if (viewerHandler.videoAutoMute) { @@ -497,7 +498,6 @@ class VideoViewerDesktopState extends State { Thumbnail( item: widget.booruItem, isStandalone: false, - ignoreColumnsCount: true, ), MediaLoading( item: widget.booruItem, @@ -532,7 +532,6 @@ class VideoViewerDesktopState extends State { volumeThumbColor: accentColor, volumeActiveColor: accentColor, showControls: true, - showFullscreenButton: true, filterQuality: FilterQuality.medium, showTimeLeft: true, ), diff --git a/lib/src/widgets/video/video_viewer_placeholder.dart b/lib/src/widgets/video/video_viewer_placeholder.dart index 943fea6a..4b34eb35 100644 --- a/lib/src/widgets/video/video_viewer_placeholder.dart +++ b/lib/src/widgets/video/video_viewer_placeholder.dart @@ -24,7 +24,6 @@ class VideoViewerPlaceholder extends StatelessWidget { Thumbnail( item: item, isStandalone: false, - ignoreColumnsCount: true, ), // Image.network(item.thumbnailURL, fit: BoxFit.fill), SizedBox( diff --git a/lib/src/widgets/webview/webview_page.dart b/lib/src/widgets/webview/webview_page.dart index 4f67a6be..d4a6ce67 100644 --- a/lib/src/widgets/webview/webview_page.dart +++ b/lib/src/widgets/webview/webview_page.dart @@ -92,39 +92,49 @@ class _InAppWebviewViewState extends State { ), body: Stack( children: [ - InAppWebView( - initialUrlRequest: URLRequest(url: Uri.parse(widget.initialUrl)), - initialOptions: options, - pullToRefreshController: pullToRefreshController, - onWebViewCreated: (webViewController) { - controller.complete(webViewController); - // webViewController.clearCache(); - }, - onLoadStart: (controller, url) { - setState(() { - loadingPercentage = 0; - }); - }, - onProgressChanged: (controller, progress) { - setState(() { - loadingPercentage = progress; - }); - }, - onLoadResource: (controller, res) { - setState(() { - loadingPercentage = 100; - }); - }, - onUpdateVisitedHistory: (controller, url, isReload) { - setState(() { - loadingPercentage = 0; - }); - }, - ), + if (Platform.isAndroid || Platform.isIOS) + InAppWebView( + initialUrlRequest: URLRequest(url: Uri.parse(widget.initialUrl)), + initialOptions: options, + pullToRefreshController: pullToRefreshController, + onWebViewCreated: (webViewController) { + controller.complete(webViewController); + // webViewController.clearCache(); + }, + onLoadStart: (controller, url) { + setState(() { + loadingPercentage = 0; + }); + }, + onProgressChanged: (controller, progress) { + setState(() { + loadingPercentage = progress; + }); + }, + onLoadResource: (controller, res) { + setState(() { + loadingPercentage = 100; + }); + }, + onUpdateVisitedHistory: (controller, url, isReload) { + setState(() { + loadingPercentage = 0; + }); + }, + ) + else + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.8, + child: const Center( + child: Text('Not supported on this device'), + ), + ), + // if (loadingPercentage < 100) LinearProgressIndicator( value: loadingPercentage / 100.0, ), + // if (widget.subtitle != null && !hideSubtitle) Positioned( bottom: MediaQuery.of(context).padding.bottom + 8, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7bef61e2..c9d9e6ff 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,9 +9,7 @@ #include #include #include -#include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) awesome_notifications_registrar = @@ -23,13 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dynamic_color_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); - screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 68a694ef..3aac7e10 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,9 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST awesome_notifications dart_vlc dynamic_color - screen_retriever url_launcher_linux - window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pubspec.yaml b/pubspec.yaml index e2fc6768..d9f57cf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,87 +2,72 @@ name: lolisnatcher description: Booru Client with the ability to batch download Images. publish_to: none -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 2.3.3+183 environment: sdk: ">=2.17.0 <3.0.0" - flutter: ^3.10.6 + flutter: ^3.16.0 dependencies: flutter: sdk: flutter - alice_lightweight: ^3.4.0 - app_links: ^3.4.3 + alice_lightweight: ^3.5.1 + app_links: ^3.4.5 auto_size_text: ^3.0.0 awesome_notifications: ^0.8.2 - chewie: ^1.5.0 - crypto: ^3.0.2 - cupertino_icons: ^1.0.5 - # dart_vlc: ^0.3.0 - dart_vlc: - git: - # 0.3.0 - synced with original github repo, they now use higher version of vlclib - url: "https://github.com/NANI-SORE/dart_vlc.git" - ref: 5fbe5c8fb2dd9991ccc440347e51110001aa9435 - dio: ^5.3.2 + chewie: ^1.7.1 + crypto: ^3.0.3 + cupertino_icons: ^1.0.6 + dart_vlc: ^0.4.0 # TODO move to media_kit (see relevant PR) + dio: ^5.3.3 dropdown_search: ^5.0.6 - dynamic_color: ^1.6.6 + dynamic_color: ^1.6.8 fast_marquee: git: # url: "https://github.com/hacker1024/fast_marquee.git" # original url: "https://github.com/NANI-SORE/fast_marquee.git" ref: 828e79014ed5fd73062d6a24db14b6c114ac175b # master - forked to add richtext - flash: ^3.0.5+1 + flash: ^3.0.5+2 flex_color_picker: ^3.3.0 flutter_displaymode: ^0.6.0 flutter_improved_scrolling: ^0.0.3 - flutter_inappwebview: ^5.7.2+3 + flutter_inappwebview: ^5.8.0 flutter_inner_drawer: git: url: "https://github.com/NANI-SORE/flutter_inner_drawer.git" - ref: 8e50920055dcb04b6b58560629daced7b86be36e # master + ref: 781d16e05e987e3873c1f1562465079f10e390e7 # master flutter_linkify: ^6.0.0 - font_awesome_flutter: ^10.5.0 - get: ^4.6.5 + font_awesome_flutter: ^10.6.0 + get: ^4.6.6 google_fonts: ^6.1.0 html: ^0.15.4 huge_listview: ^2.0.11 - image: ^4.0.15 + image: ^4.1.3 intl: ^0.18.1 keyboard_actions: ^4.2.0 logger_fork: ^1.2.0 logger_flutter_fork: ^1.3.1 - # local_auth: ^2.1.0 - # marquee: ^2.2.2 # replaced with fast_marquee - path_provider: ^2.1.0 + # local_auth: ^2.1.7 + path_provider: ^2.1.1 permission_handler: ^11.0.1 photo_view: # ^0.12.0 git: url: "https://github.com/NANI-SORE/photo_view.git" - ref: a1cf7f4f964c3e58e513d4171159851bbe991ed7 # master + ref: 233a392a891000bd674a0cf91fae9771ffccf9f9 # master preload_page_view: ^0.2.0 scroll_to_index: ^3.0.1 - scrollable_positioned_list: ^0.3.5 - sqflite_common_ffi: ^2.3.0+2 + scrollable_positioned_list: ^0.3.8 + share_plus: ^7.2.1 # not used directly, but is part of some deps (i.e. alice) + sqflite_common_ffi: ^2.3.0+4 sqflite: ^2.3.0 statsfl: ^2.3.0 transparent_image: ^2.0.1 uuid: ^4.2.1 - vibration: ^1.7.5 - video_player: ^2.7.0 + vibration: ^1.8.3 + video_player: ^2.8.1 waterfall_flow: ^3.0.2 - xml: ^6.1.0 + xml: ^6.4.2 @@ -95,24 +80,12 @@ flutter_icons: dev_dependencies: - flutter_lints: ^2.0.2 + flutter_lints: ^3.0.1 flutter_test: sdk: flutter -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# TODO/reminder don't forget to check if the dependencies are updated when changing versions of other packages -# Current list of affected packages: -# - dart_vlc -dependency_overrides: - ffi: ^2.0.1 # to override dart_vlc to use newer ffi to allow installing alice - -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. flutter: # The following line ensures that the Material Icons font is @@ -122,34 +95,3 @@ flutter: assets: - assets/images/drawer_icon.png - assets/images/loading.gif - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index b02c5485..86edc67b 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -91,7 +96,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 20cb6532..31e9ccb6 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,12 +10,9 @@ #include #include #include -#include #include -#include #include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -26,16 +23,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DartVlcPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); - FlutterNativeViewPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterNativeViewPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - ScreenRetrieverPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0001474a..de2277a6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,12 +7,9 @@ list(APPEND FLUTTER_PLUGIN_LIST awesome_notifications dart_vlc dynamic_color - flutter_native_view permission_handler_windows - screen_retriever share_plus url_launcher_windows - window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST