From bea3ff8b781bafe9cd6b10b25882f69432c506b8 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Wed, 8 Feb 2023 20:05:47 +0100 Subject: [PATCH] feat(ui): Switch to a grid-based layout --- lib/src/ui/bloc/anime_list_bloc.dart | 133 +++++++++++++++------ lib/src/ui/pages/anime_list.dart | 167 +++++++++++++++------------ lib/src/ui/widgets/grid_item.dart | 72 ++++++++++++ lib/src/ui/widgets/image.dart | 53 +++++++-- lib/src/ui/widgets/list_item.dart | 4 + 5 files changed, 312 insertions(+), 117 deletions(-) create mode 100644 lib/src/ui/widgets/grid_item.dart diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index 12d9fe7..fe5bb6f 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -28,32 +28,68 @@ class AnimeListBloc extends Bloc { on(_onMangaRemoved); } + /// Internal anime state + final List _animes = List.empty(growable: true); + final List _mangas = List.empty(growable: true); + + List _getFilteredAnime({MediumTrackingState? trackingState}) { + final filterState = trackingState ?? state.animeFilterState; + + if (filterState == MediumTrackingState.all) return _animes; + + return _animes + .where((anime) => anime.state == filterState) + .toList(); + } + + List _getFilteredManga({MediumTrackingState? trackingState}) { + final filterState = trackingState ?? state.mangaFilterState; + + if (state.mangaFilterState == MediumTrackingState.all) return _mangas; + + return _mangas + .where((manga) => manga.state == filterState) + .toList(); + } + Future _onAnimeAdded(AnimeAddedEvent event, Emitter emit) async { // Add the anime to the database await GetIt.I.get().addAnime(event.data); - emit( - state.copyWith( - animes: List.from([ - ...state.animes, - event.data, - ]), - ), - ); + // Add it to the cache + _animes.add(event.data); + + if (event.data.state == state.animeFilterState || + state.animeFilterState == MediumTrackingState.all) { + emit( + state.copyWith( + animes: List.from([ + ...state.animes, + event.data, + ]), + ), + ); + } } Future _onMangaAdded(MangaAddedEvent event, Emitter emit) async { // Add the manga to the database await GetIt.I.get().addManga(event.data); - emit( - state.copyWith( - mangas: List.from([ - ...state.mangas, - event.data, - ]), - ), - ); + // Add it to the cache + _mangas.add(event.data); + + if (event.data.state == state.mangaFilterState || + state.mangaFilterState == MediumTrackingState.all) { + emit( + state.copyWith( + mangas: List.from([ + ...state.mangas, + event.data, + ]), + ), + ); + } } Future _onAnimeIncremented(AnimeEpisodeIncrementedEvent event, Emitter emit) async { @@ -101,10 +137,17 @@ class AnimeListBloc extends Bloc { } Future _onAnimesLoaded(AnimesLoadedEvent event, Emitter emit) async { + _animes.addAll( + await GetIt.I.get().loadAnimes(), + ); + _mangas.addAll( + await GetIt.I.get().loadMangas(), + ); + emit( state.copyWith( - animes: await GetIt.I.get().loadAnimes(), - mangas: await GetIt.I.get().loadMangas(), + animes: _getFilteredAnime(), + mangas: _getFilteredManga(), ), ); } @@ -113,6 +156,7 @@ class AnimeListBloc extends Bloc { emit( state.copyWith( animeFilterState: event.filterState, + animes: _getFilteredAnime(trackingState: event.filterState), ), ); } @@ -121,6 +165,7 @@ class AnimeListBloc extends Bloc { emit( state.copyWith( mangaFilterState: event.filterState, + mangas: _getFilteredManga(trackingState: event.filterState), ), ); } @@ -135,7 +180,7 @@ class AnimeListBloc extends Bloc { Future _onMangaIncremented(MangaChapterIncrementedEvent event, Emitter emit) async { final index = state.mangas.indexWhere((item) => item.id == event.id); - if (index == -1) return; + assert(index != -1, 'The manga must exist'); final manga = state.mangas[index]; if (manga.chaptersTotal != null && manga.chaptersRead + 1 > manga.chaptersTotal!) return; @@ -146,6 +191,11 @@ class AnimeListBloc extends Bloc { ); newList[index] = newManga; + // Update the cache + final cacheIndex = _mangas.indexWhere((m) => m.id == event.id); + assert(cacheIndex != -1, 'The manga must exist'); + _mangas[cacheIndex] = newManga; + emit( state.copyWith( mangas: newList, @@ -168,6 +218,11 @@ class AnimeListBloc extends Bloc { ); newList[index] = newManga; + // Update the cache + final cacheIndex = _mangas.indexWhere((m) => m.id == event.id); + assert(cacheIndex != -1, 'The manga must exist'); + _mangas[cacheIndex] = newManga; + emit( state.copyWith( mangas: newList, @@ -178,33 +233,27 @@ class AnimeListBloc extends Bloc { } Future _onAnimeUpdated(AnimeUpdatedEvent event, Emitter emit) async { + final index = _animes.indexWhere((anime) => anime.id == event.anime.id); + assert(index != -1, 'The anime must exist'); + + _animes[index] = event.anime; + emit( state.copyWith( - animes: List.from( - state.animes.map((anime) { - if (anime.id == event.anime.id) { - return event.anime; - } - - return anime; - }), - ), + animes: _getFilteredAnime(), ), ); } Future _onMangaUpdated(MangaUpdatedEvent event, Emitter emit) async { + final index = _mangas.indexWhere((manga) => manga.id == event.manga.id); + assert(index != -1, 'The manga must exist'); + + _mangas[index] = event.manga; + emit( state.copyWith( - mangas: List.from( - state.mangas.map((manga) { - if (manga.id == event.manga.id) { - return event.manga; - } - - return manga; - }), - ), + mangas: _getFilteredManga(), ), ); } @@ -218,6 +267,11 @@ class AnimeListBloc extends Bloc { ), ); + // Update the cache + final cacheIndex = _mangas.indexWhere((m) => m.id == event.id); + assert(cacheIndex != -1, 'The anime must exist'); + _mangas.removeAt(cacheIndex); + // Update the database await GetIt.I.get().deleteAnime(event.id); } @@ -231,6 +285,11 @@ class AnimeListBloc extends Bloc { ), ); + // Update the cache + final cacheIndex = _animes.indexWhere((a) => a.id == event.id); + assert(cacheIndex != -1, 'The manga must exist'); + _animes.removeAt(cacheIndex); + // Update the database await GetIt.I.get().deleteManga(event.id); } diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index 6decf4c..445891d 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -3,7 +3,8 @@ import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; -import 'package:anitrack/src/ui/widgets/list_item.dart'; +import 'package:anitrack/src/ui/widgets/grid_item.dart'; +import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:bottom_bar/bottom_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -130,79 +131,103 @@ class AnimeListPage extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), controller: _controller, children: [ - ListView.builder( - itemCount: state.animes.length, - itemBuilder: (context, index) { - final anime = state.animes[index]; - if (state.animeFilterState != MediumTrackingState.all) { - if (anime.state != state.animeFilterState) return Container(); - } - - return InkWell( - onTap: () { - context.read().add( - AnimeDetailsRequestedEvent(anime), - ); - }, - child: ListItem( - title: anime.title, - thumbnailUrl: anime.thumbnailUrl, - extra: [ - Text( - '${anime.episodesWatched}/${anime.episodesTotal ?? "???"}', - style: Theme.of(context).textTheme.titleMedium, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 120 / (100 * (16 / 9)), + ), + itemCount: state.animes.length, + itemBuilder: (context, index) { + final anime = state.animes[index]; + return GridItem( + minusCallback: () { + context.read().add( + AnimeEpisodeDecrementedEvent( + anime.id, + ), + ); + }, + plusCallback: () { + context.read().add( + AnimeEpisodeIncrementedEvent( + anime.id, + ), + ); + }, + child: AnimeCoverImage( + url: anime.thumbnailUrl, + onTap: () { + context.read().add( + AnimeDetailsRequestedEvent(anime), + ); + }, + extra: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '${anime.episodesWatched}/${anime.episodesTotal ?? "???"}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), ), - ], - onLeftSwipe: () { - context.read().add( - AnimeEpisodeDecrementedEvent(state.animes[index].id), - ); - }, - onRightSwipe: () { - context.read().add( - AnimeEpisodeIncrementedEvent(state.animes[index].id), - ); - }, - ), - ); - }, + ), + ); + }, + ), ), - ListView.builder( - itemCount: state.mangas.length, - itemBuilder: (context, index) { - final manga = state.mangas[index]; - if (state.mangaFilterState != MediumTrackingState.all) { - if (manga.state != state.mangaFilterState) return Container(); - } - - return InkWell( - onTap: () { - context.read().add( - MangaDetailsRequestedEvent(manga), - ); - }, - child: ListItem( - title: manga.title, - thumbnailUrl: manga.thumbnailUrl, - extra: [ - Text( - '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', - style: Theme.of(context).textTheme.titleMedium, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 120 / (100 * (16 / 9)), + ), + itemCount: state.mangas.length, + itemBuilder: (context, index) { + final manga = state.mangas[index]; + return GridItem( + minusCallback: () { + context.read().add( + MangaChapterDecrementedEvent( + manga.id, + ), + ); + }, + plusCallback: () { + context.read().add( + MangaChapterIncrementedEvent( + manga.id, + ), + ); + }, + child: AnimeCoverImage( + url: manga.thumbnailUrl, + onTap: () { + context.read().add( + MangaDetailsRequestedEvent(manga), + ); + }, + extra: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), ), - ], - onLeftSwipe: () { - context.read().add( - MangaChapterDecrementedEvent(state.mangas[index].id), - ); - }, - onRightSwipe: () { - context.read().add( - MangaChapterIncrementedEvent(state.mangas[index].id), - ); - }, - ), - ); - }, + ), + ); + }, + ), ), ], ), diff --git a/lib/src/ui/widgets/grid_item.dart b/lib/src/ui/widgets/grid_item.dart new file mode 100644 index 0000000..91e5e65 --- /dev/null +++ b/lib/src/ui/widgets/grid_item.dart @@ -0,0 +1,72 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class GridItem extends StatefulWidget { + const GridItem({ + required this.child, + required this.plusCallback, + required this.minusCallback, + super.key, + }); + + final Widget child; + + final void Function() plusCallback; + final void Function() minusCallback; + + @override + GridItemState createState() => GridItemState(); +} + +class GridItemState extends State { + double _offset = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragUpdate: (details) { + setState(() { + _offset += details.delta.dx; + }); + }, + onHorizontalDragEnd: (_) { + if (_offset <= 50) { + widget.plusCallback(); + } else if (_offset >= -50) { + widget.minusCallback(); + } + + // Reset the view + setState(() => _offset = 0); + }, + child: Stack( + children: [ + const Positioned( + left: 0, + right: 15, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerRight, + child: Icon(Icons.add), + ), + ), + const Positioned( + left: 15, + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(Icons.remove), + ), + ), + Positioned( + left: 160 / (1 + exp(-1 * (1/30) * _offset)) - 80, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/widgets/image.dart b/lib/src/ui/widgets/image.dart index 804bb5f..c9df12a 100644 --- a/lib/src/ui/widgets/image.dart +++ b/lib/src/ui/widgets/image.dart @@ -5,14 +5,22 @@ class AnimeCoverImage extends StatelessWidget { const AnimeCoverImage({ required this.url, this.cached = true, + this.extra, + this.onTap, super.key, }); /// The URL to the cover image. final String url; - /// Flag indicating if the image should be cached + /// Flag indicating if the image should be cached. final bool cached; + + /// An extra widget with a translucent backdrop. + final Widget? extra; + + /// Callback for when the image is tapped. + final void Function()? onTap; @override Widget build(BuildContext context) { @@ -21,14 +29,41 @@ class AnimeCoverImage extends StatelessWidget { child: SizedBox( height: 100 * (16 / 9), width: 120, - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: cached ? - CachedNetworkImageProvider(url) as ImageProvider : - NetworkImage(url), - fit: BoxFit.cover, - ), + child: InkWell( + onTap: onTap ?? () {}, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: cached ? + CachedNetworkImageProvider(url) as ImageProvider : + NetworkImage(url), + fit: BoxFit.cover, + ), + ), + ), + ), + + if (extra != null) + Positioned( + left: 0, + bottom: 0, + right: 0, + child: SizedBox( + height: 40, + child: ColoredBox( + color: Colors.black54, + child: extra, + ), + ), + ), + ], ), ), ), diff --git a/lib/src/ui/widgets/list_item.dart b/lib/src/ui/widgets/list_item.dart index 29806f2..12c1fe9 100644 --- a/lib/src/ui/widgets/list_item.dart +++ b/lib/src/ui/widgets/list_item.dart @@ -12,6 +12,7 @@ class ListItem extends StatelessWidget { this.onRightSwipe, this.cached = true, this.extra = const [], + this.imageExtra, super.key, }); @@ -23,6 +24,8 @@ class ListItem extends StatelessWidget { /// Extra widgets. final List extra; + + final Widget? imageExtra; /// Callbacks for the swipe functionality. final void Function()? onLeftSwipe; @@ -73,6 +76,7 @@ class ListItem extends StatelessWidget { children: [ AnimeCoverImage( cached: cached, + extra: imageExtra, url: thumbnailUrl, ),