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;
}
String toNameString(TrackingMediumType type) {
return MediumTrackingState.planned;
}
/// 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,11 +1,24 @@
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(
'''
@ -16,7 +29,9 @@ Future<void> _createDatabase(Database db, int version) async {
episodesWatched INTEGER NOT NULL,
thumbnailUrl TEXT NOT NULL,
title TEXT NOT NULL,
score INTEGER
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
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
// 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