Initial commit

This commit is contained in:
2023-02-03 23:33:21 +01:00
commit ee4458d613
147 changed files with 5860 additions and 0 deletions

70
lib/main.dart Normal file
View 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
View 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,
);
}
}

View 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;
}

View 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');
}
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,8 @@
part of 'anime_list_bloc.dart';
@freezed
class AnimeListState with _$AnimeListState {
factory AnimeListState({
@Default([]) List<AnimeTrackingData> animes,
}) = _AnimeListState;
}

View 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(),
);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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();
}
}

View 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 {}

View File

@@ -0,0 +1,3 @@
part of 'navigation_bloc.dart';
class NavigationState {}

View File

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

View 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),
),
);
},
);
}
}

View 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,
),
],
),
),
),
],
),
),
);
}
),
],
),
);
},
);
}
}

View 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,
),
],
),
),
),
],
),
),
);
}
}

View 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,
),
),
),
),
);
}
}

View 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,
),
);
}
}