diff --git a/lib/app/local/ani_flow_localizations.dart b/lib/app/local/ani_flow_localizations.dart index 89361262..eedf13fc 100644 --- a/lib/app/local/ani_flow_localizations.dart +++ b/lib/app/local/ani_flow_localizations.dart @@ -84,6 +84,20 @@ abstract class AFLocalizations { String get topManhwa; + String get favorite; + + String get animeList; + + String get mangaList; + + String get reviews; + + String get social; + + String get animeLabel; + + String get mangaLabel; + static AFLocalizations of([BuildContext? context]) { return Localizations.of( context ?? globalContext!, AFLocalizations)!; @@ -213,6 +227,27 @@ class EnAniFlowLocalizations extends AFLocalizations { @override String get topManhwa => 'Top Manhwa'; + + @override + String get favorite => 'Favorite'; + + @override + String get animeList => 'Anime List'; + + @override + String get mangaList => 'Manga List'; + + @override + String get reviews => 'Reviews'; + + @override + String get animeLabel => 'Anime'; + + @override + String get mangaLabel => 'Manga'; + + @override + String get social => 'Social'; } class JaAniFLowLocalizations extends AFLocalizations { @@ -338,6 +373,27 @@ class JaAniFLowLocalizations extends AFLocalizations { @override String get topManhwa => 'Top Manhwa'; + + @override + String get favorite => 'Favorites'; + + @override + String get animeList => 'Anime List'; + + @override + String get mangaList => 'Manga List'; + + @override + String get reviews => 'Reviews'; + + @override + String get animeLabel => 'アニメ'; + + @override + String get mangaLabel => '漫画'; + + @override + String get social => 'Social'; } class CNAniFlowLocalizations extends AFLocalizations { @@ -463,4 +519,25 @@ class CNAniFlowLocalizations extends AFLocalizations { @override String get topManhwa => 'Top Manhwa'; + + @override + String get favorite => 'Favorite'; + + @override + String get animeList => 'Anime List'; + + @override + String get mangaList => 'Manga List'; + + @override + String get reviews => 'Reviews'; + + @override + String get animeLabel => '动画'; + + @override + String get mangaLabel => '漫画'; + + @override + String get social => 'Social'; } diff --git a/lib/app/navigation/ani_flow_router.dart b/lib/app/navigation/ani_flow_router.dart index 15ead877..81c174e6 100644 --- a/lib/app/navigation/ani_flow_router.dart +++ b/lib/app/navigation/ani_flow_router.dart @@ -97,7 +97,7 @@ class AFRouterDelegate extends RouterDelegate notifyListeners(); } - void navigateToDetailAnime(String animeId) { + void navigateToDetailMedia(String animeId) { _backStack += [DetailAnimeRoutePath(animeId)]; notifyListeners(); diff --git a/lib/core/common/model/favorite_category.dart b/lib/core/common/model/favorite_category.dart new file mode 100644 index 00000000..df699a77 --- /dev/null +++ b/lib/core/common/model/favorite_category.dart @@ -0,0 +1,23 @@ +import 'package:aniflow/app/local/ani_flow_localizations.dart'; +import 'package:flutter/material.dart'; + +enum FavoriteType { + anime('favorite_anime'), + manga('favorite_manga'), + character('favorite_staff'), + staff('favorite_character'); + + const FavoriteType(this.contentValues); + + final String contentValues; +} + +extension FavoriteTypeEx on FavoriteType { + String getLocalString(BuildContext context) => switch (this) { + FavoriteType.anime => AFLocalizations.of(context).animeLabel, + FavoriteType.manga => AFLocalizations.of(context).mangaLabel, + FavoriteType.character => AFLocalizations.of(context).characters, + FavoriteType.staff => AFLocalizations.of(context).staff, + }; +} + diff --git a/lib/core/data/favorite_repository.dart b/lib/core/data/favorite_repository.dart new file mode 100644 index 00000000..ef1eb9a1 --- /dev/null +++ b/lib/core/data/favorite_repository.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:aniflow/core/common/model/favorite_category.dart'; +import 'package:aniflow/core/common/model/media_type.dart'; +import 'package:aniflow/core/common/util/load_page_util.dart'; +import 'package:aniflow/core/data/load_result.dart'; +import 'package:aniflow/core/data/model/character_model.dart'; +import 'package:aniflow/core/data/model/media_model.dart'; +import 'package:aniflow/core/database/aniflow_database.dart'; +import 'package:aniflow/core/database/dao/media_dao.dart'; +import 'package:aniflow/core/database/dao/media_list_dao.dart'; +import 'package:aniflow/core/database/dao/user_data_dao.dart'; +import 'package:aniflow/core/database/model/character_entity.dart'; +import 'package:aniflow/core/database/model/media_entity.dart'; +import 'package:aniflow/core/network/ani_list_data_source.dart'; +import 'package:aniflow/core/network/model/character_dto.dart'; +import 'package:aniflow/core/network/util/http_status_util.dart'; + +abstract class FavoriteRepository { + Future>> loadFavoriteMediaByPage( + {required MediaType type, required LoadType loadType, String? userId}); + + Future>> loadFavoriteCharacterByPage( + {required LoadType loadType, String? userId}); +} + +class FavoriteRepositoryImpl implements FavoriteRepository { + final AniListDataSource aniListDataSource = AniListDataSource(); + final UserDataDao userDataDao = AniflowDatabase().getUserDataDao(); + final MediaInformationDao mediaInfoDao = + AniflowDatabase().getMediaInformationDaoDao(); + final MediaListDao mediaListDao = AniflowDatabase().getMediaListDao(); + + @override + Future>> loadFavoriteMediaByPage( + {required MediaType type, + required LoadType loadType, + String? userId}) async { + userId ??= (await _getCurrentAuthUserId()); + if (userId == null) { + return LoadError(const UnauthorizedException()); + } + + return LoadPageUtil.loadPage( + type: loadType, + onGetNetworkRes: (int page, int perPage) { + if (type == MediaType.anime) { + return aniListDataSource.getFavoriteAnimeMedia( + userId: userId!, + page: page, + perPage: perPage, + ); + } else { + return aniListDataSource.getFavoriteMangaMedia( + userId: userId!, + page: page, + perPage: perPage, + ); + } + }, + onInsertEntityToDB: (List entities) async { + await mediaInfoDao.upsertMediaInformation(entities); + if (type == MediaType.anime) { + await mediaListDao.insertFavoritesCrossRef( + userId!, FavoriteType.anime, entities.map((e) => e.id).toList()); + } else { + await mediaListDao.insertFavoritesCrossRef( + userId!, FavoriteType.manga, entities.map((e) => e.id).toList()); + } + }, + onClearDbCache: () async {}, + onGetEntityFromDB: (int page, int perPage) => + mediaListDao.getFavoriteMedia(type, userId!, page, perPage), + mapDtoToEntity: (dto) => MediaEntity.fromNetworkModel(dto), + mapEntityToModel: (entity) => MediaModel.fromDatabaseModel(entity), + ); + } + + @override + Future>> loadFavoriteCharacterByPage( + {required LoadType loadType, String? userId}) async { + userId ??= (await _getCurrentAuthUserId()); + if (userId == null) { + return LoadError(const UnauthorizedException()); + } + + return LoadPageUtil.loadPage( + type: loadType, + onGetNetworkRes: (int page, int perPage) { + return aniListDataSource.getFavoriteCharacter( + userId: userId!, + page: page, + perPage: perPage, + ); + }, + onInsertEntityToDB: (List entities) async { + await mediaInfoDao.insertCharacters(entities: entities); + await mediaListDao.insertFavoritesCrossRef(userId!, + FavoriteType.character, entities.map((e) => e.id).toList()); + }, + onClearDbCache: () async {}, + onGetEntityFromDB: (int page, int perPage) => + mediaListDao.getFavoriteCharacters(userId!, page, perPage), + mapDtoToEntity: (dto) => CharacterEntity.fromDto(dto), + mapEntityToModel: (entity) => CharacterModel.fromDatabaseEntity(entity), + ); + } + + Future _getCurrentAuthUserId() async { + return (await userDataDao.getUserData())?.id; + } +} diff --git a/lib/core/data/media_list_repository.dart b/lib/core/data/media_list_repository.dart index 133c84a9..e148ec10 100644 --- a/lib/core/data/media_list_repository.dart +++ b/lib/core/data/media_list_repository.dart @@ -194,7 +194,7 @@ class MediaListRepositoryImpl extends MediaListRepository { try { /// post mutation to network and insert result to database. - final result = await authDataSource.saveAnimeToAnimeList( + final result = await authDataSource.saveMediaToMediaList( MediaListMutationParam( entryId: int.tryParse(entryId ?? ''), mediaId: int.parse(animeId), diff --git a/lib/core/data/search_repository.dart b/lib/core/data/search_repository.dart index 9694cab1..152792a4 100644 --- a/lib/core/data/search_repository.dart +++ b/lib/core/data/search_repository.dart @@ -28,7 +28,7 @@ class SearchRepositoryImpl implements SearchRepository { required int perPage, required String search, required MediaType type}) { - return LoadPageUtil.loadPageWithoutDBCache( + return LoadPageUtil.loadPageWithoutDBCache( page: page, perPage: perPage, onGetNetworkRes: (int page, int perPage) => dataSource.searchAnimePage( diff --git a/lib/core/database/aniflow_database.dart b/lib/core/database/aniflow_database.dart index b63590ea..34dc79b1 100644 --- a/lib/core/database/aniflow_database.dart +++ b/lib/core/database/aniflow_database.dart @@ -24,6 +24,7 @@ mixin Tables { static const String mediaListTable = 'media_list_table'; static const String airingSchedulesTable = 'airing_schedules_table'; static const String mediaExternalLickTable = 'media_external_link_table'; + static const String favoriteInfoCrossRefTable = 'favoriteInfoTable'; } class AniflowDatabase { @@ -134,12 +135,12 @@ class AniflowDatabase { await _aniflowDB!.execute( 'create table if not exists ${Tables.mediaCharacterCrossRefTable} (' - '${MediaCharacterCrossRefColumns.mediaId} text,' - '${MediaCharacterCrossRefColumns.characterId} text,' - '${MediaCharacterCrossRefColumns.timeStamp} integer,' - 'primary key (${MediaCharacterCrossRefColumns.mediaId}, ${MediaCharacterCrossRefColumns.characterId}),' - 'foreign key (${MediaCharacterCrossRefColumns.mediaId}) references ${Tables.mediaTable} (${MediaTableColumns.id}),' - 'foreign key (${MediaCharacterCrossRefColumns.characterId}) references ${Tables.characterTable} (${CharacterColumns.id})' + '${CharacterCrossRefColumns.mediaId} text,' + '${CharacterCrossRefColumns.characterId} text,' + '${CharacterCrossRefColumns.timeStamp} integer,' + 'primary key (${CharacterCrossRefColumns.mediaId}, ${CharacterCrossRefColumns.characterId}),' + 'foreign key (${CharacterCrossRefColumns.mediaId}) references ${Tables.mediaTable} (${MediaTableColumns.id}),' + 'foreign key (${CharacterCrossRefColumns.characterId}) references ${Tables.characterTable} (${CharacterColumns.id})' ')'); await _aniflowDB!.execute( @@ -188,5 +189,14 @@ class AniflowDatabase { '${MediaExternalLinkColumnValues.icon} text,' 'foreign key (${MediaExternalLinkColumnValues.mediaId}) references ${Tables.mediaTable} (${MediaTableColumns.id})' ')'); + + await _aniflowDB!.execute( + 'CREATE TABLE IF NOT EXISTS ${Tables.favoriteInfoCrossRefTable} (' + '${FavoriteInfoCrossRefTableColumn.favoriteType} text,' + '${FavoriteInfoCrossRefTableColumn.id} text,' + '${FavoriteInfoCrossRefTableColumn.userId} text, ' + 'primary key (${FavoriteInfoCrossRefTableColumn.favoriteType},${FavoriteInfoCrossRefTableColumn.id},${FavoriteInfoCrossRefTableColumn.userId})' + ')'); + } } diff --git a/lib/core/database/dao/media_dao.dart b/lib/core/database/dao/media_dao.dart index 93d424ec..e56a9c83 100644 --- a/lib/core/database/dao/media_dao.dart +++ b/lib/core/database/dao/media_dao.dart @@ -108,7 +108,7 @@ mixin StaffColumns { } /// [Tables.mediaCharacterCrossRefTable] -mixin MediaCharacterCrossRefColumns { +mixin CharacterCrossRefColumns { static const String mediaId = 'media_character_cross_anime_id'; static const String characterId = 'media_character_cross_character_id'; static const String timeStamp = 'media_character_cross_time_stamp'; @@ -158,8 +158,10 @@ abstract class MediaInformationDao { Future getDetailMediaInfo(String id); - Future> getCharacterOfMediaByPage(String animeId, - {required int page, int perPage = Config.defaultPerPageCount}); + Future> getCharacterOfMediaByPage( + String animeId, + {required int page, + int perPage = Config.defaultPerPageCount}); Future> getStaffOfMediaByPage(String animeId, {required int page, int perPage = Config.defaultPerPageCount}); @@ -189,7 +191,10 @@ abstract class MediaInformationDao { void notifyMediaDetailInfoChanged(); Future insertCharacterVoiceActors( - {required int mediaId, required List entities}); + {required int mediaId, + required List entities}); + + Future insertCharacters({required List entities}); Future clearMediaCharacterCrossRef(String mediaId); @@ -217,7 +222,7 @@ class MediaInformationDaoImpl extends MediaInformationDao { Future clearMediaCharacterCrossRef(String mediaId) async { await database.aniflowDB.delete( Tables.mediaCharacterCrossRefTable, - where: '${MediaCharacterCrossRefColumns.mediaId} = ?', + where: '${CharacterCrossRefColumns.mediaId} = ?', whereArgs: [mediaId.toString()], ); } @@ -271,17 +276,19 @@ class MediaInformationDaoImpl extends MediaInformationDao { } @override - Future> getCharacterOfMediaByPage(String animeId, - {required int page, int perPage = Config.defaultPerPageCount}) async { + Future> getCharacterOfMediaByPage( + String animeId, + {required int page, + int perPage = Config.defaultPerPageCount}) async { final int limit = perPage; final int offset = (page - 1) * perPage; final characterSql = 'select * from ${Tables.characterTable} as c ' 'join ${Tables.mediaCharacterCrossRefTable} as ac ' - ' on c.${CharacterColumns.id} = ac.${MediaCharacterCrossRefColumns.characterId} ' + ' on c.${CharacterColumns.id} = ac.${CharacterCrossRefColumns.characterId} ' 'left join ${Tables.staffTable} as v ' ' on c.${CharacterColumns.voiceActorId} = v.${StaffColumns.id} ' - 'where ac.${MediaCharacterCrossRefColumns.mediaId} = \'$animeId\' ' - 'order by ${MediaCharacterCrossRefColumns.timeStamp} asc ' + 'where ac.${CharacterCrossRefColumns.mediaId} = \'$animeId\' ' + 'order by ${CharacterCrossRefColumns.timeStamp} asc ' 'limit $limit ' 'offset $offset '; List characterResults = await database.aniflowDB.rawQuery(characterSql); @@ -328,7 +335,8 @@ class MediaInformationDaoImpl extends MediaInformationDao { String externalLinkSql = 'select * from ${Tables.mediaExternalLickTable} as media ' 'where media.${MediaExternalLinkColumnValues.mediaId} = \'$id\' '; - List externalLinkResults = await database.aniflowDB.rawQuery(externalLinkSql); + List externalLinkResults = + await database.aniflowDB.rawQuery(externalLinkSql); return MediaWithDetailInfo( mediaEntity: animeEntity, @@ -438,6 +446,19 @@ class MediaInformationDaoImpl extends MediaInformationDao { return database.aniflowDB.delete(Tables.airingSchedulesTable); } + @override + Future insertCharacters({required List entities}) async { + final batch = database.aniflowDB.batch(); + for (final entity in entities) { + batch.insert( + Tables.characterTable, + entity.toJson(), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + return await batch.commit(noResult: true); + } + @override Future insertCharacterVoiceActors( {required int mediaId, @@ -459,9 +480,9 @@ class MediaInformationDaoImpl extends MediaInformationDao { batch.insert( Tables.mediaCharacterCrossRefTable, { - MediaCharacterCrossRefColumns.mediaId: mediaId, - MediaCharacterCrossRefColumns.characterId: entity.characterEntity.id, - MediaCharacterCrossRefColumns.timeStamp: + CharacterCrossRefColumns.mediaId: mediaId, + CharacterCrossRefColumns.characterId: entity.characterEntity.id, + CharacterCrossRefColumns.timeStamp: DateTime.now().microsecondsSinceEpoch, }, conflictAlgorithm: ConflictAlgorithm.replace, diff --git a/lib/core/database/dao/media_list_dao.dart b/lib/core/database/dao/media_list_dao.dart index cb3d8119..7c828113 100644 --- a/lib/core/database/dao/media_list_dao.dart +++ b/lib/core/database/dao/media_list_dao.dart @@ -2,12 +2,14 @@ import 'dart:async'; +import 'package:aniflow/core/common/model/favorite_category.dart'; import 'package:aniflow/core/common/model/media_type.dart'; import 'package:aniflow/core/common/util/global_static_constants.dart'; import 'package:aniflow/core/common/util/stream_util.dart'; import 'package:aniflow/core/data/media_list_repository.dart'; import 'package:aniflow/core/database/aniflow_database.dart'; import 'package:aniflow/core/database/dao/media_dao.dart'; +import 'package:aniflow/core/database/model/character_entity.dart'; import 'package:aniflow/core/database/model/media_entity.dart'; import 'package:aniflow/core/database/model/media_list_entity.dart'; import 'package:aniflow/core/database/model/relations/media_list_and_media_relation.dart'; @@ -25,6 +27,13 @@ mixin MediaListTableColumns { static const String updatedAt = 'media_list_updatedAt'; } +/// [Tables.favoriteInfoCrossRefTable] +mixin FavoriteInfoCrossRefTableColumn { + static const String favoriteType = 'favorite_type'; + static const String id = 'favorite_info_id'; + static const String userId = 'favorite_user_id'; +} + abstract class MediaListDao { Future removeMediaListByUserId(String userId); @@ -55,6 +64,15 @@ abstract class MediaListDao { Future insertMediaListEntities(List entities); void notifyMediaListChanged(String userId); + + Future insertFavoritesCrossRef( + String userId, FavoriteType type, List ids); + + Future> getFavoriteMedia( + MediaType type, String userId, int page, int perPage); + + Future> getFavoriteCharacters( + String userId, int page, int perPage); } class MediaListDaoImpl extends MediaListDao { @@ -209,8 +227,10 @@ class MediaListDaoImpl extends MediaListDao { Stream> getMediaListStream( String userId, List status, MediaType type) { final changeSource = _notifiers.putIfAbsent(userId, () => ValueNotifier(0)); - return StreamUtil.createStream(changeSource, - () => getMediaListByPage(userId, status, type: type ,page: 1, perPage: null)); + return StreamUtil.createStream( + changeSource, + () => getMediaListByPage(userId, status, + type: type, page: 1, perPage: null)); } @override @@ -220,4 +240,63 @@ class MediaListDaoImpl extends MediaListDao { notifier.value = notifier.value++; } } + + @override + Future insertFavoritesCrossRef( + String userId, FavoriteType type, List ids) async { + final batch = database.aniflowDB.batch(); + for (final id in ids) { + batch.insert( + Tables.favoriteInfoCrossRefTable, + { + 'favorite_type': type.contentValues, + 'favorite_info_id': id, + 'favorite_user_id': userId, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + @override + Future> getFavoriteMedia( + MediaType type, String userId, int page, int perPage) async { + final int limit = perPage; + final int offset = (page - 1) * perPage; + final favoriteValue = type == MediaType.manga + ? FavoriteType.manga.contentValues + : FavoriteType.anime.contentValues; + final sql = 'select * from ${Tables.favoriteInfoCrossRefTable} ' + 'join ${Tables.mediaTable} ' + ' on ${FavoriteInfoCrossRefTableColumn.id} = ${MediaTableColumns.id} ' + 'where ${FavoriteInfoCrossRefTableColumn.userId} = \'$userId\' ' + ' and ${FavoriteInfoCrossRefTableColumn.favoriteType} = \'$favoriteValue\' ' + 'limit $limit ' + 'offset $offset '; + + final List> result = + await database.aniflowDB.rawQuery(sql); + + return result.map((e) => MediaEntity.fromJson(e)).toList(); + } + + @override + Future> getFavoriteCharacters( + String userId, int page, int perPage) async { + final int limit = perPage; + final int offset = (page - 1) * perPage; + final sql = 'select * from ${Tables.favoriteInfoCrossRefTable} ' + 'join ${Tables.characterTable} ' + ' on ${FavoriteInfoCrossRefTableColumn.id} = ${CharacterColumns.id} ' + 'where ${FavoriteInfoCrossRefTableColumn.userId} = \'$userId\' ' + ' and ${FavoriteInfoCrossRefTableColumn.favoriteType} = \'${FavoriteType.character.contentValues}\' ' + 'limit $limit ' + 'offset $offset '; + + final List> result = + await database.aniflowDB.rawQuery(sql); + + return result.map((e) => CharacterEntity.fromJson(e)).toList(); + } } diff --git a/lib/core/database/model/character_entity.dart b/lib/core/database/model/character_entity.dart index 6fd3f3b7..451c9de9 100644 --- a/lib/core/database/model/character_entity.dart +++ b/lib/core/database/model/character_entity.dart @@ -1,5 +1,6 @@ import 'package:aniflow/core/common/model/character_role.dart'; import 'package:aniflow/core/database/dao/media_dao.dart'; +import 'package:aniflow/core/network/model/character_dto.dart'; import 'package:aniflow/core/network/model/character_edge.dart'; import 'package:aniflow/core/network/model/staff_dto.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -39,4 +40,13 @@ class CharacterEntity with _$CharacterEntity { nameEnglish: e.characterNode!.name['full'], ); } + + static CharacterEntity fromDto(CharacterDto e) { + return CharacterEntity( + id: e.id.toString(), + image: e.image['large'], + nameNative: e.name['native'], + nameEnglish: e.name['full'], + ); + } } diff --git a/lib/core/design_system/widget/media_preview_item.dart b/lib/core/design_system/widget/media_preview_item.dart index 11700b02..e5a4acde 100644 --- a/lib/core/design_system/widget/media_preview_item.dart +++ b/lib/core/design_system/widget/media_preview_item.dart @@ -1,20 +1,22 @@ -import 'package:aniflow/core/data/model/media_model.dart'; -import 'package:aniflow/core/data/model/media_title_modle.dart'; import 'package:aniflow/core/design_system/widget/af_network_image.dart'; import 'package:flutter/material.dart'; class MediaPreviewItem extends StatelessWidget { const MediaPreviewItem( - {required this.model, - required this.onClick, + {required this.onClick, + required this.coverImage, + required this.title, + this.isFollowing = false, super.key, this.width, this.textStyle}); - final MediaModel model; final VoidCallback onClick; final double? width; final TextStyle? textStyle; + final String coverImage; + final String title; + final bool isFollowing; @override Widget build(BuildContext context) { @@ -34,7 +36,7 @@ class MediaPreviewItem extends StatelessWidget { AspectRatio( aspectRatio: 3.0 / 4, child: AFNetworkImage( - imageUrl: model.coverImage, + imageUrl: coverImage, ), ), Expanded( @@ -43,7 +45,7 @@ class MediaPreviewItem extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Text( - model.title!.getLocalTitle(context), + title, textAlign: TextAlign.center, style: textStyle?.copyWith( color: Theme.of(context) @@ -58,7 +60,7 @@ class MediaPreviewItem extends StatelessWidget { ]), ), ), - model.isFollowing + isFollowing ? Align( alignment: Alignment.topRight, child: Transform.translate( diff --git a/lib/core/network/ani_list_data_source.dart b/lib/core/network/ani_list_data_source.dart index bbceb212..0771f442 100644 --- a/lib/core/network/ani_list_data_source.dart +++ b/lib/core/network/ani_list_data_source.dart @@ -8,11 +8,17 @@ import 'package:aniflow/core/network/api/media_page_query_graphql.dart'; import 'package:aniflow/core/network/api/query_anime_staff_page_graphql.dart'; import 'package:aniflow/core/network/api/query_media_character_page_graphql.dart'; import 'package:aniflow/core/network/api/search_query_graphql.dart'; +import 'package:aniflow/core/network/api/user_favorite_anime_query_graphql.dart'; +import 'package:aniflow/core/network/api/user_favorite_character_query_graphql.dart'; +import 'package:aniflow/core/network/api/user_favorite_manga_query_graphql.dart'; +import 'package:aniflow/core/network/api/user_favorite_staff_query_graphql.dart'; import 'package:aniflow/core/network/client/ani_list_dio.dart'; import 'package:aniflow/core/network/model/airing_schedule_dto.dart'; +import 'package:aniflow/core/network/model/character_dto.dart'; import 'package:aniflow/core/network/model/character_edge.dart'; import 'package:aniflow/core/network/model/media_dto.dart'; import 'package:aniflow/core/network/model/media_list_dto.dart'; +import 'package:aniflow/core/network/model/staff_dto.dart'; import 'package:aniflow/core/network/model/staff_edge.dart'; /// Anime list data source get from AniList. @@ -194,9 +200,82 @@ class AniListDataSource { final response = await AniListDio().dio.post(AniListDio.aniListUrl, data: {'query': queryGraphQL, 'variables': variablesMap}); final List resultJson = response.data['data']['page']['media']; - final List animeList = + final List mediaList = resultJson.map((e) => MediaDto.fromJson(e)).toList(); - return animeList; + return mediaList; + } + + Future> getFavoriteAnimeMedia( + {required String userId, required int page, required int perPage}) async { + final queryGraphQL = userFavoriteAnimeQueryGraphQl; + final variablesMap = { + 'page': page, + 'perPage': perPage, + 'UserId': userId, + }; + final response = await AniListDio().dio.post(AniListDio.aniListUrl, + data: {'query': queryGraphQL, 'variables': variablesMap}); + final List resultJson = + response.data['data']['User']['favourites']['anime']['nodes']; + final List mediaList = + resultJson.map((e) => MediaDto.fromJson(e)).toList(); + + return mediaList; + } + + Future> getFavoriteMangaMedia( + {required String userId, required int page, required int perPage}) async { + final queryGraphQL = userFavoriteMangaQueryGraphQl; + final variablesMap = { + 'page': page, + 'perPage': perPage, + 'UserId': userId, + }; + final response = await AniListDio().dio.post(AniListDio.aniListUrl, + data: {'query': queryGraphQL, 'variables': variablesMap}); + final List resultJson = + response.data['data']['User']['favourites']['manga']['nodes']; + final List mediaList = + resultJson.map((e) => MediaDto.fromJson(e)).toList(); + + return mediaList; + } + + Future> getFavoriteCharacter( + {required String userId, required int page, required int perPage}) async { + final queryGraphQL = userFavoriteCharacterQueryGraphQl; + final variablesMap = { + 'page': page, + 'perPage': perPage, + 'UserId': userId, + }; + final response = await AniListDio().dio.post(AniListDio.aniListUrl, + data: {'query': queryGraphQL, 'variables': variablesMap}); + final List resultJson = + response.data['data']['User']['favourites']['characters']['nodes']; + final List characters = + resultJson.map((e) => CharacterDto.fromJson(e)).toList(); + + return characters; + } + + Future> getFavoriteStaffs( + {required String userId, required int page, required int perPage}) async { + final queryGraphQL = userFavoriteStaffQueryGraphQl; + final variablesMap = { + 'page': page, + 'perPage': perPage, + 'UserId': userId, + }; + + final response = await AniListDio().dio.post(AniListDio.aniListUrl, + data: {'query': queryGraphQL, 'variables': variablesMap}); + final List resultJson = + response.data['data']['User']['favourites']['staff']['nodes']; + final List staff = + resultJson.map((e) => StaffDto.fromJson(e)).toList(); + + return staff; } } diff --git a/lib/core/network/api/user_favorite_anime_query_graphql.dart b/lib/core/network/api/user_favorite_anime_query_graphql.dart new file mode 100644 index 00000000..1b9744e4 --- /dev/null +++ b/lib/core/network/api/user_favorite_anime_query_graphql.dart @@ -0,0 +1,32 @@ + +String get userFavoriteAnimeQueryGraphQl => +''' +query(\$UserId: Int, \$perPage: Int){ + User(id: \$UserId) { + id + name + favourites(page: 1) { + anime(page: 1, perPage: \$perPage) { + nodes { + id + type + format + status + season + coverImage { + extraLarge + large + medium + color + } + title { + romaji + english + native + } + } + } + } + } +} +'''; \ No newline at end of file diff --git a/lib/core/network/api/user_favorite_character_query_graphql.dart b/lib/core/network/api/user_favorite_character_query_graphql.dart new file mode 100644 index 00000000..965ff438 --- /dev/null +++ b/lib/core/network/api/user_favorite_character_query_graphql.dart @@ -0,0 +1,25 @@ + +String get userFavoriteCharacterQueryGraphQl => +''' +query(\$UserId: Int, \$perPage: Int){ + User(id: \$UserId) { + id + name + favourites(page: 1) { + characters(page: 1, perPage: \$perPage) { + nodes { + id + image { + large + medium + } + name { + full + native + } + } + } + } + } +} +'''; \ No newline at end of file diff --git a/lib/core/network/api/user_favorite_manga_query_graphql.dart b/lib/core/network/api/user_favorite_manga_query_graphql.dart new file mode 100644 index 00000000..c634b8ce --- /dev/null +++ b/lib/core/network/api/user_favorite_manga_query_graphql.dart @@ -0,0 +1,32 @@ + +String get userFavoriteMangaQueryGraphQl => +''' +query(\$UserId: Int, \$perPage: Int){ + User(id: \$UserId) { + id + name + favourites(page: 1) { + manga(page: 1, perPage: \$perPage) { + nodes { + id + type + format + status + season + coverImage { + extraLarge + large + medium + color + } + title { + romaji + english + native + } + } + } + } + } +} +'''; \ No newline at end of file diff --git a/lib/core/network/api/user_favorite_staff_query_graphql.dart b/lib/core/network/api/user_favorite_staff_query_graphql.dart new file mode 100644 index 00000000..3506a1e3 --- /dev/null +++ b/lib/core/network/api/user_favorite_staff_query_graphql.dart @@ -0,0 +1,26 @@ + +String get userFavoriteStaffQueryGraphQl => +''' +query(\$UserId: Int, \$perPage: Int){ + User(id: \$UserId) { + id + name + favourites(page: 1) { + staff(page: 1, perPage: \$perPage) { + nodes { + id + name { + full + native + userPreferred + } + image { + large + medium + } + } + } + } + } +} +'''; \ No newline at end of file diff --git a/lib/core/network/auth_data_source.dart b/lib/core/network/auth_data_source.dart index 2c00e748..03d600a3 100644 --- a/lib/core/network/auth_data_source.dart +++ b/lib/core/network/auth_data_source.dart @@ -66,7 +66,7 @@ class AuthDataSource { return UserDataDto.fromJson(resultJson); } - Future saveAnimeToAnimeList( + Future saveMediaToMediaList( MediaListMutationParam param) async { final variablesMap = { 'mediaId': param.mediaId, diff --git a/lib/feature/airing_schedule/airing_schedule.dart b/lib/feature/airing_schedule/airing_schedule.dart index 1530927d..cb86cb75 100644 --- a/lib/feature/airing_schedule/airing_schedule.dart +++ b/lib/feature/airing_schedule/airing_schedule.dart @@ -231,7 +231,7 @@ class _TimeLineItemState extends State<_TimeLineItem> { model: schedule, onClick: () { AFRouterDelegate.of(context) - .navigateToDetailAnime(schedule.animeModel.id); + .navigateToDetailMedia(schedule.animeModel.id); }, ), ), diff --git a/lib/feature/anime_search/media_search.dart b/lib/feature/anime_search/media_search.dart index a046186f..8652e9c2 100644 --- a/lib/feature/anime_search/media_search.dart +++ b/lib/feature/anime_search/media_search.dart @@ -95,7 +95,7 @@ class _MediaSearchPageContent extends StatelessWidget { child: SearchAnimeItem( model: model, onClick: () { - AFRouterDelegate.of(context).navigateToDetailAnime(model.id); + AFRouterDelegate.of(context).navigateToDetailMedia(model.id); }, ), ); diff --git a/lib/feature/discover/discover.dart b/lib/feature/discover/discover.dart index 349d46d2..c2633022 100644 --- a/lib/feature/discover/discover.dart +++ b/lib/feature/discover/discover.dart @@ -4,6 +4,7 @@ import 'package:aniflow/core/common/model/anime_category.dart'; import 'package:aniflow/core/common/model/media_type.dart'; import 'package:aniflow/core/common/util/global_static_constants.dart'; import 'package:aniflow/core/data/model/media_model.dart'; +import 'package:aniflow/core/data/model/media_title_modle.dart'; import 'package:aniflow/core/design_system/widget/avatar_icon.dart'; import 'package:aniflow/core/design_system/widget/loading_indicator.dart'; import 'package:aniflow/core/design_system/widget/media_preview_item.dart'; @@ -72,9 +73,7 @@ class DiscoverScreen extends StatelessWidget { ), body: RefreshIndicator( onRefresh: () async { - await context - .read() - .onPullToRefreshTriggered(); + await context.read().onPullToRefreshTriggered(); }, child: CustomScrollView( cacheExtent: Config.defaultCatchExtend, @@ -132,7 +131,7 @@ class DiscoverScreen extends StatelessWidget { AFRouterDelegate.of(context).navigateToAnimeList(category); }, onAnimeClick: (id) { - AFRouterDelegate.of(context).navigateToDetailAnime(id); + AFRouterDelegate.of(context).navigateToDetailMedia(id); }, ), ); @@ -168,10 +167,13 @@ class _MediaCategoryPreview extends StatelessWidget { sliver: SliverList.builder( itemCount: animeModels.length, itemBuilder: (BuildContext context, int index) { + final model = animeModels[index]; return MediaPreviewItem( width: 160, textStyle: Theme.of(context).textTheme.titleSmall, - model: animeModels[index], + coverImage: model.coverImage, + title: model.title!.getLocalTitle(context), + isFollowing: model.isFollowing, onClick: () => onAnimeClick?.call(animeModels[index].id), ); diff --git a/lib/feature/media_page/media_page.dart b/lib/feature/media_page/media_page.dart index 2c686697..e75bfb54 100644 --- a/lib/feature/media_page/media_page.dart +++ b/lib/feature/media_page/media_page.dart @@ -4,6 +4,7 @@ import 'package:aniflow/core/data/auth_repository.dart'; import 'package:aniflow/core/data/media_information_repository.dart'; import 'package:aniflow/core/data/media_list_repository.dart'; import 'package:aniflow/core/data/model/media_model.dart'; +import 'package:aniflow/core/data/model/media_title_modle.dart'; import 'package:aniflow/core/design_system/widget/media_preview_item.dart'; import 'package:aniflow/feature/common/page_loading_state.dart'; import 'package:aniflow/feature/common/paging_bloc.dart'; @@ -89,10 +90,12 @@ class _MediaListPageContent extends StatelessWidget { Widget _buildGridItems(BuildContext context, MediaModel model) { return MediaPreviewItem( - model: model, textStyle: Theme.of(context).textTheme.labelMedium, + coverImage: model.coverImage, + title: model.title!.getLocalTitle(context), + isFollowing: model.isFollowing, onClick: () { - AFRouterDelegate.of(context).navigateToDetailAnime( + AFRouterDelegate.of(context).navigateToDetailMedia( model.id, ); }, diff --git a/lib/feature/media_track/media_track.dart b/lib/feature/media_track/media_track.dart index fed27471..e818cb1e 100644 --- a/lib/feature/media_track/media_track.dart +++ b/lib/feature/media_track/media_track.dart @@ -99,7 +99,7 @@ class _AnimeTrackPageContent extends StatelessWidget { // mark watch }, onClick: () { - AFRouterDelegate.of(context).navigateToDetailAnime( + AFRouterDelegate.of(context).navigateToDetailMedia( item.animeModel!.id, ); }, diff --git a/lib/feature/profile/boc/profile_bloc.dart b/lib/feature/profile/boc/profile_bloc.dart new file mode 100644 index 00000000..f20d0b85 --- /dev/null +++ b/lib/feature/profile/boc/profile_bloc.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:aniflow/core/data/auth_repository.dart'; +import 'package:aniflow/core/data/model/user_data_model.dart'; +import 'package:aniflow/feature/profile/boc/profile_state.dart'; +import 'package:bloc/bloc.dart'; + +sealed class ProfileEvent {} + +class _OnUserDataLoaded extends ProfileEvent { + _OnUserDataLoaded({required this.userData}); + + final UserData userData; +} + +class ProfileBloc extends Bloc { + ProfileBloc({ + required AuthRepository authRepository, + String? userId, + }) : + super(ProfileState()) { + on<_OnUserDataLoaded>(_onUserDataLoaded); + + _userDataSub = + authRepository.getUserDataStream().distinct().listen((userData) { + if (userData != null) { + add(_OnUserDataLoaded(userData: userData)); + } + }); + } + + StreamSubscription? _userDataSub; + + @override + Future close() { + _userDataSub?.cancel(); + + return super.close(); + } + + FutureOr _onUserDataLoaded( + _OnUserDataLoaded event, Emitter emit) { + emit(state.copyWith(userData: event.userData)); + } +} diff --git a/lib/feature/profile/boc/profile_state.dart b/lib/feature/profile/boc/profile_state.dart new file mode 100644 index 00000000..5c74b481 --- /dev/null +++ b/lib/feature/profile/boc/profile_state.dart @@ -0,0 +1,21 @@ +import 'package:aniflow/core/common/model/favorite_category.dart'; +import 'package:aniflow/core/data/model/user_data_model.dart'; +import 'package:aniflow/feature/common/page_loading_state.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_state.freezed.dart'; + +@freezed +class ProfileState with _$ProfileState { + factory ProfileState({ + @Default(false) bool isLoading, + UserData? userData, + @Default({ + FavoriteType.anime: PageLoading(data: [], page: 1), + FavoriteType.manga: PageLoading(data: [], page: 1), + FavoriteType.staff: PageLoading(data: [], page: 1), + FavoriteType.character: PageLoading(data: [], page: 1), + }) + Map> favoriteDataMap, + }) = _ProfileState; +} diff --git a/lib/feature/profile/boc/profile_state.freezed.dart b/lib/feature/profile/boc/profile_state.freezed.dart new file mode 100644 index 00000000..400f2b37 --- /dev/null +++ b/lib/feature/profile/boc/profile_state.freezed.dart @@ -0,0 +1,212 @@ +// 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, unnecessary_question_mark + +part of 'profile_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(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 _$ProfileState { + bool get isLoading => throw _privateConstructorUsedError; + UserData? get userData => throw _privateConstructorUsedError; + Map> get favoriteDataMap => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $ProfileStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProfileStateCopyWith<$Res> { + factory $ProfileStateCopyWith( + ProfileState value, $Res Function(ProfileState) then) = + _$ProfileStateCopyWithImpl<$Res, ProfileState>; + @useResult + $Res call( + {bool isLoading, + UserData? userData, + Map> favoriteDataMap}); + + $UserDataCopyWith<$Res>? get userData; +} + +/// @nodoc +class _$ProfileStateCopyWithImpl<$Res, $Val extends ProfileState> + implements $ProfileStateCopyWith<$Res> { + _$ProfileStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isLoading = null, + Object? userData = freezed, + Object? favoriteDataMap = null, + }) { + return _then(_value.copyWith( + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + userData: freezed == userData + ? _value.userData + : userData // ignore: cast_nullable_to_non_nullable + as UserData?, + favoriteDataMap: null == favoriteDataMap + ? _value.favoriteDataMap + : favoriteDataMap // ignore: cast_nullable_to_non_nullable + as Map>, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $UserDataCopyWith<$Res>? get userData { + if (_value.userData == null) { + return null; + } + + return $UserDataCopyWith<$Res>(_value.userData!, (value) { + return _then(_value.copyWith(userData: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$_ProfileStateCopyWith<$Res> + implements $ProfileStateCopyWith<$Res> { + factory _$$_ProfileStateCopyWith( + _$_ProfileState value, $Res Function(_$_ProfileState) then) = + __$$_ProfileStateCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool isLoading, + UserData? userData, + Map> favoriteDataMap}); + + @override + $UserDataCopyWith<$Res>? get userData; +} + +/// @nodoc +class __$$_ProfileStateCopyWithImpl<$Res> + extends _$ProfileStateCopyWithImpl<$Res, _$_ProfileState> + implements _$$_ProfileStateCopyWith<$Res> { + __$$_ProfileStateCopyWithImpl( + _$_ProfileState _value, $Res Function(_$_ProfileState) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isLoading = null, + Object? userData = freezed, + Object? favoriteDataMap = null, + }) { + return _then(_$_ProfileState( + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + userData: freezed == userData + ? _value.userData + : userData // ignore: cast_nullable_to_non_nullable + as UserData?, + favoriteDataMap: null == favoriteDataMap + ? _value._favoriteDataMap + : favoriteDataMap // ignore: cast_nullable_to_non_nullable + as Map>, + )); + } +} + +/// @nodoc + +class _$_ProfileState implements _ProfileState { + _$_ProfileState( + {this.isLoading = false, + this.userData, + final Map> favoriteDataMap = const { + FavoriteType.anime: PageLoading(data: [], page: 1), + FavoriteType.manga: PageLoading(data: [], page: 1), + FavoriteType.staff: PageLoading(data: [], page: 1), + FavoriteType.character: PageLoading(data: [], page: 1) + }}) + : _favoriteDataMap = favoriteDataMap; + + @override + @JsonKey() + final bool isLoading; + @override + final UserData? userData; + final Map> _favoriteDataMap; + @override + @JsonKey() + Map> get favoriteDataMap { + if (_favoriteDataMap is EqualUnmodifiableMapView) return _favoriteDataMap; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_favoriteDataMap); + } + + @override + String toString() { + return 'ProfileState(isLoading: $isLoading, userData: $userData, favoriteDataMap: $favoriteDataMap)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_ProfileState && + (identical(other.isLoading, isLoading) || + other.isLoading == isLoading) && + (identical(other.userData, userData) || + other.userData == userData) && + const DeepCollectionEquality() + .equals(other._favoriteDataMap, _favoriteDataMap)); + } + + @override + int get hashCode => Object.hash(runtimeType, isLoading, userData, + const DeepCollectionEquality().hash(_favoriteDataMap)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$_ProfileStateCopyWith<_$_ProfileState> get copyWith => + __$$_ProfileStateCopyWithImpl<_$_ProfileState>(this, _$identity); +} + +abstract class _ProfileState implements ProfileState { + factory _ProfileState( + {final bool isLoading, + final UserData? userData, + final Map> favoriteDataMap}) = + _$_ProfileState; + + @override + bool get isLoading; + @override + UserData? get userData; + @override + Map> get favoriteDataMap; + @override + @JsonKey(ignore: true) + _$$_ProfileStateCopyWith<_$_ProfileState> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/feature/profile/boc/profile_tab_category.dart b/lib/feature/profile/boc/profile_tab_category.dart new file mode 100644 index 00000000..f3e04d2b --- /dev/null +++ b/lib/feature/profile/boc/profile_tab_category.dart @@ -0,0 +1,20 @@ +import 'package:aniflow/app/local/ani_flow_localizations.dart'; +import 'package:flutter/cupertino.dart'; + +enum ProfileTabType { + favorite, + animeList, + mangaList, + social, + reviews; +} + +extension ProfileTabTypeEx on ProfileTabType { + String getLocalString(BuildContext context) => switch (this) { + ProfileTabType.favorite => AFLocalizations.of(context).favorite, + ProfileTabType.animeList => AFLocalizations.of(context).animeList, + ProfileTabType.mangaList => AFLocalizations.of(context).mangaList, + ProfileTabType.reviews => AFLocalizations.of(context).reviews, + ProfileTabType.social => AFLocalizations.of(context).reviews, + }; +} diff --git a/lib/feature/profile/profile.dart b/lib/feature/profile/profile.dart index ebfbf56f..58ab6b8f 100644 --- a/lib/feature/profile/profile.dart +++ b/lib/feature/profile/profile.dart @@ -1,23 +1,40 @@ -import 'package:aniflow/core/design_system/widget/comming_soon.dart'; +import 'package:aniflow/core/data/auth_repository.dart'; +import 'package:aniflow/core/data/favorite_repository.dart'; +import 'package:aniflow/core/data/model/user_data_model.dart'; +import 'package:aniflow/core/design_system/widget/af_network_image.dart'; +import 'package:aniflow/feature/profile/boc/profile_bloc.dart'; +import 'package:aniflow/feature/profile/boc/profile_state.dart'; +import 'package:aniflow/feature/profile/boc/profile_tab_category.dart'; +import 'package:aniflow/feature/profile/sub_favorite/bloc/profile_favorite_bloc.dart'; +import 'package:aniflow/feature/profile/sub_favorite/profile_favorite.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class ProfilePage extends Page { - const ProfilePage({super.key}); + const ProfilePage({this.userId, super.key}); + + final String? userId; @override Route createRoute(BuildContext context) { - return ProfileRoute(settings: this); + return ProfileRoute(settings: this, userId: userId); } } class ProfileRoute extends PageRoute with MaterialRouteTransitionMixin { - ProfileRoute({super.settings}): super(allowSnapshotting: false); + ProfileRoute({required this.userId, super.settings}) + : super(allowSnapshotting: false); + + final String? userId; @override Widget buildContent(BuildContext context) { - return const Scaffold( - body: _ProfilePageContent(), - ); + return BlocProvider( + create: (BuildContext context) => ProfileBloc( + userId: userId, + authRepository: context.read(), + ), + child: const _ProfilePageContent()); } @override @@ -29,6 +46,201 @@ class _ProfilePageContent extends StatelessWidget { @override Widget build(BuildContext context) { - return const ComingSoonPage(); + return BlocBuilder( + buildWhen: (pre, current) => pre.userData != current.userData, + builder: (context, state) { + final userState = state.userData; + if (userState == null) { + return const SizedBox(); + } else { + return MultiBlocProvider(providers: [ + BlocProvider( + create: (BuildContext context) => ProfileFavoriteBloc( + userState.id, + context.read(), + ), + ), + ], child: _UserProfile(userState: userState)); + } + }, + ); } } + +class _UserProfile extends StatefulWidget { + const _UserProfile({required this.userState}); + + final UserData userState; + + @override + State<_UserProfile> createState() => _UserProfileState(); +} + +class _UserProfileState extends State<_UserProfile> + with TickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = + TabController(length: ProfileTabType.values.length, vsync: this); + } + + @override + void dispose() { + super.dispose(); + + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: SliverPersistentHeader( + delegate: _CustomSliverAppBarDelegate( + state: widget.userState, + tabController: _tabController, + tabs: ProfileTabType.values + .map((e) => Text(e.getLocalString(context))) + .toList(), + ), + pinned: true, + floating: true, + ), + ), + ]; + }, + body: TabBarView( + controller: _tabController, + children: ProfileTabType.values + .map((e) => _buildPageByProfileCategory(e)) + .toList(), + ), + ), + ); + } + + Widget _buildPageByProfileCategory(ProfileTabType category) { + switch (category) { + case ProfileTabType.favorite: + return const ProfileFavoriteTabPage(); + case ProfileTabType.animeList: + case ProfileTabType.mangaList: + case ProfileTabType.reviews: + case ProfileTabType.social: + return const SizedBox(); + } + } +} + +class _CustomSliverAppBarDelegate extends SliverPersistentHeaderDelegate { + const _CustomSliverAppBarDelegate({ + required this.state, + required this.tabController, + required this.tabs, + }); + + final TabController tabController; + final List tabs; + + final UserData state; + final _maxExtent = 360.0; + final _minExtent = 160.0; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + _buildBackground(context, shrinkOffset), + _buildAppbar(shrinkOffset), + ], + ), + ), + Container( + height: 50, + color: Theme.of(context).colorScheme.background, + child: TabBar( + controller: tabController, + isScrollable: true, + tabs: tabs, + ), + ) + ], + ); + } + + @override + double get maxExtent => _maxExtent; + + @override + double get minExtent => _minExtent; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + false; + + Widget _buildBackground(BuildContext context, double shrinkOffset) => Opacity( + opacity: 1 - shrinkOffset / (_maxExtent - _minExtent), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Stack( + children: [ + SizedBox.expand( + child: AFNetworkImage(imageUrl: state.bannerImage!), + ), + Container( + height: 50, + color: + Theme.of(context).colorScheme.background.withAlpha(122), + ), + Align( + alignment: Alignment.bottomCenter, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: 30), + FractionallySizedBox( + heightFactor: 0.5, + child: AFNetworkImage(imageUrl: state.avatar), + ), + const SizedBox(width: 10), + Text( + state.name, + style: Theme.of(context) + .textTheme + .headlineLarge! + .copyWith(color: Colors.white), + ) + ], + ), + ) + ], + ), + ), + ], + ), + ); + + Widget _buildAppbar(double shrinkOffset) => Opacity( + opacity: shrinkOffset / (_maxExtent - _minExtent), + child: AppBar( + title: Text(state.name), + automaticallyImplyLeading: false, + ), + ); +} diff --git a/lib/feature/profile/sub_favorite/bloc/profile_favorite_bloc.dart b/lib/feature/profile/sub_favorite/bloc/profile_favorite_bloc.dart new file mode 100644 index 00000000..6c27d136 --- /dev/null +++ b/lib/feature/profile/sub_favorite/bloc/profile_favorite_bloc.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:aniflow/app/local/ani_flow_localizations.dart'; +import 'package:aniflow/core/common/model/favorite_category.dart'; +import 'package:aniflow/core/common/model/media_type.dart'; +import 'package:aniflow/core/common/util/collection_util.dart'; +import 'package:aniflow/core/common/util/global_static_constants.dart'; +import 'package:aniflow/core/common/util/logger.dart'; +import 'package:aniflow/core/data/favorite_repository.dart'; +import 'package:aniflow/core/data/load_result.dart'; +import 'package:aniflow/core/design_system/widget/aniflow_snackbar.dart'; +import 'package:aniflow/core/network/util/http_status_util.dart'; +import 'package:aniflow/feature/common/page_loading_state.dart'; +import 'package:aniflow/feature/profile/sub_favorite/bloc/profile_favorite_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +sealed class ProfileFavoriteEvent {} + +class _OnLoadStateChanged extends ProfileFavoriteEvent { + _OnLoadStateChanged(this.isLoading); + + final bool isLoading; +} + +class _OnMediaLoaded extends ProfileFavoriteEvent { + _OnMediaLoaded(this.dataList, this.type); + + final List dataList; + final FavoriteType type; +} + +class ProfileFavoriteBloc + extends Bloc { + ProfileFavoriteBloc( + String userId, + FavoriteRepository favoriteRepository, + ) : _userId = userId, + _favoriteRepository = favoriteRepository, + super(ProfileFavoriteState()) { + on<_OnLoadStateChanged>(_onLoadStateChanged); + on<_OnMediaLoaded>(_onMediaLoaded); + + _init(); + } + + final String _userId; + final FavoriteRepository _favoriteRepository; + + void _init() async { + unawaited(_reloadAllFavoriteData(isRefresh: true)); + } + + Future _reloadAllFavoriteData({required bool isRefresh}) async { + add(_OnLoadStateChanged(true)); + + /// wait refresh tasks. + final result = await Future.wait(_getAllLoadTask(isRefresh: isRefresh)); + + if (result.any((e) => e == false)) { + logger.d('AimeTracker refresh failed'); + + /// data sync failed and show snack bar message. + showSnackBarMessage(label: AFLocalizations.of().dataRefreshFailed); + } + + add(_OnLoadStateChanged(false)); + } + + List> _getAllLoadTask({required bool isRefresh}) { + return FavoriteType.values + .map((e) => _createLoadFavoriteTask(e, isRefresh: isRefresh)) + .toList(); + } + + Future _createLoadFavoriteTask(FavoriteType favoriteType, + {bool isRefresh = false}) async { + final LoadResult result; + final loadType = isRefresh + ? const Refresh() + : const Append(page: 1, perPage: Config.defaultPerPageCount); + + switch (favoriteType) { + case FavoriteType.anime: + result = await _favoriteRepository.loadFavoriteMediaByPage( + type: MediaType.anime, userId: _userId, loadType: loadType); + case FavoriteType.manga: + result = await _favoriteRepository.loadFavoriteMediaByPage( + type: MediaType.manga, userId: _userId, loadType: loadType); + case FavoriteType.character: + result = await _favoriteRepository.loadFavoriteCharacterByPage( + userId: _userId, loadType: loadType); + case FavoriteType.staff: + result = LoadError(const NotFoundException()); + } + switch (result) { + case LoadSuccess(data: final data): + add(_OnMediaLoaded(data, favoriteType)); + return true; + case LoadError(exception: final _): + return false; + default: + return false; + } + } + + FutureOr _onMediaLoaded( + _OnMediaLoaded event, Emitter emit) { + final result = PageReady(data: event.dataList, page: 1); + final favoriteType = event.type; + + Map> stateMap = + state.favoriteDataMap.toMutableMap()..[favoriteType] = result; + + final newState = state.copyWith(favoriteDataMap: stateMap); + + emit(newState); + } + + FutureOr _onLoadStateChanged( + _OnLoadStateChanged event, Emitter emit) {} +} diff --git a/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.dart b/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.dart new file mode 100644 index 00000000..b8bf5cd5 --- /dev/null +++ b/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.dart @@ -0,0 +1,19 @@ +import 'package:aniflow/core/common/model/favorite_category.dart'; +import 'package:aniflow/feature/common/page_loading_state.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_favorite_state.freezed.dart'; + +@freezed +class ProfileFavoriteState with _$ProfileFavoriteState { + factory ProfileFavoriteState({ + @Default(false) bool isLoading, + @Default({ + FavoriteType.anime: PageLoading(data: [], page: 1), + FavoriteType.manga: PageLoading(data: [], page: 1), + FavoriteType.staff: PageLoading(data: [], page: 1), + FavoriteType.character: PageLoading(data: [], page: 1), + }) + Map> favoriteDataMap, + }) = _ProfileFavoriteState; +} \ No newline at end of file diff --git a/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.freezed.dart b/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.freezed.dart new file mode 100644 index 00000000..9fb15e15 --- /dev/null +++ b/lib/feature/profile/sub_favorite/bloc/profile_favorite_state.freezed.dart @@ -0,0 +1,174 @@ +// 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, unnecessary_question_mark + +part of 'profile_favorite_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(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 _$ProfileFavoriteState { + bool get isLoading => throw _privateConstructorUsedError; + Map> get favoriteDataMap => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $ProfileFavoriteStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProfileFavoriteStateCopyWith<$Res> { + factory $ProfileFavoriteStateCopyWith(ProfileFavoriteState value, + $Res Function(ProfileFavoriteState) then) = + _$ProfileFavoriteStateCopyWithImpl<$Res, ProfileFavoriteState>; + @useResult + $Res call( + {bool isLoading, Map> favoriteDataMap}); +} + +/// @nodoc +class _$ProfileFavoriteStateCopyWithImpl<$Res, + $Val extends ProfileFavoriteState> + implements $ProfileFavoriteStateCopyWith<$Res> { + _$ProfileFavoriteStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isLoading = null, + Object? favoriteDataMap = null, + }) { + return _then(_value.copyWith( + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + favoriteDataMap: null == favoriteDataMap + ? _value.favoriteDataMap + : favoriteDataMap // ignore: cast_nullable_to_non_nullable + as Map>, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$_ProfileFavoriteStateCopyWith<$Res> + implements $ProfileFavoriteStateCopyWith<$Res> { + factory _$$_ProfileFavoriteStateCopyWith(_$_ProfileFavoriteState value, + $Res Function(_$_ProfileFavoriteState) then) = + __$$_ProfileFavoriteStateCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool isLoading, Map> favoriteDataMap}); +} + +/// @nodoc +class __$$_ProfileFavoriteStateCopyWithImpl<$Res> + extends _$ProfileFavoriteStateCopyWithImpl<$Res, _$_ProfileFavoriteState> + implements _$$_ProfileFavoriteStateCopyWith<$Res> { + __$$_ProfileFavoriteStateCopyWithImpl(_$_ProfileFavoriteState _value, + $Res Function(_$_ProfileFavoriteState) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isLoading = null, + Object? favoriteDataMap = null, + }) { + return _then(_$_ProfileFavoriteState( + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + favoriteDataMap: null == favoriteDataMap + ? _value._favoriteDataMap + : favoriteDataMap // ignore: cast_nullable_to_non_nullable + as Map>, + )); + } +} + +/// @nodoc + +class _$_ProfileFavoriteState implements _ProfileFavoriteState { + _$_ProfileFavoriteState( + {this.isLoading = false, + final Map> favoriteDataMap = const { + FavoriteType.anime: PageLoading(data: [], page: 1), + FavoriteType.manga: PageLoading(data: [], page: 1), + FavoriteType.staff: PageLoading(data: [], page: 1), + FavoriteType.character: PageLoading(data: [], page: 1) + }}) + : _favoriteDataMap = favoriteDataMap; + + @override + @JsonKey() + final bool isLoading; + final Map> _favoriteDataMap; + @override + @JsonKey() + Map> get favoriteDataMap { + if (_favoriteDataMap is EqualUnmodifiableMapView) return _favoriteDataMap; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_favoriteDataMap); + } + + @override + String toString() { + return 'ProfileFavoriteState(isLoading: $isLoading, favoriteDataMap: $favoriteDataMap)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_ProfileFavoriteState && + (identical(other.isLoading, isLoading) || + other.isLoading == isLoading) && + const DeepCollectionEquality() + .equals(other._favoriteDataMap, _favoriteDataMap)); + } + + @override + int get hashCode => Object.hash(runtimeType, isLoading, + const DeepCollectionEquality().hash(_favoriteDataMap)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$_ProfileFavoriteStateCopyWith<_$_ProfileFavoriteState> get copyWith => + __$$_ProfileFavoriteStateCopyWithImpl<_$_ProfileFavoriteState>( + this, _$identity); +} + +abstract class _ProfileFavoriteState implements ProfileFavoriteState { + factory _ProfileFavoriteState( + {final bool isLoading, + final Map> favoriteDataMap}) = + _$_ProfileFavoriteState; + + @override + bool get isLoading; + @override + Map> get favoriteDataMap; + @override + @JsonKey(ignore: true) + _$$_ProfileFavoriteStateCopyWith<_$_ProfileFavoriteState> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/feature/profile/sub_favorite/profile_favorite.dart b/lib/feature/profile/sub_favorite/profile_favorite.dart new file mode 100644 index 00000000..291bacb0 --- /dev/null +++ b/lib/feature/profile/sub_favorite/profile_favorite.dart @@ -0,0 +1,102 @@ +import 'package:aniflow/app/navigation/ani_flow_router.dart'; +import 'package:aniflow/core/common/model/favorite_category.dart'; +import 'package:aniflow/core/data/model/character_model.dart'; +import 'package:aniflow/core/data/model/media_model.dart'; +import 'package:aniflow/core/data/model/media_title_modle.dart'; +import 'package:aniflow/core/design_system/widget/media_preview_item.dart'; +import 'package:aniflow/core/design_system/widget/vertical_animated_scale_switcher.dart'; +import 'package:aniflow/feature/common/page_loading_state.dart'; +import 'package:aniflow/feature/profile/sub_favorite/bloc/profile_favorite_bloc.dart'; +import 'package:aniflow/feature/profile/sub_favorite/bloc/profile_favorite_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProfileFavoriteTabPage extends StatelessWidget { + const ProfileFavoriteTabPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (pre, current) => + pre.favoriteDataMap != current.favoriteDataMap, + builder: (BuildContext context, ProfileFavoriteState state) { + final favoriteMap = state.favoriteDataMap; + return CustomScrollView( + key: const PageStorageKey('anime'), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + for (var type in FavoriteType.values) + ..._buildFavoriteCategory(context, type, favoriteMap[type]!), + ], + ); + }, + ); + } + + List _buildFavoriteCategory(BuildContext context, FavoriteType type, + PagingState> state) { + List items = state.data; + return [ + SliverToBoxAdapter( + child: VerticalScaleSwitcher( + visible: items.isNotEmpty, + child: _buildFavoriteTitle(context, type), + ), + ), + SliverGrid.builder( + itemCount: items.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 3.0 / 5.0, + ), + itemBuilder: (context, index) { + return _buildGridItems(context, type, items[index]); + }, + ), + ]; + } + + Widget _buildFavoriteTitle(BuildContext context, FavoriteType type) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row(children: [ + Text( + type.getLocalString(context), + style: Theme.of(context).textTheme.titleMedium, + ), + ]), + ); + } + + Widget _buildGridItems( + BuildContext context, FavoriteType type, dynamic model) { + final String coverImage; + final String title; + final String id; + switch (type) { + case FavoriteType.anime: + case FavoriteType.manga: + coverImage = (model as MediaModel).coverImage; + title = model.title!.getLocalTitle(context); + id = model.id; + case FavoriteType.character: + coverImage = (model as CharacterModel).image; + title = model.nameNative; + id = model.id; + case FavoriteType.staff: + coverImage = ''; + title = ''; + id = ''; + } + return MediaPreviewItem( + coverImage: coverImage, + title: title, + textStyle: Theme.of(context).textTheme.labelMedium, + onClick: () { + AFRouterDelegate.of(context).navigateToDetailMedia(id); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 37645261..471c0d8b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:aniflow/app/app.dart'; import 'package:aniflow/core/data/auth_repository.dart'; +import 'package:aniflow/core/data/favorite_repository.dart'; import 'package:aniflow/core/data/media_information_repository.dart'; import 'package:aniflow/core/data/media_list_repository.dart'; import 'package:aniflow/core/data/search_repository.dart'; @@ -35,5 +36,8 @@ void main() async { RepositoryProvider( create: (context) => SearchRepositoryImpl(), ), + RepositoryProvider( + create: (context) => FavoriteRepositoryImpl(), + ), ], child: const AnimeTrackerApp())); } diff --git a/test/core/data/repository/favorite_repository_test.dart b/test/core/data/repository/favorite_repository_test.dart new file mode 100644 index 00000000..db9bb127 --- /dev/null +++ b/test/core/data/repository/favorite_repository_test.dart @@ -0,0 +1,28 @@ +import 'package:aniflow/core/common/model/media_type.dart'; +import 'package:aniflow/core/data/favorite_repository.dart'; +import 'package:aniflow/core/data/load_result.dart'; +import 'package:aniflow/core/data/model/media_model.dart'; +import 'package:aniflow/core/database/aniflow_database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + group('favorite_repository_test', () { + final animeDatabase = AniflowDatabase(); + FavoriteRepository favoriteRepository = FavoriteRepositoryImpl(); + + setUp(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + SharedPreferences.setMockInitialValues({}); + await animeDatabase.initDatabase(isTest: true); + }); + + test('favorite_anime_test', () async { + final result = await favoriteRepository.loadFavoriteMediaByPage( + type: MediaType.anime, userId: '6378393', loadType: const Refresh()); + expect(result.runtimeType, LoadSuccess>); + }); + }); +} diff --git a/test/core/database/user_anime_list_dao_test.dart b/test/core/database/user_anime_list_dao_test.dart index ffc66ed3..75a8bab3 100644 --- a/test/core/database/user_anime_list_dao_test.dart +++ b/test/core/database/user_anime_list_dao_test.dart @@ -1,4 +1,5 @@ import 'package:aniflow/core/common/model/anime_category.dart'; +import 'package:aniflow/core/common/model/favorite_category.dart'; import 'package:aniflow/core/common/model/media_type.dart'; import 'package:aniflow/core/data/media_list_repository.dart'; import 'package:aniflow/core/database/aniflow_database.dart'; @@ -10,6 +11,9 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void main() { group('user_anime_list_test', () { final animeDatabase = AniflowDatabase(); + final mediaDao = animeDatabase.getMediaInformationDaoDao(); + final mediaListDap = animeDatabase.getMediaListDao(); + final dummyUserAnimeListEntity = [ MediaListEntity( id: '1', @@ -159,5 +163,17 @@ void main() { final item3 = await dao.getMediaListItem(mediaId: '55', entryId: '2'); expect(item3, equals(dummyUserAnimeListEntity[1])); }); + + test('get_favorite_anime', () async { + await mediaDao.upsertMediaInformation(dummyMediaData); + await mediaListDap + .insertFavoritesCrossRef('1', FavoriteType.anime, ['33']); + await mediaListDap + .insertFavoritesCrossRef('1', FavoriteType.manga, ['55']); + + final res = + await mediaListDap.getFavoriteMedia(MediaType.anime, '1', 1, 10); + expect(res, equals([dummyMediaData[0]])); + }); }); } diff --git a/test/core/network/ani_list_data_source_test.dart b/test/core/network/ani_list_data_source_test.dart index 8050a25a..154b989d 100644 --- a/test/core/network/ani_list_data_source_test.dart +++ b/test/core/network/ani_list_data_source_test.dart @@ -62,5 +62,22 @@ void main() { await AniListDataSource() .getStaffPage(animeId: 140501, page: 1, perPage: 3); }); + + test('get_favorite_anime', () async { + await AniListDataSource() + .getFavoriteAnimeMedia(userId: '6378393',page: 1, perPage: 10); + }); + test('get_favorite_manga', () async { + await AniListDataSource() + .getFavoriteAnimeMedia(userId: '6378393',page: 1, perPage: 10); + }); + test('get_favorite_character', () async { + await AniListDataSource() + .getFavoriteAnimeMedia(userId: '6378393',page: 1, perPage: 10); + }); + test('get_favorite_staff', () async { + await AniListDataSource() + .getFavoriteAnimeMedia(userId: '6378393',page: 1, perPage: 10); + }); }); } diff --git a/test/core/network/auth_data_source_test.dart b/test/core/network/auth_data_source_test.dart index d9bacf21..f9689b51 100644 --- a/test/core/network/auth_data_source_test.dart +++ b/test/core/network/auth_data_source_test.dart @@ -19,7 +19,7 @@ void main() { test('save_media_list_motion', () async { try { - await authDataSource.saveAnimeToAnimeList(MediaListMutationParam( + await authDataSource.saveMediaToMediaList(MediaListMutationParam( entryId: 1, mediaId: 2, progress: 3,