feat(ui): Switch to a grid-based layout

This commit is contained in:
PapaTutuWawa 2023-02-08 20:05:47 +01:00
parent 4688924ec2
commit bea3ff8b78
5 changed files with 312 additions and 117 deletions

View File

@ -28,32 +28,68 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
on<MangaRemovedEvent>(_onMangaRemoved); on<MangaRemovedEvent>(_onMangaRemoved);
} }
/// Internal anime state
final List<AnimeTrackingData> _animes = List<AnimeTrackingData>.empty(growable: true);
final List<MangaTrackingData> _mangas = List<MangaTrackingData>.empty(growable: true);
List<AnimeTrackingData> _getFilteredAnime({MediumTrackingState? trackingState}) {
final filterState = trackingState ?? state.animeFilterState;
if (filterState == MediumTrackingState.all) return _animes;
return _animes
.where((anime) => anime.state == filterState)
.toList();
}
List<MangaTrackingData> _getFilteredManga({MediumTrackingState? trackingState}) {
final filterState = trackingState ?? state.mangaFilterState;
if (state.mangaFilterState == MediumTrackingState.all) return _mangas;
return _mangas
.where((manga) => manga.state == filterState)
.toList();
}
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async {
// Add the anime to the database // Add the anime to the database
await GetIt.I.get<DatabaseService>().addAnime(event.data); await GetIt.I.get<DatabaseService>().addAnime(event.data);
emit( // Add it to the cache
state.copyWith( _animes.add(event.data);
animes: List.from([
...state.animes, if (event.data.state == state.animeFilterState ||
event.data, state.animeFilterState == MediumTrackingState.all) {
]), emit(
), state.copyWith(
); animes: List.from([
...state.animes,
event.data,
]),
),
);
}
} }
Future<void> _onMangaAdded(MangaAddedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onMangaAdded(MangaAddedEvent event, Emitter<AnimeListState> emit) async {
// Add the manga to the database // Add the manga to the database
await GetIt.I.get<DatabaseService>().addManga(event.data); await GetIt.I.get<DatabaseService>().addManga(event.data);
emit( // Add it to the cache
state.copyWith( _mangas.add(event.data);
mangas: List.from([
...state.mangas, if (event.data.state == state.mangaFilterState ||
event.data, state.mangaFilterState == MediumTrackingState.all) {
]), emit(
), state.copyWith(
); mangas: List.from([
...state.mangas,
event.data,
]),
),
);
}
} }
Future<void> _onAnimeIncremented(AnimeEpisodeIncrementedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onAnimeIncremented(AnimeEpisodeIncrementedEvent event, Emitter<AnimeListState> emit) async {
@ -101,10 +137,17 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
} }
Future<void> _onAnimesLoaded(AnimesLoadedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onAnimesLoaded(AnimesLoadedEvent event, Emitter<AnimeListState> emit) async {
_animes.addAll(
await GetIt.I.get<DatabaseService>().loadAnimes(),
);
_mangas.addAll(
await GetIt.I.get<DatabaseService>().loadMangas(),
);
emit( emit(
state.copyWith( state.copyWith(
animes: await GetIt.I.get<DatabaseService>().loadAnimes(), animes: _getFilteredAnime(),
mangas: await GetIt.I.get<DatabaseService>().loadMangas(), mangas: _getFilteredManga(),
), ),
); );
} }
@ -113,6 +156,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
emit( emit(
state.copyWith( state.copyWith(
animeFilterState: event.filterState, animeFilterState: event.filterState,
animes: _getFilteredAnime(trackingState: event.filterState),
), ),
); );
} }
@ -121,6 +165,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
emit( emit(
state.copyWith( state.copyWith(
mangaFilterState: event.filterState, mangaFilterState: event.filterState,
mangas: _getFilteredManga(trackingState: event.filterState),
), ),
); );
} }
@ -135,7 +180,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
Future<void> _onMangaIncremented(MangaChapterIncrementedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onMangaIncremented(MangaChapterIncrementedEvent event, Emitter<AnimeListState> emit) async {
final index = state.mangas.indexWhere((item) => item.id == event.id); 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]; final manga = state.mangas[index];
if (manga.chaptersTotal != null && manga.chaptersRead + 1 > manga.chaptersTotal!) return; if (manga.chaptersTotal != null && manga.chaptersRead + 1 > manga.chaptersTotal!) return;
@ -146,6 +191,11 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
); );
newList[index] = newManga; 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( emit(
state.copyWith( state.copyWith(
mangas: newList, mangas: newList,
@ -168,6 +218,11 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
); );
newList[index] = newManga; 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( emit(
state.copyWith( state.copyWith(
mangas: newList, mangas: newList,
@ -178,33 +233,27 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
} }
Future<void> _onAnimeUpdated(AnimeUpdatedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onAnimeUpdated(AnimeUpdatedEvent event, Emitter<AnimeListState> emit) async {
final index = _animes.indexWhere((anime) => anime.id == event.anime.id);
assert(index != -1, 'The anime must exist');
_animes[index] = event.anime;
emit( emit(
state.copyWith( state.copyWith(
animes: List.from( animes: _getFilteredAnime(),
state.animes.map((anime) {
if (anime.id == event.anime.id) {
return event.anime;
}
return anime;
}),
),
), ),
); );
} }
Future<void> _onMangaUpdated(MangaUpdatedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onMangaUpdated(MangaUpdatedEvent event, Emitter<AnimeListState> emit) async {
final index = _mangas.indexWhere((manga) => manga.id == event.manga.id);
assert(index != -1, 'The manga must exist');
_mangas[index] = event.manga;
emit( emit(
state.copyWith( state.copyWith(
mangas: List.from( mangas: _getFilteredManga(),
state.mangas.map((manga) {
if (manga.id == event.manga.id) {
return event.manga;
}
return manga;
}),
),
), ),
); );
} }
@ -218,6 +267,11 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
), ),
); );
// 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 // Update the database
await GetIt.I.get<DatabaseService>().deleteAnime(event.id); await GetIt.I.get<DatabaseService>().deleteAnime(event.id);
} }
@ -231,6 +285,11 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
), ),
); );
// 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 // Update the database
await GetIt.I.get<DatabaseService>().deleteManga(event.id); await GetIt.I.get<DatabaseService>().deleteManga(event.id);
} }

