From 5cbe745a5ebd3e70af58df9afd88e72423904f82 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Sun, 19 May 2024 23:51:21 +0200 Subject: [PATCH] Associate email --- lib/components/accounts/account_card.dart | 41 +++ lib/components/accounts/associate_email.dart | 175 +++++++++++++ lib/components/accounts/edit_identifier.dart | 81 ++++++ lib/components/followed_stream_builder.dart | 7 +- lib/controllers/member_controller.dart | 53 +++- lib/dtos/member_dto.dart | 1 + lib/dtos/member_dto.freezed.dart | 23 +- lib/dtos/member_dto.g.dart | 2 + lib/l10n/app_fr.arb | 19 +- lib/main.dart | 12 + lib/utils/http_request.dart | 13 +- lib/views/account_settings_view.dart | 124 +++++++++ lib/views/account_view.dart | 258 +++---------------- pubspec.lock | 40 +-- pubspec.yaml | 6 +- 15 files changed, 587 insertions(+), 268 deletions(-) create mode 100644 lib/components/accounts/account_card.dart create mode 100644 lib/components/accounts/associate_email.dart create mode 100644 lib/components/accounts/edit_identifier.dart create mode 100644 lib/views/account_settings_view.dart diff --git a/lib/components/accounts/account_card.dart b/lib/components/accounts/account_card.dart new file mode 100644 index 0000000..faf5d8e --- /dev/null +++ b/lib/components/accounts/account_card.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class AccountCard extends StatelessWidget { + final String label; + final String value; + + const AccountCard({ + super.key, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/accounts/associate_email.dart b/lib/components/accounts/associate_email.dart new file mode 100644 index 0000000..7e87b1b --- /dev/null +++ b/lib/components/accounts/associate_email.dart @@ -0,0 +1,175 @@ +import 'package:application/controllers/member_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:vibration/vibration.dart'; + +class AssociateEmail extends StatefulWidget { + const AssociateEmail({super.key}); + + @override + State createState() => _AssociateEmailState(); +} + +class _AssociateEmailState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _codeController = TextEditingController(); + bool _isInvalidEmailError = false; + bool _isConflictEmailError = false; + bool _isCodeInError = false; + bool _isLoading = false; + String? _actionUuid; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.associateEmail), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.email, + errorText: errorText(context), + ), + controller: _emailController, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.code, + errorText: _isCodeInError + ? AppLocalizations.of(context)!.invalidCode + : null, + ), + controller: _codeController, + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _isLoading + ? null + : () { + saveEmail(); + }, + child: Text(AppLocalizations.of(context)!.sendCode), + ), + ], + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _isLoading + ? null + : () { + validateAction(context); + }, + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ), + ), + ); + } + + String? errorText(BuildContext context) { + if (_isInvalidEmailError) { + return AppLocalizations.of(context)!.invalidEmail; + } + + if (_isConflictEmailError) { + return AppLocalizations.of(context)!.conflictEmail; + } + + return null; + } + + Future saveEmail() async { + if (_emailController.text.isEmpty) { + vibrate(); + return; + } + + if (!isValidEmail(_emailController.text)) { + vibrate(); + updateState(invalidEmail: true); + return; + } + + if (_emailController.text == MemberController.instance.member?.email) { + vibrate(); + updateState(conflictEmail: true); + return; + } + + updateState(invalidEmail: false, conflictEmail: false, isLoading: true); + + try { + _actionUuid = + await MemberController.instance.associateEmail(_emailController.text); + } on ConflictEmailException { + vibrate(); + updateState(conflictEmail: true, invalidEmail: false); + } catch (e) { + vibrate(); + updateState(conflictEmail: false, invalidEmail: true); + } finally { + updateState(isLoading: false); + } + } + + void vibrate() { + Vibration.vibrate(duration: 200, amplitude: 255); + } + + bool isValidEmail(String email) { + return RegExp(r'^[A-Za-z0-9+_.-]+@(.+)$').hasMatch(email); + } + + void updateState({bool? invalidEmail, bool? conflictEmail, bool? isLoading}) { + if (context.mounted) { + setState(() { + if (invalidEmail != null) _isInvalidEmailError = invalidEmail; + if (conflictEmail != null) _isConflictEmailError = conflictEmail; + if (isLoading != null) _isLoading = isLoading; + }); + } + } + + Future validateAction(BuildContext context) async { + if (_codeController.text.isEmpty || _actionUuid == null) { + Vibration.vibrate(duration: 200, amplitude: 255); + return; + } + + try { + await MemberController.instance + .validateAction(_actionUuid!, _codeController.text); + + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'OK', + textAlign: TextAlign.center, + ), + ), + ); + } + } catch (e) { + vibrate(); + + if (context.mounted) { + setState(() { + _isCodeInError = true; + }); + } + } + } +} diff --git a/lib/components/accounts/edit_identifier.dart b/lib/components/accounts/edit_identifier.dart new file mode 100644 index 0000000..243c09d --- /dev/null +++ b/lib/components/accounts/edit_identifier.dart @@ -0,0 +1,81 @@ +import 'package:application/controllers/member_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:vibration/vibration.dart'; + +class EditIdentifier extends StatelessWidget { + final TextEditingController _controller = TextEditingController(); + + EditIdentifier({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.enterNewIdentifier, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.identifier, + ), + controller: _controller, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context)!.cancel), + ), + ElevatedButton( + onPressed: () { + saveIdentifier(context); + }, + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ); + } + + Future saveIdentifier(BuildContext context) async { + if (_controller.text.isEmpty) { + Vibration.vibrate(duration: 200, amplitude: 255); + return; + } + + final oldIdentifier = MemberController.instance.identifier; + + try { + await MemberController.instance.testLogin(_controller.text); + await MemberController.instance.login(identifier: _controller.text); + } catch (e) { + Vibration.vibrate(duration: 200, amplitude: 255); + + if (MemberController.instance.identifier != oldIdentifier) { + await MemberController.instance.login(identifier: oldIdentifier); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.invalidIdentifier, + textAlign: TextAlign.center, + ), + ), + ); + } + } finally { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + } +} diff --git a/lib/components/followed_stream_builder.dart b/lib/components/followed_stream_builder.dart index 6718f1d..4018c61 100644 --- a/lib/components/followed_stream_builder.dart +++ b/lib/components/followed_stream_builder.dart @@ -1,6 +1,7 @@ import 'package:application/controllers/member_controller.dart'; import 'package:application/dtos/anime_dto.dart'; import 'package:application/dtos/episode_mapping_dto.dart'; +import 'package:application/dtos/member_dto.dart'; import 'package:flutter/material.dart'; class FollowedStreamBuilder extends StatelessWidget { @@ -17,11 +18,11 @@ class FollowedStreamBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder( stream: MemberController.instance.streamController.stream, - initialData: null, + initialData: MemberController.instance.member, builder: (context, snapshot) { - final memberDto = MemberController.instance.member; + final memberDto = snapshot.data; final containsAnime = anime != null && memberDto!.followedAnimes.contains(anime?.uuid); diff --git a/lib/controllers/member_controller.dart b/lib/controllers/member_controller.dart index 74641e4..78eceae 100644 --- a/lib/controllers/member_controller.dart +++ b/lib/controllers/member_controller.dart @@ -13,7 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class MemberController { static MemberController instance = MemberController(); late final SharedPreferences _sharedPreferences; - final streamController = StreamController.broadcast(); + final streamController = StreamController.broadcast(); String? identifier; MemberDto? member; @@ -46,7 +46,7 @@ class MemberController { } Future register() async { - final response = await HttpRequest().post('/v1/members/private-register'); + final response = await HttpRequest().post('/v1/members/register'); if (response.statusCode != 201) { throw const HttpException('Failed to register'); @@ -60,7 +60,7 @@ class MemberController { Future testLogin(String identifier) async { final response = - await HttpRequest().post('/v1/members/private-login', body: identifier); + await HttpRequest().post('/v1/members/login', body: identifier); if (response.statusCode == 404) { throw const HttpException('Failed to login, identifier not found'); @@ -83,6 +83,39 @@ class MemberController { } member = MemberDto.fromJson(json); + streamController.add(member!); + } + + Future associateEmail(String email) async { + final response = await HttpRequest().post( + '/v1/members/associate-email', + token: member!.token, + body: email, + ); + + if (response.statusCode == 409) { + throw const ConflictEmailException(); + } + + if (response.statusCode != 201) { + throw const HttpException('Failed to associate email'); + } + + return jsonDecode(utf8.decode(response.bodyBytes))['uuid'] as String; + } + + Future validateAction(String uuid, String code) async { + final response = await HttpRequest().post( + '/v1/member-actions/validate?uuid=$uuid', + token: member!.token, + body: code, + ); + + if (response.statusCode != 200) { + throw const HttpException('Failed to validate action'); + } + + await login(); } Future followAnime(AnimeDto anime) async { @@ -97,7 +130,7 @@ class MemberController { } member!.followedAnimes.add(anime.uuid); - streamController.add(null); + streamController.add(member!); } Future unfollowAnime(AnimeDto anime) async { @@ -112,7 +145,7 @@ class MemberController { } member!.followedAnimes.remove(anime.uuid); - streamController.add(null); + streamController.add(member!); } Future followAllEpisodes(AnimeDto anime) async { @@ -137,7 +170,7 @@ class MemberController { member!.followedEpisodes.addAll(data); member = member!.copyWith(totalDuration: member!.totalDuration + duration); - streamController.add(null); + streamController.add(member!); } Future followEpisode(EpisodeMappingDto episode) async { @@ -158,7 +191,7 @@ class MemberController { member!.followedEpisodes.add(episode.uuid); member = member! .copyWith(totalDuration: member!.totalDuration + episode.duration); - streamController.add(null); + streamController.add(member!); } Future unfollowEpisode(EpisodeMappingDto episode) async { @@ -175,7 +208,7 @@ class MemberController { member!.followedEpisodes.remove(episode.uuid); member = member! .copyWith(totalDuration: member!.totalDuration - episode.duration); - streamController.add(null); + streamController.add(member!); } String buildTotalDuration() { @@ -200,3 +233,7 @@ class MemberController { return parts.join(' '); } } + +class ConflictEmailException implements Exception { + const ConflictEmailException(); +} diff --git a/lib/dtos/member_dto.dart b/lib/dtos/member_dto.dart index 933dc03..018f75c 100644 --- a/lib/dtos/member_dto.dart +++ b/lib/dtos/member_dto.dart @@ -11,6 +11,7 @@ class MemberDto with _$MemberDto { required String creationDateTime, required String lastUpdateDateTime, required bool isPrivate, + required String? email, required List followedAnimes, required List followedEpisodes, required int totalDuration, diff --git a/lib/dtos/member_dto.freezed.dart b/lib/dtos/member_dto.freezed.dart index 8cbe9eb..77aa972 100644 --- a/lib/dtos/member_dto.freezed.dart +++ b/lib/dtos/member_dto.freezed.dart @@ -25,6 +25,7 @@ mixin _$MemberDto { String get creationDateTime => throw _privateConstructorUsedError; String get lastUpdateDateTime => throw _privateConstructorUsedError; bool get isPrivate => throw _privateConstructorUsedError; + String? get email => throw _privateConstructorUsedError; List get followedAnimes => throw _privateConstructorUsedError; List get followedEpisodes => throw _privateConstructorUsedError; int get totalDuration => throw _privateConstructorUsedError; @@ -46,6 +47,7 @@ abstract class $MemberDtoCopyWith<$Res> { String creationDateTime, String lastUpdateDateTime, bool isPrivate, + String? email, List followedAnimes, List followedEpisodes, int totalDuration}); @@ -69,6 +71,7 @@ class _$MemberDtoCopyWithImpl<$Res, $Val extends MemberDto> Object? creationDateTime = null, Object? lastUpdateDateTime = null, Object? isPrivate = null, + Object? email = freezed, Object? followedAnimes = null, Object? followedEpisodes = null, Object? totalDuration = null, @@ -94,6 +97,10 @@ class _$MemberDtoCopyWithImpl<$Res, $Val extends MemberDto> ? _value.isPrivate : isPrivate // ignore: cast_nullable_to_non_nullable as bool, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, followedAnimes: null == followedAnimes ? _value.followedAnimes : followedAnimes // ignore: cast_nullable_to_non_nullable @@ -124,6 +131,7 @@ abstract class _$$MemberDtoImplCopyWith<$Res> String creationDateTime, String lastUpdateDateTime, bool isPrivate, + String? email, List followedAnimes, List followedEpisodes, int totalDuration}); @@ -145,6 +153,7 @@ class __$$MemberDtoImplCopyWithImpl<$Res> Object? creationDateTime = null, Object? lastUpdateDateTime = null, Object? isPrivate = null, + Object? email = freezed, Object? followedAnimes = null, Object? followedEpisodes = null, Object? totalDuration = null, @@ -170,6 +179,10 @@ class __$$MemberDtoImplCopyWithImpl<$Res> ? _value.isPrivate : isPrivate // ignore: cast_nullable_to_non_nullable as bool, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, followedAnimes: null == followedAnimes ? _value.followedAnimes : followedAnimes // ignore: cast_nullable_to_non_nullable @@ -195,6 +208,7 @@ class _$MemberDtoImpl implements _MemberDto { required this.creationDateTime, required this.lastUpdateDateTime, required this.isPrivate, + required this.email, required this.followedAnimes, required this.followedEpisodes, required this.totalDuration}); @@ -213,6 +227,8 @@ class _$MemberDtoImpl implements _MemberDto { @override final bool isPrivate; @override + final String? email; + @override final List followedAnimes; @override final List followedEpisodes; @@ -221,7 +237,7 @@ class _$MemberDtoImpl implements _MemberDto { @override String toString() { - return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, lastUpdateDateTime: $lastUpdateDateTime, isPrivate: $isPrivate, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration)'; + return 'MemberDto(uuid: $uuid, token: $token, creationDateTime: $creationDateTime, lastUpdateDateTime: $lastUpdateDateTime, isPrivate: $isPrivate, email: $email, followedAnimes: $followedAnimes, followedEpisodes: $followedEpisodes, totalDuration: $totalDuration)'; } @override @@ -237,6 +253,7 @@ class _$MemberDtoImpl implements _MemberDto { other.lastUpdateDateTime == lastUpdateDateTime) && (identical(other.isPrivate, isPrivate) || other.isPrivate == isPrivate) && + (identical(other.email, email) || other.email == email) && const DeepCollectionEquality() .equals(other.followedAnimes, followedAnimes) && const DeepCollectionEquality() @@ -254,6 +271,7 @@ class _$MemberDtoImpl implements _MemberDto { creationDateTime, lastUpdateDateTime, isPrivate, + email, const DeepCollectionEquality().hash(followedAnimes), const DeepCollectionEquality().hash(followedEpisodes), totalDuration); @@ -279,6 +297,7 @@ abstract class _MemberDto implements MemberDto { required final String creationDateTime, required final String lastUpdateDateTime, required final bool isPrivate, + required final String? email, required final List followedAnimes, required final List followedEpisodes, required final int totalDuration}) = _$MemberDtoImpl; @@ -297,6 +316,8 @@ abstract class _MemberDto implements MemberDto { @override bool get isPrivate; @override + String? get email; + @override List get followedAnimes; @override List get followedEpisodes; diff --git a/lib/dtos/member_dto.g.dart b/lib/dtos/member_dto.g.dart index c9f0367..cdc1b46 100644 --- a/lib/dtos/member_dto.g.dart +++ b/lib/dtos/member_dto.g.dart @@ -13,6 +13,7 @@ _$MemberDtoImpl _$$MemberDtoImplFromJson(Map json) => creationDateTime: json['creationDateTime'] as String, lastUpdateDateTime: json['lastUpdateDateTime'] as String, isPrivate: json['isPrivate'] as bool, + email: json['email'] as String?, followedAnimes: (json['followedAnimes'] as List) .map((e) => e as String) .toList(), @@ -29,6 +30,7 @@ Map _$$MemberDtoImplToJson(_$MemberDtoImpl instance) => 'creationDateTime': instance.creationDateTime, 'lastUpdateDateTime': instance.lastUpdateDateTime, 'isPrivate': instance.isPrivate, + 'email': instance.email, 'followedAnimes': instance.followedAnimes, 'followedEpisodes': instance.followedEpisodes, 'totalDuration': instance.totalDuration, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d8f8bb8..d6f6690 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -6,11 +6,9 @@ "whatYouMightHaveMissed1": "Ce que tu aurais pu ", "whatYouMightHaveMissed2": "manquer", - "episode": "Épisode", "special": "Spécial", "film": "Film", - "information": "Saison {season} | {episodeType} {number}", "subtitles": "Sous-titrage", @@ -33,9 +31,12 @@ "showLess": "Voir moins", "anonymousAccount": "Compte anonyme", - "createAccount": "Créer un compte", + "associateEmail": "Associer un email", + "code": "Code", + "sendCode": "Envoyer le code", "member": "Membre", "account": "Compte", + "email": "Email", "identifier": "Identifiant", "identifierSubtitle": "Sauvegardez-le pour récupérer vos données", "noIdentifier": "Aucun identifiant", @@ -43,17 +44,21 @@ "enterNewIdentifier": "Saisissez un nouvel identifiant", "cancel": "Annuler", "save": "Sauvegarder", - "invalidIdentifier": "Identifiant invalide", + "settings": "Paramètres", + "noEmail": "Pas d'email associé", "anonymousWarningTitle": "Avertissement sur la sauvegarde de données", "anonymousWarningContent1": "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.", - "anonymousWarningContent2": "Vous trouverez votre identifiant unique dans la section \"Compte\" de l'application.", - "anonymousWarningContent3": "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.", - + "anonymousWarningContent2": "Vous trouverez votre identifiant unique dans la section \"Compte\" dans les paramètres de l'application.", + "anonymousWarningContent3": "Pour éviter toute perte de données, nous vous recommandons vivement d'associer une adresse e-mail. 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.", "oops": "OOPS", "noAnimeToday": "Il n'y a pas d'animé aujourd'hui !", "noInternet": "Pas de connexion Internet", "tryAgain": "Réessayer", + "invalidEmail": "Email invalide", + "conflictEmail": "Cet email est déjà associé à un autre compte", + "invalidCode": "Code invalide", + "invalidIdentifier": "Identifiant invalide", "animesAdded": "Animés ajoutés", "episodesWatched": "Épisodes vus", diff --git a/lib/main.dart b/lib/main.dart index 0e63a7a..5abc0a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:application/controllers/member_controller.dart'; import 'package:application/controllers/missed_anime_controller.dart'; import 'package:application/dtos/missed_anime_dto.dart'; import 'package:application/utils/constant.dart'; +import 'package:application/views/account_settings_view.dart'; import 'package:application/views/account_view.dart'; import 'package:application/views/calendar_view.dart'; import 'package:application/views/home_view.dart'; @@ -217,6 +218,17 @@ class _MyHomePageState extends State { : Icons.filter_alt_off, ), ), + if (_currentIndex == 3) + IconButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AccountSettingsView(), + ), + ); + }, + icon: const Icon(Icons.settings), + ), IconButton( onPressed: () { Navigator.of(context).push( diff --git a/lib/utils/http_request.dart b/lib/utils/http_request.dart index 547abe8..84c488e 100644 --- a/lib/utils/http_request.dart +++ b/lib/utils/http_request.dart @@ -51,7 +51,7 @@ class HttpRequest { headers['Authorization'] = 'Bearer $token'; } - final response = await http + return await http .post( Uri.parse( Constant.apiUrl + endpoint, @@ -60,8 +60,6 @@ class HttpRequest { body: body, ) .timeout(_timeout); - - return response; } Future put( @@ -73,8 +71,7 @@ class HttpRequest { 'Content-Type': 'application/json', 'Authorization': 'Bearer $token', }; - - final response = await http + return await http .put( Uri.parse( Constant.apiUrl + endpoint, @@ -83,8 +80,6 @@ class HttpRequest { body: body, ) .timeout(_timeout); - - return response; } Future delete( @@ -97,7 +92,7 @@ class HttpRequest { 'Authorization': 'Bearer $token', }; - final response = await http + return await http .delete( Uri.parse( Constant.apiUrl + endpoint, @@ -106,7 +101,5 @@ class HttpRequest { body: body, ) .timeout(_timeout); - - return response; } } diff --git a/lib/views/account_settings_view.dart b/lib/views/account_settings_view.dart new file mode 100644 index 0000000..c8639ad --- /dev/null +++ b/lib/views/account_settings_view.dart @@ -0,0 +1,124 @@ +import 'package:application/components/accounts/associate_email.dart'; +import 'package:application/components/accounts/edit_identifier.dart'; +import 'package:application/controllers/member_controller.dart'; +import 'package:application/dtos/member_dto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:vibration/vibration.dart'; + +class AccountSettingsView extends StatelessWidget { + const AccountSettingsView({super.key}); + + @override + Widget build(BuildContext context) { + final appLocalizations = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(appLocalizations.settings), + ), + body: StreamBuilder( + stream: MemberController.instance.streamController.stream, + initialData: MemberController.instance.member, + builder: (context, snapshot) { + final memberDto = snapshot.data; + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + appLocalizations.account, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + ListTile( + title: Text(appLocalizations.identifier), + subtitle: Text(appLocalizations.identifierSubtitle), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + MemberController.instance.identifier ?? + appLocalizations.noIdentifier, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: () { + showAdaptiveDialog( + context: context, + builder: (context) => EditIdentifier(), + ); + }, + child: const Icon(Icons.edit), + ), + ], + ), + onTap: () { + if (MemberController.instance.identifier == null) { + return; + } + + // Copy to clipboard + Clipboard.setData( + ClipboardData( + text: MemberController.instance.identifier!, + ), + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check, color: Colors.white), + const SizedBox(width: 8), + Text(appLocalizations.identifierCopied), + ], + ), + ), + ); + + Vibration.vibrate(duration: 200, amplitude: 255); + }, + ), + ListTile( + title: Text(appLocalizations.email), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + memberDto?.email ?? appLocalizations.noEmail, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AssociateEmail(), + ), + ); + }, + child: const Icon(Icons.edit), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index f014d6b..e54e3d0 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,7 +1,7 @@ +import 'package:application/components/accounts/account_card.dart'; +import 'package:application/components/accounts/associate_email.dart'; import 'package:application/controllers/member_controller.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:vibration/vibration.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class AccountView extends StatelessWidget { @@ -9,6 +9,9 @@ class AccountView extends StatelessWidget { @override Widget build(BuildContext context) { + final appLocalizations = AppLocalizations.of(context); + final member = MemberController.instance.member; + return ListView( children: [ Row( @@ -49,7 +52,7 @@ class AccountView extends StatelessWidget { direction: Axis.horizontal, children: [ Text( - AppLocalizations.of(context)!.anonymousAccount, + appLocalizations!.anonymousAccount, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -68,18 +71,19 @@ class AccountView extends StatelessWidget { context: context, builder: (context) { return AlertDialog( - title: Text(AppLocalizations.of(context)! - .anonymousWarningTitle), + title: Text( + appLocalizations.anonymousWarningTitle, + ), content: SingleChildScrollView( child: Column( children: [ - Text(AppLocalizations.of(context)! + Text(appLocalizations .anonymousWarningContent1), const SizedBox(height: 16), - Text(AppLocalizations.of(context)! + Text(appLocalizations .anonymousWarningContent2), const SizedBox(height: 16), - Text(AppLocalizations.of(context)! + Text(appLocalizations .anonymousWarningContent3), ], ), @@ -92,42 +96,25 @@ class AccountView extends StatelessWidget { ], ), Text( - AppLocalizations.of(context)!.member, + appLocalizations.member, style: const TextStyle( fontSize: 16, color: Colors.grey, ), ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - content: const Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'WORK IN PROGRESS', - textAlign: TextAlign.center, - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(AppLocalizations.of(context)!.cancel), - ), - ], - ); - }, - ); - }, - child: Text(AppLocalizations.of(context)!.createAccount), - ), + if (member?.email == null) ...[ + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AssociateEmail(), + ), + ); + }, + child: Text(appLocalizations.associateEmail), + ), + ], ], ), ], @@ -139,20 +126,15 @@ class AccountView extends StatelessWidget { children: [ Expanded( child: AccountCard( - label: AppLocalizations.of(context)!.animesAdded, - value: MemberController.instance.member?.followedAnimes.length - .toString() ?? - '0', + label: appLocalizations.animesAdded, + value: member?.followedAnimes.length.toString() ?? '0', ), ), const SizedBox(width: 16), Expanded( child: AccountCard( - label: AppLocalizations.of(context)!.episodesWatched, - value: MemberController - .instance.member?.followedEpisodes.length - .toString() ?? - '0', + label: appLocalizations.episodesWatched, + value: member?.followedEpisodes.length.toString() ?? '0', ), ), ], @@ -165,7 +147,7 @@ class AccountView extends StatelessWidget { children: [ Expanded( child: AccountCard( - label: AppLocalizations.of(context)!.watchTime, + label: appLocalizations.watchTime, value: MemberController.instance.buildTotalDuration(), ), ), @@ -173,186 +155,22 @@ class AccountView extends StatelessWidget { ), ), const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), child: Text( - AppLocalizations.of(context)!.account, - style: const TextStyle( + 'Nos recommandations', + style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey, ), ), ), - ListTile( - title: Text(AppLocalizations.of(context)!.identifier), - subtitle: Text(AppLocalizations.of(context)!.identifierSubtitle), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - MemberController.instance.identifier ?? - AppLocalizations.of(context)!.noIdentifier, - style: const TextStyle(fontSize: 14), - ), - const SizedBox(width: 16), - GestureDetector( - onTap: () { - showAdaptiveDialog( - context: context, - builder: (context) { - return EditIdentifier(); - }, - ); - }, - child: const Icon(Icons.edit), - ), - ], - ), - onTap: () { - if (MemberController.instance.identifier == null) { - return; - } - - // Copy to clipboard - Clipboard.setData( - ClipboardData(text: MemberController.instance.identifier!), - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.check, color: Colors.white), - const SizedBox(width: 8), - Text(AppLocalizations.of(context)!.identifierCopied), - ], - ), - ), - ); - - Vibration.vibrate(duration: 200, amplitude: 255); - }, + const ListTile( + title: Text('Bah non, toujours pas de recommandations.'), + subtitle: Text("Mais ça va venir, promis ! (Peut-être d'ici 2027)"), ), ], ); } } - -class AccountCard extends StatelessWidget { - final String label; - final String value; - - const AccountCard({ - super.key, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ); - } -} - -class EditIdentifier extends StatelessWidget { - final TextEditingController _controller = TextEditingController(); - - EditIdentifier({super.key}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - AppLocalizations.of(context)!.enterNewIdentifier, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.identifier, - ), - controller: _controller, - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(AppLocalizations.of(context)!.cancel), - ), - ElevatedButton( - onPressed: () { - saveIdentifier(context); - }, - child: Text(AppLocalizations.of(context)!.save), - ), - ], - ); - } - - Future saveIdentifier(BuildContext context) async { - if (_controller.text.isEmpty) { - Vibration.vibrate(duration: 200, amplitude: 255); - return; - } - - final oldIdentifier = MemberController.instance.identifier; - - try { - await MemberController.instance.testLogin(_controller.text); - await MemberController.instance.login(identifier: _controller.text); - } catch (e) { - Vibration.vibrate(duration: 200, amplitude: 255); - - if (MemberController.instance.identifier != oldIdentifier) { - await MemberController.instance.login(identifier: oldIdentifier); - } - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.invalidIdentifier, - textAlign: TextAlign.center, - ), - ), - ); - } - } finally { - if (context.mounted) { - Navigator.of(context).pop(); - } - } - } -} diff --git a/pubspec.lock b/pubspec.lock index 01e1722..0760ba6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "2350805d7afefb0efe7acd325cb19d3ae8ba4039b906eade3807ffb69938a01f" + sha256: e4be6711f96d3d4eebe79728897d645b7a5585bbfdd6d534878d202c171266d7 url: "https://pub.dev" source: hosted - version: "1.3.33" + version: "1.3.34" analyzer: dependency: transitive description: @@ -301,10 +301,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "372d94ced114b9c40cb85e18c50ac94a7e998c8eec630c50d7aec047847d27bf" + sha256: "4b5100e2dbc3fe72c2d4241a046d3f01457fe11293283a324f5c52575e3406f8" url: "https://pub.dev" source: hosted - version: "2.31.0" + version: "2.31.1" firebase_core_platform_interface: dependency: transitive description: @@ -325,26 +325,26 @@ packages: dependency: "direct main" description: name: firebase_messaging - sha256: e0882a7426821f7caccaabfc15a535155cd15b4daa73a5a7b3af701a552d73ab + sha256: "62c27bd7c9c724b5ee5fd52e06224b8861d6e692f08b3d7bc3ada28552f27d41" url: "https://pub.dev" source: hosted - version: "14.9.2" + version: "14.9.3" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "52e12cc50e1395ad7ea3552dcbe9958fb1994b5afcf58ee4c0db053932a6fce5" + sha256: f73e9fe4bc25307520b714cec39a2e1a625c64511d497964f3e06c5d60146948 url: "https://pub.dev" source: hosted - version: "4.5.35" + version: "4.5.36" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "8812cc5929380b783f92290d934bf32e2fea06701583f47cdccd5f13f4f24522" + sha256: "5ed108929f988d55c497f1456ad5e407501a746869306f058b6d2b2e704790f3" url: "https://pub.dev" source: hosted - version: "3.8.5" + version: "3.8.6" fixnum: dependency: transitive description: @@ -998,10 +998,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9" url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_ios: dependency: transitive description: @@ -1070,10 +1070,18 @@ packages: dependency: "direct main" description: name: vibration - sha256: "778ace40e84852e6cf6017cdbaf6790a837d73ff3dd50b27da9ac232a19de8fc" + sha256: "06588a845a4ebc73ab7ff7da555c2b3dbcd9676164b5856a38bf0b2287f1045d" url: "https://pub.dev" source: hosted - version: "1.8.4" + version: "1.9.0" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "735a5fef0f284de0ad9449a5ed7d36ba017c6f59b5b20ac64418af4a6bd35ee7" + url: "https://pub.dev" + source: hosted + version: "0.0.1" vm_service: dependency: transitive description: @@ -1102,10 +1110,10 @@ packages: dependency: transitive description: name: web_socket - sha256: bfe704c186c6e32a46f6607f94d079cd0b747b9a489fceeecc93cd3adb98edd5 + sha256: "217f49b5213796cb508d6a942a5dc604ce1cb6a0a6b3d8cb3f0c314f0ecea712" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.1.4" web_socket_channel: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 372c08b..23e7b84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,8 +10,8 @@ environment: dependencies: cached_network_image: ^3.3.1 - firebase_core: ^2.31.0 - firebase_messaging: ^14.9.2 + firebase_core: ^2.31.1 + firebase_messaging: ^14.9.3 flutter: sdk: flutter flutter_localizations: @@ -26,7 +26,7 @@ dependencies: shared_preferences: ^2.2.3 shorebird_code_push: ^1.1.3 url_launcher: ^6.2.6 - vibration: ^1.8.4 + vibration: ^1.9.0 dev_dependencies: build_runner: ^2.4.10