Use shared library index for manga cards

Replace per-widget Isar StreamBuilder usage with a single shared
library index managed in MangaHomeScreen.

- Subscribe once to manga updates and build a name -> Manga map
- Pass libraryManga down to card widgets instead of querying per item
- Remove StreamBuilder logic from MangaImageCardWidget and list tile
- Use library data for cover, tracker image, and favorite state
- Add favorite overlay indicator based on libraryManga
- Clean up redundant filtering and improve performance

This reduces rebuild overhead and avoids multiple database listeners
per list/grid item.
This commit is contained in:
NBA2K1 2026-04-18 23:00:56 +02:00
parent e6b10f7a97
commit 525eeea3ac
2 changed files with 216 additions and 225 deletions

View file

@ -2,8 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/models/source.dart';
@ -67,6 +69,8 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
late bool isLocal = source.name == "local" && source.lang == "";
late List<dynamic> filters = isLocal ? [] : getFilterList(source: source);
final List<MManga> _mangaList = [];
late StreamSubscription<List<Manga>> _mangaStreamSub;
Map<String, Manga> _libraryIndex = {};
List<TypeMangaSelector> _types(BuildContext context) {
final l10n = l10nLocalizations(context)!;
return [
@ -120,10 +124,28 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_mangaStreamSub = isar.mangas
.filter()
.sourceEqualTo(source.name)
.langEqualTo(source.lang)
.watch(fireImmediately: true)
.listen((mangas) {
if (mounted) {
setState(() {
_libraryIndex = {
for (final m in mangas.where(
(e) => e.sourceId == null || e.sourceId == source.id,
))
if (m.name != null) m.name!: m,
};
});
}
});
}
@override
void dispose() {
_mangaStreamSub.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_textEditingController.dispose();
@ -571,6 +593,8 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
itemType: source.itemType,
manga: _mangaList[index],
source: source,
libraryManga:
_libraryIndex[_mangaList[index].name],
);
},
)
@ -598,6 +622,9 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
manga: _mangaList[index],
source: source,
isComfortableGrid: isComfortableGrid,
libraryManga:
_libraryIndex[_mangaList[index]
.name],
);
},
);
@ -700,12 +727,14 @@ class MangaHomeImageCard extends ConsumerStatefulWidget {
final ItemType itemType;
final Source source;
final bool isComfortableGrid;
final Manga? libraryManga;
const MangaHomeImageCard({
super.key,
required this.manga,
required this.source,
required this.itemType,
required this.isComfortableGrid,
this.libraryManga,
});
@override
@ -720,6 +749,7 @@ class _MangaHomeImageCardState extends ConsumerState<MangaHomeImageCard> {
source: widget.source,
itemType: widget.itemType,
isComfortableGrid: widget.isComfortableGrid,
libraryManga: widget.libraryManga,
);
}
}
@ -728,11 +758,13 @@ class MangaHomeImageCardListTile extends ConsumerStatefulWidget {
final MManga manga;
final ItemType itemType;
final Source source;
final Manga? libraryManga;
const MangaHomeImageCardListTile({
super.key,
required this.manga,
required this.source,
required this.itemType,
this.libraryManga,
});
@override
@ -748,6 +780,7 @@ class _MangaHomeImageCardListTileState
getMangaDetail: widget.manga,
source: widget.source,
itemType: widget.itemType,
libraryManga: widget.libraryManga,
);
}
}

View file

