diff --git a/lam7a/lib/features/profile/repository/profile_repository.dart b/lam7a/lib/features/profile/repository/profile_repository.dart index c305ece..d4717c2 100644 --- a/lam7a/lib/features/profile/repository/profile_repository.dart +++ b/lam7a/lib/features/profile/repository/profile_repository.dart @@ -61,6 +61,14 @@ class ProfileRepository { return _fromDto(dto); } + Future deleteAvatar() async { + await _api.deleteProfilePicture(); + } + + Future deleteBanner() async { + await _api.deleteBanner(); + } + Future followUser(int id) => _api.followUser(id); Future unfollowUser(int id) => _api.unfollowUser(id); diff --git a/lam7a/lib/features/profile/services/profile_api_service.dart b/lam7a/lib/features/profile/services/profile_api_service.dart index 98f1e08..d13528f 100644 --- a/lam7a/lib/features/profile/services/profile_api_service.dart +++ b/lam7a/lib/features/profile/services/profile_api_service.dart @@ -46,4 +46,8 @@ abstract class ProfileApiService { int page, int limit, ); + + Future deleteProfilePicture(); + Future deleteBanner(); + } diff --git a/lam7a/lib/features/profile/services/profile_api_service_impl.dart b/lam7a/lib/features/profile/services/profile_api_service_impl.dart index 97a28c2..93e86e4 100644 --- a/lam7a/lib/features/profile/services/profile_api_service_impl.dart +++ b/lam7a/lib/features/profile/services/profile_api_service_impl.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // profile_api_service_impl.dart import 'dart:io'; import 'package:dio/dio.dart'; @@ -153,4 +154,14 @@ class ProfileApiServiceImpl implements ProfileApiService { return _extractList(res); } + @override + Future deleteProfilePicture() async { + await _api.delete(endpoint: '/profile/me/profile-picture'); + } + + @override + Future deleteBanner() async { + await _api.delete(endpoint: '/profile/me/banner'); + } + } diff --git a/lam7a/lib/features/profile/ui/view/edit_profile_page.dart b/lam7a/lib/features/profile/ui/view/edit_profile_page.dart index 3bcce94..1335bed 100644 --- a/lam7a/lib/features/profile/ui/view/edit_profile_page.dart +++ b/lam7a/lib/features/profile/ui/view/edit_profile_page.dart @@ -100,6 +100,29 @@ class _EditProfilePageState extends ConsumerState { if (f != null) setState(() => newBannerPath = f.path); } + Future deleteAvatar() async { + final repo = ref.read(profileRepositoryProvider); + await repo.deleteAvatar(); + + if (mounted) { + setState(() { + newAvatarPath = null; + }); + } + } + + Future deleteBanner() async { + final repo = ref.read(profileRepositoryProvider); + await repo.deleteBanner(); + + if (mounted) { + setState(() { + newBannerPath = null; + }); + } + } + + Future save() async { if (_saving) return; setState(() => _saving = true); @@ -224,11 +247,37 @@ class _EditProfilePageState extends ConsumerState { onTap: pickBanner, child: Image(image: bannerProvider, width: double.infinity, height: 160, fit: BoxFit.cover), ), - const SizedBox(height: 12), - Container( - alignment: Alignment.centerLeft, - child: GestureDetector(key: const ValueKey('edit_profile_avatar_picker'), onTap: pickAvatar, child: CircleAvatar(radius: 46, backgroundImage: avatarProvider)) + if (widget.user.bannerImageUrl != null || newBannerPath != null) + TextButton( + onPressed: deleteBanner, + child: const Text( + 'Remove banner', + style: TextStyle(color: Colors.red), + ), ), + const SizedBox(height: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + key: const ValueKey('edit_profile_avatar_picker'), + onTap: pickAvatar, + child: CircleAvatar( + radius: 46, + backgroundImage: avatarProvider, + ), + ), + + if (widget.user.profileImageUrl != null || newAvatarPath != null) + TextButton( + onPressed: deleteAvatar, + child: const Text( + 'Remove avatar', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), const SizedBox(height: 12), buildField('Name', nameCtrl, maxLength: 30, fieldKey: const ValueKey('edit_profile_name_field'),), diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart index ef861dc..03be4df 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // feature/profile/ui/viewmodel/profile_posts_viewmodel.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lam7a/features/tweet/repository/tweet_repository.dart'; diff --git a/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart b/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart index f9cd1db..65a3a25 100644 --- a/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart +++ b/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart @@ -61,6 +61,28 @@ class EditProfileFormState extends ConsumerState { if (picked != null) setState(() => newAvatar = File(picked.path)); } + Future deleteAvatar() async { + final repo = ref.read(profileRepositoryProvider); + await repo.deleteAvatar(); + + if (mounted) { + setState(() { + newAvatar = null; + }); + } + } + + Future deleteBanner() async { + final repo = ref.read(profileRepositoryProvider); + await repo.deleteBanner(); + + if (mounted) { + setState(() { + newBanner = null; + }); + } + } + Future saveProfile() async { setState(() => saving = true); final repo = ref.read(profileRepositoryProvider); @@ -114,9 +136,38 @@ class EditProfileFormState extends ConsumerState { return SingleChildScrollView( key: const ValueKey('edit_profile_form'), child: Column(children: [ - GestureDetector(key: const ValueKey('edit_profile_banner_picker'), onTap: pickBanner, child: Image(image: bannerImage, width: double.infinity, height: 180, fit: BoxFit.cover)), + //GestureDetector(key: const ValueKey('edit_profile_banner_picker'), onTap: pickBanner, child: Image(image: bannerImage, width: double.infinity, height: 180, fit: BoxFit.cover)), + GestureDetector( + key: const ValueKey('edit_profile_banner_picker'), + onTap: pickBanner, + child: Image(image: bannerImage, width: double.infinity, height: 180, fit: BoxFit.cover), + ), + if (widget.user.bannerImageUrl != null || newBanner != null) + TextButton( + onPressed: deleteBanner, + child: const Text( + 'Remove banner', + style: TextStyle(color: Colors.red), + ), + ), const SizedBox(height: 12), - GestureDetector(key: const ValueKey('edit_profile_avatar_picker'), onTap: pickAvatar, child: CircleAvatar(backgroundImage: avatarImage, radius: 48)), + //GestureDetector(key: const ValueKey('edit_profile_avatar_picker'), onTap: pickAvatar, child: CircleAvatar(backgroundImage: avatarImage, radius: 48)), + GestureDetector( + key: const ValueKey('edit_profile_avatar_picker'), + onTap: pickAvatar, + child: CircleAvatar(backgroundImage: avatarImage, radius: 48), + ), + if (widget.user.profileImageUrl != null || newAvatar != null) + TextButton( + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + ), + onPressed: deleteAvatar, + child: const Text( + 'Remove avatar', + style: TextStyle(color: Colors.red), + ), + ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lam7a/test/profile/utils/profile_tweet_mapper_test.dart b/lam7a/test/profile/utils/profile_tweet_mapper_test.dart new file mode 100644 index 0000000..efbe8e1 --- /dev/null +++ b/lam7a/test/profile/utils/profile_tweet_mapper_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lam7a/features/profile/utils/profile_tweet_mapper.dart'; +import 'package:lam7a/features/common/models/tweet_model.dart'; + +void main() { + group('read()', () { + test('returns first non-null key value', () { + final json = { + 'a': null, + 'b': 5, + 'c': 10, + }; + + final result = read(json, ['a', 'b', 'c']); + expect(result, 5); + }); + + test('returns null when all keys are missing or null', () { + final json = {'a': null}; + + final result = read(json, ['x', 'y']); + expect(result, isNull); + }); + }); + + group('convertProfileJsonToTweetModel', () { + test('maps normal tweet correctly', () { + final json = { + 'postId': 1, + 'userId': 2, + 'username': 'user', + 'name': 'User Name', + 'text': 'Hello world', + 'likesCount': 3, + 'retweetsCount': 1, + 'commentsCount': 2, + 'date': '2024-01-01T12:00:00Z', + }; + + final tweet = convertProfileJsonToTweetModel(json); + + expect(tweet, isA()); + expect(tweet.id, '1'); + expect(tweet.userId, '2'); + expect(tweet.body, 'Hello world'); + expect(tweet.likes, 3); + expect(tweet.repost, 1); + expect(tweet.comments, 2); + expect(tweet.isRepost, false); + expect(tweet.isQuote, false); + }); + + test('maps repost tweet correctly', () { + final json = { + 'postId': 100, + 'userId': 1, + 'username': 'reposter', + 'name': 'Reposter', + 'date': '2024-01-02T10:00:00Z', + 'isRepost': true, + 'originalPostData': { + 'postId': 50, + 'userId': 9, + 'username': 'original', + 'name': 'Original User', + 'text': 'Original tweet', + 'likesCount': 10, + 'retweetsCount': 5, + 'commentsCount': 3, + 'date': '2024-01-01T09:00:00Z', + }, + }; + + final tweet = convertProfileJsonToTweetModel(json); + + expect(tweet.isRepost, true); + expect(tweet.originalTweet, isNotNull); + expect(tweet.body, 'Original tweet'); + expect(tweet.originalTweet!.username, 'original'); + }); + + test('maps quote tweet correctly', () { + final json = { + 'postId': 200, + 'userId': 3, + 'username': 'quoter', + 'name': 'Quoter', + 'text': 'My opinion', + 'likesCount': 1, + 'retweetsCount': 0, + 'commentsCount': 0, + 'date': '2024-01-03T11:00:00Z', + 'isQuote': true, + 'originalPostData': { + 'postId': 80, + 'userId': 7, + 'username': 'parent', + 'name': 'Parent User', + 'text': 'Parent tweet', + 'likesCount': 20, + 'retweetsCount': 2, + 'commentsCount': 4, + 'date': '2024-01-02T08:00:00Z', + }, + }; + + final tweet = convertProfileJsonToTweetModel(json); + + expect(tweet.isQuote, true); + expect(tweet.originalTweet, isNotNull); + expect(tweet.body, 'My opinion'); + expect(tweet.originalTweet!.body, 'Parent tweet'); + }); + }); +} diff --git a/lam7a/test/profile/widgets/profile_more_menu_test.dart b/lam7a/test/profile/widgets/profile_more_menu_test.dart new file mode 100644 index 0000000..9ff94e4 --- /dev/null +++ b/lam7a/test/profile/widgets/profile_more_menu_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lam7a/features/profile/ui/widgets/profile_more_menu.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import 'package:lam7a/features/profile/repository/profile_repository.dart'; + +import '../helpers/fake_profile_api.dart'; + +void main() { + Widget wrap({ + required UserModel user, + required FakeProfileApiService api, + required VoidCallback onAction, + }) { + return ProviderScope( + overrides: [ + profileRepositoryProvider.overrideWithValue( + ProfileRepository(api), + ), + ], + child: MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: [ + ProfileMoreMenu( + user: user, + username: user.username ?? '', + onAction: onAction, + ), + ], + ), + ), + ), + ); + } + + UserModel baseUser({ + ProfileStateOfMute mute = ProfileStateOfMute.notmuted, + ProfileStateBlocked blocked = ProfileStateBlocked.notblocked, + }) { + return UserModel( + id: 1, + profileId: 1, + username: 'test', + name: 'Test User', + stateMute: mute, + stateBlocked: blocked, + ); + } + + testWidgets('shows Mute and Block when user is not muted or blocked', + (tester) async { + final api = FakeProfileApiService(); + + await tester.pumpWidget( + wrap( + user: baseUser(), + api: api, + onAction: () {}, + ), + ); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Mute'), findsOneWidget); + expect(find.text('Block'), findsOneWidget); + }); + + testWidgets('tapping mute calls muteUser and onAction', (tester) async { + final api = FakeProfileApiService(); + bool actionCalled = false; + + await tester.pumpWidget( + wrap( + user: baseUser(), + api: api, + onAction: () => actionCalled = true, + ), + ); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_mute'))); + await tester.pumpAndSettle(); + + expect(api.muteCalled, true); + expect(actionCalled, true); + }); + + testWidgets('tapping unmute calls unmuteUser', (tester) async { + final api = FakeProfileApiService(); + + await tester.pumpWidget( + wrap( + user: baseUser(mute: ProfileStateOfMute.muted), + api: api, + onAction: () {}, + ), + ); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Unmute'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_mute'))); + await tester.pumpAndSettle(); + + expect(api.unmuteCalled, true); + }); + + testWidgets('tapping block calls blockUser and onAction', (tester) async { + final api = FakeProfileApiService(); + bool actionCalled = false; + + await tester.pumpWidget( + wrap( + user: baseUser(), + api: api, + onAction: () => actionCalled = true, + ), + ); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_block'))); + await tester.pumpAndSettle(); + + expect(api.blockCalled, true); + expect(actionCalled, true); + }); + + testWidgets('tapping unblock calls unblockUser', (tester) async { + final api = FakeProfileApiService(); + + await tester.pumpWidget( + wrap( + user: baseUser(blocked: ProfileStateBlocked.blocked), + api: api, + onAction: () {}, + ), + ); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Unblock'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile_more_menu_block'))); + await tester.pumpAndSettle(); + + expect(api.unblockCalled, true); + }); +}