Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lam7a/lib/features/profile/repository/profile_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ class ProfileRepository {
return _fromDto(dto);
}

Future<void> deleteAvatar() async {
await _api.deleteProfilePicture();
}

Future<void> deleteBanner() async {
await _api.deleteBanner();
}

Future<void> followUser(int id) => _api.followUser(id);
Future<void> unfollowUser(int id) => _api.unfollowUser(id);

Expand Down
4 changes: 4 additions & 0 deletions lam7a/lib/features/profile/services/profile_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ abstract class ProfileApiService {
int page,
int limit,
);

Future<void> deleteProfilePicture();
Future<void> deleteBanner();

}
11 changes: 11 additions & 0 deletions lam7a/lib/features/profile/services/profile_api_service_impl.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// coverage:ignore-file
// profile_api_service_impl.dart
import 'dart:io';
import 'package:dio/dio.dart';
Expand Down Expand Up @@ -153,4 +154,14 @@ class ProfileApiServiceImpl implements ProfileApiService {
return _extractList(res);
}

@override
Future<void> deleteProfilePicture() async {
await _api.delete(endpoint: '/profile/me/profile-picture');
}

@override
Future<void> deleteBanner() async {
await _api.delete(endpoint: '/profile/me/banner');
}

}
57 changes: 53 additions & 4 deletions lam7a/lib/features/profile/ui/view/edit_profile_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
if (f != null) setState(() => newBannerPath = f.path);
}

Future<void> deleteAvatar() async {
final repo = ref.read(profileRepositoryProvider);
await repo.deleteAvatar();

if (mounted) {
setState(() {
newAvatarPath = null;
});
}
}

Future<void> deleteBanner() async {
final repo = ref.read(profileRepositoryProvider);
await repo.deleteBanner();

if (mounted) {
setState(() {
newBannerPath = null;
});
}
}


Future<void> save() async {
if (_saving) return;
setState(() => _saving = true);
Expand Down Expand Up @@ -224,11 +247,37 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
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'),),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
55 changes: 53 additions & 2 deletions lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@ class EditProfileFormState extends ConsumerState<EditProfileForm> {
if (picked != null) setState(() => newAvatar = File(picked.path));
}

Future<void> deleteAvatar() async {
final repo = ref.read(profileRepositoryProvider);
await repo.deleteAvatar();

if (mounted) {
setState(() {
newAvatar = null;
});
}
}

Future<void> deleteBanner() async {
final repo = ref.read(profileRepositoryProvider);
await repo.deleteBanner();

if (mounted) {
setState(() {
newBanner = null;
});
}
}

Future<UserModel?> saveProfile() async {
setState(() => saving = true);
final repo = ref.read(profileRepositoryProvider);
Expand Down Expand Up @@ -114,9 +136,38 @@ class EditProfileFormState extends ConsumerState<EditProfileForm> {
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),
Expand Down
115 changes: 115 additions & 0 deletions lam7a/test/profile/utils/profile_tweet_mapper_test.dart
Original file line number Diff line number Diff line change
@@ -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<T>()', () {
test('returns first non-null key value', () {
final json = {
'a': null,
'b': 5,
'c': 10,
};

final result = read<int>(json, ['a', 'b', 'c']);
expect(result, 5);
});

test('returns null when all keys are missing or null', () {
final json = {'a': null};

final result = read<String>(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<TweetModel>());
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');
});
});
}
Loading
Loading