Compare commits

..

8 Commits

34 changed files with 1746 additions and 442 deletions

View File

@ -33,4 +33,5 @@
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
</manifest>

View File

@ -9,7 +9,15 @@
"importMangaDesc": "Import manga list exported from MyAnimeList.",
"invalidMangaListTitle": "Invalid manga list",
"invalidMangaListBody": "The selected file is not a MAL manga list. It lacks the \".xml.gz\" suffix.",
"importIndicator": "$current of $total"
"importIndicator": "$current of $total",
"exportData": "Export data",
"importData": "Import data",
"dataExportSuccess": "Data successfully exported",
"dataImportSuccess": "Data successfully imported",
"importInvalidData": {
"title": "Invalid AniTrack Data",
"content": "The selected file is not an AniTrack data export. It lacks the \".json.gz\" suffix."
}
},
"about": {
"title": "About",
@ -19,6 +27,7 @@
"addNewItem": "Add new item"
},
"content": {
"list": "List",
"anime": "Anime",
"manga": "Manga"
},
@ -39,6 +48,19 @@
"chapters": "Chapters",
"volumesOwned": "Volumes owned"
},
"calendar": {
"calendar": "Calendar",
"days": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"unknown": "Unknown"
}
},
"data": {
"ongoing": {
"anime": "Watching",

View File

@ -5,3 +5,5 @@ targets:
options:
input_directory: assets/i18n
output_directory: lib/i18n
fallback_strategy: base_locale
base_locale: en

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/src/service/database.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/calendar_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/bloc/settings_bloc.dart';
@ -9,6 +10,7 @@ import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/pages/about.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/calendar.dart';
import 'package:anitrack/src/ui/pages/details.dart';
import 'package:anitrack/src/ui/pages/settings.dart';
import 'package:flutter/material.dart';
@ -32,6 +34,7 @@ void main() async {
GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc());
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
GetIt.I.registerSingleton<SettingsBloc>(SettingsBloc());
GetIt.I.registerSingleton<CalendarBloc>(CalendarBloc());
// Load animes
GetIt.I.get<AnimeListBloc>().add(
@ -59,6 +62,9 @@ void main() async {
BlocProvider<SettingsBloc>(
create: (_) => GetIt.I.get<SettingsBloc>(),
),
BlocProvider<CalendarBloc>(
create: (_) => GetIt.I.get<CalendarBloc>(),
),
],
child: MyApp(navKey),
),
@ -95,6 +101,8 @@ class MyApp extends StatelessWidget {
return AnimeListPage.route;
case animeSearchRoute:
return AnimeSearchPage.route;
case calendarRoute:
return CalendarPage.route;
case detailsRoute:
return DetailsPage.route;
case aboutRoute:

View File

@ -1,9 +1,20 @@
import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'anime.freezed.dart';
part 'anime.g.dart';
class BoolConverter implements JsonConverter<bool, int> {
const BoolConverter();
@override
bool fromJson(int json) => json.toBool();
@override
int toJson(bool object) => object.toInt();
}
/// Data about a tracked anime
@freezed
class AnimeTrackingData with _$AnimeTrackingData, TrackingMedium {
@ -25,6 +36,12 @@ class AnimeTrackingData with _$AnimeTrackingData, TrackingMedium {
/// URL to the thumbnail/cover art for the anime.
String thumbnailUrl,
/// Flag whether the anime is airing
@BoolConverter() bool airing,
/// The day of the week the anime is airing
String? broadcastDay,
) = _AnimeTrackingData;
/// JSON

View File

@ -39,6 +39,13 @@ mixin _$AnimeTrackingData {
/// URL to the thumbnail/cover art for the anime.
String get thumbnailUrl => throw _privateConstructorUsedError;
/// Flag whether the anime is airing
@BoolConverter()
bool get airing => throw _privateConstructorUsedError;
/// The day of the week the anime is airing
String? get broadcastDay => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AnimeTrackingDataCopyWith<AnimeTrackingData> get copyWith =>
@ -56,7 +63,9 @@ abstract class $AnimeTrackingDataCopyWith<$Res> {
String title,
int episodesWatched,
int? episodesTotal,
String thumbnailUrl});
String thumbnailUrl,
@BoolConverter() bool airing,
String? broadcastDay});
}
/// @nodoc
@ -76,6 +85,8 @@ class _$AnimeTrackingDataCopyWithImpl<$Res>
Object? episodesWatched = freezed,
Object? episodesTotal = freezed,
Object? thumbnailUrl = freezed,
Object? airing = freezed,
Object? broadcastDay = freezed,
}) {
return _then(_value.copyWith(
id: id == freezed
@ -102,6 +113,14 @@ class _$AnimeTrackingDataCopyWithImpl<$Res>
? _value.thumbnailUrl
: thumbnailUrl // ignore: cast_nullable_to_non_nullable
as String,
airing: airing == freezed
? _value.airing
: airing // ignore: cast_nullable_to_non_nullable
as bool,
broadcastDay: broadcastDay == freezed
? _value.broadcastDay
: broadcastDay // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
@ -119,7 +138,9 @@ abstract class _$$_AnimeTrackingDataCopyWith<$Res>
String title,
int episodesWatched,
int? episodesTotal,
String thumbnailUrl});
String thumbnailUrl,
@BoolConverter() bool airing,
String? broadcastDay});
}
/// @nodoc
@ -141,6 +162,8 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res>
Object? episodesWatched = freezed,
Object? episodesTotal = freezed,
Object? thumbnailUrl = freezed,
Object? airing = freezed,
Object? broadcastDay = freezed,
}) {
return _then(_$_AnimeTrackingData(
id == freezed
@ -167,6 +190,14 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res>
? _value.thumbnailUrl
: thumbnailUrl // ignore: cast_nullable_to_non_nullable
as String,
airing == freezed
? _value.airing
: airing // ignore: cast_nullable_to_non_nullable
as bool,
broadcastDay == freezed
? _value.broadcastDay
: broadcastDay // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
@ -174,8 +205,15 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$_AnimeTrackingData implements _AnimeTrackingData {
_$_AnimeTrackingData(this.id, @MediumTrackingStateConverter() this.state,
this.title, this.episodesWatched, this.episodesTotal, this.thumbnailUrl);
_$_AnimeTrackingData(
this.id,
@MediumTrackingStateConverter() this.state,
this.title,
this.episodesWatched,
this.episodesTotal,
this.thumbnailUrl,
@BoolConverter() this.airing,
this.broadcastDay);
factory _$_AnimeTrackingData.fromJson(Map<String, dynamic> json) =>
_$$_AnimeTrackingDataFromJson(json);
@ -205,9 +243,18 @@ class _$_AnimeTrackingData implements _AnimeTrackingData {
@override
final String thumbnailUrl;
/// Flag whether the anime is airing
@override
@BoolConverter()
final bool airing;
/// The day of the week the anime is airing
@override
final String? broadcastDay;
@override
String toString() {
return 'AnimeTrackingData(id: $id, state: $state, title: $title, episodesWatched: $episodesWatched, episodesTotal: $episodesTotal, thumbnailUrl: $thumbnailUrl)';
return 'AnimeTrackingData(id: $id, state: $state, title: $title, episodesWatched: $episodesWatched, episodesTotal: $episodesTotal, thumbnailUrl: $thumbnailUrl, airing: $airing, broadcastDay: $broadcastDay)';
}
@override
@ -223,7 +270,10 @@ class _$_AnimeTrackingData implements _AnimeTrackingData {
const DeepCollectionEquality()
.equals(other.episodesTotal, episodesTotal) &&
const DeepCollectionEquality()
.equals(other.thumbnailUrl, thumbnailUrl));
.equals(other.thumbnailUrl, thumbnailUrl) &&
const DeepCollectionEquality().equals(other.airing, airing) &&
const DeepCollectionEquality()
.equals(other.broadcastDay, broadcastDay));
}
@JsonKey(ignore: true)
@ -235,7 +285,9 @@ class _$_AnimeTrackingData implements _AnimeTrackingData {
const DeepCollectionEquality().hash(title),
const DeepCollectionEquality().hash(episodesWatched),
const DeepCollectionEquality().hash(episodesTotal),
const DeepCollectionEquality().hash(thumbnailUrl));
const DeepCollectionEquality().hash(thumbnailUrl),
const DeepCollectionEquality().hash(airing),
const DeepCollectionEquality().hash(broadcastDay));
@JsonKey(ignore: true)
@override
@ -258,7 +310,9 @@ abstract class _AnimeTrackingData implements AnimeTrackingData {
final String title,
final int episodesWatched,
final int? episodesTotal,
final String thumbnailUrl) = _$_AnimeTrackingData;
final String thumbnailUrl,
@BoolConverter() final bool airing,
final String? broadcastDay) = _$_AnimeTrackingData;
factory _AnimeTrackingData.fromJson(Map<String, dynamic> json) =
_$_AnimeTrackingData.fromJson;
@ -289,6 +343,15 @@ abstract class _AnimeTrackingData implements AnimeTrackingData {
/// URL to the thumbnail/cover art for the anime.
String get thumbnailUrl;
@override
/// Flag whether the anime is airing
@BoolConverter()
bool get airing;
@override
/// The day of the week the anime is airing
String? get broadcastDay;
@override
@JsonKey(ignore: true)
_$$_AnimeTrackingDataCopyWith<_$_AnimeTrackingData> get copyWith =>
throw _privateConstructorUsedError;

View File

@ -14,6 +14,8 @@ _$_AnimeTrackingData _$$_AnimeTrackingDataFromJson(Map<String, dynamic> json) =>
json['episodesWatched'] as int,
json['episodesTotal'] as int?,
json['thumbnailUrl'] as String,
const BoolConverter().fromJson(json['airing'] as int),
json['broadcastDay'] as String?,
);
Map<String, dynamic> _$$_AnimeTrackingDataToJson(
@ -25,4 +27,6 @@ Map<String, dynamic> _$$_AnimeTrackingDataToJson(
'episodesWatched': instance.episodesWatched,
'episodesTotal': instance.episodesTotal,
'thumbnailUrl': instance.thumbnailUrl,
'airing': const BoolConverter().toJson(instance.airing),
'broadcastDay': instance.broadcastDay,
};

View File

@ -5,6 +5,8 @@ class SearchResult {
this.total,
this.thumbnailUrl,
this.description,
this.isAiring,
this.broadcastDay,
);
/// The title of the anime.
@ -22,4 +24,10 @@ class SearchResult {
/// The description of the anime
final String description;
/// Flag whether the anime is airing.
final bool isAiring;
/// The day of the week the anime is airing.
final String? broadcastDay;
}

View File

@ -10,62 +10,46 @@ enum TrackingMediumType {
/// The state of the medium we're tracking, i.e. reading/watching, dropped, ...
enum MediumTrackingState {
/// Currently watching or reading
ongoing,
ongoing(0),
/// Done
completed,
completed(1),
/// Plan to watch or read
planned,
planned(2),
/// Dropped
dropped,
dropped(3),
/// Paused
paused,
paused(4),
/// Meta state
all,
}
all(-1);
/// Interface for the Anime and Manga data classes
abstract class TrackingMedium {
/// The ID of the medium
final String id = '';
const MediumTrackingState(this.id);
/// The title of the medium
final String title = '';
/// The URL of the cover image.
final String thumbnailUrl = '';
/// The tracking state
final MediumTrackingState state = MediumTrackingState.planned;
}
extension MediumStateExtension on MediumTrackingState {
int toInteger() {
assert(
this != MediumTrackingState.all,
'MediumTrackingState.all must not be serialized',
);
switch (this) {
case MediumTrackingState.ongoing:
return 0;
case MediumTrackingState.completed:
return 1;
case MediumTrackingState.planned:
return 2;
case MediumTrackingState.dropped:
return 3;
case MediumTrackingState.paused:
return 4;
case MediumTrackingState.all:
return -1;
factory MediumTrackingState.fromInt(int id) {
switch (id) {
case 0:
return MediumTrackingState.ongoing;
case 1:
return MediumTrackingState.completed;
case 2:
return MediumTrackingState.planned;
case 3:
return MediumTrackingState.dropped;
case 4:
return MediumTrackingState.paused;
}
return MediumTrackingState.planned;
}
String toNameString(TrackingMediumType type) {
/// The id of the value.
final int id;
String getName(TrackingMediumType type) {
assert(
this != MediumTrackingState.all,
'MediumTrackingState.all must not be stringified',
@ -98,28 +82,28 @@ extension MediumStateExtension on MediumTrackingState {
}
}
/// Interface for the Anime and Manga data classes
abstract class TrackingMedium {
/// The ID of the medium
final String id = '';
/// The title of the medium
final String title = '';
/// The URL of the cover image.
final String thumbnailUrl = '';
/// The tracking state
final MediumTrackingState state = MediumTrackingState.planned;
}
class MediumTrackingStateConverter
implements JsonConverter<MediumTrackingState, int> {
const MediumTrackingStateConverter();
@override
MediumTrackingState fromJson(int json) {
switch (json) {
case 0:
return MediumTrackingState.ongoing;
case 1:
return MediumTrackingState.completed;
case 2:
return MediumTrackingState.planned;
case 3:
return MediumTrackingState.dropped;
case 4:
return MediumTrackingState.paused;
}
return MediumTrackingState.planned;
}
MediumTrackingState fromJson(int json) => MediumTrackingState.fromInt(json);
@override
int toJson(MediumTrackingState state) => state.toInteger();
int toJson(MediumTrackingState state) => state.id;
}

View File

@ -1,22 +1,37 @@
import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/data/manga.dart';
import 'package:anitrack/src/service/migrations/0000_airing.dart';
import 'package:anitrack/src/service/migrations/0000_score.dart';
import 'package:sqflite/sqflite.dart';
const animeTable = 'Anime';
const mangaTable = 'Manga';
extension BoolToInt on bool {
int toInt() {
return this ? 1 : 0;
}
}
extension IntToBool on int {
bool toBool() {
return this == 1;
}
}
Future<void> _createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $animeTable(
id TEXT NOT NULL PRIMARY KEY,
state INTEGER NOT NULL,
episodesTotal INTEGER,
id TEXT NOT NULL PRIMARY KEY,
state INTEGER NOT NULL,
episodesTotal INTEGER,
episodesWatched INTEGER NOT NULL,
thumbnailUrl TEXT NOT NULL,
title TEXT NOT NULL,
score INTEGER
thumbnailUrl TEXT NOT NULL,
title TEXT NOT NULL,
score INTEGER,
airing INTEGER NOT NULL,
broadcastDay TEXT
)''',
);
await db.execute(
@ -40,7 +55,7 @@ class DatabaseService {
Future<void> initialize() async {
_db = await openDatabase(
'anitrack.db',
version: 2,
version: 3,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
// keys in the onConfigure phase, but re-enable them here.
@ -56,6 +71,9 @@ class DatabaseService {
if (oldVersion < 2) {
await migrateFromV1ToV2(db);
}
if (oldVersion < 3) {
await migrateFromV2ToV3(db);
}
},
);
}

View File

@ -0,0 +1,11 @@
import 'package:anitrack/src/service/database.dart';
import 'package:sqflite/sqflite.dart';
Future<void> migrateFromV2ToV3(Database db) async {
await db.execute(
'ALTER TABLE $animeTable ADD COLUMN airing INTEGER NOT NULL DEFAULT ${true.toInt()};',
);
await db.execute(
'ALTER TABLE $animeTable ADD COLUMN broadcastDay TEXT;',
);
}

View File

@ -3,6 +3,7 @@ import 'package:anitrack/src/data/manga.dart';
import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
@ -35,6 +36,8 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
final List<MangaTrackingData> _mangas =
List<MangaTrackingData>.empty(growable: true);
List<AnimeTrackingData> get unfilteredAnime => _animes;
List<AnimeTrackingData> _getFilteredAnime({
MediumTrackingState? trackingState,
}) {
@ -63,7 +66,16 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addAnime(event.data);
// Add it to the cache
_animes.add(event.data);
if (event.checkIfExists) {
final shouldAdd =
_animes.firstWhereOrNull((element) => element.id == event.data.id) ==
null;
if (shouldAdd) {
_animes.add(event.data);
}
} else {
_animes.add(event.data);
}
emit(
state.copyWith(
@ -80,7 +92,17 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addManga(event.data);
// Add it to the cache
_mangas.add(event.data);
// Add it to the cache
if (event.checkIfExists) {
final shouldAdd =
_mangas.firstWhereOrNull((element) => element.id == event.data.id) ==
null;
if (shouldAdd) {
_mangas.add(event.data);
}
} else {
_mangas.add(event.data);
}
emit(
state.copyWith(
@ -270,6 +292,8 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
animes: _getFilteredAnime(),
),
);
await GetIt.I.get<DatabaseService>().updateAnime(event.anime);
}
Future<void> _onMangaUpdated(

View File

@ -17,10 +17,14 @@ class AnimeEpisodeDecrementedEvent extends AnimeListEvent {
}
class AnimeAddedEvent extends AnimeListEvent {
AnimeAddedEvent(this.data);
AnimeAddedEvent(this.data, {this.checkIfExists = false});
/// The anime to add.
final AnimeTrackingData data;
/// If true, checks if the anime with the id is already in the list.
/// If it is, does nothing.
final bool checkIfExists;
}
/// Triggered when animes are to be loaded from the database
@ -43,9 +47,12 @@ class AnimeTrackingTypeChanged extends AnimeListEvent {
}
class AnimeUpdatedEvent extends AnimeListEvent {
AnimeUpdatedEvent(this.anime);
AnimeUpdatedEvent(this.anime, {this.commit = false});
final AnimeTrackingData anime;
/// Commit the new anime data to the database.
final bool commit;
}
class AnimeRemovedEvent extends AnimeListEvent {
@ -56,10 +63,14 @@ class AnimeRemovedEvent extends AnimeListEvent {
}
class MangaAddedEvent extends AnimeListEvent {
MangaAddedEvent(this.data);
MangaAddedEvent(this.data, {this.checkIfExists = false});
/// The manga to add.
final MangaTrackingData data;
/// If true, checks if the manga with the id is already in the list.
/// If it is, does nothing.
final bool checkIfExists;
}
/// Triggered when the manga filter is changed

View File

@ -82,6 +82,8 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
anime.episodes,
anime.imageUrl,
anime.synopsis ?? '',
anime.airing,
anime.broadcast?.split(' ').first,
),
)
.toList(),
@ -104,6 +106,9 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
manga.chapters,
manga.imageUrl,
manga.synopsis ?? '',
// TODO(Unknown): Implement for Manga
false,
null,
),
)
.toList(),
@ -126,6 +131,8 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
0,
event.result.total,
event.result.thumbnailUrl,
event.result.isAiring,
event.result.broadcastDay,
),
)
: list.MangaAddedEvent(

View File

@ -0,0 +1,78 @@
import 'dart:async';
import 'package:anitrack/src/ui/bloc/anime_list_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 'calendar_state.dart';
part 'calendar_bloc.freezed.dart';
part 'calendar_event.dart';
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
CalendarBloc() : super(CalendarState(false, 0, 0)) {
on<RefreshPerformedEvent>(_onRefreshPerformed);
}
Future<void> _onRefreshPerformed(
RefreshPerformedEvent event,
Emitter<CalendarState> emit,
) async {
emit(
state.copyWith(
refreshing: true,
refreshingCount: 0,
refreshingTotal: 0,
),
);
final al = GetIt.I.get<AnimeListBloc>();
final animes = al.unfilteredAnime.where((anime) => anime.airing);
emit(
state.copyWith(
refreshing: true,
refreshingCount: 0,
refreshingTotal: animes.length,
),
);
for (final anime in animes) {
emit(state.copyWith(refreshingCount: state.refreshingCount + 1));
String? broadcastDay;
bool airing;
try {
final apiData = await Jikan().getAnime(int.parse(anime.id));
airing = apiData.airing;
broadcastDay = apiData.broadcast?.split(' ').first;
} catch (ex) {
print('API request for anime ${anime.id} failed: $ex');
airing = false;
}
print('Anime "${anime.title}": airing=${airing}');
if (!airing) {
al.add(
AnimeUpdatedEvent(
anime.copyWith(airing: false, broadcastDay: null),
commit: true,
),
);
} else if (anime.broadcastDay != broadcastDay) {
print('Updating Anime "${anime.title}": broadcastDay=$broadcastDay');
al.add(
AnimeUpdatedEvent(
anime.copyWith(airing: true, broadcastDay: broadcastDay),
commit: true,
),
);
}
// Prevent hammering Jikan
await Future<void>.delayed(const Duration(milliseconds: 500));
}
emit(state.copyWith(refreshing: false));
}
}

View File

@ -0,0 +1,169 @@
// 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 'calendar_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 _$CalendarState {
bool get refreshing => throw _privateConstructorUsedError;
int get refreshingCount => throw _privateConstructorUsedError;
int get refreshingTotal => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$CalendarStateCopyWith<CalendarState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $CalendarStateCopyWith<$Res> {
factory $CalendarStateCopyWith(
CalendarState value, $Res Function(CalendarState) then) =
_$CalendarStateCopyWithImpl<$Res>;
$Res call({bool refreshing, int refreshingCount, int refreshingTotal});
}
/// @nodoc
class _$CalendarStateCopyWithImpl<$Res>
implements $CalendarStateCopyWith<$Res> {
_$CalendarStateCopyWithImpl(this._value, this._then);
final CalendarState _value;
// ignore: unused_field
final $Res Function(CalendarState) _then;
@override
$Res call({
Object? refreshing = freezed,
Object? refreshingCount = freezed,
Object? refreshingTotal = freezed,
}) {
return _then(_value.copyWith(
refreshing: refreshing == freezed
? _value.refreshing
: refreshing // ignore: cast_nullable_to_non_nullable
as bool,
refreshingCount: refreshingCount == freezed
? _value.refreshingCount
: refreshingCount // ignore: cast_nullable_to_non_nullable
as int,
refreshingTotal: refreshingTotal == freezed
? _value.refreshingTotal
: refreshingTotal // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
abstract class _$$_CalendarStateCopyWith<$Res>
implements $CalendarStateCopyWith<$Res> {
factory _$$_CalendarStateCopyWith(
_$_CalendarState value, $Res Function(_$_CalendarState) then) =
__$$_CalendarStateCopyWithImpl<$Res>;
@override
$Res call({bool refreshing, int refreshingCount, int refreshingTotal});
}
/// @nodoc
class __$$_CalendarStateCopyWithImpl<$Res>
extends _$CalendarStateCopyWithImpl<$Res>
implements _$$_CalendarStateCopyWith<$Res> {
__$$_CalendarStateCopyWithImpl(
_$_CalendarState _value, $Res Function(_$_CalendarState) _then)
: super(_value, (v) => _then(v as _$_CalendarState));
@override
_$_CalendarState get _value => super._value as _$_CalendarState;
@override
$Res call({
Object? refreshing = freezed,
Object? refreshingCount = freezed,
Object? refreshingTotal = freezed,
}) {
return _then(_$_CalendarState(
refreshing == freezed
? _value.refreshing
: refreshing // ignore: cast_nullable_to_non_nullable
as bool,
refreshingCount == freezed
? _value.refreshingCount
: refreshingCount // ignore: cast_nullable_to_non_nullable
as int,
refreshingTotal == freezed
? _value.refreshingTotal
: refreshingTotal // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
class _$_CalendarState implements _CalendarState {
_$_CalendarState(this.refreshing, this.refreshingCount, this.refreshingTotal);
@override
final bool refreshing;
@override
final int refreshingCount;
@override
final int refreshingTotal;
@override
String toString() {
return 'CalendarState(refreshing: $refreshing, refreshingCount: $refreshingCount, refreshingTotal: $refreshingTotal)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_CalendarState &&
const DeepCollectionEquality()
.equals(other.refreshing, refreshing) &&
const DeepCollectionEquality()
.equals(other.refreshingCount, refreshingCount) &&
const DeepCollectionEquality()
.equals(other.refreshingTotal, refreshingTotal));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(refreshing),
const DeepCollectionEquality().hash(refreshingCount),
const DeepCollectionEquality().hash(refreshingTotal));
@JsonKey(ignore: true)
@override
_$$_CalendarStateCopyWith<_$_CalendarState> get copyWith =>
__$$_CalendarStateCopyWithImpl<_$_CalendarState>(this, _$identity);
}
abstract class _CalendarState implements CalendarState {
factory _CalendarState(final bool refreshing, final int refreshingCount,
final int refreshingTotal) = _$_CalendarState;
@override
bool get refreshing;
@override
int get refreshingCount;
@override
int get refreshingTotal;
@override
@JsonKey(ignore: true)
_$$_CalendarStateCopyWith<_$_CalendarState> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,6 @@
part of 'calendar_bloc.dart';
abstract class CalendarEvent {}
/// Triggered by the UI when the user wants to refresh the airing anime list.
class RefreshPerformedEvent extends CalendarEvent {}

View File

@ -0,0 +1,10 @@
part of 'calendar_bloc.dart';
@freezed
class CalendarState with _$CalendarState {
factory CalendarState(
bool refreshing,
int refreshingCount,
int refreshingTotal,
) = _CalendarState;
}

View File

@ -28,6 +28,7 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
emit(
state.copyWith(
trackingType: TrackingMediumType.anime,
heroImagePrefix: event.heroImagePrefix,
data: event.anime,
),
);

View File

@ -17,6 +17,7 @@ final _privateConstructorUsedError = UnsupportedError(
/// @nodoc
mixin _$DetailsState {
TrackingMedium? get data => throw _privateConstructorUsedError;
String? get heroImagePrefix => throw _privateConstructorUsedError;
TrackingMediumType get trackingType => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
@ -29,7 +30,10 @@ abstract class $DetailsStateCopyWith<$Res> {
factory $DetailsStateCopyWith(
DetailsState value, $Res Function(DetailsState) then) =
_$DetailsStateCopyWithImpl<$Res>;
$Res call({TrackingMedium? data, TrackingMediumType trackingType});
$Res call(
{TrackingMedium? data,
String? heroImagePrefix,
TrackingMediumType trackingType});
}
/// @nodoc
@ -43,6 +47,7 @@ class _$DetailsStateCopyWithImpl<$Res> implements $DetailsStateCopyWith<$Res> {
@override
$Res call({
Object? data = freezed,
Object? heroImagePrefix = freezed,
Object? trackingType = freezed,
}) {
return _then(_value.copyWith(
@ -50,6 +55,10 @@ class _$DetailsStateCopyWithImpl<$Res> implements $DetailsStateCopyWith<$Res> {
? _value.data
: data // ignore: cast_nullable_to_non_nullable
as TrackingMedium?,
heroImagePrefix: heroImagePrefix == freezed
? _value.heroImagePrefix
: heroImagePrefix // ignore: cast_nullable_to_non_nullable
as String?,
trackingType: trackingType == freezed
? _value.trackingType
: trackingType // ignore: cast_nullable_to_non_nullable
@ -65,7 +74,10 @@ abstract class _$$_DetailsStateCopyWith<$Res>
_$_DetailsState value, $Res Function(_$_DetailsState) then) =
__$$_DetailsStateCopyWithImpl<$Res>;
@override
$Res call({TrackingMedium? data, TrackingMediumType trackingType});
$Res call(
{TrackingMedium? data,
String? heroImagePrefix,
TrackingMediumType trackingType});
}
/// @nodoc
@ -82,6 +94,7 @@ class __$$_DetailsStateCopyWithImpl<$Res>
@override
$Res call({
Object? data = freezed,
Object? heroImagePrefix = freezed,
Object? trackingType = freezed,
}) {
return _then(_$_DetailsState(
@ -89,6 +102,10 @@ class __$$_DetailsStateCopyWithImpl<$Res>
? _value.data
: data // ignore: cast_nullable_to_non_nullable
as TrackingMedium?,
heroImagePrefix: heroImagePrefix == freezed
? _value.heroImagePrefix
: heroImagePrefix // ignore: cast_nullable_to_non_nullable
as String?,
trackingType: trackingType == freezed
? _value.trackingType
: trackingType // ignore: cast_nullable_to_non_nullable
@ -100,17 +117,22 @@ class __$$_DetailsStateCopyWithImpl<$Res>
/// @nodoc
class _$_DetailsState implements _DetailsState {
_$_DetailsState({this.data, this.trackingType = TrackingMediumType.anime});
_$_DetailsState(
{this.data,
this.heroImagePrefix,
this.trackingType = TrackingMediumType.anime});
@override
final TrackingMedium? data;
@override
final String? heroImagePrefix;
@override
@JsonKey()
final TrackingMediumType trackingType;
@override
String toString() {
return 'DetailsState(data: $data, trackingType: $trackingType)';
return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType)';
}
@override
@ -119,6 +141,8 @@ class _$_DetailsState implements _DetailsState {
(other.runtimeType == runtimeType &&
other is _$_DetailsState &&
const DeepCollectionEquality().equals(other.data, data) &&
const DeepCollectionEquality()
.equals(other.heroImagePrefix, heroImagePrefix) &&
const DeepCollectionEquality()
.equals(other.trackingType, trackingType));
}
@ -127,6 +151,7 @@ class _$_DetailsState implements _DetailsState {
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(data),
const DeepCollectionEquality().hash(heroImagePrefix),
const DeepCollectionEquality().hash(trackingType));
@JsonKey(ignore: true)
@ -138,11 +163,14 @@ class _$_DetailsState implements _DetailsState {
abstract class _DetailsState implements DetailsState {
factory _DetailsState(
{final TrackingMedium? data,
final String? heroImagePrefix,
final TrackingMediumType trackingType}) = _$_DetailsState;
@override
TrackingMedium? get data;
@override
String? get heroImagePrefix;
@override
TrackingMediumType get trackingType;
@override
@JsonKey(ignore: true)

View File

@ -3,10 +3,15 @@ part of 'details_bloc.dart';
abstract class DetailsEvent {}
class AnimeDetailsRequestedEvent extends DetailsEvent {
AnimeDetailsRequestedEvent(this.anime);
AnimeDetailsRequestedEvent(
this.anime, {
this.heroImagePrefix,
});
/// The anime to show details about
final AnimeTrackingData anime;
final String? heroImagePrefix;
}
class MangaDetailsRequestedEvent extends DetailsEvent {

View File

@ -4,6 +4,7 @@ part of 'details_bloc.dart';
class DetailsState with _$DetailsState {
factory DetailsState({
TrackingMedium? data,
String? heroImagePrefix,
@Default(TrackingMediumType.anime) TrackingMediumType trackingType,
}) = _DetailsState;
}

View File

@ -1,19 +1,25 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:anitrack/i18n/strings.g.dart';
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/service/database.dart';
import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:archive/archive.dart' as archive;
import 'package:archive/archive_io.dart';
import 'package:bloc/bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:jikan_api/jikan_api.dart';
import 'package:path/path.dart' as path;
import 'package:xml/xml.dart';
part 'settings_state.dart';
part 'settings_event.dart';
part 'settings_bloc.freezed.dart';
part 'settings_event.dart';
part 'settings_state.dart';
MediumTrackingState malStatusToTrackingState(String status) {
switch (status) {
@ -39,6 +45,8 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
SettingsBloc() : super(SettingsState()) {
on<AnimeListImportedEvent>(_onAnimeListImported);
on<MangaListImportedEvent>(_onMangaListImported);
on<DataExportedEvent>(_onDataExported);
on<DataImportedEvent>(_onDataImported);
}
void _showLoadingSpinner(Emitter<SettingsState> emit) {
@ -119,6 +127,9 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
// 0 means that MAL does not know
totalEpisodes == 0 ? null : totalEpisodes,
data.imageUrl,
// NOTE: When the calendar gets refreshed, this should also get cleared
true,
null,
),
);
}
@ -202,4 +213,69 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
// Hide the spinner again
_hideLoadingSpinner(emit);
}
Future<void> _onDataExported(
DataExportedEvent event,
Emitter<SettingsState> emit,
) async {
final al = GetIt.I.get<AnimeListBloc>();
final data = {
// TODO(Unknown): Track the version here to (maybe) to migrations
'animes': al.state.animes.map((anime) => anime.toJson()).toList(),
'mangas': al.state.mangas.map((manga) => manga.toJson()).toList(),
};
final exportData = jsonEncode(data);
final date = DateTime.now();
final outputPath = path.join(
event.path,
'anitrack_${date.year}${date.month}${date.day}.json.gz',
);
archive.GZipEncoder().encode(
InputStream(utf8.encode(exportData)),
output: OutputFileStream(outputPath),
);
await Fluttertoast.showToast(
msg: t.settings.dataExportSuccess,
);
}
Future<void> _onDataImported(
DataImportedEvent event,
Emitter<SettingsState> emit,
) async {
final al = GetIt.I.get<AnimeListBloc>();
final exportArchive = archive.GZipDecoder().decodeBytes(
await File(event.path).readAsBytes(),
);
final json = jsonDecode(utf8.decode(exportArchive)) as Map<String, dynamic>;
// Process anime
for (final animeRaw
in (json['animes']! as List<dynamic>).cast<Map<dynamic, dynamic>>()) {
final anime = AnimeTrackingData.fromJson(
animeRaw.cast<String, dynamic>(),
);
al.add(
AnimeAddedEvent(anime, checkIfExists: true),
);
}
// Process manga
for (final mangaRaw
in (json['mangas']! as List<dynamic>).cast<Map<dynamic, dynamic>>()) {
final manga = MangaTrackingData.fromJson(
mangaRaw.cast<String, dynamic>(),
);
al.add(
MangaAddedEvent(manga, checkIfExists: true),
);
}
await Fluttertoast.showToast(
msg: t.settings.dataImportSuccess,
);
}
}

View File

@ -34,3 +34,19 @@ class MangaListImportedEvent extends SettingsEvent {
/// The type of list we're importing
final ImportListType type;
}
/// Triggered when a data export should be produced.
class DataExportedEvent extends SettingsEvent {
DataExportedEvent(this.path);
/// The path where the export should be stored.
final String path;
}
/// Triggered when a data export has been picked for import.
class DataImportedEvent extends SettingsEvent {
DataImportedEvent(this.path);
/// The path of the data export to import.
final String path;
}

View File

@ -1,5 +1,6 @@
const animeListRoute = '/anime/list';
const animeSearchRoute = '/anime/search';
const detailsRoute = '/anime/details';
const calendarRoute = '/calendar';
const aboutRoute = '/about';
const settingsRoute = '/settings';

58
lib/src/ui/helpers.dart Normal file
View File

@ -0,0 +1,58 @@
import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:flutter/material.dart';
Widget getDrawer(BuildContext context) {
return Drawer(
child: ListView(
children: [
const DrawerHeader(
decoration: BoxDecoration(
color: Color(0xffcf4aff),
),
child: Text(
'AniTrack',
style: TextStyle(
color: Color(0xff232323),
fontSize: 24,
),
),
),
ListTile(
leading: const Icon(Icons.list),
title: Text(t.content.list),
onTap: () {
Navigator.of(context).pushNamedAndRemoveUntil(
animeListRoute,
(_) => false,
);
},
),
ListTile(
leading: const Icon(Icons.calendar_today),
title: Text(t.calendar.calendar),
onTap: () {
Navigator.of(context).pushNamedAndRemoveUntil(
calendarRoute,
(_) => false,
);
},
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(t.settings.title),
onTap: () {
Navigator.of(context).pushNamed(settingsRoute);
},
),
ListTile(
leading: const Icon(Icons.info),
title: Text(t.about.title),
onTap: () {
Navigator.of(context).pushNamed(aboutRoute);
},
),
],
),
);
}

View File

@ -4,6 +4,7 @@ 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/helpers.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';
@ -71,23 +72,23 @@ class AnimeListPageState extends State<AnimeListPage> {
return [
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.ongoing,
child: Text(MediumTrackingState.ongoing.toNameString(type)),
child: Text(MediumTrackingState.ongoing.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.completed,
child: Text(MediumTrackingState.completed.toNameString(type)),
child: Text(MediumTrackingState.completed.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.planned,
child: Text(MediumTrackingState.planned.toNameString(type)),
child: Text(MediumTrackingState.planned.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.toNameString(type)),
child: Text(MediumTrackingState.dropped.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.toNameString(type)),
child: Text(MediumTrackingState.paused.getName(type)),
),
const PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.all,
@ -140,38 +141,7 @@ class AnimeListPageState extends State<AnimeListPage> {
_getPopupButton(context, state),
],
),
drawer: Drawer(
child: ListView(
children: [
const DrawerHeader(
decoration: BoxDecoration(
color: Color(0xffcf4aff),
),
child: Text(
'AniTrack',
style: TextStyle(
color: Color(0xff232323),
fontSize: 24,
),
),
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(t.settings.title),
onTap: () {
Navigator.of(context).pushNamed(settingsRoute);
},
),
ListTile(
leading: const Icon(Icons.info),
title: Text(t.about.title),
onTap: () {
Navigator.of(context).pushNamed(aboutRoute);
},
),
],
),
),
drawer: getDrawer(context),
body: PageView(
// Prevent swiping between pages
// (https://github.com/flutter/flutter/issues/37510#issuecomment-612663656)

View File

@ -0,0 +1,317 @@
import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:anitrack/src/ui/bloc/calendar_bloc.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/helpers.dart';
import 'package:anitrack/src/ui/widgets/grid_item.dart';
import 'package:anitrack/src/ui/widgets/image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
enum Weekday {
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday,
unknown;
String toName() {
switch (this) {
case Weekday.monday:
return t.calendar.days.monday;
case Weekday.tuesday:
return t.calendar.days.tuesday;
case Weekday.wednesday:
return t.calendar.days.wednesday;
case Weekday.thursday:
return t.calendar.days.thursday;
case Weekday.friday:
return t.calendar.days.friday;
case Weekday.saturday:
return t.calendar.days.saturday;
case Weekday.sunday:
return t.calendar.days.sunday;
case Weekday.unknown:
return t.calendar.days.unknown;
}
}
}
extension AddIfExists<K, V> on Map<K, List<V>> {
void addOrSet(K key, V value) {
if (containsKey(key)) {
this[key]!.add(value);
} else {
this[key] = List<V>.from([value]);
}
}
}
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key});
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const CalendarPage(),
settings: const RouteSettings(
name: calendarRoute,
),
);
@override
CalendarPageState createState() => CalendarPageState();
}
class CalendarPageState extends State<CalendarPage> {
List<Widget> _renderWeekdayList(
BuildContext context,
Weekday day,
Map<Weekday, List<AnimeTrackingData>> data,
) {
if (!data.containsKey(day)) {
return const [];
}
assert(data[day]!.isNotEmpty, 'There should be at least one anime');
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
right: 16,
top: 20,
bottom: 4,
),
child: Text(
day.toName(),
style: Theme.of(context).textTheme.titleLarge,
),
),
),
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 120 / (100 * (16 / 9)),
),
itemCount: data[day]!.length,
itemBuilder: (context, index) {
final anime = data[day]![index];
return GridItem(
child: AnimeCoverImage(
url: anime.thumbnailUrl,
hero: 'calendar_${anime.id}',
onTap: () {
context.read<DetailsBloc>().add(
AnimeDetailsRequestedEvent(
anime,
heroImagePrefix: 'calendar_',
),
);
},
),
);
},
),
];
}
@override
Widget build(BuildContext context) {
final airingAnimeMap = <Weekday, List<AnimeTrackingData>>{};
for (final anime in GetIt.I.get<AnimeListBloc>().unfilteredAnime) {
if (!anime.airing) continue;
final Weekday day;
switch (anime.broadcastDay) {
case 'Mondays':
day = Weekday.monday;
break;
case 'Tuesdays':
day = Weekday.tuesday;
break;
case 'Wednesdays':
day = Weekday.wednesday;
break;
case 'Thursdays':
day = Weekday.thursday;
break;
case 'Fridays':
day = Weekday.friday;
break;
case 'Saturdays':
day = Weekday.saturday;
break;
case 'Sundays':
day = Weekday.sunday;
break;
default:
day = Weekday.unknown;
break;
}
airingAnimeMap.addOrSet(day, anime);
}
return BlocListener<CalendarBloc, CalendarState>(
listenWhen: (previous, current) =>
previous.refreshing != current.refreshing,
listener: (context, state) {
// Force an update
if (!state.refreshing) {
setState(() {});
}
},
child: WillPopScope(
onWillPop: () async => !context.read<CalendarBloc>().state.refreshing,
child: Stack(
children: [
Positioned(
left: 8,
right: 8,
top: 0,
bottom: 0,
child: Scaffold(
appBar: AppBar(
title: Text(t.calendar.calendar),
actions: [
IconButton(
onPressed: () {
context
.read<CalendarBloc>()
.add(RefreshPerformedEvent());
},
icon: const Icon(Icons.refresh),
),
],
),
drawer: getDrawer(context),
body: CustomScrollView(
slivers: [
// Render all available weekdays
..._renderWeekdayList(
context,
Weekday.unknown,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.monday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.tuesday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.wednesday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.thursday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.friday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.saturday,
airingAnimeMap,
),
..._renderWeekdayList(
context,
Weekday.sunday,
airingAnimeMap,
),
// Provide a nice bottom padding, while keeping the elastic effect attached
// to the bottom-most edge.
const SliverToBoxAdapter(
child: SizedBox(
height: 16,
),
),
],
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: BlocBuilder<CalendarBloc, CalendarState>(
buildWhen: (previous, current) =>
previous.refreshing != current.refreshing,
builder: (context, state) {
if (!state.refreshing) {
return const SizedBox();
}
return const ModalBarrier(
dismissible: false,
color: Colors.black54,
);
},
),
),
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
if (!state.refreshing) {
return const SizedBox();
}
return Center(
child: SizedBox(
width: 150,
height: 150,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.grey.shade800,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.all(25),
child: CircularProgressIndicator(),
),
Text(
t.settings.importIndicator(
current: state.refreshingCount,
total: state.refreshingTotal,
),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}

View File

@ -42,7 +42,7 @@ class DetailsPage extends StatelessWidget {
children: [
AnimeCoverImage(
url: state.data!.thumbnailUrl,
hero: state.data!.id,
hero: '${state.heroImagePrefix}${state.data!.id}',
),
Expanded(
child: Padding(
@ -69,10 +69,14 @@ class DetailsPage extends StatelessWidget {
builder: (context) {
return AlertDialog(
title: Text(
t.details.removeTitle(title: state.data!.title),
t.details.removeTitle(
title: state.data!.title,
),
),
content: Text(
t.details.removeBody(title: state.data!.title),
t.details.removeBody(
title: state.data!.title,
),
),
actions: [
TextButton(
@ -83,14 +87,18 @@ class DetailsPage extends StatelessWidget {
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: Text(t.details.removeButton),
child: Text(
t.details.removeButton,
),
),
TextButton(
onPressed: () {
Navigator.of(context)
.pop(false);
},
child: Text(t.details.cancelButton),
child: Text(
t.details.cancelButton,
),
),
],
);
@ -150,27 +158,27 @@ class DetailsPage extends StatelessWidget {
SelectorItem(
MediumTrackingState.ongoing,
MediumTrackingState.ongoing
.toNameString(state.trackingType),
.getName(state.trackingType),
),
SelectorItem(
MediumTrackingState.completed,
MediumTrackingState.completed
.toNameString(state.trackingType),
.getName(state.trackingType),
),
SelectorItem(
MediumTrackingState.planned,
MediumTrackingState.planned
.toNameString(state.trackingType),
.getName(state.trackingType),
),
SelectorItem(
MediumTrackingState.dropped,
MediumTrackingState.dropped
.toNameString(state.trackingType),
.getName(state.trackingType),
),
SelectorItem(
MediumTrackingState.paused,
MediumTrackingState.paused
.toNameString(state.trackingType),
.getName(state.trackingType),
),
],
initialValue: state.data!.state,

View File

@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:permission_handler/permission_handler.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@ -91,6 +92,51 @@ class SettingsPage extends StatelessWidget {
);
},
),
ListTile(
title: Text(t.settings.exportData),
onTap: () async {
// Pick the file
final result =
await FilePicker.platform.getDirectoryPath();
if (result == null) return;
if (!(await Permission.manageExternalStorage
.request())
.isGranted) return;
GetIt.I.get<SettingsBloc>().add(
DataExportedEvent(
result,
),
);
},
),
ListTile(
title: Text(t.settings.importData),
onTap: () async {
// Pick the file
final result = await FilePicker.platform.pickFiles();
if (result == null) return;
if (!result.files.first.path!.endsWith('.json.gz')) {
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: Text(t.settings.importInvalidData.title),
content:
Text(t.settings.importInvalidData.content),
),
);
return;
}
GetIt.I.get<SettingsBloc>().add(
DataImportedEvent(
result.files.first.path!,
),
);
},
),
],
),
),

View File

@ -4,15 +4,18 @@ import 'package:flutter/material.dart';
class GridItem extends StatefulWidget {
const GridItem({
required this.child,
required this.plusCallback,
required this.minusCallback,
this.plusCallback,
this.minusCallback,
this.enableDrag = true,
super.key,
});
final Widget child;
final void Function() plusCallback;
final void Function() minusCallback;
final bool enableDrag;
final void Function()? plusCallback;
final void Function()? minusCallback;
@override
GridItemState createState() => GridItemState();
@ -26,16 +29,20 @@ class GridItemState extends State<GridItem> {
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (details) {
if (!widget.enableDrag) return;
setState(() {
_offset += details.delta.dx;
_translationX = 160 / (1 + exp(-1 * (1 / 30) * _offset)) - 80;
});
},
onHorizontalDragEnd: (_) {
if (_translationX <= -60) {
widget.plusCallback();
} else if (_translationX >= 60) {
widget.minusCallback();
if (!widget.enableDrag) return;
if (_translationX <= -40) {
widget.plusCallback!();
} else if (_translationX >= 40) {
widget.minusCallback!();
}
// Reset the view

View File

@ -360,6 +360,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c"
url: "https://pub.dev"
source: hosted
version: "8.2.2"
freezed:
dependency: "direct dev"
description:
@ -561,7 +569,7 @@ packages:
source: hosted
version: "2.1.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b
@ -624,6 +632,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.11.1"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "63e5216aae014a72fe9579ccd027323395ce7a98271d9defa9d57320d001af81"
url: "https://pub.dev"
source: hosted
version: "10.4.3"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: c0c9754479a4c4b1c1f3862ddc11930c9b3f03bef2816bb4ea6eed1e13551d6f
url: "https://pub.dev"
source: hosted
version: "10.3.2"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5"
url: "https://pub.dev"
source: hosted
version: "9.1.4"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: "7c6b1500385dd1d2ca61bb89e2488ca178e274a69144d26bbd65e33eae7c02a9"
url: "https://pub.dev"
source: hosted
version: "3.11.3"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098
url: "https://pub.dev"
source: hosted
version: "0.1.3"
petitparser:
dependency: transitive
description:

View File

@ -2,7 +2,7 @@ name: anitrack
description: An anime and manga tracker
publish_to: 'none'
version: 0.1.2+8
version: 0.1.3+2010
environment:
sdk: '>=2.18.4 <3.0.0'
@ -18,10 +18,13 @@ dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
fluttertoast: ^8.2.2
freezed_annotation: 2.1.0
get_it: ^7.2.0
jikan_api: ^2.0.0
json_annotation: 4.6.0
path: ^1.8.2
permission_handler: ^10.4.3
slang: 3.19.0
slang_flutter: 3.19.0
sqflite: ^2.2.4+1