View File

@ -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/anime_search_bloc.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.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:bottom_bar/bottom_bar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -130,79 +131,103 @@ class AnimeListPage extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
controller: _controller, controller: _controller,
children: [ children: [
ListView.builder( Padding(
itemCount: state.animes.length, padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) { child: GridView.builder(
final anime = state.animes[index]; gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
if (state.animeFilterState != MediumTrackingState.all) { crossAxisCount: 3,
if (anime.state != state.animeFilterState) return Container(); mainAxisSpacing: 8,
} crossAxisSpacing: 8,
childAspectRatio: 120 / (100 * (16 / 9)),
return InkWell( ),
onTap: () { itemCount: state.animes.length,
context.read<DetailsBloc>().add( itemBuilder: (context, index) {
AnimeDetailsRequestedEvent(anime), final anime = state.animes[index];
); return GridItem(
}, minusCallback: () {
child: ListItem( context.read<AnimeListBloc>().add(
title: anime.title, AnimeEpisodeDecrementedEvent(
thumbnailUrl: anime.thumbnailUrl, anime.id,
extra: [ ),
Text( );
'${anime.episodesWatched}/${anime.episodesTotal ?? "???"}', },
style: Theme.of(context).textTheme.titleMedium, plusCallback: () {
context.read<AnimeListBloc>().add(
AnimeEpisodeIncrementedEvent(
anime.id,
),
);
},
child: AnimeCoverImage(
url: anime.thumbnailUrl,
onTap: () {
context.read<DetailsBloc>().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<AnimeListBloc>().add( },
AnimeEpisodeDecrementedEvent(state.animes[index].id), ),
);
},
onRightSwipe: () {
context.read<AnimeListBloc>().add(
AnimeEpisodeIncrementedEvent(state.animes[index].id),
);
},
),
);
},
), ),
ListView.builder( Padding(
itemCount: state.mangas.length, padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) { child: GridView.builder(
final manga = state.mangas[index]; gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
if (state.mangaFilterState != MediumTrackingState.all) { crossAxisCount: 3,
if (manga.state != state.mangaFilterState) return Container(); mainAxisSpacing: 8,
} crossAxisSpacing: 8,
childAspectRatio: 120 / (100 * (16 / 9)),
return InkWell( ),
onTap: () { itemCount: state.mangas.length,
context.read<DetailsBloc>().add( itemBuilder: (context, index) {
MangaDetailsRequestedEvent(manga), final manga = state.mangas[index];
); return GridItem(
}, minusCallback: () {
child: ListItem( context.read<AnimeListBloc>().add(
title: manga.title, MangaChapterDecrementedEvent(
thumbnailUrl: manga.thumbnailUrl, manga.id,
extra: [ ),
Text( );
'${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', },
style: Theme.of(context).textTheme.titleMedium, plusCallback: () {
context.read<AnimeListBloc>().add(
MangaChapterIncrementedEvent(
manga.id,
),
);
},
child: AnimeCoverImage(
url: manga.thumbnailUrl,
onTap: () {
context.read<DetailsBloc>().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<AnimeListBloc>().add( },
MangaChapterDecrementedEvent(state.mangas[index].id), ),
);
},
onRightSwipe: () {
context.read<AnimeListBloc>().add(
MangaChapterIncrementedEvent(state.mangas[index].id),
);
},
),
);
},
), ),
], ],
), ),

View File

@ -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<GridItem> {
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,
),
],
),
);
}
}

