diff --git a/lib/drawer.dart b/lib/drawer.dart index bebcb71..7241b0a 100644 --- a/lib/drawer.dart +++ b/lib/drawer.dart @@ -83,7 +83,6 @@ class MainDrawer extends ConsumerWidget { shadows: [ Shadow( offset: Offset(1.5, 1.5), - blurRadius: 0.5, color: Color.fromARGB(255, 0, 0, 0), ), ], diff --git a/lib/main.dart b/lib/main.dart index 8a1694e..93b7bb3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -298,7 +298,7 @@ class _AppState extends ConsumerState { path: GagakuRoute.proxyHome, pageBuilder: (context, state) => CustomTransitionPage( key: state.pageKey, - child: WebSourceHistory( + child: WebSourceHistoryWidget( controller: _proxycontrollers[0], ), transitionsBuilder: Styles.fadeThroughTransitionBuilder, @@ -309,7 +309,7 @@ class _AppState extends ConsumerState { path: GagakuRoute.proxySaved, pageBuilder: (context, state) => CustomTransitionPage( key: state.pageKey, - child: WebSourceFavorites( + child: WebSourceFavoritesWidget( controller: _proxycontrollers[1], ), transitionsBuilder: Styles.fadeThroughTransitionBuilder, @@ -324,14 +324,14 @@ class _AppState extends ConsumerState { pageBuilder: buildWebMangaViewPage, ), GoRoute( - path: GagakuRoute.webMangaSource, + path: GagakuRoute.webMangaChapter, parentNavigatorKey: _rootNavigatorKey, - pageBuilder: buildWebMangaViewPage, + pageBuilder: buildWebReaderPage, ), GoRoute( - path: GagakuRoute.webMangaFull, + path: GagakuRoute.webMangaSource, parentNavigatorKey: _rootNavigatorKey, - pageBuilder: buildWebReaderPage, + pageBuilder: buildWebMangaViewPage, ), GoRoute( path: GagakuRoute.webMangaSourceChapter, diff --git a/lib/mangadex/library.dart b/lib/mangadex/library.dart index b75155d..aeb716d 100644 --- a/lib/mangadex/library.dart +++ b/lib/mangadex/library.dart @@ -34,134 +34,124 @@ class MangaDexLibraryView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final type = ref.watch(libraryViewTypeProvider); - final currentPage = useState(0); - bool isLoading = false; + final statuses = useMemoized(() => MangaReadingStatus.values.skip(1).toList()); + final initialtype = ref.read(libraryViewTypeProvider); + final tabController = useTabController(initialLength: statuses.length, initialIndex: statuses.indexOf(initialtype)); - final listProvider = ref.watch(_getLibraryListByTypeProvider(type)); + useEffect(() { + void tabCallback() { + ref.read(libraryViewTypeProvider.notifier).state = statuses.elementAt(tabController.index); + } - List children; + tabController.addListener(tabCallback); + return () => tabController.removeListener(tabCallback); + }, [tabController]); - switch (listProvider) { - case AsyncValue(value: final list?): - final titlesProvider = ref.watch(getMangaListByPageProvider(list, currentPage.value)); - - isLoading = titlesProvider.isLoading; - - children = [ - Expanded( - child: switch (titlesProvider) { - AsyncValue(:final error?, :final stackTrace?) => RefreshIndicator( - onRefresh: () async { - ref.read(userLibraryProvider.notifier).clear(); - return ref.refresh(_getLibraryListByTypeProvider(type).future); - }, - child: ErrorList( - error: error, - stackTrace: stackTrace, - message: "getMangaListByPageProvider(${list.toString()}, ${currentPage.value}) failed", - ), - ), - AsyncValue(value: final mangas) => RefreshIndicator( - onRefresh: () async { - ref.read(userLibraryProvider.notifier).clear(); - final lt = ref.read(libraryViewTypeProvider); - return ref.refresh(_getLibraryListByTypeProvider(lt).future); - }, - child: MangaListWidget( - title: Text( - '${list.length} Mangas', - style: const TextStyle(fontSize: 24), - ), - physics: const AlwaysScrollableScrollPhysics(), - controller: controller, - children: [ - if (mangas != null) MangaListViewSliver(items: mangas), - ], - ), - ), - }, - ), - NumberPaginator( - numberPages: max((list.length / MangaDexEndpoints.searchLimit).ceil(), 1), - onPageChange: (int index) { - currentPage.value = index; - }, - ) - ]; - break; - case AsyncValue(:final error?, :final stackTrace?): - children = [ - Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.read(userLibraryProvider.notifier).clear(); - return ref.refresh(_getLibraryListByTypeProvider(type).future); - }, - child: ErrorList( - error: error, - stackTrace: stackTrace, - message: "_getLibraryListByTypeProvider($type) failed", + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScrollConfiguration( + behavior: const MouseTouchScrollBehavior(), + child: TabBar( + tabAlignment: TabAlignment.center, + isScrollable: true, + controller: tabController, + tabs: List.generate( + statuses.length, + (int index) => Tab( + text: statuses.elementAt(index).label, ), ), ), - ]; - break; - case AsyncValue(:final progress): - children = [ - ListSpinner( - progress: progress?.toDouble(), - ) - ]; - break; - } + ), + Expanded( + child: TabBarView( + controller: tabController, + children: [ + for (final type in statuses) + HookConsumer( + builder: (context, ref, child) { + final currentPage = useState(0); + final scrollController = useScrollController(); + final listProvider = ref.watch(_getLibraryListByTypeProvider(type)); + + switch (listProvider) { + case AsyncValue(value: final list?): + final titlesProvider = ref.watch(getMangaListByPageProvider(list, currentPage.value)); - return Scaffold( - body: Center( - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: const Alignment(-0.95, 0.0), - child: DropdownMenu( - initialSelection: type, - enableFilter: false, - enableSearch: false, - requestFocusOnTap: false, - inputDecorationTheme: InputDecorationTheme( - filled: true, - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide( - width: 2.0, - color: theme.colorScheme.inversePrimary, - ), - ), - ), - onSelected: (MangaReadingStatus? status) async { - if (status != null) { - ref.read(libraryViewTypeProvider.notifier).state = status; - currentPage.value = 0; - } - }, - dropdownMenuEntries: List>.generate( - MangaReadingStatus.values.skip(1).length, - (int index) => DropdownMenuEntry( - value: MangaReadingStatus.values.skip(1).elementAt(index), - label: MangaReadingStatus.values.skip(1).elementAt(index).label, - ), - ), - ), + return Column( + children: [ + Expanded( + child: Stack( + children: [ + switch (titlesProvider) { + AsyncValue(:final error?, :final stackTrace?) => RefreshIndicator( + onRefresh: () async { + ref.read(userLibraryProvider.notifier).clear(); + return ref.refresh(_getLibraryListByTypeProvider(type).future); + }, + child: ErrorList( + error: error, + stackTrace: stackTrace, + message: + "getMangaListByPageProvider(${list.toString()}, ${currentPage.value}) failed", + ), + ), + AsyncValue(value: final mangas) => RefreshIndicator( + onRefresh: () async { + ref.read(userLibraryProvider.notifier).clear(); + return ref.refresh(_getLibraryListByTypeProvider(type).future); + }, + child: MangaListWidget( + title: Text( + '${list.length} Mangas', + style: const TextStyle(fontSize: 24), + ), + physics: const AlwaysScrollableScrollPhysics(), + controller: scrollController, + children: [ + if (mangas != null) MangaListViewSliver(items: mangas), + ], + ), + ), + }, + if (titlesProvider.isLoading) ...Styles.loadingOverlay, + ], + ), + ), + NumberPaginator( + numberPages: max((list.length / MangaDexEndpoints.searchLimit).ceil(), 1), + onPageChange: (int index) { + currentPage.value = index; + }, + ) + ], + ); + case AsyncValue(:final error?, :final stackTrace?): + return Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.read(userLibraryProvider.notifier).clear(); + return ref.refresh(_getLibraryListByTypeProvider(type).future); + }, + child: ErrorList( + error: error, + stackTrace: stackTrace, + message: "_getLibraryListByTypeProvider($type) failed", + ), + ), + ); + case AsyncValue(:final progress): + return ListSpinner( + progress: progress?.toDouble(), + ); + } + }, ), - ...children, - ], - ), - if (isLoading) ...Styles.loadingOverlay, - ], + ], + ), ), - ), + ], ); } } diff --git a/lib/mangadex/manga_view.dart b/lib/mangadex/manga_view.dart index 1a08d86..80acc6d 100644 --- a/lib/mangadex/manga_view.dart +++ b/lib/mangadex/manga_view.dart @@ -234,7 +234,6 @@ class MangaDexMangaViewWidget extends HookConsumerWidget { shadows: [ Shadow( offset: Offset(0.5, 0.5), - blurRadius: 1.0, color: Color.fromARGB(255, 0, 0, 0), ), ], @@ -250,7 +249,6 @@ class MangaDexMangaViewWidget extends HookConsumerWidget { shadows: [ Shadow( offset: Offset(1.0, 1.0), - blurRadius: 1.0, color: Color.fromARGB(255, 0, 0, 0), ), ], diff --git a/lib/model.dart b/lib/model.dart index 2481920..c069610 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -26,7 +26,7 @@ abstract class GagakuRoute { static const proxySaved = '/proxy/saved'; static const web = '/read'; static const webManga = '/read/:proxy/:code'; - static const webMangaFull = '/read/:proxy/:code/:chapter/:page'; + static const webMangaChapter = '/read/:proxy/:code/:chapter/:page'; static const webMangaSource = '/read/:source/:url(.*)'; static const webMangaSourceChapter = '/read-chapter/:source/:url(.*)'; diff --git a/lib/ui.dart b/lib/ui.dart index a5227cb..1f5b600 100644 --- a/lib/ui.dart +++ b/lib/ui.dart @@ -72,16 +72,21 @@ class GridAlbumImage extends StatelessWidget { return Material( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), clipBehavior: Clip.antiAlias, - child: ShaderMask( - shaderCallback: (rect) { - return LinearGradient( - begin: gradient, - end: Alignment.bottomCenter, - colors: const [Colors.black, Colors.transparent], - ).createShader(Rect.fromLTRB(0, 0, rect.width, rect.height)); - }, - blendMode: BlendMode.dstIn, - child: child, + child: Stack( + children: [ + child, + SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: FractionalOffset.bottomCenter, + end: gradient, + colors: const [Colors.black, Colors.transparent], + ), + ), + ), + ), + ], ), ); } @@ -107,25 +112,21 @@ class GridAlbumTextBar extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( height: height, - child: Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: !bottom ? const Radius.circular(4) : Radius.zero, - bottom: bottom ? const Radius.circular(4) : Radius.zero, - ), - ), - clipBehavior: Clip.antiAlias, - child: GridTileBar( - leading: leading, - backgroundColor: backgroundColor, - title: Text( - text, - softWrap: true, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - overflow: TextOverflow.fade, - ), + child: GridTileBar( + leading: leading, + backgroundColor: backgroundColor, + title: Text( + text, + softWrap: true, + style: const TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(1.0, 1.0), + color: Color.fromARGB(255, 0, 0, 0), + ), + ], + overflow: TextOverflow.fade, ), ), ), @@ -518,7 +519,6 @@ class TitleFlexBar extends StatelessWidget { shadows: [ Shadow( offset: Offset(1.5, 1.5), - blurRadius: 0.5, color: Color.fromARGB(255, 0, 0, 0), ), ], @@ -660,7 +660,7 @@ class Styles { ), ); - static final coverArtGradientTween = Tween(begin: Alignment.center, end: Alignment.topCenter); + static final coverArtGradientTween = Tween(begin: FractionalOffset.center, end: FractionalOffset.topCenter); static const List loadingOverlay = [ ModalBarrier(dismissible: false, color: Colors.black87), diff --git a/lib/web/favorites.dart b/lib/web/favorites.dart index 5530025..3c0ca59 100644 --- a/lib/web/favorites.dart +++ b/lib/web/favorites.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gagaku/ui.dart'; +import 'package:gagaku/web/model/config.dart'; import 'package:gagaku/web/model/model.dart'; +import 'package:gagaku/web/model/types.dart'; import 'package:gagaku/web/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class WebSourceFavorites extends HookConsumerWidget { - const WebSourceFavorites({ +class WebSourceFavoritesWidget extends HookConsumerWidget { + const WebSourceFavoritesWidget({ super.key, this.controller, }); @@ -14,45 +17,65 @@ class WebSourceFavorites extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final favProvider = ref.watch(webSourceFavoritesProvider); + final cfg = ref.watch(webConfigProvider); + final tabController = useTabController(initialLength: cfg.categories.length, keys: [cfg]); return Material( - child: switch (favProvider) { - AsyncValue(value: final list?) when list.isEmpty => const Center( - child: Text('Add some favorites!'), - ), - AsyncValue(value: final list?) => Column( - children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 10.0), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Favorites', - style: TextStyle(fontSize: 24), - ), - ), - ), - Expanded( - child: WebMangaListWidget( - physics: const AlwaysScrollableScrollPhysics(), - controller: controller, - children: [ - WebMangaListViewSliver( - items: list, - reorderable: true, - showRemoveButton: false, - ), - ], + child: Column( + children: [ + ScrollConfiguration( + behavior: const MouseTouchScrollBehavior(), + child: TabBar( + tabAlignment: TabAlignment.center, + isScrollable: true, + controller: tabController, + tabs: List.generate( + cfg.categories.length, + (int index) => Tab( + text: cfg.categories.elementAt(index).name, ), ), - ], + ), ), - AsyncValue(:final error?, :final stackTrace?) => ErrorColumn(error: error, stackTrace: stackTrace), - _ => const Center( - child: CircularProgressIndicator(), + Expanded( + child: TabBarView( + controller: tabController, + children: [ + for (final cat in cfg.categories) + Consumer( + builder: (context, ref, child) { + final items = ref.watch(webSourceFavoritesProvider.select( + (value) => switch (value) { + AsyncValue(value: final data?) => data.containsKey(cat.id) ? data[cat.id]! : [], + _ => [], + }, + )); + + if (items.isEmpty) { + return const Center( + child: Text('No items'), + ); + } + + return WebMangaListWidget( + physics: const AlwaysScrollableScrollPhysics(), + controller: controller, + children: [ + WebMangaListViewSliver( + items: items, + favoritesKey: cat.id, + reorderable: true, + showRemoveButton: false, + ), + ], + ); + }, + ) + ], + ), ), - }, + ], + ), ); } } diff --git a/lib/web/history.dart b/lib/web/history.dart index fb7a471..6447077 100644 --- a/lib/web/history.dart +++ b/lib/web/history.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gagaku/ui.dart'; +import 'package:gagaku/web/model/config.dart'; import 'package:gagaku/web/model/model.dart'; import 'package:gagaku/web/ui.dart'; import 'package:gagaku/web/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class WebSourceHistory extends HookConsumerWidget { - const WebSourceHistory({ +class WebSourceHistoryWidget extends HookConsumerWidget { + const WebSourceHistoryWidget({ super.key, this.controller, }); @@ -17,6 +18,7 @@ class WebSourceHistory extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final api = ref.watch(proxyProvider); + final cfg = ref.watch(webConfigProvider); final scrollController = controller ?? useScrollController(); final historyProvider = ref.watch(webSourceHistoryProvider); @@ -107,7 +109,10 @@ class WebSourceHistory extends HookConsumerWidget { physics: const AlwaysScrollableScrollPhysics(), controller: scrollController, children: [ - WebMangaListViewSliver(items: history.toList()), + WebMangaListViewSliver( + items: history.toList(), + favoritesKey: cfg.defaultCategory, + ), ], ), ), diff --git a/lib/web/manga_view.dart b/lib/web/manga_view.dart index fee20e6..8ea3b5d 100644 --- a/lib/web/manga_view.dart +++ b/lib/web/manga_view.dart @@ -6,6 +6,7 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:gagaku/model.dart'; import 'package:gagaku/ui.dart'; import 'package:gagaku/util.dart'; +import 'package:gagaku/web/model/config.dart'; import 'package:gagaku/web/model/model.dart'; import 'package:gagaku/web/model/types.dart'; import 'package:go_router/go_router.dart'; @@ -218,7 +219,7 @@ class WebMangaViewWidget extends HookConsumerWidget { useEffect(() { Future.delayed(Duration.zero, () { ref.read(webSourceHistoryProvider.notifier).add(link); - ref.read(webSourceFavoritesProvider.notifier).replace(link); + ref.read(webSourceFavoritesProvider.notifier).updateAll(link); }); return null; }, []); @@ -251,7 +252,6 @@ class WebMangaViewWidget extends HookConsumerWidget { shadows: [ Shadow( offset: Offset(1.0, 1.0), - blurRadius: 1.0, color: Color.fromARGB(255, 0, 0, 0), ), ], @@ -271,41 +271,7 @@ class WebMangaViewWidget extends HookConsumerWidget { OverflowBar( spacing: 8.0, children: [ - Consumer( - builder: (context, ref, child) { - final favorited = ref.watch(webSourceFavoritesProvider.select( - (value) => switch (value) { - AsyncValue(value: final data?) => data.indexWhere((e) => e.url == info.getURL()) > -1, - _ => null, - }, - )); - - if (favorited == null) { - return const SizedBox( - width: 36, - height: 36, - child: Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator(), - ), - ); - } - - return IconButton( - tooltip: favorited ? 'Remove from Favorites' : 'Add to Favorites', - style: Styles.squareIconButtonStyle(backgroundColor: theme.colorScheme.surface.withAlpha(200)), - color: favorited ? theme.colorScheme.primary : null, - onPressed: () async { - if (favorited) { - ref.read(webSourceFavoritesProvider.notifier).remove(link); - } else { - ref.read(webSourceFavoritesProvider.notifier).add(link); - } - }, - icon: Icon(favorited ? Icons.favorite : Icons.favorite_border), - ); - }, - ), + _FavoritesMenu(link: link, info: info), Consumer( builder: (context, ref, child) { final key = info.getKey(); @@ -655,3 +621,86 @@ class ChapterButtonWidget extends HookConsumerWidget { ); } } + +class _FavoritesMenu extends HookConsumerWidget { + const _FavoritesMenu({ + required this.link, + required this.info, + }); + + final HistoryLink link; + final SourceInfo info; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final cfg = ref.watch(webConfigProvider); + + final favorites = ref.watch(webSourceFavoritesProvider.select( + (value) => switch (value) { + AsyncValue(value: final data?) => data, + _ => null, + }, + )); + + final favorited = + useMemoized(() => favorites?.values.any((l) => l.any((e) => e.url == info.getURL())) ?? false, [favorites]); + + return MenuAnchor( + builder: (context, controller, child) { + return Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + child: favorites == null + ? const SizedBox( + width: 36, + height: 36, + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ), + ) + : InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: child, + ), + ); + }, + menuChildren: favorites != null + ? List.generate( + cfg.categories.length, + (index) => Builder( + builder: (context) { + final cat = cfg.categories.elementAt(index); + return CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + title: Text(cat.name), + value: favorites[cat.id]?.contains(link) ?? false, + onChanged: (bool? value) async { + if (value == true) { + await ref.read(webSourceFavoritesProvider.notifier).add(cat.id, link); + } else { + await ref.read(webSourceFavoritesProvider.notifier).remove(cat.id, link); + } + }, + ); + }, + ), + ) + : [], + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + favorited ? Icons.favorite : Icons.favorite_border, + color: favorited ? theme.colorScheme.primary : null, + ), + ), + ); + } +} diff --git a/lib/web/model/config.dart b/lib/web/model/config.dart index 7514b15..5e6e10e 100644 --- a/lib/web/model/config.dart +++ b/lib/web/model/config.dart @@ -4,15 +4,33 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:gagaku/model.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; part 'config.freezed.dart'; part 'config.g.dart'; +const _defaultUUID = 'e9d5c6c4-a29c-4a74-aaf2-8f2f8d2c06c2'; +const _defaultCategory = WebSourceCategory(_defaultUUID, 'Default'); + +@JsonSerializable() +class WebSourceCategory { + const WebSourceCategory(this.id, this.name); + WebSourceCategory.name(this.name) : id = const Uuid().v4(); + + final String id; + final String name; + + factory WebSourceCategory.fromJson(Map json) => _$WebSourceCategoryFromJson(json); + Map toJson() => _$WebSourceCategoryToJson(this); +} + @unfreezed class WebSourceConfig with _$WebSourceConfig { factory WebSourceConfig({ @Default('') String sourceDirectory, @Default([]) List repoList, + @Default([_defaultCategory]) List categories, + @Default(_defaultUUID) String defaultCategory, }) = _WebSourceConfig; factory WebSourceConfig.fromJson(Map json) => _$WebSourceConfigFromJson(json); @@ -38,7 +56,12 @@ class WebConfig extends _$WebConfig { return _fetch(); } - void saveWith({String? sourceDirectory, List? repoList}) { + void saveWith({ + String? sourceDirectory, + List? repoList, + List? categories, + String? defaultCategory, + }) { var update = state; if (sourceDirectory != null) { @@ -49,6 +72,14 @@ class WebConfig extends _$WebConfig { update = update.copyWith(repoList: repoList); } + if (categories != null) { + update = update.copyWith(categories: categories); + } + + if (defaultCategory != null) { + update = update.copyWith(defaultCategory: defaultCategory); + } + state = update; final box = Hive.box(gagakuBox); diff --git a/lib/web/model/config.freezed.dart b/lib/web/model/config.freezed.dart index 546c161..2f867e4 100644 --- a/lib/web/model/config.freezed.dart +++ b/lib/web/model/config.freezed.dart @@ -24,6 +24,11 @@ mixin _$WebSourceConfig { set sourceDirectory(String value) => throw _privateConstructorUsedError; List get repoList => throw _privateConstructorUsedError; set repoList(List value) => throw _privateConstructorUsedError; + List get categories => throw _privateConstructorUsedError; + set categories(List value) => + throw _privateConstructorUsedError; + String get defaultCategory => throw _privateConstructorUsedError; + set defaultCategory(String value) => throw _privateConstructorUsedError; /// Serializes this WebSourceConfig to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -41,7 +46,11 @@ abstract class $WebSourceConfigCopyWith<$Res> { WebSourceConfig value, $Res Function(WebSourceConfig) then) = _$WebSourceConfigCopyWithImpl<$Res, WebSourceConfig>; @useResult - $Res call({String sourceDirectory, List repoList}); + $Res call( + {String sourceDirectory, + List repoList, + List categories, + String defaultCategory}); } /// @nodoc @@ -61,6 +70,8 @@ class _$WebSourceConfigCopyWithImpl<$Res, $Val extends WebSourceConfig> $Res call({ Object? sourceDirectory = null, Object? repoList = null, + Object? categories = null, + Object? defaultCategory = null, }) { return _then(_value.copyWith( sourceDirectory: null == sourceDirectory @@ -71,6 +82,14 @@ class _$WebSourceConfigCopyWithImpl<$Res, $Val extends WebSourceConfig> ? _value.repoList : repoList // ignore: cast_nullable_to_non_nullable as List, + categories: null == categories + ? _value.categories + : categories // ignore: cast_nullable_to_non_nullable + as List, + defaultCategory: null == defaultCategory + ? _value.defaultCategory + : defaultCategory // ignore: cast_nullable_to_non_nullable + as String, ) as $Val); } } @@ -83,7 +102,11 @@ abstract class _$$WebSourceConfigImplCopyWith<$Res> __$$WebSourceConfigImplCopyWithImpl<$Res>; @override @useResult - $Res call({String sourceDirectory, List repoList}); + $Res call( + {String sourceDirectory, + List repoList, + List categories, + String defaultCategory}); } /// @nodoc @@ -101,6 +124,8 @@ class __$$WebSourceConfigImplCopyWithImpl<$Res> $Res call({ Object? sourceDirectory = null, Object? repoList = null, + Object? categories = null, + Object? defaultCategory = null, }) { return _then(_$WebSourceConfigImpl( sourceDirectory: null == sourceDirectory @@ -111,6 +136,14 @@ class __$$WebSourceConfigImplCopyWithImpl<$Res> ? _value.repoList : repoList // ignore: cast_nullable_to_non_nullable as List, + categories: null == categories + ? _value.categories + : categories // ignore: cast_nullable_to_non_nullable + as List, + defaultCategory: null == defaultCategory + ? _value.defaultCategory + : defaultCategory // ignore: cast_nullable_to_non_nullable + as String, )); } } @@ -118,7 +151,11 @@ class __$$WebSourceConfigImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$WebSourceConfigImpl implements _WebSourceConfig { - _$WebSourceConfigImpl({this.sourceDirectory = '', this.repoList = const []}); + _$WebSourceConfigImpl( + {this.sourceDirectory = '', + this.repoList = const [], + this.categories = const [_defaultCategory], + this.defaultCategory = _defaultUUID}); factory _$WebSourceConfigImpl.fromJson(Map json) => _$$WebSourceConfigImplFromJson(json); @@ -129,10 +166,16 @@ class _$WebSourceConfigImpl implements _WebSourceConfig { @override @JsonKey() List repoList; + @override + @JsonKey() + List categories; + @override + @JsonKey() + String defaultCategory; @override String toString() { - return 'WebSourceConfig(sourceDirectory: $sourceDirectory, repoList: $repoList)'; + return 'WebSourceConfig(sourceDirectory: $sourceDirectory, repoList: $repoList, categories: $categories, defaultCategory: $defaultCategory)'; } /// Create a copy of WebSourceConfig @@ -153,8 +196,11 @@ class _$WebSourceConfigImpl implements _WebSourceConfig { } abstract class _WebSourceConfig implements WebSourceConfig { - factory _WebSourceConfig({String sourceDirectory, List repoList}) = - _$WebSourceConfigImpl; + factory _WebSourceConfig( + {String sourceDirectory, + List repoList, + List categories, + String defaultCategory}) = _$WebSourceConfigImpl; factory _WebSourceConfig.fromJson(Map json) = _$WebSourceConfigImpl.fromJson; @@ -165,6 +211,12 @@ abstract class _WebSourceConfig implements WebSourceConfig { @override List get repoList; set repoList(List value); + @override + List get categories; + set categories(List value); + @override + String get defaultCategory; + set defaultCategory(String value); /// Create a copy of WebSourceConfig /// with the given fields replaced by the non-null parameter values. diff --git a/lib/web/model/config.g.dart b/lib/web/model/config.g.dart index 6d44584..55b9c3e 100644 --- a/lib/web/model/config.g.dart +++ b/lib/web/model/config.g.dart @@ -6,6 +6,18 @@ part of 'config.dart'; // JsonSerializableGenerator // ************************************************************************** +WebSourceCategory _$WebSourceCategoryFromJson(Map json) => + WebSourceCategory( + json['id'] as String, + json['name'] as String, + ); + +Map _$WebSourceCategoryToJson(WebSourceCategory instance) => + { + 'id': instance.id, + 'name': instance.name, + }; + _$WebSourceConfigImpl _$$WebSourceConfigImplFromJson( Map json) => _$WebSourceConfigImpl( @@ -14,6 +26,12 @@ _$WebSourceConfigImpl _$$WebSourceConfigImplFromJson( ?.map((e) => e as String) .toList() ?? const [], + categories: (json['categories'] as List?) + ?.map( + (e) => WebSourceCategory.fromJson(e as Map)) + .toList() ?? + const [_defaultCategory], + defaultCategory: json['defaultCategory'] as String? ?? _defaultUUID, ); Map _$$WebSourceConfigImplToJson( @@ -21,6 +39,8 @@ Map _$$WebSourceConfigImplToJson( { 'sourceDirectory': instance.sourceDirectory, 'repoList': instance.repoList, + 'categories': instance.categories, + 'defaultCategory': instance.defaultCategory, }; // ************************************************************************** @@ -88,7 +108,7 @@ final class WebConfigProvider $NotifierProviderElement(this, pointer); } -String _$webConfigHash() => r'd2fe9e2dcf17052f5a15a350429e7671220ce0fe'; +String _$webConfigHash() => r'581d5a27382777d75f47e53f7f5ae3542b91d0ac'; abstract class _$WebConfig extends $Notifier { WebSourceConfig build(); diff --git a/lib/web/model/model.dart b/lib/web/model/model.dart index e38bae6..08f2f61 100644 --- a/lib/web/model/model.dart +++ b/lib/web/model/model.dart @@ -235,22 +235,44 @@ class ProxyHandler { @Riverpod(keepAlive: true) class WebSourceFavorites extends _$WebSourceFavorites { - Future> _fetch() async { + Future>> _fetch() async { + final cfg = ref.read(webConfigProvider); final box = Hive.box(gagakuBox); final str = box.get('web_favorites'); if (str == null || (str as String).isEmpty) { - return []; + return {}; } - final content = json.decode(str) as List; - final links = content.map((e) => HistoryLink.fromJson(e)); + final content = json.decode(str); + + if (content is List) { + final links = content.map((e) => HistoryLink.fromJson(e)).toList(); + return { + cfg.defaultCategory: links, + }; + } else if (content is Map) { + final map = (content as Map) + .map((key, value) => MapEntry(key, (value as List).map((e) => HistoryLink.fromJson(e)).toList())); + + final keys = map.keys.toList(); + for (final key in keys) { + // If key doesnt exist in current categories, convert the + // list to default + if (cfg.categories.indexWhere((e) => e.id == key) == -1) { + final list = map.remove(key); + map[cfg.defaultCategory] = [...?map[cfg.defaultCategory], ...?list]; + } + } - return links.toList(); + return map; + } + + return {}; } @override - FutureOr> build() async { + FutureOr>> build() async { return _fetch(); } @@ -259,7 +281,7 @@ class WebSourceFavorites extends _$WebSourceFavorites { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - final empty = []; + final empty = >{}; final box = Hive.box(gagakuBox); await box.put('web_favorites', json.encode(empty)); @@ -268,66 +290,70 @@ class WebSourceFavorites extends _$WebSourceFavorites { }); } - Future add(HistoryLink link) async { + Future add(String category, HistoryLink link) async { final oldstate = await future; state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - while (oldstate.contains(link)) { - oldstate.remove(link); + final list = oldstate[category]; + + while (list?.contains(link) ?? false) { + list?.remove(link); } - final udp = [link, ...oldstate]; + oldstate[category] = [link, ...?list]; - final links = udp.map((e) => e.toJson()).toList(); + final udp = {...oldstate}; final box = Hive.box(gagakuBox); - await box.put('web_favorites', json.encode(links)); + await box.put('web_favorites', json.encode(udp)); return udp; }); } - Future replace(HistoryLink link) async { + Future updateAll(HistoryLink link) async { final oldstate = await future; state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - final idx = oldstate.indexOf(link); + for (final cat in oldstate.keys) { + final idx = oldstate[cat]!.indexOf(link); - if (idx != -1) { - oldstate[idx] = link; + if (idx != -1) { + oldstate[cat]![idx] = link; + } } - final udp = [...oldstate]; - - final links = udp.map((e) => e.toJson()).toList(); + final udp = {...oldstate}; final box = Hive.box(gagakuBox); - await box.put('web_favorites', json.encode(links)); + await box.put('web_favorites', json.encode(udp)); return udp; }); } - Future remove(HistoryLink link) async { + Future remove(String category, HistoryLink link) async { final oldstate = await future; state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - final udp = [...oldstate]; + final list = oldstate[category]; - while (udp.contains(link)) { - udp.remove(link); + while (list?.contains(link) ?? false) { + list?.remove(link); } - final links = udp.map((e) => e.toJson()).toList(); + oldstate[category] = [...?list]; + + final udp = {...oldstate}; final box = Hive.box(gagakuBox); - await box.put('web_favorites', json.encode(links)); + await box.put('web_favorites', json.encode(udp)); return udp; }); } - Future updateList(int oldIndex, int newIndex) async { + Future updateList(String category, int oldIndex, int newIndex) async { final oldstate = await future; state = await AsyncValue.guard(() async { if (oldIndex < newIndex) { @@ -335,14 +361,36 @@ class WebSourceFavorites extends _$WebSourceFavorites { newIndex -= 1; } - final element = oldstate.removeAt(oldIndex); - oldstate.insert(newIndex, element); + if (oldstate.containsKey(category)) { + final element = oldstate[category]!.removeAt(oldIndex); + oldstate[category]!.insert(newIndex, element); + } + + final udp = {...oldstate}; + + final box = Hive.box(gagakuBox); + await box.put('web_favorites', json.encode(udp)); + + return udp; + }); + } + + Future reconfigureCategories(List categories, String defaultCategory) async { + final oldstate = await future; + state = await AsyncValue.guard(() async { + // Move all deleted category lists to default + final oldkeys = oldstate.keys.toList(); + for (final oldcat in oldkeys) { + if (categories.indexWhere((e) => e.id == oldcat) == -1) { + final list = oldstate.remove(oldcat); + oldstate[defaultCategory] = [...?oldstate[defaultCategory], ...?list]; + } + } - final udp = [...oldstate]; - final map = udp.map((e) => e.toJson()).toList(); + final udp = {...oldstate}; final box = Hive.box(gagakuBox); - await box.put('web_favorites', json.encode(map)); + await box.put('web_favorites', json.encode(udp)); return udp; }); diff --git a/lib/web/model/model.g.dart b/lib/web/model/model.g.dart index 99b5a1c..818915d 100644 --- a/lib/web/model/model.g.dart +++ b/lib/web/model/model.g.dart @@ -70,8 +70,8 @@ String _$proxyHash() => r'9c1283bf072913b04bc06342dad4b6ff01fd1e7e'; @ProviderFor(WebSourceFavorites) const webSourceFavoritesProvider = WebSourceFavoritesProvider._(); -final class WebSourceFavoritesProvider - extends $AsyncNotifierProvider> { +final class WebSourceFavoritesProvider extends $AsyncNotifierProvider< + WebSourceFavorites, Map>> { const WebSourceFavoritesProvider._( {super.runNotifierBuildOverride, WebSourceFavorites Function()? create}) : _createCb = create, @@ -105,8 +105,8 @@ final class WebSourceFavoritesProvider @$internal @override WebSourceFavoritesProvider $copyWithBuild( - FutureOr> Function( - Ref>>, + FutureOr>> Function( + Ref>>>, WebSourceFavorites, ) build, ) { @@ -115,19 +115,21 @@ final class WebSourceFavoritesProvider @$internal @override - $AsyncNotifierProviderElement> - $createElement($ProviderPointer pointer) => - $AsyncNotifierProviderElement(this, pointer); + $AsyncNotifierProviderElement>> $createElement( + $ProviderPointer pointer) => + $AsyncNotifierProviderElement(this, pointer); } String _$webSourceFavoritesHash() => - r'59188a5d4f269b6f6d3ce28bdd0a08c0a84b6b71'; + r'0c35d691ceb6ad9704eaf3c2b110d3a0b4660032'; -abstract class _$WebSourceFavorites extends $AsyncNotifier> { - FutureOr> build(); +abstract class _$WebSourceFavorites + extends $AsyncNotifier>> { + FutureOr>> build(); @$internal @override - FutureOr> runBuild() => build(); + FutureOr>> runBuild() => build(); } @ProviderFor(WebSourceHistory) diff --git a/lib/web/search.dart b/lib/web/search.dart index 6cd4dc3..43bf984 100644 --- a/lib/web/search.dart +++ b/lib/web/search.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gagaku/config.dart'; import 'package:gagaku/ui.dart'; +import 'package:gagaku/web/model/config.dart'; import 'package:gagaku/web/model/model.dart'; import 'package:gagaku/web/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,6 +15,7 @@ class WebSourceSearchWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final gagakucfg = ref.watch(gagakuSettingsProvider); + final cfg = ref.watch(webConfigProvider); final controller = useTextEditingController(); final searchTerm = useState(''); final sources = ref.watch(webSourceManagerProvider); @@ -131,6 +133,7 @@ class WebSourceSearchWidget extends HookConsumerWidget { return GridMangaItem( key: ValueKey(item.hashCode), link: item, + favoritesKey: cfg.defaultCategory, showFavoriteButton: false, showRemoveButton: false, ); diff --git a/lib/web/settings.dart b/lib/web/settings.dart index 89bccfe..70c3be1 100644 --- a/lib/web/settings.dart +++ b/lib/web/settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gagaku/ui.dart'; import 'package:gagaku/web/model/config.dart'; +import 'package:gagaku/web/model/model.dart'; import 'package:gagaku/web/repo_list.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -19,7 +20,7 @@ class WebSourceSettingsWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final nav = Navigator.of(context); final cfg = ref.watch(webConfigProvider); - final config = useState(cfg); + final config = useState(cfg.copyWith()); return Scaffold( appBar: AppBar( @@ -35,7 +36,14 @@ class WebSourceSettingsWidget extends HookConsumerWidget { icon: const Icon(Icons.save), label: const Text('Save Settings'), onPressed: () { - ref.read(webConfigProvider.notifier).saveWith(sourceDirectory: config.value.sourceDirectory); + ref.read(webConfigProvider.notifier).saveWith( + sourceDirectory: config.value.sourceDirectory, + categories: config.value.categories, + defaultCategory: config.value.defaultCategory, + ); + ref + .read(webSourceFavoritesProvider.notifier) + .reconfigureCategories(config.value.categories, config.value.defaultCategory); nav.pop(); }, ), @@ -109,9 +117,223 @@ class WebSourceSettingsWidget extends HookConsumerWidget { ); }, ), + SettingCardWidget( + title: const Text( + 'Categories', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + subtitle: const Text('Set up categories'), + builder: (context) { + return Center( + child: ElevatedButton.icon( + onPressed: () async { + final result = await nav.push<(List, String)>(SlideTransitionRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => CategoryManager( + categories: [...config.value.categories], + defaultCategory: config.value.defaultCategory, + ), + )); + + if (result != null) { + config.value.categories = result.$1; + config.value.defaultCategory = result.$2; + } + }, + icon: const Icon(Icons.library_add), + label: const Text('Manage'), + ), + ); + }, + ), ], ), ), ); } } + +class CategoryManager extends HookConsumerWidget { + const CategoryManager({super.key, required this.categories, required this.defaultCategory}); + + final List categories; + final String defaultCategory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nav = Navigator.of(context); + final list = useState(categories); + final defaultCat = useState(defaultCategory); + + return Scaffold( + appBar: AppBar( + flexibleSpace: const TitleFlexBar(title: 'Categories'), + actions: [ + IconButton( + tooltip: 'Add New Category', + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return NewCategoryDialog( + list: list.value, + ); + }); + + if (result != null) { + final cat = WebSourceCategory.name(result); + + list.value = [...list.value, cat]; + } + }, + icon: const Icon(Icons.add), + ), + IconButton( + tooltip: 'Save', + onPressed: () { + nav.pop((list.value, defaultCat.value)); + }, + icon: const Icon(Icons.save), + ), + ], + ), + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Hint: Drag to reorder'), + Expanded( + child: ReorderableListView.builder( + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + + final items = list.value; + + final item = items.removeAt(oldIndex); + items.insert(newIndex, item); + + list.value = items; + }, + itemCount: list.value.length, + itemBuilder: (context, index) { + final item = list.value.elementAt(index); + return Card( + key: ValueKey(item.id), + child: ListTile( + leading: const Icon(Icons.category), + title: Text(item.name), + trailing: OverflowBar( + children: [ + ElevatedButton( + onPressed: item.id == defaultCat.value + ? null + : () { + defaultCat.value = item.id; + }, + child: const Text('Make Default'), + ), + IconButton( + tooltip: 'Rename', + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return NewCategoryDialog(list: list.value, rename: item.name); + }); + + if (result != null) { + final cat = WebSourceCategory(item.id, result); + + final idx = list.value.indexOf(item); + if (idx != -1) { + list.value[idx] = cat; + } + + list.value = [...list.value]; + } + }, + icon: const Icon(Icons.edit), + ), + IconButton( + tooltip: 'Delete', + color: Colors.red, + onPressed: list.value.length != 1 + ? () { + list.value.remove(item); + list.value = [...list.value]; + + if (item.id == defaultCat.value) { + defaultCat.value = list.value.first.id; + } + } + : null, + icon: const Icon(Icons.delete), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class NewCategoryDialog extends HookWidget { + const NewCategoryDialog({super.key, required this.list, this.rename}); + + final List list; + final String? rename; + + @override + Widget build(BuildContext context) { + final fieldController = useTextEditingController(text: rename); + + return AlertDialog( + title: Text('${rename == null ? 'Add' : 'Rename'} Category'), + content: TextFormField( + controller: fieldController, + decoration: const InputDecoration(hintText: 'Category name'), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) { + if (value == null || value.isEmpty) return 'Category name cannot be empty.'; + if (list.indexWhere((e) => e.name == value) != -1) return 'Category name already used.'; + + return null; + }, + ), + actions: [ + TextButton( + child: const Text('CANCEL'), + onPressed: () { + Navigator.of(context).pop(); + fieldController.clear(); + }, + ), + HookBuilder( + builder: (_) { + final nameIsValid = useListenableSelector(fieldController, + () => fieldController.text.isNotEmpty && list.indexWhere((e) => e.name == fieldController.text) == -1); + return ElevatedButton( + onPressed: nameIsValid + ? () { + Navigator.of(context).pop(fieldController.text); + fieldController.clear(); + } + : null, + child: Text(rename == null ? 'Add' : 'Rename'), + ); + }, + ), + ], + ); + } +} diff --git a/lib/web/widgets.dart b/lib/web/widgets.dart index cb917ae..9b86ede 100644 --- a/lib/web/widgets.dart +++ b/lib/web/widgets.dart @@ -103,10 +103,12 @@ class WebMangaListViewSliver extends ConsumerWidget { const WebMangaListViewSliver({ super.key, required this.items, + required this.favoritesKey, this.reorderable = false, this.showRemoveButton = true, }); + final String favoritesKey; final List items; final bool reorderable; final bool showRemoveButton; @@ -121,7 +123,7 @@ class WebMangaListViewSliver extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final api = ref.watch(proxyProvider); final view = ref.watch(_mangaListViewProvider); - final cfg = ref.watch(gagakuSettingsProvider); + final gcfg = ref.watch(gagakuSettingsProvider); final theme = Theme.of(context); switch (view) { @@ -129,7 +131,7 @@ class WebMangaListViewSliver extends ConsumerWidget { return reorderable ? SliverReorderableList( onReorder: (int oldIndex, int newIndex) => - ref.read(webSourceFavoritesProvider.notifier).updateList(oldIndex, newIndex), + ref.read(webSourceFavoritesProvider.notifier).updateList(favoritesKey, oldIndex, newIndex), itemCount: items.length, findChildIndexCallback: _findChildIndexCb, itemBuilder: (context, index) { @@ -144,7 +146,7 @@ class WebMangaListViewSliver extends ConsumerWidget { builder: (context, refx, child) { final favorited = ref.watch(webSourceFavoritesProvider.select( (value) => switch (value) { - AsyncValue(value: final data?) => data.indexWhere((e) => e.url == item.url) > -1, + AsyncValue(value: final data?) => data.values.any((l) => l.contains(item)), _ => null, }, )); @@ -159,9 +161,9 @@ class WebMangaListViewSliver extends ConsumerWidget { color: favorited ? theme.colorScheme.primary : null, onPressed: () async { if (favorited) { - ref.read(webSourceFavoritesProvider.notifier).remove(item); + ref.read(webSourceFavoritesProvider.notifier).remove(favoritesKey, item); } else { - ref.read(webSourceFavoritesProvider.notifier).add(item); + ref.read(webSourceFavoritesProvider.notifier).add(favoritesKey, item); } }, ); @@ -202,7 +204,7 @@ class WebMangaListViewSliver extends ConsumerWidget { builder: (context, refx, child) { final favorited = ref.watch(webSourceFavoritesProvider.select( (value) => switch (value) { - AsyncValue(value: final data?) => data.indexWhere((e) => e.url == item.url) > -1, + AsyncValue(value: final data?) => data.values.any((l) => l.contains(item)), _ => null, }, )); @@ -217,9 +219,9 @@ class WebMangaListViewSliver extends ConsumerWidget { color: favorited ? theme.colorScheme.primary : null, onPressed: () async { if (favorited) { - ref.read(webSourceFavoritesProvider.notifier).remove(item); + ref.read(webSourceFavoritesProvider.notifier).remove(favoritesKey, item); } else { - ref.read(webSourceFavoritesProvider.notifier).add(item); + ref.read(webSourceFavoritesProvider.notifier).add(favoritesKey, item); } }, ); @@ -258,7 +260,7 @@ class WebMangaListViewSliver extends ConsumerWidget { default: return SliverGrid.builder( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: cfg.gridAlbumExtent.grid, + maxCrossAxisExtent: gcfg.gridAlbumExtent.grid, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.7, @@ -269,6 +271,7 @@ class WebMangaListViewSliver extends ConsumerWidget { return GridMangaItem( key: ValueKey(item.hashCode), link: item, + favoritesKey: favoritesKey, showRemoveButton: showRemoveButton, ); }, @@ -282,10 +285,12 @@ class GridMangaItem extends HookConsumerWidget { const GridMangaItem({ super.key, required this.link, + required this.favoritesKey, this.showFavoriteButton = true, this.showRemoveButton = true, }); + final String favoritesKey; final HistoryLink link; final bool showFavoriteButton; final bool showRemoveButton; @@ -339,7 +344,6 @@ class GridMangaItem extends HookConsumerWidget { child: Stack( children: [ GridTile( - //header: , footer: GridAlbumTextBar( height: 80, text: link.title, @@ -353,7 +357,7 @@ class GridMangaItem extends HookConsumerWidget { builder: (context, refx, child) { final favorited = ref.watch(webSourceFavoritesProvider.select( (value) => switch (value) { - AsyncValue(value: final data?) => data.indexWhere((e) => e.url == link.url) > -1, + AsyncValue(value: final data?) => data.values.any((l) => l.contains(link)), _ => null, }, )); @@ -369,9 +373,9 @@ class GridMangaItem extends HookConsumerWidget { tooltip: favorited ? 'Remove from Favorites' : 'Add to Favorites', onPressed: () async { if (favorited) { - ref.read(webSourceFavoritesProvider.notifier).remove(link); + ref.read(webSourceFavoritesProvider.notifier).remove(favoritesKey, link); } else { - ref.read(webSourceFavoritesProvider.notifier).add(link); + ref.read(webSourceFavoritesProvider.notifier).add(favoritesKey, link); } }, child: Icon( diff --git a/pubspec.lock b/pubspec.lock index 40a4de3..380e504 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -66,10 +66,10 @@ packages: dependency: transitive description: name: asn1lib - sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + sha256: "2ca377ad4d677bbadca278e0ba4da4e057b80a10b927bfc8f7d8bda8fe2ceb75" url: "https://pub.dev" source: hosted - version: "1.5.3" + version: "1.5.4" async: dependency: transitive description: @@ -940,10 +940,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.2.3" permission_handler_windows: dependency: transitive description: