Initial commit
This commit is contained in:
70
lib/main.dart
Normal file
70
lib/main.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
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/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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
void main() {
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
|
||||
GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc());
|
||||
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
|
||||
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
|
||||
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AnimeListBloc>(
|
||||
create: (_) => GetIt.I.get<AnimeListBloc>(),
|
||||
),
|
||||
BlocProvider<AnimeSearchBloc>(
|
||||
create: (_) => GetIt.I.get<AnimeSearchBloc>(),
|
||||
),
|
||||
BlocProvider<NavigationBloc>(
|
||||
create: (_) => GetIt.I.get<NavigationBloc>(),
|
||||
),
|
||||
],
|
||||
child: MyApp(navKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp(
|
||||
this.navKey, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final GlobalKey<NavigatorState> navKey;
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'AniTrack',
|
||||
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
navigatorKey: navKey,
|
||||
onGenerateRoute: (settings) {
|
||||
switch (settings.name) {
|
||||
case '/':
|
||||
case animeListRoute: return AnimeListPage.route;
|
||||
case animeSearchRoute: return AnimeSearchPage.route;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/src/data/anime.dart
Normal file
50
lib/src/data/anime.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
/// The watch state of an anime
|
||||
enum AnimeTrackingState {
|
||||
watching,
|
||||
completed,
|
||||
planToWatch,
|
||||
dropped,
|
||||
}
|
||||
|
||||
/// Data about a tracked anime
|
||||
class AnimeTrackingData {
|
||||
const AnimeTrackingData(
|
||||
this.id,
|
||||
this.state,
|
||||
this.title,
|
||||
this.episodesWatched,
|
||||
this.episodesTotal,
|
||||
this.thumbnailUrl,
|
||||
);
|
||||
|
||||
/// The ID of the anime
|
||||
final String id;
|
||||
|
||||
/// The state of the anime
|
||||
final AnimeTrackingState state;
|
||||
|
||||
/// The title of the anime
|
||||
final String title;
|
||||
|
||||
/// Episodes in total.
|
||||
final int? episodesTotal;
|
||||
|
||||
/// Episodes watched.
|
||||
final int episodesWatched;
|
||||
|
||||
/// URL to the thumbnail/cover art for the anime.
|
||||
final String thumbnailUrl;
|
||||
|
||||
AnimeTrackingData copyWith(
|
||||
int episodesWatched,
|
||||
) {
|
||||
return AnimeTrackingData(
|
||||
id,
|
||||
state,
|
||||
title,
|
||||
episodesWatched,
|
||||
episodesTotal,
|
||||
thumbnailUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/src/data/search_result.dart
Normal file
25
lib/src/data/search_result.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
class AnimeSearchResult {
|
||||
const AnimeSearchResult(
|
||||
this.title,
|
||||
this.id,
|
||||
this.episodesTotal,
|
||||
this.thumbnailUrl,
|
||||
this.description,
|
||||
);
|
||||
|
||||
/// The title of the anime.
|
||||
final String title;
|
||||
|
||||
/// The id of the anime.
|
||||
final String id;
|
||||
|
||||
/// The URL to a thumbnail image.
|
||||
final String thumbnailUrl;
|
||||
|
||||
/// The amount of total episodes. If null, it means that there is not total amount
|
||||
/// of episodes set.
|
||||
final int? episodesTotal;
|
||||
|
||||
/// The description of the anime
|
||||
final String description;
|
||||
}
|
||||
68
lib/src/ui/bloc/anime_list_bloc.dart
Normal file
68
lib/src/ui/bloc/anime_list_bloc.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'dart:math';
|
||||
import 'package:anitrack/src/data/anime.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'anime_list_state.dart';
|
||||
part 'anime_list_event.dart';
|
||||
part 'anime_list_bloc.freezed.dart';
|
||||
|
||||
class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
|
||||
AnimeListBloc() : super(AnimeListState()) {
|
||||
on<AnimeAddedEvent>(_onAnimeAdded);
|
||||
on<AnimeEpisodeIncrementedEvent>(_onIncremented);
|
||||
on<AnimeEpisodeDecrementedEvent>(_onDecremented);
|
||||
}
|
||||
|
||||
// TODO: Remove
|
||||
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
animes: List.from([
|
||||
...state.animes,
|
||||
event.data,
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onIncremented(AnimeEpisodeIncrementedEvent event, Emitter<AnimeListState> emit) async {
|
||||
final index = state.animes.indexWhere((item) => item.id == event.id);
|
||||
if (index == -1) return;
|
||||
|
||||
final anime = state.animes[index];
|
||||
if (anime.episodesTotal != null && anime.episodesWatched + 1 > anime.episodesTotal!) return;
|
||||
|
||||
final newList = List<AnimeTrackingData>.from(state.animes);
|
||||
newList[index] = anime.copyWith(
|
||||
anime.episodesWatched + 1,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
animes: newList,
|
||||
),
|
||||
);
|
||||
print('${event.id} incremented');
|
||||
}
|
||||
|
||||
Future<void> _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter<AnimeListState> emit) async {
|
||||
final index = state.animes.indexWhere((item) => item.id == event.id);
|
||||
if (index == -1) return;
|
||||
|
||||
final anime = state.animes[index];
|
||||
if (anime.episodesWatched - 1 < 0) return;
|
||||
|
||||
final newList = List<AnimeTrackingData>.from(state.animes);
|
||||
newList[index] = anime.copyWith(
|
||||
anime.episodesWatched - 1,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
animes: newList,
|
||||
),
|
||||
);
|
||||
print('${event.id} decremented');
|
||||
}
|
||||
}
|
||||
137
lib/src/ui/bloc/anime_list_bloc.freezed.dart
Normal file
137
lib/src/ui/bloc/anime_list_bloc.freezed.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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 'anime_list_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 _$AnimeListState {
|
||||
List<AnimeTrackingData> get animes => throw _privateConstructorUsedError;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
$AnimeListStateCopyWith<AnimeListState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AnimeListStateCopyWith<$Res> {
|
||||
factory $AnimeListStateCopyWith(
|
||||
AnimeListState value, $Res Function(AnimeListState) then) =
|
||||
_$AnimeListStateCopyWithImpl<$Res>;
|
||||
$Res call({List<AnimeTrackingData> animes});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AnimeListStateCopyWithImpl<$Res>
|
||||
implements $AnimeListStateCopyWith<$Res> {
|
||||
_$AnimeListStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
final AnimeListState _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function(AnimeListState) _then;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? animes = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
animes: animes == freezed
|
||||
? _value.animes
|
||||
: animes // ignore: cast_nullable_to_non_nullable
|
||||
as List<AnimeTrackingData>,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$_AnimeListStateCopyWith<$Res>
|
||||
implements $AnimeListStateCopyWith<$Res> {
|
||||
factory _$$_AnimeListStateCopyWith(
|
||||
_$_AnimeListState value, $Res Function(_$_AnimeListState) then) =
|
||||
__$$_AnimeListStateCopyWithImpl<$Res>;
|
||||
@override
|
||||
$Res call({List<AnimeTrackingData> animes});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$_AnimeListStateCopyWithImpl<$Res>
|
||||
extends _$AnimeListStateCopyWithImpl<$Res>
|
||||
implements _$$_AnimeListStateCopyWith<$Res> {
|
||||
__$$_AnimeListStateCopyWithImpl(
|
||||
_$_AnimeListState _value, $Res Function(_$_AnimeListState) _then)
|
||||
: super(_value, (v) => _then(v as _$_AnimeListState));
|
||||
|
||||
@override
|
||||
_$_AnimeListState get _value => super._value as _$_AnimeListState;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? animes = freezed,
|
||||
}) {
|
||||
return _then(_$_AnimeListState(
|
||||
animes: animes == freezed
|
||||
? _value._animes
|
||||
: animes // ignore: cast_nullable_to_non_nullable
|
||||
as List<AnimeTrackingData>,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$_AnimeListState implements _AnimeListState {
|
||||
_$_AnimeListState({final List<AnimeTrackingData> animes = const []})
|
||||
: _animes = animes;
|
||||
|
||||
final List<AnimeTrackingData> _animes;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<AnimeTrackingData> get animes {
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_animes);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AnimeListState(animes: $animes)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$_AnimeListState &&
|
||||
const DeepCollectionEquality().equals(other._animes, _animes));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, const DeepCollectionEquality().hash(_animes));
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
_$$_AnimeListStateCopyWith<_$_AnimeListState> get copyWith =>
|
||||
__$$_AnimeListStateCopyWithImpl<_$_AnimeListState>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _AnimeListState implements AnimeListState {
|
||||
factory _AnimeListState({final List<AnimeTrackingData> animes}) =
|
||||
_$_AnimeListState;
|
||||
|
||||
@override
|
||||
List<AnimeTrackingData> get animes;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$_AnimeListStateCopyWith<_$_AnimeListState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
24
lib/src/ui/bloc/anime_list_event.dart
Normal file
24
lib/src/ui/bloc/anime_list_event.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
part of 'anime_list_bloc.dart';
|
||||
|
||||
abstract class AnimeListEvent {}
|
||||
|
||||
class AnimeEpisodeIncrementedEvent extends AnimeListEvent {
|
||||
AnimeEpisodeIncrementedEvent(this.id);
|
||||
|
||||
/// The ID of the anime
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AnimeEpisodeDecrementedEvent extends AnimeListEvent {
|
||||
AnimeEpisodeDecrementedEvent(this.id);
|
||||
|
||||
/// The ID of the anime
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AnimeAddedEvent extends AnimeListEvent {
|
||||
AnimeAddedEvent(this.data);
|
||||
|
||||
/// The anime to add.
|
||||
final AnimeTrackingData data;
|
||||
}
|
||||
8
lib/src/ui/bloc/anime_list_state.dart
Normal file
8
lib/src/ui/bloc/anime_list_state.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
part of 'anime_list_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class AnimeListState with _$AnimeListState {
|
||||
factory AnimeListState({
|
||||
@Default([]) List<AnimeTrackingData> animes,
|
||||
}) = _AnimeListState;
|
||||
}
|
||||
98
lib/src/ui/bloc/anime_search_bloc.dart
Normal file
98
lib/src/ui/bloc/anime_search_bloc.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:anitrack/src/data/anime.dart';
|
||||
import 'package:anitrack/src/data/search_result.dart';
|
||||
import 'package:anitrack/src/ui/constants.dart';
|
||||
import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart' as list;
|
||||
import 'package:anitrack/src/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jikan_api/jikan_api.dart';
|
||||
|
||||
part 'anime_search_state.dart';
|
||||
part 'anime_search_event.dart';
|
||||
part 'anime_search_bloc.freezed.dart';
|
||||
|
||||
class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
|
||||
AnimeSearchBloc() : super(AnimeSearchState()) {
|
||||
on<AnimeSearchRequestedEvent>(_onRequested);
|
||||
on<SearchQueryChangedEvent>(_onQueryChanged);
|
||||
on<SearchQuerySubmittedEvent>(_onQuerySubmitted);
|
||||
on<AnimeAddedEvent>(_onAnimeAdded);
|
||||
}
|
||||
|
||||
Future<void> _onRequested(AnimeSearchRequestedEvent event, Emitter<AnimeSearchState> emit) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchQuery: '',
|
||||
working: false,
|
||||
searchResults: [],
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedEvent(
|
||||
NavigationDestination(animeSearchRoute),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onQueryChanged(SearchQueryChangedEvent event, Emitter<AnimeSearchState> emit) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchQuery: event.query,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onQuerySubmitted(SearchQuerySubmittedEvent event, Emitter<AnimeSearchState> emit) async {
|
||||
if (state.searchQuery.isEmpty) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
working: true,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await Jikan().searchAnime(
|
||||
query: state.searchQuery,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
working: false,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchResults: result.map((Anime anime) => AnimeSearchResult(
|
||||
anime.title,
|
||||
anime.malId.toString(),
|
||||
anime.episodes,
|
||||
anime.imageUrl,
|
||||
anime.synopsis ?? '',
|
||||
),).toList(),
|
||||
),
|
||||
);
|
||||
print(result);
|
||||
}
|
||||
|
||||
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeSearchState> emit) async {
|
||||
GetIt.I.get<list.AnimeListBloc>().add(
|
||||
list.AnimeAddedEvent(
|
||||
AnimeTrackingData(
|
||||
event.result.id,
|
||||
AnimeTrackingState.watching,
|
||||
event.result.title,
|
||||
0,
|
||||
event.result.episodesTotal,
|
||||
event.result.thumbnailUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PoppedRouteEvent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
lib/src/ui/bloc/anime_search_bloc.freezed.dart
Normal file
188
lib/src/ui/bloc/anime_search_bloc.freezed.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
// 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 'anime_search_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 _$AnimeSearchState {
|
||||
String get searchQuery => throw _privateConstructorUsedError;
|
||||
bool get working => throw _privateConstructorUsedError;
|
||||
List<AnimeSearchResult> get searchResults =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
$AnimeSearchStateCopyWith<AnimeSearchState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AnimeSearchStateCopyWith<$Res> {
|
||||
factory $AnimeSearchStateCopyWith(
|
||||
AnimeSearchState value, $Res Function(AnimeSearchState) then) =
|
||||
_$AnimeSearchStateCopyWithImpl<$Res>;
|
||||
$Res call(
|
||||
{String searchQuery,
|
||||
bool working,
|
||||
List<AnimeSearchResult> searchResults});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AnimeSearchStateCopyWithImpl<$Res>
|
||||
implements $AnimeSearchStateCopyWith<$Res> {
|
||||
_$AnimeSearchStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
final AnimeSearchState _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function(AnimeSearchState) _then;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? searchQuery = freezed,
|
||||
Object? working = freezed,
|
||||
Object? searchResults = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
searchQuery: searchQuery == freezed
|
||||
? _value.searchQuery
|
||||
: searchQuery // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
working: working == freezed
|
||||
? _value.working
|
||||
: working // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
searchResults: searchResults == freezed
|
||||
? _value.searchResults
|
||||
: searchResults // ignore: cast_nullable_to_non_nullable
|
||||
as List<AnimeSearchResult>,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$_AnimeSearchStateCopyWith<$Res>
|
||||
implements $AnimeSearchStateCopyWith<$Res> {
|
||||
factory _$$_AnimeSearchStateCopyWith(
|
||||
_$_AnimeSearchState value, $Res Function(_$_AnimeSearchState) then) =
|
||||
__$$_AnimeSearchStateCopyWithImpl<$Res>;
|
||||
@override
|
||||
$Res call(
|
||||
{String searchQuery,
|
||||
bool working,
|
||||
List<AnimeSearchResult> searchResults});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$_AnimeSearchStateCopyWithImpl<$Res>
|
||||
extends _$AnimeSearchStateCopyWithImpl<$Res>
|
||||
implements _$$_AnimeSearchStateCopyWith<$Res> {
|
||||
__$$_AnimeSearchStateCopyWithImpl(
|
||||
_$_AnimeSearchState _value, $Res Function(_$_AnimeSearchState) _then)
|
||||
: super(_value, (v) => _then(v as _$_AnimeSearchState));
|
||||
|
||||
@override
|
||||
_$_AnimeSearchState get _value => super._value as _$_AnimeSearchState;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? searchQuery = freezed,
|
||||
Object? working = freezed,
|
||||
Object? searchResults = freezed,
|
||||
}) {
|
||||
return _then(_$_AnimeSearchState(
|
||||
searchQuery: searchQuery == freezed
|
||||
? _value.searchQuery
|
||||
: searchQuery // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
working: working == freezed
|
||||
? _value.working
|
||||
: working // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
searchResults: searchResults == freezed
|
||||
? _value._searchResults
|
||||
: searchResults // ignore: cast_nullable_to_non_nullable
|
||||
as List<AnimeSearchResult>,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$_AnimeSearchState implements _AnimeSearchState {
|
||||
_$_AnimeSearchState(
|
||||
{this.searchQuery = '',
|
||||
this.working = false,
|
||||
final List<AnimeSearchResult> searchResults = const []})
|
||||
: _searchResults = searchResults;
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final String searchQuery;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool working;
|
||||
final List<AnimeSearchResult> _searchResults;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<AnimeSearchResult> get searchResults {
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_searchResults);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AnimeSearchState(searchQuery: $searchQuery, working: $working, searchResults: $searchResults)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$_AnimeSearchState &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.searchQuery, searchQuery) &&
|
||||
const DeepCollectionEquality().equals(other.working, working) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._searchResults, _searchResults));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(searchQuery),
|
||||
const DeepCollectionEquality().hash(working),
|
||||
const DeepCollectionEquality().hash(_searchResults));
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
_$$_AnimeSearchStateCopyWith<_$_AnimeSearchState> get copyWith =>
|
||||
__$$_AnimeSearchStateCopyWithImpl<_$_AnimeSearchState>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _AnimeSearchState implements AnimeSearchState {
|
||||
factory _AnimeSearchState(
|
||||
{final String searchQuery,
|
||||
final bool working,
|
||||
final List<AnimeSearchResult> searchResults}) = _$_AnimeSearchState;
|
||||
|
||||
@override
|
||||
String get searchQuery;
|
||||
@override
|
||||
bool get working;
|
||||
@override
|
||||
List<AnimeSearchResult> get searchResults;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$_AnimeSearchStateCopyWith<_$_AnimeSearchState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
24
lib/src/ui/bloc/anime_search_event.dart
Normal file
24
lib/src/ui/bloc/anime_search_event.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
part of 'anime_search_bloc.dart';
|
||||
|
||||
abstract class AnimeSearchEvent {}
|
||||
|
||||
class AnimeSearchRequestedEvent extends AnimeSearchEvent {}
|
||||
|
||||
/// Triggered when the search query is changed.
|
||||
class SearchQueryChangedEvent extends AnimeSearchEvent {
|
||||
SearchQueryChangedEvent(this.query);
|
||||
|
||||
/// The current value of the query
|
||||
final String query;
|
||||
}
|
||||
|
||||
/// Triggered when the search is submitted.
|
||||
class SearchQuerySubmittedEvent extends AnimeSearchEvent {}
|
||||
|
||||
/// Triggered when an anime is added to the tracking list
|
||||
class AnimeAddedEvent extends AnimeSearchEvent {
|
||||
AnimeAddedEvent(this.result);
|
||||
|
||||
/// The search result to add.
|
||||
final AnimeSearchResult result;
|
||||
}
|
||||
10
lib/src/ui/bloc/anime_search_state.dart
Normal file
10
lib/src/ui/bloc/anime_search_state.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
part of 'anime_search_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class AnimeSearchState with _$AnimeSearchState {
|
||||
factory AnimeSearchState({
|
||||
@Default('') String searchQuery,
|
||||
@Default(false) bool working,
|
||||
@Default([]) List<AnimeSearchResult> searchResults,
|
||||
}) = _AnimeSearchState;
|
||||
}
|
||||
46
lib/src/ui/bloc/navigation_bloc.dart
Normal file
46
lib/src/ui/bloc/navigation_bloc.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'navigation_event.dart';
|
||||
part 'navigation_state.dart';
|
||||
|
||||
class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
|
||||
NavigationBloc(this.navigationKey) : super(NavigationState()) {
|
||||
on<PushedNamedEvent>(_onPushedNamed);
|
||||
on<PushedNamedAndRemoveUntilEvent>(_onPushedNamedAndRemoveUntil);
|
||||
on<PushedNamedReplaceEvent>(_onPushedNamedReplaceEvent);
|
||||
on<PoppedRouteEvent>(_onPoppedRoute);
|
||||
}
|
||||
final GlobalKey<NavigatorState> navigationKey;
|
||||
|
||||
Future<void> _onPushedNamed(PushedNamedEvent event, Emitter<NavigationState> emit) async {
|
||||
await navigationKey.currentState!.pushNamed(
|
||||
event.destination.path,
|
||||
arguments: event.destination.arguments,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPushedNamedAndRemoveUntil(PushedNamedAndRemoveUntilEvent event, Emitter<NavigationState> emit) async {
|
||||
await navigationKey.currentState!.pushNamedAndRemoveUntil(
|
||||
event.destination.path,
|
||||
event.predicate,
|
||||
arguments: event.destination.arguments,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPushedNamedReplaceEvent(PushedNamedReplaceEvent event, Emitter<NavigationState> emit) async {
|
||||
await navigationKey.currentState!.pushReplacementNamed(
|
||||
event.destination.path,
|
||||
arguments: event.destination.arguments,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPoppedRoute(PoppedRouteEvent event, Emitter<NavigationState> emit) async {
|
||||
navigationKey.currentState!.pop();
|
||||
}
|
||||
|
||||
bool canPop() {
|
||||
return navigationKey.currentState!.canPop();
|
||||
}
|
||||
}
|
||||
32
lib/src/ui/bloc/navigation_event.dart
Normal file
32
lib/src/ui/bloc/navigation_event.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
part of 'navigation_bloc.dart';
|
||||
|
||||
class NavigationDestination {
|
||||
const NavigationDestination(
|
||||
this.path,
|
||||
{
|
||||
this.arguments,
|
||||
}
|
||||
);
|
||||
final String path;
|
||||
final Object? arguments;
|
||||
}
|
||||
|
||||
abstract class NavigationEvent {}
|
||||
|
||||
class PushedNamedEvent extends NavigationEvent {
|
||||
PushedNamedEvent(this.destination);
|
||||
final NavigationDestination destination;
|
||||
}
|
||||
|
||||
class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
|
||||
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
|
||||
final NavigationDestination destination;
|
||||
final RoutePredicate predicate;
|
||||
}
|
||||
|
||||
class PushedNamedReplaceEvent extends NavigationEvent {
|
||||
PushedNamedReplaceEvent(this.destination);
|
||||
final NavigationDestination destination;
|
||||
}
|
||||
|
||||
class PoppedRouteEvent extends NavigationEvent {}
|
||||
3
lib/src/ui/bloc/navigation_state.dart
Normal file
3
lib/src/ui/bloc/navigation_state.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
part of 'navigation_bloc.dart';
|
||||
|
||||
class NavigationState {}
|
||||
2
lib/src/ui/constants.dart
Normal file
2
lib/src/ui/constants.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
const animeListRoute = '/anime/list';
|
||||
const animeSearchRoute = '/anime/search';
|
||||
56
lib/src/ui/pages/anime_list.dart
Normal file
56
lib/src/ui/pages/anime_list.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:anitrack/src/data/anime.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/constants.dart';
|
||||
import 'package:anitrack/src/ui/widgets/anime.dart';
|
||||
|
||||
class AnimeListPage extends StatelessWidget {
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
builder: (_) => AnimeListPage(),
|
||||
settings: const RouteSettings(
|
||||
name: animeListRoute,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AnimeListBloc, AnimeListState>(
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Animes'),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: state.animes.length,
|
||||
itemBuilder: (context, index) {
|
||||
return AnimeListWidget(
|
||||
data: state.animes[index],
|
||||
onLeftSwipe: () {
|
||||
context.read<AnimeListBloc>().add(
|
||||
AnimeEpisodeDecrementedEvent(state.animes[index].id),
|
||||
);
|
||||
},
|
||||
onRightSwipe: () {
|
||||
context.read<AnimeListBloc>().add(
|
||||
AnimeEpisodeIncrementedEvent(state.animes[index].id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
context.read<AnimeSearchBloc>().add(
|
||||
AnimeSearchRequestedEvent(),
|
||||
);
|
||||
},
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/src/ui/pages/anime_search.dart
Normal file
112
lib/src/ui/pages/anime_search.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:anitrack/src/data/anime.dart';
|
||||
import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart';
|
||||
import 'package:anitrack/src/ui/constants.dart';
|
||||
import 'package:anitrack/src/ui/widgets/anime.dart';
|
||||
import 'package:anitrack/src/ui/widgets/image.dart';
|
||||
|
||||
class AnimeSearchPage extends StatelessWidget {
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
builder: (_) => AnimeSearchPage(),
|
||||
settings: const RouteSettings(
|
||||
name: animeSearchRoute,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AnimeSearchBloc, AnimeSearchState>(
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Anime Search'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Search query',
|
||||
),
|
||||
onSubmitted: (_) {
|
||||
context.read<AnimeSearchBloc>().add(
|
||||
SearchQuerySubmittedEvent(),
|
||||
);
|
||||
},
|
||||
onChanged: (value) {
|
||||
context.read<AnimeSearchBloc>().add(
|
||||
SearchQueryChangedEvent(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (state.working)
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: state.searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.searchResults[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
context.read<AnimeSearchBloc>().add(
|
||||
AnimeAddedEvent(item),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimeCoverImage(
|
||||
url: item.thumbnailUrl,
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
Text(
|
||||
item.description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
maxLines: 4,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/src/ui/widgets/anime.dart
Normal file
97
lib/src/ui/widgets/anime.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:anitrack/src/data/anime.dart';
|
||||
import 'package:anitrack/src/ui/widgets/swipe_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swipeable_tile/swipeable_tile.dart';
|
||||
|
||||
/// A widget for displaying simple data about an anime in the listview
|
||||
class AnimeListWidget extends StatelessWidget {
|
||||
const AnimeListWidget({
|
||||
required this.data,
|
||||
required this.onLeftSwipe,
|
||||
required this.onRightSwipe,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The anime to display
|
||||
final AnimeTrackingData data;
|
||||
|
||||
/// Callbacks for the swipe functionality
|
||||
final void Function() onLeftSwipe;
|
||||
final void Function() onRightSwipe;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwipeableTile.swipeToTrigger(
|
||||
onSwiped: (direction) {
|
||||
if (direction == SwipeDirection.endToStart) {
|
||||
onRightSwipe();
|
||||
} else if (direction == SwipeDirection.startToEnd) {
|
||||
onLeftSwipe();
|
||||
}
|
||||
},
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
key: UniqueKey(),
|
||||
backgroundBuilder: (_, direction, __) {
|
||||
if (direction == SwipeDirection.endToStart) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SwipeIcon(
|
||||
Icons.add,
|
||||
),
|
||||
);
|
||||
} else if (direction == SwipeDirection.startToEnd) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SwipeIcon(
|
||||
Icons.remove,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
},
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
direction: SwipeDirection.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 100,
|
||||
child: Image.network(
|
||||
data.thumbnailUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
data.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
Text(
|
||||
'${data.episodesWatched}/${data.episodesTotal ?? "???"}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/src/ui/widgets/image.dart
Normal file
29
lib/src/ui/widgets/image.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnimeCoverImage extends StatelessWidget {
|
||||
const AnimeCoverImage({
|
||||
required this.url,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The URL to the cover image.
|
||||
final String url;
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 100 * (16 / 9),
|
||||
width: 100,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(url),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/src/ui/widgets/swipe_icon.dart
Normal file
23
lib/src/ui/widgets/swipe_icon.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// An icon that is below an anime list item
|
||||
class SwipeIcon extends StatelessWidget {
|
||||
const SwipeIcon(
|
||||
this.icon, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The icon to display
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user