@ -22,6 +22,7 @@ class MangaImageCardWidget extends ConsumerWidget {
final ItemType itemType;
final bool isComfortableGrid;
final MManga? getMangaDetail;
final Manga? libraryManga;
const MangaImageCardWidget({
required this.source,
@ -29,57 +30,150 @@ class MangaImageCardWidget extends ConsumerWidget {
required this.getMangaDetail,
required this.isComfortableGrid,
required this.itemType,
this.libraryManga,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return StreamBuilder(
stream: isar.mangas
.filter()
.langEqualTo(source.lang)
.nameEqualTo(getMangaDetail!.name)
.sourceEqualTo(source.name)
.watch(fireImmediately: true),
builder: (context, snapshot) {
bool hasData = snapshot.hasData;
final mangaList = hasData
? snapshot.data!
.where(
(element) => element.sourceId == null
? true
: element.sourceId == source.id,
)
.toList()
: [];
hasData = hasData && mangaList.isNotEmpty;
return CoverViewWidget(
bottomTextWidget: BottomTextWidget(
maxLines: 1,
text: getMangaDetail!.name!,
isComfortableGrid: isComfortableGrid,
),
isComfortableGrid: isComfortableGrid,
image: hasData && mangaList.first.customCoverImage != null
? MemoryImage(mangaList.first.customCoverImage as Uint8List)
as ImageProvider
: CustomExtendedNetworkImageProvider(
toImgUrl(
hasData
? mangaList.first.customCoverFromTracker ??
mangaList.first.imageUrl ??
""
: getMangaDetail!.imageUrl ?? "",
),
headers: ref.watch(
headersProvider(
source: source.name!,
lang: source.lang!,
sourceId: source.id,
),
),
cache: true,
cacheMaxAge: const Duration(days: 7),
final hasData = libraryManga != null;
return CoverViewWidget(
bottomTextWidget: BottomTextWidget(
maxLines: 1,
text: getMangaDetail!.name!,
isComfortableGrid: isComfortableGrid,
),
isComfortableGrid: isComfortableGrid,
image: hasData && libraryManga!.customCoverImage != null
? MemoryImage(libraryManga!.customCoverImage as Uint8List)
as ImageProvider
: CustomExtendedNetworkImageProvider(
toImgUrl(
hasData
? libraryManga!.customCoverFromTracker ??
libraryManga!.imageUrl ??
""
: getMangaDetail!.imageUrl ?? "",
),
headers: ref.watch(
headersProvider(
source: source.name!,
lang: source.lang!,
sourceId: source.id,
),
),
cache: true,
cacheMaxAge: const Duration(days: 7),
),
onTap: () => pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
sourceId: source.id,
),
onLongPress: () => pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
addToFavourite: true,
sourceId: source.id,
),
onSecondaryTap: () => pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
addToFavourite: true,
sourceId: source.id,
),
children: [
Container(
color: hasData && libraryManga!.favorite!
? Colors.black.withValues(alpha: 0.5)
: null,
),
if (hasData && libraryManga!.favorite!)
Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.all(4),
child: Container(
decoration: BoxDecoration(
color: context.primaryColor,
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.collections_bookmark_outlined,
size: 16,
color: context.dynamicWhiteBlackColor,
),
),
),
),
),
if (!isComfortableGrid)
BottomTextWidget(
isTorrent: source.isTorrent,
text: getMangaDetail!.name!,
),
],
);
}
}
class MangaImageCardListTileWidget extends ConsumerWidget {
final Source source;
final ItemType itemType;
final MManga? getMangaDetail;
final Manga? libraryManga;
const MangaImageCardListTileWidget({
required this.source,
super.key,
required this.itemType,
required this.getMangaDetail,
this.libraryManga,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasData = libraryManga != null;
final image = hasData && libraryManga!.customCoverImage != null
? MemoryImage(libraryManga!.customCoverImage as Uint8List)
as ImageProvider
: CustomExtendedNetworkImageProvider(
toImgUrl(
hasData
? libraryManga!.customCoverFromTracker ??
libraryManga!.imageUrl ??
""
: getMangaDetail!.imageUrl ?? "",
),
headers: ref.watch(
headersProvider(
source: source.name!,
lang: source.lang!,
sourceId: source.id,
),
),
);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Material(
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
pushToMangaReaderDetail(
ref: ref,
@ -115,18 +209,46 @@ class MangaImageCardWidget extends ConsumerWidget {
sourceId: source.id,
);
},
children: [
Container(
color: hasData && mangaList.first.favorite!
? Colors.black.withValues(alpha: 0.5)
: null,
),
if (hasData && mangaList.first.favorite!)
Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Stack(
children: [
Material(
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: Image(
height: 55,
width: 40,
fit: BoxFit.cover,
image: image,
),
),
Container(
height: 55,
width: 40,
color: hasData && libraryManga!.favorite!
? Colors.black.withValues(alpha: 0.5)
: null,
),
],
),
),
Expanded(
child: Text(
getMangaDetail!.name!,
maxLines: 2,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: context.textColor,
),
),
),
if (hasData && libraryManga!.favorite!)
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: context.primaryColor,
@ -142,174 +264,10 @@ class MangaImageCardWidget extends ConsumerWidget {
),
),
),
),
if (!isComfortableGrid)
BottomTextWidget(
isTorrent: source.isTorrent,
text: getMangaDetail!.name!,
),
],
);
},
);
}
}
class MangaImageCardListTileWidget extends ConsumerWidget {
final Source source;
final ItemType itemType;
final MManga? getMangaDetail;
const MangaImageCardListTileWidget({
required this.source,
super.key,
required this.itemType,
required this.getMangaDetail,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return StreamBuilder(
stream: isar.mangas
.filter()
.langEqualTo(source.lang)
.nameEqualTo(getMangaDetail!.name)
.sourceEqualTo(source.name)
.watch(fireImmediately: true),
builder: (context, snapshot) {
bool hasData = snapshot.hasData;
final mangaList = hasData
? snapshot.data!
.where(
(element) => element.sourceId == null
? true
: element.sourceId == source.id,
)
.toList()
: [];
hasData = hasData && mangaList.isNotEmpty;
final image = hasData && mangaList.first.customCoverImage != null
? MemoryImage(mangaList.first.customCoverImage as Uint8List)
as ImageProvider
: CustomExtendedNetworkImageProvider(
toImgUrl(
hasData
? mangaList.first.customCoverFromTracker ??
mangaList.first.imageUrl ??
""
: getMangaDetail!.imageUrl ?? "",
),
headers: ref.watch(
headersProvider(
source: source.name!,
lang: source.lang!,
sourceId: source.id,
),
),
);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Material(
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
sourceId: source.id,
);
},
onLongPress: () {
pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
addToFavourite: true,
sourceId: source.id,
);
},
onSecondaryTap: () {
pushToMangaReaderDetail(
ref: ref,
context: context,
getManga: getMangaDetail!,
lang: source.lang!,
source: source.name!,
itemType: itemType,
addToFavourite: true,
sourceId: source.id,
);
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Stack(
children: [
Material(
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: Image(
height: 55,
width: 40,
fit: BoxFit.cover,
image: image,
),
),
Container(
height: 55,
width: 40,
color: hasData && mangaList.first.favorite!
? Colors.black.withValues(alpha: 0.5)
: null,
),
],
),
),
Expanded(
child: Text(
getMangaDetail!.name!,
maxLines: 2,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: context.textColor,
),
),
),
if (hasData && mangaList.first.favorite!)
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: context.primaryColor,
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.collections_bookmark_outlined,
size: 16,
color: context.dynamicWhiteBlackColor,
),
),
),
),
],
),
),
],
),
);
},
),
),
);
}
}