feat(ui): Implement a simple details screen

This commit is contained in:
PapaTutuWawa 2023-02-04 17:17:00 +01:00
parent 8794c3cd60
commit 1892ec5e7a
12 changed files with 574 additions and 40 deletions

View File

@ -1,9 +1,11 @@
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/bloc/navigation_bloc.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/pages/anime_list.dart';
import 'package:anitrack/src/ui/pages/anime_search.dart';
import 'package:anitrack/src/ui/pages/details.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -23,6 +25,7 @@ void main() async {
GetIt.I.registerSingleton<DatabaseService>(database);
GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc());
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc());
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
// Load animes
@ -39,6 +42,9 @@ void main() async {
BlocProvider<AnimeSearchBloc>(
create: (_) => GetIt.I.get<AnimeSearchBloc>(),
),
BlocProvider<DetailsBloc>(
create: (_) => GetIt.I.get<DetailsBloc>(),
),
BlocProvider<NavigationBloc>(
create: (_) => GetIt.I.get<NavigationBloc>(),
),
@ -77,6 +83,7 @@ class MyApp extends StatelessWidget {
case '/':
case animeListRoute: return AnimeListPage.route;
case animeSearchRoute: return AnimeSearchPage.route;
case detailsRoute: return DetailsPage.route;
}
return null;

View File

@ -24,6 +24,18 @@ extension AnimeTrackStateExtension on AnimeTrackingState {
case AnimeTrackingState.all: return -1;
}
}
String toNameString() {
assert(this != AnimeTrackingState.all, 'AnimeTrackingState.all must not be stringified');
switch (this) {
case AnimeTrackingState.watching: return 'Watching';
case AnimeTrackingState.completed: return 'Completed';
case AnimeTrackingState.planToWatch: return 'Plan to watch';
case AnimeTrackingState.dropped: return 'Dropped';
case AnimeTrackingState.all: return 'All';
}
}
}
class AnimeTrackingStateConverter implements JsonConverter<AnimeTrackingState, int> {

View File

@ -7,7 +7,7 @@ part 'manga.g.dart';
enum MangaTrackingState {
reading, // 0
completed, // 1
planToWatch, // 2
planToRead, // 2
dropped, // 3
/// This is a pseudo state, i.e. it should never be set
all, // -1
@ -19,11 +19,23 @@ extension MangaTrackStateExtension on MangaTrackingState {
switch (this) {
case MangaTrackingState.reading: return 0;
case MangaTrackingState.completed: return 1;
case MangaTrackingState.planToWatch: return 2;
case MangaTrackingState.planToRead: return 2;
case MangaTrackingState.dropped: return 3;
case MangaTrackingState.all: return -1;
}
}
String toNameString() {
assert(this != MangaTrackingState.all, 'MangaTrackingState.all must not be stringified');
switch (this) {
case MangaTrackingState.reading: return 'Reading';
case MangaTrackingState.completed: return 'Completed';
case MangaTrackingState.planToRead: return 'Plan to read';
case MangaTrackingState.dropped: return 'Dropped';
case MangaTrackingState.all: return 'All';
}
}
}
class MangaTrackingStateConverter implements JsonConverter<MangaTrackingState, int> {
@ -34,11 +46,11 @@ class MangaTrackingStateConverter implements JsonConverter<MangaTrackingState, i
switch (json) {
case 0: return MangaTrackingState.reading;
case 1: return MangaTrackingState.completed;
case 2: return MangaTrackingState.planToWatch;
case 2: return MangaTrackingState.planToRead;
case 3: return MangaTrackingState.dropped;
}
return MangaTrackingState.planToWatch;
return MangaTrackingState.planToRead;
}
@override

View File

@ -23,6 +23,8 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
on<MangaFilterChangedEvent>(_onMangasFiltered);
on<MangaChapterIncrementedEvent>(_onMangaIncremented);
on<MangaChapterDecrementedEvent>(_onMangaDecremented);
on<AnimeUpdatedEvent>(_onAnimeUpdated);
on<MangaUpdatedEvent>(_onMangaUpdated);
}
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async {
@ -173,4 +175,36 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().updateManga(newManga);
}
Future<void> _onAnimeUpdated(AnimeUpdatedEvent event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
animes: List.from(
state.animes.map((anime) {
if (anime.id == event.anime.id) {
return event.anime;
}
return anime;
}),
),
),
);
}
Future<void> _onMangaUpdated(MangaUpdatedEvent event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
mangas: List.from(
state.mangas.map((manga) {
if (manga.id == event.manga.id) {
return event.manga;
}
return manga;
}),
),
),
);
}
}

