diff --git a/assets/avatar.jpg b/assets/avatar.jpg new file mode 100644 index 0000000..de599b0 Binary files /dev/null and b/assets/avatar.jpg differ diff --git a/lib/components/anime_component.dart b/lib/components/anime_component.dart index b1c4056..a651280 100644 --- a/lib/components/anime_component.dart +++ b/lib/components/anime_component.dart @@ -1,4 +1,5 @@ import 'package:application/components/card_component.dart'; +import 'package:application/components/followed_stream_builder.dart'; import 'package:application/components/image_component.dart'; import 'package:application/components/lang_type_component.dart'; import 'package:application/dtos/anime_dto.dart'; @@ -27,13 +28,49 @@ class AnimeComponent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ImageComponent( - uuid: anime.uuid, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), + SizedBox( height: 280, + child: Stack( + children: [ + ImageComponent( + uuid: anime.uuid, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + FollowedStreamBuilder( + anime: anime, + builder: (context, containsAnime, _) { + if (!containsAnime) { + return const SizedBox.shrink(); + } + + return Positioned( + top: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.bookmark, + color: containsAnime ? bookmarkColor : null, + size: 16, + ), + ), + ), + ); + }, + ), + ], + ), ), const SizedBox(height: 4), Padding( diff --git a/lib/components/episodes/episode_action_bar.dart b/lib/components/episodes/episode_action_bar.dart index 98aa235..4be4a4d 100644 --- a/lib/components/episodes/episode_action_bar.dart +++ b/lib/components/episodes/episode_action_bar.dart @@ -27,7 +27,7 @@ class EpisodeActionBar extends StatelessWidget { ), ), ), - const WatchlistButton(), + WatchlistButton(episode: episode), ], ); } diff --git a/lib/components/episodes/watchlist_button.dart b/lib/components/episodes/watchlist_button.dart index 353ad05..f5deb24 100644 --- a/lib/components/episodes/watchlist_button.dart +++ b/lib/components/episodes/watchlist_button.dart @@ -1,3 +1,7 @@ +import 'package:application/components/followed_stream_builder.dart'; +import 'package:application/controllers/member_controller.dart'; +import 'package:application/dtos/anime_dto.dart'; +import 'package:application/dtos/episode_mapping_dto.dart'; import 'package:flutter/material.dart'; import 'package:like_button/like_button.dart'; import 'package:vibration/vibration.dart'; @@ -5,48 +9,73 @@ import 'package:vibration/vibration.dart'; const _bookmarkColor = Colors.yellow; class WatchlistButton extends StatelessWidget { - const WatchlistButton({super.key}); + final EpisodeMappingDto? episode; + final AnimeDto? anime; + + const WatchlistButton({ + super.key, + this.episode, + this.anime, + }); @override Widget build(BuildContext context) { - return LikeButton( - likeBuilder: (isLiked) { - return Icon( - isLiked ? Icons.bookmark : Icons.bookmark_border, - color: isLiked ? _bookmarkColor : null, - ); - }, - circleColor: const CircleColor( - start: _bookmarkColor, - end: _bookmarkColor, - ), - bubblesColor: const BubblesColor( - dotPrimaryColor: _bookmarkColor, - dotSecondaryColor: _bookmarkColor, - ), - onTap: (isLiked) async { - if (!isLiked) { - // Vibration.vibrate(duration: 100); - Vibration.vibrate(pattern: [0, 50, 125, 50, 125, 50]); + return FollowedStreamBuilder( + builder: (context, containsAnime, containsEpisode) { + return LikeButton( + isLiked: containsAnime || containsEpisode, + likeBuilder: (isLiked) { + return Icon( + isLiked ? Icons.bookmark : Icons.bookmark_border, + color: isLiked ? _bookmarkColor : null, + ); + }, + circleColor: const CircleColor( + start: _bookmarkColor, + end: _bookmarkColor, + ), + bubblesColor: const BubblesColor( + dotPrimaryColor: _bookmarkColor, + dotSecondaryColor: _bookmarkColor, + ), + onTap: (isLiked) async { + if (!isLiked) { + if (anime != null) { + MemberController.instance.followAnime(anime!); + } else { + MemberController.instance.followEpisode(episode!); + } - // Create snackbar - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.bookmark), - SizedBox(width: 8), - Text('Bookmarked'), - ], - ), - duration: Duration(seconds: 2), - ), - ); - } + Vibration.vibrate(pattern: [0, 50, 125, 50, 125, 50]); - return !isLiked; + // Create snackbar + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bookmark), + SizedBox(width: 8), + Text('Bookmarked'), + ], + ), + duration: Duration(seconds: 2), + ), + ); + } else { + if (anime != null) { + MemberController.instance.unfollowAnime(anime!); + } else { + MemberController.instance.unfollowEpisode(episode!); + } + } + + return !isLiked; + }, + ); }, + anime: anime, + episode: episode, ); } } diff --git a/lib/components/followed_stream_builder.dart b/lib/components/followed_stream_builder.dart new file mode 100644 index 0000000..6718f1d --- /dev/null +++ b/lib/components/followed_stream_builder.dart @@ -0,0 +1,35 @@ +import 'package:application/controllers/member_controller.dart'; +import 'package:application/dtos/anime_dto.dart'; +import 'package:application/dtos/episode_mapping_dto.dart'; +import 'package:flutter/material.dart'; + +class FollowedStreamBuilder extends StatelessWidget { + final Widget Function(BuildContext, bool, bool) builder; + final AnimeDto? anime; + final EpisodeMappingDto? episode; + + const FollowedStreamBuilder({ + super.key, + required this.builder, + this.anime, + this.episode, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: MemberController.instance.streamController.stream, + initialData: null, + builder: (context, snapshot) { + final memberDto = MemberController.instance.member; + + final containsAnime = + anime != null && memberDto!.followedAnimes.contains(anime?.uuid); + final containsEpisode = episode != null && + memberDto!.followedEpisodes.contains(episode?.uuid); + + return builder(context, containsAnime, containsEpisode); + }, + ); + } +} diff --git a/lib/controllers/anime_details_controller.dart b/lib/controllers/anime_details_controller.dart index bbf39ce..8637be4 100644 --- a/lib/controllers/anime_details_controller.dart +++ b/lib/controllers/anime_details_controller.dart @@ -6,7 +6,9 @@ import 'package:application/utils/http_request.dart'; import 'package:flutter/material.dart'; enum Sort { - oldest(value: 'sort=season,episodeType,number&desc=episodeType'), + oldest( + value: 'sort=releaseDateTime,season,episodeType,number&desc=episodeType', + ), newest( value: 'sort=releaseDateTime,season,episodeType,number&desc=releaseDateTime,season,episodeType,number', diff --git a/lib/controllers/anime_weekly_controller.dart b/lib/controllers/anime_weekly_controller.dart index d19d07f..a939505 100644 --- a/lib/controllers/anime_weekly_controller.dart +++ b/lib/controllers/anime_weekly_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:application/controllers/member_controller.dart'; import 'package:application/dtos/week_day_dto.dart'; import 'package:application/utils/http_request.dart'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ class AnimeWeeklyController { final scrollController = ScrollController(); final streamController = StreamController>.broadcast(); bool isLoading = false; + bool memberMode = false; Future init() async { weekDays.clear(); @@ -37,7 +39,15 @@ class AnimeWeeklyController { isLoading = true; try { - final json = await HttpRequest.instance.get('/v1/animes/weekly'); + String endpoint = '/v1/animes/weekly'; + String? token = ''; + + if (memberMode) { + endpoint = '/v1/animes/member-weekly'; + token = MemberController.instance.member!.token; + } + + final json = await HttpRequest.instance.get(endpoint, token: token); weekDays.addAll( json.map((e) => WeekDayDto.fromJson(e as Map)), diff --git a/lib/controllers/member_controller.dart b/lib/controllers/member_controller.dart new file mode 100644 index 0000000..8c9a72f --- /dev/null +++ b/lib/controllers/member_controller.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:application/dtos/anime_dto.dart'; +import 'package:application/dtos/episode_mapping_dto.dart'; +import 'package:application/dtos/member_dto.dart'; +import 'package:application/utils/http_request.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MemberController { + static MemberController instance = MemberController(); + late final SharedPreferences _sharedPreferences; + final streamController = StreamController.broadcast(); + String? identifier; + MemberDto? member; + + Future init() async { + _sharedPreferences = await SharedPreferences.getInstance(); + identifier = + _sharedPreferences.getString('identifier') ?? 'test-private-identifier'; + await login(); + } + + Future login() async { + final response = + await HttpRequest().post('/v1/members/private-login', identifier); + + if (response.statusCode != 200) { + throw Exception('Failed to login'); + } + + final json = jsonDecode(utf8.decode(response.bodyBytes)); + member = MemberDto.fromJson(json); + } + + Future followAnime(AnimeDto anime) async { + final response = await HttpRequest().put( + '/v1/members/animes', + jsonEncode({'uuid': anime.uuid}), + token: member!.token, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to follow anime'); + } + + member!.followedAnimes.add(anime.uuid); + streamController.add(null); + } + + Future unfollowAnime(AnimeDto anime) async { + final response = await HttpRequest().delete( + '/v1/members/animes', + jsonEncode({'uuid': anime.uuid}), + token: member!.token, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to unfollow anime'); + } + + member!.followedAnimes.remove(anime.uuid); + streamController.add(null); + } + + Future followEpisode(EpisodeMappingDto episode) async { + if (!member!.followedAnimes.contains(episode.anime.uuid)) { + await followAnime(episode.anime); + } + + final response = await HttpRequest().put( + '/v1/members/episodes', + jsonEncode({'uuid': episode.uuid}), + token: member!.token, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to follow episode'); + } + + member!.followedEpisodes.add(episode.uuid); + member = member! + .copyWith(totalDuration: member!.totalDuration + episode.duration); + streamController.add(null); + } + + Future unfollowEpisode(EpisodeMappingDto episode) async { + final response = await HttpRequest().delete( + '/v1/members/episodes', + jsonEncode({'uuid': episode.uuid}), + token: member!.token, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to unfollow episode'); + } + + member!.followedEpisodes.remove(episode.uuid); + member = member! + .copyWith(totalDuration: member!.totalDuration - episode.duration); + streamController.add(null); + } + + String buildTotalDuration() { + final duration = Duration(seconds: member!.totalDuration); + // Build string like '1d 2h 3m 4s' + // If a value is 0, it is not included + final parts = []; + + if (duration.inDays > 0) { + parts.add('${duration.inDays}j'); + } + + if (duration.inHours > 0) { + parts.add('${duration.inHours % 24}h'); + } + + if (duration.inMinutes > 0) { + parts.add('${duration.inMinutes % 60}m'); + } + + if (duration.inSeconds > 0) { + parts.add('${duration.inSeconds % 60}s'); + } + + return parts.join(' '); + } +} diff --git a/lib/dtos/member_dto.dart b/lib/dtos/member_dto.dart new file mode 100644 index 0000000..933dc03 --- /dev/null +++ b/lib/dtos/member_dto.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'member_dto.freezed.dart'; +part 'member_dto.g.dart'; + +@Freezed(makeCollectionsUnmodifiable: false) +class MemberDto with _$MemberDto { + const factory MemberDto({ + required String uuid, + required String token, + required String creationDateTime, + required String lastUpdateDateTime, + required bool isPrivate, + required List followedAnimes, + required List followedEpisodes, + required int totalDuration, + }) = _MemberDto; + + factory MemberDto.fromJson(Map json) => + _$MemberDtoFromJson(json); +} diff --git a/lib/dtos/member_dto.freezed.dart b/lib/dtos/member_dto.freezed.dart new file mode 100644 index 0000000..8cbe9eb --- /dev/null +++ b/lib/dtos/member_dto.freezed.dart @@ -0,0 +1,309 @@ +// 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 'member_dto.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#adding-getters-and-methods-to-our-models'); + +MemberDto _$MemberDtoFromJson(Map json) { + return _MemberDto.fromJson(json); +} + +/// @nodoc +mixin _$MemberDto { + String get uuid => throw _privateConstructorUsedError; + String get token => throw _privateConstructorUsedError; + String get creationDateTime => throw _privateConstructorUsedError; + String get lastUpdateDateTime => throw _privateConstructorUsedError; + bool get isPrivate => throw _privateConstructorUsedError; + List get followedAnimes => throw _privateConstructorUsedError; + List get followedEpisodes => throw _privateConstructorUsedError; + int get totalDuration => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $MemberDtoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MemberDtoCopyWith<$Res> { + factory $MemberDtoCopyWith(MemberDto value, $Res Function(MemberDto) then) = + _$MemberDtoCopyWithImpl<$Res, MemberDto>; + @useResult + $Res call( + {String uuid, + String token, + String creationDateTime, + String lastUpdateDateTime, + bool isPrivate, + List followedAnimes, + List followedEpisodes, + int totalDuration}); +} + +/// @nodoc +class _$MemberDtoCopyWithImpl<$Res, $Val extends MemberDto> + implements $MemberDtoCopyWith<$Res> { + _$MemberDtoCopyWithImpl(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? uuid = null, + Object? token = null, + Object? creationDateTime = null, + Object? lastUpdateDateTime = null, + Object? isPrivate = null, + Object? followedAnimes = null, + Object? followedEpisodes = null, + Object? totalDuration = null, + }) { + return _then(_value.copyWith( + uuid: null == uuid + ? _value.uuid + : uuid // ignore: cast_nullable_to_non_nullable + as String, + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + creationDateTime: null == creationDateTime + ? _value.creationDateTime + : creationDateTime // ignore: cast_nullable_to_non_nullable + as String, + lastUpdateDateTime: null == lastUpdateDateTime + ? _value.lastUpdateDateTime + : lastUpdateDateTime // ignore: cast_nullable_to_non_nullable + as String, + isPrivate: null == isPrivate + ? _value.isPrivate + : isPrivate // ignore: cast_nullable_to_non_nullable + as bool, + followedAnimes: null == followedAnimes + ? _value.followedAnimes + : followedAnimes // ignore: cast_nullable_to_non_nullable + as List, + followedEpisodes: null == followedEpisodes + ? _value.followedEpisodes + : followedEpisodes // ignore: cast_nullable_to_non_nullable + as List, + totalDuration: null == totalDuration + ? _value.totalDuration + : totalDuration // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MemberDtoImplCopyWith<$Res> + implements $MemberDtoCopyWith<$Res> { + factory _$$MemberDtoImplCopyWith( + _$MemberDtoImpl value, $Res Function(_$MemberDtoImpl) then) = + __$$MemberDtoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String uuid, + String token, + String creationDateTime, + String lastUpdateDateTime, + bool isPrivate, + List followedAnimes, + List followedEpisodes, + int totalDuration}); +} + +/// @nodoc +class __$$MemberDtoImplCopyWithImpl<$Res> + extends _$MemberDtoCopyWithImpl<$Res, _$MemberDtoImpl> + implements _$$MemberDtoImplCopyWith<$Res> { + __$$MemberDtoImplCopyWithImpl( + _$MemberDtoImpl _value, $Res Function(_$MemberDtoImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? uuid = null, + Object? token = null, + Object? creationDateTime = null, + Object? lastUpdateDateTime = null, + Object? isPrivate = null, + Object? followedAnimes = null, + Object? followedEpisodes = null, + Object? totalDuration = null, + }) { + return _then(_$MemberDtoImpl( + uuid: null == uuid + ? _value.uuid + : uuid // ignore: cast_nullable_to_non_nullable + as String, + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + creationDateTime: null == creationDateTime + ? _value.creationDateTime + : creationDateTime // ignore: cast_nullable_to_non_nullable + as String, + lastUpdateDateTime: null == lastUpdateDateTime + ? _value.lastUpdateDateTime + : lastUpdateDateTime // ignore: cast_nullable_to_non_nullable + as String, + isPrivate: null == isPrivate + ? _value.isPrivate + : isPrivate // ignore: cast_nullable_to_non_nullable + as bool, + followedAnimes: null == followedAnimes + ? _value.followedAnimes + : followedAnimes // ignore: cast_nullable_to_non_nullable + as List, + followedEpisodes: null == followedEpisodes + ? _value.followedEpisodes + : followedEpisodes // ignore: cast_nullable_to_non_nullable + as List, + totalDuration: null == totalDuration + ? _value.totalDuration + : totalDuration // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$MemberDtoImpl implements _MemberDto { + const _$MemberDtoImpl( + {required this.uuid, + required this.token, + required this.creationDateTime, + required this.lastUpdateDateTime, + required this.isPrivate, + required this.followedAnimes, + required this.followedEpisodes, + required this.totalDuration}); + + factory _$MemberDtoImpl.fromJson(Map json) => + _$$MemberDtoImplFromJson(json); + + @override + final String uuid; + @override + final String token; + @override + final String creationDateTime; + @override + final String lastUpdateDateTime; + @override + final bool isPrivate; + @override + final List followedAnimes; + @override + final List followedEpisodes; + @override + final int totalDuration; + + @override + String toString() { + return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, lastUpdateDateTime: $lastUpdateDateTime, isPrivate: $isPrivate, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MemberDtoImpl && + (identical(other.uuid, uuid) || other.uuid == uuid) && + (identical(other.token, token) || other.token == token) && + (identical(other.creationDateTime, creationDateTime) || + other.creationDateTime == creationDateTime) && + (identical(other.lastUpdateDateTime, lastUpdateDateTime) || + other.lastUpdateDateTime == lastUpdateDateTime) && + (identical(other.isPrivate, isPrivate) || + other.isPrivate == isPrivate) && + const DeepCollectionEquality() + .equals(other.followedAnimes, followedAnimes) && + const DeepCollectionEquality() + .equals(other.followedEpisodes, followedEpisodes) && + (identical(other.totalDuration, totalDuration) || + other.totalDuration == totalDuration)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + uuid, + token, + creationDateTime, + lastUpdateDateTime, + isPrivate, + const DeepCollectionEquality().hash(followedAnimes), + const DeepCollectionEquality().hash(followedEpisodes), + totalDuration); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$MemberDtoImplCopyWith<_$MemberDtoImpl> get copyWith => + __$$MemberDtoImplCopyWithImpl<_$MemberDtoImpl>(this, _$identity); + + @override + Map toJson() { + return _$$MemberDtoImplToJson( + this, + ); + } +} + +abstract class _MemberDto implements MemberDto { + const factory _MemberDto( + {required final String uuid, + required final String token, + required final String creationDateTime, + required final String lastUpdateDateTime, + required final bool isPrivate, + required final List followedAnimes, + required final List followedEpisodes, + required final int totalDuration}) = _$MemberDtoImpl; + + factory _MemberDto.fromJson(Map json) = + _$MemberDtoImpl.fromJson; + + @override + String get uuid; + @override + String get token; + @override + String get creationDateTime; + @override + String get lastUpdateDateTime; + @override + bool get isPrivate; + @override + List get followedAnimes; + @override + List get followedEpisodes; + @override + int get totalDuration; + @override + @JsonKey(ignore: true) + _$$MemberDtoImplCopyWith<_$MemberDtoImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/dtos/member_dto.g.dart b/lib/dtos/member_dto.g.dart new file mode 100644 index 0000000..32bb011 --- /dev/null +++ b/lib/dtos/member_dto.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$MemberDtoImpl _$$MemberDtoImplFromJson(Map json) => + _$MemberDtoImpl( + uuid: json['uuid'] as String, + token: json['token'] as String, + creationDateTime: json['creationDateTime'] as String, + lastUpdateDateTime: json['lastUpdateDateTime'] as String, + isPrivate: json['isPrivate'] as bool, + followedAnimes: (json['followedAnimes'] as List) + .map((e) => e as String) + .toList(), + followedEpisodes: (json['followedEpisodes'] as List) + .map((e) => e as String) + .toList(), + totalDuration: json['totalDuration'] as int, + ); + +Map _$$MemberDtoImplToJson(_$MemberDtoImpl instance) => + { + 'uuid': instance.uuid, + 'token': instance.token, + 'creationDateTime': instance.creationDateTime, + 'lastUpdateDateTime': instance.lastUpdateDateTime, + 'isPrivate': instance.isPrivate, + 'followedAnimes': instance.followedAnimes, + 'followedEpisodes': instance.followedEpisodes, + 'totalDuration': instance.totalDuration, + }; diff --git a/lib/main.dart b/lib/main.dart index ce3b5f4..31f4378 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,8 @@ import 'package:application/components/update_available_component.dart'; import 'package:application/controllers/anime_controller.dart'; import 'package:application/controllers/anime_search_controller.dart'; import 'package:application/controllers/anime_weekly_controller.dart'; +import 'package:application/controllers/member_controller.dart'; +import 'package:application/views/account_view.dart'; import 'package:application/views/calendar_view.dart'; import 'package:application/views/home_view.dart'; import 'package:application/controllers/episode_controller.dart'; @@ -29,6 +31,7 @@ Future main() async { .init() .then((value) => AnimeController.instance.init()), AnimeWeeklyController.instance.init(), + MemberController.instance.init(), ]); AnimeSearchController.instance.init(); @@ -177,6 +180,22 @@ class _MyHomePageState extends State { height: 36, ), actions: [ + if (_currentIndex == 2) + IconButton( + onPressed: () { + setState(() { + AnimeWeeklyController.instance.memberMode = + !AnimeWeeklyController.instance.memberMode; + }); + + AnimeWeeklyController.instance.init(); + }, + icon: Icon( + AnimeWeeklyController.instance.memberMode + ? Icons.filter_alt + : Icons.filter_alt_off, + ), + ), IconButton( onPressed: () { Navigator.of(context).push( @@ -196,30 +215,11 @@ class _MyHomePageState extends State { _currentIndex = index; }); }, - children: [ - const HomeView(), - const SimulcastView(), - const CalendarView(), - ListView( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'My Account', - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - Text( - 'WORK IN PROGRESS', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ], - ), + children: const [ + HomeView(), + SimulcastView(), + CalendarView(), + AccountView(), ], ), bottomNavigationBar: BottomNavigationBar( diff --git a/lib/utils/constant.dart b/lib/utils/constant.dart index c2ee916..149699f 100644 --- a/lib/utils/constant.dart +++ b/lib/utils/constant.dart @@ -1,4 +1,4 @@ class Constant { - static const apiUrl = 'https://api.shikkanime.fr'; - static const baseUrl = 'https://www.shikkanime.fr'; + static const apiUrl = 'http://192.168.1.71:37100/api'; + static const baseUrl = 'http://192.168.1.71:37100'; } diff --git a/lib/utils/http_request.dart b/lib/utils/http_request.dart index e87a588..ff2ce13 100644 --- a/lib/utils/http_request.dart +++ b/lib/utils/http_request.dart @@ -7,11 +7,18 @@ import 'package:http/http.dart' as http; class HttpRequest { static final instance = HttpRequest(); - Future get(String endpoint) async { + Future get(String endpoint, {String? token}) async { + final headers = {}; + + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await http.get( Uri.parse( Constant.apiUrl + endpoint, ), + headers: headers, ); if (response.statusCode != 200) { @@ -24,4 +31,69 @@ class HttpRequest { Future getPage(String endpoint) async { return PageableDto.fromJson(await get>(endpoint)); } + + Future post(String endpoint, Object? body, + {String? token}) async { + final headers = {}; + + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await http.post( + Uri.parse( + Constant.apiUrl + endpoint, + ), + headers: headers, + body: body, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to post data'); + } + + return response; + } + + Future put(String endpoint, Object? body, + {String? token}) async { + final headers = { + 'Content-Type': 'application/json', + }; + + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await http.put( + Uri.parse( + Constant.apiUrl + endpoint, + ), + headers: headers, + body: body, + ); + + return response; + } + + Future delete(String endpoint, Object? body, + {String? token}) async { + final headers = { + 'Content-Type': 'application/json', + }; + + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await http.delete( + Uri.parse( + Constant.apiUrl + endpoint, + ), + headers: headers, + body: body, + ); + + return response; + } } diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart new file mode 100644 index 0000000..5ab5a6f --- /dev/null +++ b/lib/views/account_view.dart @@ -0,0 +1,340 @@ +import 'package:application/controllers/member_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:vibration/vibration.dart'; + +class AccountView extends StatelessWidget { + const AccountView({super.key}); + + @override + Widget build(BuildContext context) { + final primaryColor = Theme.of(context).primaryColor; + + return ListView( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + children: [ + const CircleAvatar( + radius: 40, + backgroundImage: AssetImage('assets/avatar.jpg'), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon( + Icons.edit, + size: 15, + color: Colors.grey, + ), + ), + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flex( + direction: Axis.horizontal, + children: [ + const Text( + 'Compte anonyme', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + GestureDetector( + child: const Icon( + Icons.info, + color: Colors.grey, + size: 20, + ), + onTap: () { + showAdaptiveDialog( + context: context, + builder: (context) { + return const AlertDialog( + title: Text( + 'Avertissement sur la sauvegarde de données', + ), + content: SingleChildScrollView( + child: Column( + children: [ + Text( + 'Nous sauvegardons vos données de manière anonyme sur nos serveurs. Chaque compte est associé à un identifiant unique. Cependant, il est important de noter que si vous perdez cet identifiant, vous risquez de perdre l\'accès à toutes vos données.', + ), + SizedBox(height: 16), + Text( + 'Vous trouverez votre identifiant unique dans la section "Compte" de l\'application.', + ), + SizedBox(height: 16), + Text( + 'Pour éviter toute perte de données, nous vous recommandons vivement de créer un compte sécurisé en enregistrant une adresse e-mail et un mot de passe. Cela vous permettra de récupérer facilement vos données en cas de besoin et de sécuriser votre compte contre toute perte accidentelle.', + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ), + const Text( + 'Membre', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () {}, + child: const Text('Créer un compte'), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + MemberController + .instance.member?.followedAnimes.length + .toString() ?? + '0', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Animés ajoutés', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + MemberController + .instance.member?.followedEpisodes.length + .toString() ?? + '0', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Épisodes ajoutés', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + MemberController.instance.buildTotalDuration(), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'Temps de visionnage', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Compte', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + ListTile( + title: const Text('Identifiant unique'), + subtitle: const Text('Sauvegardez-le pour récupérer vos données'), + trailing: Text( + MemberController.instance.identifier ?? 'Aucun identifiant', + style: const TextStyle(fontSize: 14), + ), + onTap: () { + if (MemberController.instance.identifier == null) { + return; + } + + // Copy to clipboard + Clipboard.setData( + ClipboardData(text: MemberController.instance.identifier!), + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, color: Colors.white), + SizedBox(width: 8), + Text('Identifiant copié dans le presse-papiers'), + ], + ), + ), + ); + + Vibration.vibrate(duration: 200, amplitude: 255); + }, + ), + // const SizedBox(height: 16), + // // Preferences section + // const Padding( + // padding: EdgeInsets.symmetric(horizontal: 16), + // child: Text( + // 'Préférences', + // style: TextStyle( + // fontSize: 16, + // fontWeight: FontWeight.bold, + // color: Colors.grey, + // ), + // ), + // ), + // ListTile( + // title: const Text('Langue audio'), + // trailing: const Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Text('Japonais', style: TextStyle(fontSize: 14)), + // SizedBox(width: 8), + // Icon(Icons.arrow_forward_ios), + // ], + // ), + // onTap: () {}, + // ), + // ListTile( + // title: const Text('Sous-titres'), + // trailing: const Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Text('Français', style: TextStyle(fontSize: 14)), + // SizedBox(width: 8), + // Icon(Icons.arrow_forward_ios), + // ], + // ), + // onTap: () {}, + // ), + // ListTile( + // title: const Text('Plateforme de streaming'), + // trailing: const Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Text('Crunchyroll', style: TextStyle(fontSize: 14)), + // SizedBox(width: 8), + // Icon(Icons.arrow_forward_ios), + // ], + // ), + // onTap: () {}, + // ), + // const SizedBox(height: 16), + // const Padding( + // padding: EdgeInsets.symmetric(horizontal: 16), + // child: Text( + // 'Autres', + // style: TextStyle( + // fontSize: 16, + // fontWeight: FontWeight.bold, + // color: Colors.grey, + // ), + // ), + // ), + // ListTile( + // title: const Text('Tri par défaut'), + // trailing: const Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Text('Les plus anciens', style: TextStyle(fontSize: 14)), + // SizedBox(width: 8), + // Icon(Icons.arrow_forward_ios), + // ], + // ), + // onTap: () {}, + // ), + ], + ); + } +} diff --git a/lib/views/anime_details_view.dart b/lib/views/anime_details_view.dart index d06d93d..5890435 100644 --- a/lib/views/anime_details_view.dart +++ b/lib/views/anime_details_view.dart @@ -5,8 +5,10 @@ import 'package:application/components/lang_type_component.dart'; import 'package:application/controllers/anime_details_controller.dart'; import 'package:application/dtos/anime_dto.dart'; import 'package:application/dtos/episode_mapping_dto.dart'; +import 'package:application/utils/constant.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:share_plus/share_plus.dart'; class AnimeDetailsView extends StatefulWidget { final AnimeDto anime; @@ -70,7 +72,11 @@ class _AnimeDetailsViewState extends State { ]; }, onSelected: (int value) { - // Not implemented + if (value == 1) { + Share.share( + '${Constant.baseUrl}/animes/${widget.anime.slug}', + ); + } }, ), ], @@ -122,7 +128,7 @@ class _AnimeDetailsViewState extends State { maxLines: 2, ), ), - const WatchlistButton(), + WatchlistButton(anime: widget.anime), ], ), for (final langType in widget.anime.langTypes) diff --git a/pubspec.lock b/pubspec.lock index 26474ab..4e5bcee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + url: "https://pub.dev" + source: hosted + version: "0.3.4+1" crypto: dependency: transitive description: @@ -697,10 +705,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" + sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.9.0" pool: dependency: transitive description: @@ -757,6 +765,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51 + url: "https://pub.dev" + source: hosted + version: "8.0.3" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + url: "https://pub.dev" + source: hosted + version: "2.3.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fa060c4..75a746f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: json_annotation: ^4.8.1 like_button: ^2.0.5 restart_app: ^1.2.1 + share_plus: ^8.0.3 + shared_preferences: ^2.2.3 shorebird_code_push: ^1.1.3 url_launcher: ^6.2.6 vibration: ^1.8.4 @@ -54,6 +56,7 @@ flutter: assets: - assets/icon.png - assets/icon_128x128.png + - assets/avatar.jpg - shorebird.yaml fonts: - family: Satoshi