View File

@ -5,15 +5,23 @@ class AnimeCoverImage extends StatelessWidget {
const AnimeCoverImage({ const AnimeCoverImage({
required this.url, required this.url,
this.cached = true, this.cached = true,
this.extra,
this.onTap,
super.key, super.key,
}); });
/// The URL to the cover image. /// The URL to the cover image.
final String url; final String url;
/// Flag indicating if the image should be cached /// Flag indicating if the image should be cached.
final bool 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return ClipRRect(
@ -21,14 +29,41 @@ class AnimeCoverImage extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 100 * (16 / 9), height: 100 * (16 / 9),
width: 120, width: 120,
child: DecoratedBox( child: InkWell(
decoration: BoxDecoration( onTap: onTap ?? () {},
image: DecorationImage( child: Stack(
image: cached ? children: [
CachedNetworkImageProvider(url) as ImageProvider : Positioned(
NetworkImage(url), left: 0,
fit: BoxFit.cover, 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,
),
),
),
],
), ),
), ),
), ),

View File

@ -12,6 +12,7 @@ class ListItem extends StatelessWidget {
this.onRightSwipe, this.onRightSwipe,
this.cached = true, this.cached = true,
this.extra = const [], this.extra = const [],
this.imageExtra,
super.key, super.key,
}); });
@ -24,6 +25,8 @@ class ListItem extends StatelessWidget {
/// Extra widgets. /// Extra widgets.
final List<Widget> extra; final List<Widget> extra;
final Widget? imageExtra;
/// Callbacks for the swipe functionality. /// Callbacks for the swipe functionality.
final void Function()? onLeftSwipe; final void Function()? onLeftSwipe;
final void Function()? onRightSwipe; final void Function()? onRightSwipe;
@ -73,6 +76,7 @@ class ListItem extends StatelessWidget {
children: [ children: [
AnimeCoverImage( AnimeCoverImage(
cached: cached, cached: cached,
extra: imageExtra,
url: thumbnailUrl, url: thumbnailUrl,
), ),