View File

@ -42,6 +42,12 @@ class AnimeTrackingTypeChanged extends AnimeListEvent {
final TrackingMediumType type;
}
class AnimeUpdatedEvent extends AnimeListEvent {
AnimeUpdatedEvent(this.anime);
final AnimeTrackingData anime;
}
class MangaAddedEvent extends AnimeListEvent {
MangaAddedEvent(this.data);
@ -70,3 +76,9 @@ class MangaChapterDecrementedEvent extends AnimeListEvent {
/// The ID of the anime
final String id;
}
class MangaUpdatedEvent extends AnimeListEvent {
MangaUpdatedEvent(this.manga);
final MangaTrackingData manga;
}

View File

@ -0,0 +1,84 @@
import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/data/manga.dart';
import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:anitrack/src/ui/bloc/navigation_bloc.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
part 'details_state.dart';
part 'details_event.dart';
part 'details_bloc.freezed.dart';
class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
DetailsBloc() : super(DetailsState()) {
on<AnimeDetailsRequestedEvent>(_onAnimeRequested);
on<MangaDetailsRequestedEvent>(_onMangaRequested);
on<DetailsUpdatedEvent>(_onDetailsUpdated);
}
Future<void> _onAnimeRequested(AnimeDetailsRequestedEvent event, Emitter<DetailsState> emit) async {
emit(
state.copyWith(
trackingType: TrackingMediumType.anime,
data: event.anime,
),
);
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
NavigationDestination(
detailsRoute,
),
),
);
}
Future<void> _onMangaRequested(MangaDetailsRequestedEvent event, Emitter<DetailsState> emit) async {
emit(
state.copyWith(
trackingType: TrackingMediumType.manga,
data: event.manga,
),
);
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
NavigationDestination(
detailsRoute,
),
),
);
}
Future<void> _onDetailsUpdated(DetailsUpdatedEvent event, Emitter<DetailsState> emit) async {
if (state.trackingType == TrackingMediumType.anime) {
emit(
state.copyWith(
data: event.data,
),
);
await GetIt.I.get<DatabaseService>().updateAnime(event.data as AnimeTrackingData);
GetIt.I.get<AnimeListBloc>().add(
AnimeUpdatedEvent(event.data as AnimeTrackingData),
);
} else {
emit(
state.copyWith(
data: event.data,
),
);
await GetIt.I.get<DatabaseService>().updateManga(event.data as MangaTrackingData);
GetIt.I.get<AnimeListBloc>().add(
MangaUpdatedEvent(event.data as MangaTrackingData),
);
}
}
}

View File

@ -0,0 +1,151 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target
part of 'details_bloc.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DetailsState {
dynamic get data => throw _privateConstructorUsedError;
TrackingMediumType get trackingType => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DetailsStateCopyWith<DetailsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DetailsStateCopyWith<$Res> {
factory $DetailsStateCopyWith(
DetailsState value, $Res Function(DetailsState) then) =
_$DetailsStateCopyWithImpl<$Res>;
$Res call({dynamic data, TrackingMediumType trackingType});
}
/// @nodoc
class _$DetailsStateCopyWithImpl<$Res> implements $DetailsStateCopyWith<$Res> {
_$DetailsStateCopyWithImpl(this._value, this._then);
final DetailsState _value;
// ignore: unused_field
final $Res Function(DetailsState) _then;
@override
$Res call({
Object? data = freezed,
Object? trackingType = freezed,
}) {
return _then(_value.copyWith(
data: data == freezed
? _value.data
: data // ignore: cast_nullable_to_non_nullable
as dynamic,
trackingType: trackingType == freezed
? _value.trackingType
: trackingType // ignore: cast_nullable_to_non_nullable
as TrackingMediumType,
));
}
}
/// @nodoc
abstract class _$$_DetailsStateCopyWith<$Res>
implements $DetailsStateCopyWith<$Res> {
factory _$$_DetailsStateCopyWith(
_$_DetailsState value, $Res Function(_$_DetailsState) then) =
__$$_DetailsStateCopyWithImpl<$Res>;
@override
$Res call({dynamic data, TrackingMediumType trackingType});
}
/// @nodoc
class __$$_DetailsStateCopyWithImpl<$Res>
extends _$DetailsStateCopyWithImpl<$Res>
implements _$$_DetailsStateCopyWith<$Res> {
__$$_DetailsStateCopyWithImpl(
_$_DetailsState _value, $Res Function(_$_DetailsState) _then)
: super(_value, (v) => _then(v as _$_DetailsState));
@override
_$_DetailsState get _value => super._value as _$_DetailsState;
@override
$Res call({
Object? data = freezed,
Object? trackingType = freezed,
}) {
return _then(_$_DetailsState(
data: data == freezed
? _value.data
: data // ignore: cast_nullable_to_non_nullable
as dynamic,
trackingType: trackingType == freezed
? _value.trackingType
: trackingType // ignore: cast_nullable_to_non_nullable
as TrackingMediumType,
));
}
}
/// @nodoc
class _$_DetailsState implements _DetailsState {
_$_DetailsState({this.data, this.trackingType = TrackingMediumType.anime});
@override
final dynamic data;
@override
@JsonKey()
final TrackingMediumType trackingType;
@override
String toString() {
return 'DetailsState(data: $data, trackingType: $trackingType)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DetailsState &&
const DeepCollectionEquality().equals(other.data, data) &&
const DeepCollectionEquality()
.equals(other.trackingType, trackingType));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(data),
const DeepCollectionEquality().hash(trackingType));
@JsonKey(ignore: true)
@override
_$$_DetailsStateCopyWith<_$_DetailsState> get copyWith =>
__$$_DetailsStateCopyWithImpl<_$_DetailsState>(this, _$identity);
}
abstract class _DetailsState implements DetailsState {
factory _DetailsState(
{final dynamic data,
final TrackingMediumType trackingType}) = _$_DetailsState;
@override
dynamic get data;
@override
TrackingMediumType get trackingType;
@override
@JsonKey(ignore: true)
_$$_DetailsStateCopyWith<_$_DetailsState> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,23 @@
part of 'details_bloc.dart';
abstract class DetailsEvent {}
class AnimeDetailsRequestedEvent extends DetailsEvent {
AnimeDetailsRequestedEvent(this.anime);
/// The anime to show details about
final AnimeTrackingData anime;
}
class MangaDetailsRequestedEvent extends DetailsEvent {
MangaDetailsRequestedEvent(this.manga);
/// The manga to show details about
final MangaTrackingData manga;
}
class DetailsUpdatedEvent extends DetailsEvent {
DetailsUpdatedEvent(this.data);
final dynamic data;
}

View File

@ -0,0 +1,9 @@
part of 'details_bloc.dart';
@freezed
class DetailsState with _$DetailsState {
factory DetailsState({
dynamic data,
@Default(TrackingMediumType.anime) TrackingMediumType trackingType,
}) = _DetailsState;
}

View File

@ -1,2 +1,3 @@
const animeListRoute = '/anime/list';
const animeSearchRoute = '/anime/search';
const detailsRoute = '/anime/details';

View File

@ -3,6 +3,7 @@ import 'package:anitrack/src/data/manga.dart';
import 'package:anitrack/src/data/type.dart';
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:bottom_bar/bottom_bar.dart';
@ -83,8 +84,8 @@ class AnimeListPage extends StatelessWidget {
child: Text('Completed'),
),
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.planToWatch,
child: Text('Plan to watch'),
value: MangaTrackingState.planToRead,
child: Text('Plan to read'),
),
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.dropped,
@ -123,25 +124,32 @@ class AnimeListPage extends StatelessWidget {
if (anime.state != state.animeFilterState) return Container();
}
return ListItem(
title: anime.title,
thumbnailUrl: anime.thumbnailUrl,
extra: [
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),
return InkWell(
onTap: () {
context.read<DetailsBloc>().add(
AnimeDetailsRequestedEvent(anime),
);
},
child: ListItem(
title: anime.title,
thumbnailUrl: anime.thumbnailUrl,
extra: [
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),
);
},
),
);
},
),
@ -153,25 +161,32 @@ class AnimeListPage extends StatelessWidget {
if (manga.state != state.mangaFilterState) return Container();
}
return ListItem(
title: manga.title,
thumbnailUrl: manga.thumbnailUrl,
extra: [
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),
return InkWell(
onTap: () {
context.read<DetailsBloc>().add(
MangaDetailsRequestedEvent(manga),
);
},
child: ListItem(
title: manga.title,
thumbnailUrl: manga.thumbnailUrl,
extra: [
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,174 @@
import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/data/manga.dart';
import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/widgets/image.dart';
import 'package:anitrack/src/ui/widgets/list_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Widget _makeListTile(BuildContext context, dynamic value, String text, bool selected) {
return ListTile(
title: Text(text),
trailing: selected ?
Icon(Icons.check) :
null,
onTap: () {
Navigator.of(context).pop(value);
},
);
}
class DetailsPage extends StatelessWidget {
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => DetailsPage(),
settings: const RouteSettings(
name: detailsRoute,
),
);
String _getTrackingStateText(DetailsState state) {
if (state.trackingType == TrackingMediumType.anime) {
return (state.data as AnimeTrackingData).state.toNameString();
} else {
return (state.data as MangaTrackingData).state.toNameString();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Details'),
),
body: BlocBuilder<DetailsBloc, DetailsState>(
builder: (context, state) {
return state.data == null ?
Container() :
Padding(
padding: const EdgeInsets.all(8),
child: ListView(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimeCoverImage(
url: state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).thumbnailUrl :
(state.data as MangaTrackingData).thumbnailUrl,
),
Padding(
padding: const EdgeInsets.only(
left: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).title :
(state.data as MangaTrackingData).title,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.titleLarge,
maxLines: 2,
softWrap: true,
overflow: TextOverflow.ellipsis,
),
TextButton(
child: Text(
_getTrackingStateText(state),
),
onPressed: () async {
final result = await showModalBottomSheet<dynamic>(
context: context,
builder: (context) {
return ListView(
shrinkWrap: true,
children: [
_makeListTile(
context,
state.trackingType == TrackingMediumType.anime ?
AnimeTrackingState.watching :
MangaTrackingState.reading,
state.trackingType == TrackingMediumType.anime ?
'Watching' :
'Reading',
state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).state == AnimeTrackingState.watching :
(state.data as MangaTrackingData).state == MangaTrackingState.reading,
),
_makeListTile(
context,
state.trackingType == TrackingMediumType.anime ?
AnimeTrackingState.completed :
MangaTrackingState.completed,
'Completed',
state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).state == AnimeTrackingState.completed :
(state.data as MangaTrackingData).state == MangaTrackingState.completed,
),
_makeListTile(
context,
state.trackingType == TrackingMediumType.anime ?
AnimeTrackingState.planToWatch :
MangaTrackingState.planToRead,
state.trackingType == TrackingMediumType.anime ?
'Plan to watch' :
'Plan to read',
state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).state == AnimeTrackingState.planToWatch :
(state.data as MangaTrackingData).state == MangaTrackingState.planToRead,
),
_makeListTile(
context,
state.trackingType == TrackingMediumType.anime ?
AnimeTrackingState.dropped :
MangaTrackingState.dropped,
'Dropped',
state.trackingType == TrackingMediumType.anime ?
(state.data as AnimeTrackingData).state == AnimeTrackingState.dropped :
(state.data as MangaTrackingData).state == MangaTrackingState.dropped,
),
],
);
},
);
if (result == null) return;
if (state.trackingType == TrackingMediumType.anime) {
context.read<DetailsBloc>().add(
DetailsUpdatedEvent(
(state.data as AnimeTrackingData).copyWith(
state: result as AnimeTrackingState,
),
),
);
} else {
context.read<DetailsBloc>().add(
DetailsUpdatedEvent(
(state.data as MangaTrackingData).copyWith(
state: result as MangaTrackingState,
),
),
);
}
},
),
],
),
),
],
),
],
),
);
},
),
);
}
}