From 408dddcef4ae343c7162418b1de6f31ad3d30bb4 Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Sun, 14 Dec 2025 20:07:59 +0200 Subject: [PATCH 1/9] name/birthday/website all issues are solved --- .../profile/ui/view/edit_profile_page.dart | 103 +++++++++++++++++- .../profile/ui/widgets/edit_profile_form.dart | 31 +++++- .../ui/widgets/profile_header_widget.dart | 29 +++++ 3 files changed, 157 insertions(+), 6 deletions(-) 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 c0e0acb..77df220 100644 --- a/lam7a/lib/features/profile/ui/view/edit_profile_page.dart +++ b/lam7a/lib/features/profile/ui/view/edit_profile_page.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:lam7a/core/models/user_model.dart'; import 'package:lam7a/features/profile/repository/profile_repository.dart'; @@ -46,6 +48,48 @@ class _EditProfilePageState extends ConsumerState { super.dispose(); } + bool _isValidName(String name) { + final trimmed = name.trim(); + + if (trimmed.isEmpty) return false; + if (trimmed.length < 5) return false; + if (trimmed.length > 30) return false; + + + final validNameRegex = RegExp(r'^[a-zA-Z0-9 _.-]+$'); + return validNameRegex.hasMatch(trimmed); + } + + bool _isValidBirthYear(String birthDate) { + if (birthDate.isEmpty) return true; + + final maxAllowedYear = 2010; + final parts = birthDate.split('-'); + if (parts.isEmpty) return false; + + final year = int.tryParse(parts[0]); + if (year == null) return false; + + // Max allowed year = 2010 + return year <= maxAllowedYear; + } + + bool _isValidWebsite(String website) { + if (website.isEmpty) return true; // optional field + + // No emojis or spaces + final validChars = RegExp(r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=%]+$"); + if (!validChars.hasMatch(website)) return false; + + // Must look like a domain + final domainRegex = RegExp( + r'^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}$', + ); + + return domainRegex.hasMatch(website); + } + + Future pickAvatar() async { final f = await ImagePicker().pickImage(source: ImageSource.gallery); if (f != null) setState(() => newAvatarPath = f.path); @@ -60,12 +104,63 @@ class _EditProfilePageState extends ConsumerState { if (_saving) return; setState(() => _saving = true); + final name = nameCtrl.text.trim(); + + if (!_isValidName(name)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Name must be 5–30 characters and cannot contain emojis or only spaces', + ), + ), + ); + } + setState(() => _saving = false); + return; + } + + final birthDate = birthDateCtrl.text.trim(); + + if (!_isValidBirthYear(birthDate)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Birth year must be 2010 or earlier', + ), + ), + ); + } + setState(() => _saving = false); + return; + } + + final website = websiteCtrl.text.trim(); + + if (!_isValidWebsite(website)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a valid website URL'), + ), + ); + } + setState(() => _saving = false); + return; + } + + final normalizedWebsite = + website.isNotEmpty && !website.startsWith('http') + ? 'https://$website' + : website; + final updated = widget.user.copyWith( - name: nameCtrl.text.trim(), + name: name, bio: bioCtrl.text.trim(), location: locationCtrl.text.trim(), - website: websiteCtrl.text.trim(), - birthDate: birthDateCtrl.text.trim(), + website: normalizedWebsite, + birthDate: birthDate, profileImageUrl: newAvatarPath ?? widget.user.profileImageUrl, bannerImageUrl: newBannerPath ?? widget.user.bannerImageUrl, ); @@ -145,7 +240,7 @@ class _EditProfilePageState extends ConsumerState { ), const SizedBox(height: 12), - buildField('Name', nameCtrl, fieldKey: const ValueKey('edit_profile_name_field'),), + buildField('Name', nameCtrl, maxLength: 30, fieldKey: const ValueKey('edit_profile_name_field'),), buildField('Bio', bioCtrl, maxLines: 3, maxLength: 160, fieldKey: const ValueKey('edit_profile_bio_field'),), buildField('Location', locationCtrl, fieldKey: const ValueKey('edit_profile_location_field'),), buildField('Website', websiteCtrl, fieldKey: const ValueKey('edit_profile_website_field'),), 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 192464f..3c8385f 100644 --- a/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart +++ b/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart @@ -1,6 +1,7 @@ // lib/features/profile/ui/widgets/edit_profile_form.dart import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lam7a/core/models/user_model.dart'; @@ -42,6 +43,14 @@ class EditProfileFormState extends ConsumerState { super.dispose(); } + bool _isValidName(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return false; + if (trimmed.length < 5 || trimmed.length > 30) return false; + final validNameRegex = RegExp(r'^[a-zA-Z0-9 _.-]+$'); + return validNameRegex.hasMatch(trimmed); + } + Future pickBanner() async { final picked = await ImagePicker().pickImage(source: ImageSource.gallery); if (picked != null) setState(() => newBanner = File(picked.path)); @@ -55,8 +64,26 @@ class EditProfileFormState extends ConsumerState { Future saveProfile() async { setState(() => saving = true); final repo = ref.read(profileRepositoryProvider); + + final name = nameController.text.trim(); + + if (!_isValidName(name)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Name must be 5–30 characters and cannot contain emojis or only spaces', + ), + ), + ); + } + setState(() => saving = false); + return null; + } + + final updated = widget.user.copyWith( - name: nameController.text.trim(), + name: name, bio: bioController.text.trim(), location: locationController.text.trim(), website: websiteController.text.trim(), @@ -94,7 +121,7 @@ class EditProfileFormState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column(children: [ - TextField(key: const ValueKey('edit_profile_name_input'), controller: nameController, maxLength: 20, decoration: const InputDecoration(labelText: 'Display name')), + TextField(key: const ValueKey('edit_profile_name_input'), controller: nameController, maxLength: 30, decoration: const InputDecoration(labelText: 'Display name')), //TextField(controller: bioController, decoration: const InputDecoration(labelText: 'Bio')), TextField( key: const ValueKey('edit_profile_bio_input'), diff --git a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart index d18c388..482c3c0 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart @@ -1,6 +1,7 @@ // lib/features/profile/ui/widgets/profile_header_widget.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:lam7a/core/models/user_model.dart'; import 'package:lam7a/features/profile/ui/view/edit_profile_page.dart'; @@ -116,6 +117,34 @@ class ProfileHeaderWidget extends ConsumerWidget { ], ), + if ((user.website ?? '').isNotEmpty) + GestureDetector( + key: const ValueKey('profile_website'), + onTap: () async { + final url = Uri.parse(user.website!); + if (await canLaunchUrl(url)) { + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link, size: 16), + const SizedBox(width: 4), + Text( + user.website!, + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + if ((user.createdAt ?? '').isNotEmpty) Row(key: const ValueKey('profile_joined_date'), mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.calendar_today_outlined, size: 16), const SizedBox(width: 4), Text('Joined ${user.createdAt!.split("T").first}')]), From 2c8166d4a0e377ed463977603a2d6d49b70440ce Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 03:13:20 +0200 Subject: [PATCH 2/9] testing issues are solved --- .../ui/view/followers_following_page.dart | 38 ++++++++++++++++--- .../profile/ui/view/profile_screen.dart | 5 ++- .../ui/viewmodel/profile_posts_viewmodel.dart | 6 +-- .../ui/widgets/profile_header_widget.dart | 34 +++++++++++++++-- .../profile/ui/widgets/profile_more_menu.dart | 1 + 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/lam7a/lib/features/profile/ui/view/followers_following_page.dart b/lam7a/lib/features/profile/ui/view/followers_following_page.dart index 8359e84..b601599 100644 --- a/lam7a/lib/features/profile/ui/view/followers_following_page.dart +++ b/lam7a/lib/features/profile/ui/view/followers_following_page.dart @@ -30,6 +30,7 @@ class _FollowersFollowingPageState extends ConsumerState bool _loadingFollowers = true; bool _loadingFollowing = true; + bool _hasChanges = false; @override void initState() { @@ -73,14 +74,23 @@ Widget _buildTile(UserModel u) { return InkWell( key: ValueKey('user_tile_${u.id}'), - onTap: () { - Navigator.push( + onTap: () async { + final changed = await Navigator.push( context, MaterialPageRoute( builder: (_) => const ProfileScreen(), settings: RouteSettings(arguments: {"username": u.username}), ), ); + + if (changed == true) { + setState(() { + _followers?.removeWhere((e) => e.id == u.id); + _following?.removeWhere((e) => e.id == u.id); + }); + _hasChanges = true; + } + _hasChanges = true; }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), @@ -115,11 +125,23 @@ Widget _buildTile(UserModel u) { ), ), ), + // FollowButton( + // key: ValueKey('follow_button_${u.id}'), + // user: u, + // onFollowStateChanged: () => _loadData(), + // ), FollowButton( key: ValueKey('follow_button_${u.id}'), user: u, - onFollowStateChanged: () => _loadData(), - ), + onFollowStateChanged: () { + setState(() { + _following?.removeWhere((e) => e.id == u.id); + _followers?.removeWhere((e) => e.id == u.id); + }); + _hasChanges = true; + _loadData(); + }, + ), ], ), const SizedBox(height: 1), @@ -148,7 +170,12 @@ Widget _buildTile(UserModel u) { @override Widget build(BuildContext context) { - return Scaffold( + return WillPopScope( + onWillPop: () async { + Navigator.pop(context, _hasChanges); + return false; // prevent default pop + }, + child: Scaffold( key: const ValueKey('followers_following_scaffold'), appBar: AppBar( key: const ValueKey('followers_following_appbar'), @@ -188,6 +215,7 @@ Widget _buildTile(UserModel u) { ), ], ), + ) ); } } diff --git a/lam7a/lib/features/profile/ui/view/profile_screen.dart b/lam7a/lib/features/profile/ui/view/profile_screen.dart index 0fbf016..7ca96ab 100644 --- a/lam7a/lib/features/profile/ui/view/profile_screen.dart +++ b/lam7a/lib/features/profile/ui/view/profile_screen.dart @@ -125,7 +125,10 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { key: const ValueKey('profile_more_menu'), user: widget.user, username: widget.username, - onAction: refresh, + onAction: () async { + await refresh(); + Navigator.pop(context, true); + }, ), ], flexibleSpace: Stack( 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 9098a9c..35b1336 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart @@ -142,11 +142,7 @@ final profileRepliesProvider = return repo.fetchUserReplies(userId); }); -// final profileLikesProvider = -// FutureProvider.family, String>((ref, userId) async { -// final repo = ref.read(tweetRepositoryProvider); -// return repo.fetchUserLikes(userId); -// }); + final profileLikesProvider = FutureProvider.family, String>((ref, userId) async { final repo = ref.read(tweetRepositoryProvider); diff --git a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart index 482c3c0..67da183 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart @@ -159,8 +159,21 @@ class ProfileHeaderWidget extends ConsumerWidget { child: Row(children: [ GestureDetector( key: const ValueKey('profile_following_button'), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 1))); + onTap: () async { + //Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 1))); + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FollowersFollowingPage( + userId: user.id ?? 0, + initialTab: 1, + ), + ), + ); + + if (changed == true) { + onEdited?.call(); // refresh profile + } }, child: RichText( text: TextSpan(children: [ @@ -174,8 +187,21 @@ class ProfileHeaderWidget extends ConsumerWidget { GestureDetector( key: const ValueKey('profile_followers_button'), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 0))); + onTap: () async { + //Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 0))); + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FollowersFollowingPage( + userId: user.id ?? 0, + initialTab: 1, + ), + ), + ); + + if (changed == true) { + onEdited?.call(); // refresh profile + } }, child: RichText( text: TextSpan(children: [ diff --git a/lam7a/lib/features/profile/ui/widgets/profile_more_menu.dart b/lam7a/lib/features/profile/ui/widgets/profile_more_menu.dart index 49a9407..ae1f880 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_more_menu.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_more_menu.dart @@ -37,6 +37,7 @@ class ProfileMoreMenu extends ConsumerWidget { } else { await repo.blockUser(user.id!); } + } onAction(); From 749076f6ad2f355da159a75c9b622e0bfcf7489a Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 03:32:00 +0200 Subject: [PATCH 3/9] marge fix --- lam7a/pubspec.lock | 60 ++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/lam7a/pubspec.lock b/lam7a/pubspec.lock index 051257a..ad82a6c 100644 --- a/lam7a/pubspec.lock +++ b/lam7a/pubspec.lock @@ -748,10 +748,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -876,10 +876,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.6.0" image_picker: dependency: "direct main" description: @@ -908,10 +908,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986" + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" url: "https://pub.dev" source: hosted - version: "0.8.13+2" + version: "0.8.13+3" image_picker_linux: dependency: transitive description: @@ -1104,14 +1104,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" - url: "https://pub.dev" - source: hosted - version: "9.1.0" overlay_support: dependency: "direct main" description: @@ -1164,10 +1156,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -1348,18 +1340,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" url: "https://pub.dev" source: hosted - version: "2.4.17" + version: "2.4.18" shared_preferences_foundation: dependency: transitive description: @@ -1436,10 +1428,10 @@ packages: dependency: "direct main" description: name: skeletonizer - sha256: eebc03dc86b298e2d7f61e0ebce5713e9dbbc3e786f825909b4591756f196eb6 + sha256: "5d2d44120916cc749ede54c236cef60c2478742806df0b1f065212f00721b185" url: "https://pub.dev" source: hosted - version: "2.1.0+1" + version: "2.1.1" sky_engine: dependency: transitive description: flutter @@ -1449,10 +1441,10 @@ packages: dependency: "direct main" description: name: socket_io_client - sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d + sha256: e8deafca92e944ac33fd7f93ce7ea453cedc4dac41c9c2526dbd5fdff9636dca url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" socket_io_common: dependency: transitive description: @@ -1729,18 +1721,18 @@ packages: dependency: transitive description: name: video_player_android - sha256: "3f7ef3fb7b29f510e58f4d56b6ffbc3463b1071f2cf56e10f8d25f5b991ed85b" + sha256: d74b66f283afff135d5be0ceccca2ca74dff7df1e9b1eaca6bd4699875d3ae60 url: "https://pub.dev" source: hosted - version: "2.8.21" + version: "2.8.22" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "6bced1739cf1f96f03058118adb8ac0dd6f96aa1a1a6e526424ab92fd2a6a77d" + sha256: e4d33b79a064498c6eb3a6a492b6a5012573d4943c28d566caf1a6c0840fe78d url: "https://pub.dev" source: hosted - version: "2.8.7" + version: "2.8.8" video_player_platform_interface: dependency: transitive description: @@ -1769,10 +1761,10 @@ packages: dependency: transitive description: name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.0" web: dependency: transitive description: @@ -1817,10 +1809,10 @@ packages: dependency: transitive description: name: webview_flutter_android - sha256: "6ff028d8b8829b4e2482f7821dfb0b039f91c34a07075ed06f36172aa7afabda" + sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 url: "https://pub.dev" source: hosted - version: "4.10.10" + version: "4.10.11" webview_flutter_platform_interface: dependency: transitive description: @@ -1833,10 +1825,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: a57b76a081bed3bf3a71a486bdf83642b00f1a7342043d50367cea68f338b1af + sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc url: "https://pub.dev" source: hosted - version: "3.23.4" + version: "3.23.5" window_to_front: dependency: transitive description: From 8649901f0a5c71335bd6ac14a52b5e701fcda52d Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 17:55:59 +0200 Subject: [PATCH 4/9] paging fix --- .../add_tweet/ui/view/add_tweet_screen.dart | 8 +- .../profile/services/profile_api_service.dart | 18 + .../services/profile_api_service_impl.dart | 39 ++ .../profile/ui/view/profile_screen.dart | 439 +++++++++++++++--- .../viewmodel/profile_likes_pagination.dart | 73 +++ .../viewmodel/profile_posts_pagination.dart | 74 +++ .../viewmodel/profile_replies_pagination.dart | 75 +++ .../profile/utils/profile_tweet_mapper.dart | 100 ++++ .../features/tweet/ui/widgets/tweet_feed.dart | 5 +- 9 files changed, 752 insertions(+), 79 deletions(-) create mode 100644 lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart create mode 100644 lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart create mode 100644 lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart create mode 100644 lam7a/lib/features/profile/utils/profile_tweet_mapper.dart diff --git a/lam7a/lib/features/add_tweet/ui/view/add_tweet_screen.dart b/lam7a/lib/features/add_tweet/ui/view/add_tweet_screen.dart index ee728be..98fd64e 100644 --- a/lam7a/lib/features/add_tweet/ui/view/add_tweet_screen.dart +++ b/lam7a/lib/features/add_tweet/ui/view/add_tweet_screen.dart @@ -12,8 +12,12 @@ import 'package:lam7a/features/add_tweet/ui/widgets/add_tweet_header_widget.dart import 'package:lam7a/features/add_tweet/ui/widgets/add_tweet_toolbar_widget.dart'; import 'package:lam7a/features/tweet/ui/viewmodel/tweet_viewmodel.dart'; import 'package:lam7a/features/tweet/ui/widgets/tweet_body_summary_widget.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; +// import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; +//import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_likes_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_replies_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_pagination.dart'; + import 'dart:io'; import 'package:permission_handler/permission_handler.dart'; diff --git a/lam7a/lib/features/profile/services/profile_api_service.dart b/lam7a/lib/features/profile/services/profile_api_service.dart index f5d6fe0..98f1e08 100644 --- a/lam7a/lib/features/profile/services/profile_api_service.dart +++ b/lam7a/lib/features/profile/services/profile_api_service.dart @@ -28,4 +28,22 @@ abstract class ProfileApiService { Future>> getFollowers(int id); Future>> getFollowing(int id); + + Future>> getProfilePosts( + String userId, + int page, + int limit, + ); + + Future>> getProfileReplies( + String userId, + int page, + int limit, + ); + + Future>> getProfileLikes( + String userId, + int page, + int limit, + ); } 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 58563b4..a18bfe8 100644 --- a/lam7a/lib/features/profile/services/profile_api_service_impl.dart +++ b/lam7a/lib/features/profile/services/profile_api_service_impl.dart @@ -119,4 +119,43 @@ class ProfileApiServiceImpl implements ProfileApiService { await _api.delete(endpoint: "/users/$id/block"); } + @override + Future>> getProfilePosts( + String userId, int page, int limit) async { + final res = await _api.get( + endpoint: "/posts/profile/$userId", + queryParameters: { + "page": page, + "limit": limit, + }, + ); + return _extractList(res); + } + + @override + Future>> getProfileReplies( + String userId, int page, int limit) async { + final res = await _api.get( + endpoint: "/posts/profile/$userId/replies", + queryParameters: { + "page": page, + "limit": limit, + }, + ); + return _extractList(res); + } + + @override + Future>> getProfileLikes( + String userId, int page, int limit) async { + final res = await _api.get( + endpoint: "/posts/liked/$userId", + queryParameters: { + "page": page, + "limit": limit, + }, + ); + return _extractList(res); + } + } diff --git a/lam7a/lib/features/profile/ui/view/profile_screen.dart b/lam7a/lib/features/profile/ui/view/profile_screen.dart index 7ca96ab..e76468c 100644 --- a/lam7a/lib/features/profile/ui/view/profile_screen.dart +++ b/lam7a/lib/features/profile/ui/view/profile_screen.dart @@ -1,3 +1,292 @@ +// // feature/profile/ui/view/profile_screen.dart +// import 'package:flutter/material.dart'; +// import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// import 'package:lam7a/core/models/user_model.dart'; +// import 'package:lam7a/core/providers/authentication.dart'; + +// import '../widgets/profile_header_widget.dart'; +// import '../widgets/profile_more_menu.dart'; +// import '../widgets/blocked_profile_view.dart'; + +// import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; +// import 'package:lam7a/features/tweet/ui/widgets/tweet_summary_widget.dart'; +// import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; + +// class ProfileScreen extends ConsumerWidget { +// const ProfileScreen({super.key}); + +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final args = +// ModalRoute.of(context)!.settings.arguments as Map?; +// final username = args?['username'] as String?; + +// if (username == null || username.isEmpty) { +// return const Scaffold( +// body: Center(child: Text("No username provided")), +// ); +// } + +// final asyncUser = ref.watch(profileViewModelProvider(username)); + +// return asyncUser.when( +// loading: () => const Scaffold( +// key: const ValueKey('profile_loaded_scaffold'), +// body: Center(child: CircularProgressIndicator()), +// ), +// error: (err, _) => Scaffold( +// body: Center(child: Text("Error: $err")), +// ), +// data: (user) => _ProfileLoaded(user: user, username: username), +// ); +// } +// } + +// class _ProfileLoaded extends ConsumerStatefulWidget { +// final UserModel user; +// final String username; + +// const _ProfileLoaded({ +// required this.user, +// required this.username, +// }); + +// @override +// ConsumerState<_ProfileLoaded> createState() => _ProfileLoadedState(); +// } + +// class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { +// final ScrollController _scrollController = ScrollController(); +// bool _hideAvatar = false; + +// @override +// void initState() { +// super.initState(); +// _scrollController.addListener(_onScroll); +// } + +// void _onScroll() { +// final shouldHide = _scrollController.offset > 60; +// if (shouldHide != _hideAvatar) { +// setState(() => _hideAvatar = shouldHide); +// } +// } + +// @override +// void dispose() { +// _scrollController.dispose(); +// super.dispose(); +// } + +// @override +// Widget build(BuildContext context) { +// final myUser = ref.watch(authenticationProvider).user; +// final isOwnProfile = myUser?.id == widget.user.profileId; + +// Future refresh() async { +// ref.invalidate(profileViewModelProvider(widget.username)); +// ref.invalidate(profilePostsProvider(widget.user.profileId!.toString())); +// ref.invalidate(profileRepliesProvider(widget.user.profileId!.toString())); +// ref.invalidate(profileLikesProvider(widget.user.profileId!.toString())); +// } + +// if (widget.user.stateBlocked == ProfileStateBlocked.blocked) { +// return BlockedProfileView( +// username: widget.username, +// userId: widget.user.profileId!, +// onUnblock: refresh, +// ); +// } + +// const double avatarRadius = 46; + +// return Scaffold( +// body: RefreshIndicator( +// key: const ValueKey('profile_refresh_indicator'), +// onRefresh: refresh, +// child: NestedScrollView( +// key: const ValueKey('profile_nested_scroll'), +// controller: _scrollController, // ✅ IMPORTANT +// headerSliverBuilder: (_, __) => [ +// SliverAppBar( +// key: const ValueKey('profile_sliver_appbar'), +// pinned: true, +// expandedHeight: 120, +// backgroundColor: Colors.white, +// leading: IconButton( +// key: const ValueKey('profile_back_button'), +// icon: const Icon(Icons.arrow_back), +// onPressed: () => Navigator.pop(context), +// ), +// actions: [ +// if (!isOwnProfile) +// ProfileMoreMenu( +// key: const ValueKey('profile_more_menu'), +// user: widget.user, +// username: widget.username, +// onAction: () async { +// await refresh(); +// Navigator.pop(context, true); +// }, +// ), +// ], +// flexibleSpace: Stack( +// clipBehavior: Clip.none, +// children: [ +// Positioned.fill( +// child: (widget.user.bannerImageUrl != null && +// widget.user.bannerImageUrl!.isNotEmpty) +// ? Image.network( +// widget.user.bannerImageUrl!, +// key: const ValueKey('profile_banner'), +// fit: BoxFit.cover, +// ) +// : Container(color: Colors.grey.shade300), +// ), +// Positioned( +// bottom: -avatarRadius, +// left: 16, +// child: Offstage( +// offstage: _hideAvatar, // ✅ avatar disappears +// child: CircleAvatar( +// key: const ValueKey('profile_avatar_outer'), +// radius: avatarRadius, +// backgroundColor: Colors.white, +// child: CircleAvatar( +// key: const ValueKey('profile_avatar_inner'), +// radius: avatarRadius - 3, +// backgroundImage: +// (widget.user.profileImageUrl != null && +// widget.user.profileImageUrl!.isNotEmpty) +// ? NetworkImage( +// widget.user.profileImageUrl!) +// : const AssetImage( +// "assets/images/user_profile.png") +// as ImageProvider, +// ), +// ), +// ), +// ), +// ], +// ), +// ), +// const SliverToBoxAdapter(child: SizedBox(height: 10)), +// SliverToBoxAdapter( +// key: const ValueKey('profile_header_section'), +// child: ProfileHeaderWidget( +// key: const ValueKey('profile_header_widget'), +// user: widget.user, +// isOwnProfile: isOwnProfile, +// onEdited: refresh, +// ), +// ), +// ], +// body: DefaultTabController( +// length: 3, +// child: Column( +// children: [ +// const TabBar( +// key: ValueKey('profile_tabbar'), +// tabs: [ +// Tab(key: ValueKey('profile_tab_posts'), text: "Posts"), +// Tab(key: ValueKey('profile_tab_replies'), text: "Replies"), +// Tab(key: ValueKey('profile_tab_likes'), text: "Likes"), +// ], +// ), +// Expanded( +// child: TabBarView( +// key: const ValueKey('profile_tabbar_view'), +// children: [ +// _ProfilePostsTab( +// key: const ValueKey('profile_posts_tab'), +// userId: widget.user.profileId!.toString()), +// _ProfileRepliesTab( +// key: const ValueKey('profile_replies_tab'), +// userId: widget.user.profileId!.toString()), +// _ProfileLikesTab( +// key: const ValueKey('profile_likes_tab'), +// userId: widget.user.profileId!.toString()), +// ], +// ), +// ), +// ], +// ), +// ), +// ), +// ), +// ); +// } +// } + +// // ---------------- POSTS TAB ---------------- +// class _ProfilePostsTab extends ConsumerWidget { +// final String userId; +// const _ProfilePostsTab({super.key, required this.userId}); + +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final asyncPosts = ref.watch(profilePostsProvider(userId)); + +// return asyncPosts.when( +// data: (tweets) => tweets.isEmpty +// ? const Center(child: Text("No posts yet")) +// : ListView.builder( +// itemCount: tweets.length, +// itemBuilder: (_, i) => +// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), +// ), +// loading: () => const Center(child: CircularProgressIndicator()), +// error: (_, __) => const Center(child: Text("Error loading posts")), +// ); +// } +// } + +// // ---------------- REPLIES TAB ---------------- +// class _ProfileRepliesTab extends ConsumerWidget { +// final String userId; +// const _ProfileRepliesTab({super.key, required this.userId}); + +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final asyncReplies = ref.watch(profileRepliesProvider(userId)); + +// return asyncReplies.when( +// data: (tweets) => tweets.isEmpty +// ? const Center(child: Text("No replies yet")) +// : ListView.builder( +// itemCount: tweets.length, +// itemBuilder: (_, i) => +// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), +// ), +// loading: () => const Center(child: CircularProgressIndicator()), +// error: (_, __) => const Center(child: Text("Error loading replies")), +// ); +// } +// } + +// // ---------------- LIKES TAB ---------------- +// class _ProfileLikesTab extends ConsumerWidget { +// final String userId; +// const _ProfileLikesTab({super.key, required this.userId}); + +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final asyncLikes = ref.watch(profileLikesProvider(userId)); + +// return asyncLikes.when( +// data: (tweets) => tweets.isEmpty +// ? const Center(child: Text("No liked posts")) +// : ListView.builder( +// itemCount: tweets.length, +// itemBuilder: (_, i) => +// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), +// ), +// loading: () => const Center(child: CircularProgressIndicator()), +// error: (_, __) => const Center(child: Text("Error loading liked posts")), +// ); +// } +// } // feature/profile/ui/view/profile_screen.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,9 +298,12 @@ import '../widgets/profile_header_widget.dart'; import '../widgets/profile_more_menu.dart'; import '../widgets/blocked_profile_view.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; -import 'package:lam7a/features/tweet/ui/widgets/tweet_summary_widget.dart'; import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_replies_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_likes_pagination.dart'; + +import 'package:lam7a/features/common/widgets/tweets_list.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @@ -32,13 +324,15 @@ class ProfileScreen extends ConsumerWidget { return asyncUser.when( loading: () => const Scaffold( - key: const ValueKey('profile_loaded_scaffold'), body: Center(child: CircularProgressIndicator()), ), error: (err, _) => Scaffold( body: Center(child: Text("Error: $err")), ), - data: (user) => _ProfileLoaded(user: user, username: username), + data: (user) => _ProfileLoaded( + user: user, + username: username, + ), ); } } @@ -84,11 +378,13 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { final myUser = ref.watch(authenticationProvider).user; final isOwnProfile = myUser?.id == widget.user.profileId; + final userId = widget.user.profileId!.toString(); + Future refresh() async { ref.invalidate(profileViewModelProvider(widget.username)); - ref.invalidate(profilePostsProvider(widget.user.profileId!.toString())); - ref.invalidate(profileRepliesProvider(widget.user.profileId!.toString())); - ref.invalidate(profileLikesProvider(widget.user.profileId!.toString())); + ref.invalidate(profilePostsProvider(userId)); + ref.invalidate(profileRepliesProvider(userId)); + ref.invalidate(profileLikesProvider(userId)); } if (widget.user.stateBlocked == ProfileStateBlocked.blocked) { @@ -103,31 +399,26 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { return Scaffold( body: RefreshIndicator( - key: const ValueKey('profile_refresh_indicator'), onRefresh: refresh, child: NestedScrollView( - key: const ValueKey('profile_nested_scroll'), - controller: _scrollController, // ✅ IMPORTANT + controller: _scrollController, headerSliverBuilder: (_, __) => [ SliverAppBar( - key: const ValueKey('profile_sliver_appbar'), pinned: true, expandedHeight: 120, backgroundColor: Colors.white, leading: IconButton( - key: const ValueKey('profile_back_button'), icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), actions: [ if (!isOwnProfile) ProfileMoreMenu( - key: const ValueKey('profile_more_menu'), user: widget.user, username: widget.username, onAction: () async { await refresh(); - Navigator.pop(context, true); + Navigator.pop(context, true); }, ), ], @@ -139,7 +430,6 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { widget.user.bannerImageUrl!.isNotEmpty) ? Image.network( widget.user.bannerImageUrl!, - key: const ValueKey('profile_banner'), fit: BoxFit.cover, ) : Container(color: Colors.grey.shade300), @@ -148,19 +438,16 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { bottom: -avatarRadius, left: 16, child: Offstage( - offstage: _hideAvatar, // ✅ avatar disappears + offstage: _hideAvatar, child: CircleAvatar( - key: const ValueKey('profile_avatar_outer'), radius: avatarRadius, backgroundColor: Colors.white, child: CircleAvatar( - key: const ValueKey('profile_avatar_inner'), radius: avatarRadius - 3, backgroundImage: (widget.user.profileImageUrl != null && widget.user.profileImageUrl!.isNotEmpty) - ? NetworkImage( - widget.user.profileImageUrl!) + ? NetworkImage(widget.user.profileImageUrl!) : const AssetImage( "assets/images/user_profile.png") as ImageProvider, @@ -173,9 +460,7 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { ), const SliverToBoxAdapter(child: SizedBox(height: 10)), SliverToBoxAdapter( - key: const ValueKey('profile_header_section'), child: ProfileHeaderWidget( - key: const ValueKey('profile_header_widget'), user: widget.user, isOwnProfile: isOwnProfile, onEdited: refresh, @@ -187,26 +472,18 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { child: Column( children: [ const TabBar( - key: ValueKey('profile_tabbar'), tabs: [ - Tab(key: ValueKey('profile_tab_posts'), text: "Posts"), - Tab(key: ValueKey('profile_tab_replies'), text: "Replies"), - Tab(key: ValueKey('profile_tab_likes'), text: "Likes"), + Tab(text: "Posts"), + Tab(text: "Replies"), + Tab(text: "Likes"), ], ), Expanded( child: TabBarView( - key: const ValueKey('profile_tabbar_view'), children: [ - _ProfilePostsTab( - key: const ValueKey('profile_posts_tab'), - userId: widget.user.profileId!.toString()), - _ProfileRepliesTab( - key: const ValueKey('profile_replies_tab'), - userId: widget.user.profileId!.toString()), - _ProfileLikesTab( - key: const ValueKey('profile_likes_tab'), - userId: widget.user.profileId!.toString()), + _ProfilePostsTab(userId: userId), + _ProfileRepliesTab(userId: userId), + _ProfileLikesTab(userId: userId), ], ), ), @@ -222,22 +499,26 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { // ---------------- POSTS TAB ---------------- class _ProfilePostsTab extends ConsumerWidget { final String userId; - const _ProfilePostsTab({super.key, required this.userId}); + const _ProfilePostsTab({required this.userId}); @override Widget build(BuildContext context, WidgetRef ref) { - final asyncPosts = ref.watch(profilePostsProvider(userId)); - - return asyncPosts.when( - data: (tweets) => tweets.isEmpty - ? const Center(child: Text("No posts yet")) - : ListView.builder( - itemCount: tweets.length, - itemBuilder: (_, i) => - TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text("Error loading posts")), + final state = ref.watch(profilePostsProvider(userId)); + final notifier = ref.read(profilePostsProvider(userId).notifier); + + if (state.isLoading && state.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.items.isEmpty) { + return const Center(child: Text("No posts yet")); + } + + return TweetsListView( + tweets: state.items, + hasMore: state.hasMore, + onRefresh: notifier.refresh, + onLoadMore: notifier.loadMore, ); } } @@ -245,22 +526,26 @@ class _ProfilePostsTab extends ConsumerWidget { // ---------------- REPLIES TAB ---------------- class _ProfileRepliesTab extends ConsumerWidget { final String userId; - const _ProfileRepliesTab({super.key, required this.userId}); + const _ProfileRepliesTab({required this.userId}); @override Widget build(BuildContext context, WidgetRef ref) { - final asyncReplies = ref.watch(profileRepliesProvider(userId)); - - return asyncReplies.when( - data: (tweets) => tweets.isEmpty - ? const Center(child: Text("No replies yet")) - : ListView.builder( - itemCount: tweets.length, - itemBuilder: (_, i) => - TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text("Error loading replies")), + final state = ref.watch(profileRepliesProvider(userId)); + final notifier = ref.read(profileRepliesProvider(userId).notifier); + + if (state.isLoading && state.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.items.isEmpty) { + return const Center(child: Text("No replies yet")); + } + + return TweetsListView( + tweets: state.items, + hasMore: state.hasMore, + onRefresh: notifier.refresh, + onLoadMore: notifier.loadMore, ); } } @@ -268,22 +553,26 @@ class _ProfileRepliesTab extends ConsumerWidget { // ---------------- LIKES TAB ---------------- class _ProfileLikesTab extends ConsumerWidget { final String userId; - const _ProfileLikesTab({super.key, required this.userId}); + const _ProfileLikesTab({required this.userId}); @override Widget build(BuildContext context, WidgetRef ref) { - final asyncLikes = ref.watch(profileLikesProvider(userId)); - - return asyncLikes.when( - data: (tweets) => tweets.isEmpty - ? const Center(child: Text("No liked posts")) - : ListView.builder( - itemCount: tweets.length, - itemBuilder: (_, i) => - TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text("Error loading liked posts")), + final state = ref.watch(profileLikesProvider(userId)); + final notifier = ref.read(profileLikesProvider(userId).notifier); + + if (state.isLoading && state.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.items.isEmpty) { + return const Center(child: Text("No liked posts")); + } + + return TweetsListView( + tweets: state.items, + hasMore: state.hasMore, + onRefresh: notifier.refresh, + onLoadMore: notifier.loadMore, ); } } diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart new file mode 100644 index 0000000..70f9915 --- /dev/null +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lam7a/features/common/providers/pagination_notifier.dart'; +import 'package:lam7a/features/common/states/pagination_state.dart'; +import 'package:lam7a/features/common/models/tweet_model.dart'; +import '../../services/profile_api_service.dart'; +import '../../utils/profile_tweet_mapper.dart'; + +final profileLikesProvider = NotifierProvider.family< + ProfileLikesPaginationNotifier, + PaginationState, + String>( + ProfileLikesPaginationNotifier.new, +); + +class ProfileLikesPaginationNotifier + extends PaginationNotifier { + + final String userId; + ProfileLikesPaginationNotifier(this.userId); + + ProfileApiService get _api => ref.read(profileApiServiceProvider); + + @override + PaginationState build() { + + Future.microtask(loadInitial); + return const PaginationState(); + } + + @override + Future<(List, bool)> fetchPage(int page) async { + final raw = await _api.getProfileLikes(userId, page, pageSize); + + final items = raw + .map((e) => convertProfileJsonToTweetModel( + Map.from(e), + )) + .toList(); + + return (items, items.length == pageSize); + } + + Map normalizeTweetJson(Map e) { + return { + "id": e["postId"], + "text": e["text"], + "createdAt": e["date"], + + // 👇 REQUIRED BY TweetModel + "user": { + "id": e["userId"], + "username": e["username"], + "name": e["name"], + "avatar": e["avatar"], + "verified": e["verified"] ?? false, + }, + + "likesCount": e["likesCount"] ?? 0, + "retweetsCount": e["retweetsCount"] ?? 0, + "commentsCount": e["commentsCount"] ?? 0, + + "isLikedByMe": e["isLikedByMe"] ?? false, + "isRepostedByMe": e["isRepostedByMe"] ?? false, + "isFollowedByMe": e["isFollowedByMe"] ?? false, + + "media": e["media"] ?? [], + "mentions": e["mentions"] ?? [], + + "isRepost": e["isRepost"] ?? false, + "isQuote": e["isQuote"] ?? false, + }; + } +} diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart new file mode 100644 index 0000000..6f302e0 --- /dev/null +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart @@ -0,0 +1,74 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lam7a/features/common/providers/pagination_notifier.dart'; +import 'package:lam7a/features/common/states/pagination_state.dart'; +import 'package:lam7a/features/common/models/tweet_model.dart'; +import '../../services/profile_api_service.dart'; +import '../../utils/profile_tweet_mapper.dart'; + +final profilePostsProvider = NotifierProvider.family< + ProfilePostsPaginationNotifier, + PaginationState, + String>( + ProfilePostsPaginationNotifier.new, +); + +class ProfilePostsPaginationNotifier + extends PaginationNotifier { + + final String userId; + ProfilePostsPaginationNotifier(this.userId); + + ProfileApiService get _api => ref.read(profileApiServiceProvider); + + @override + PaginationState build() { + Future.microtask(loadInitial); + return const PaginationState(); + } + + @override + Future<(List, bool)> fetchPage(int page) async { + final raw = await _api.getProfilePosts(userId, page, pageSize); + + final items = raw + .map((e) => convertProfileJsonToTweetModel( + Map.from( + e['originalPostData'] ?? e, + ), + )) + .toList(); + + return (items, items.length == pageSize); + } + + Map normalizeTweetJson(Map e) { + return { + "id": e["postId"], + "text": e["text"], + "createdAt": e["date"], + + // 👇 REQUIRED BY TweetModel + "user": { + "id": e["userId"], + "username": e["username"], + "name": e["name"], + "avatar": e["avatar"], + "verified": e["verified"] ?? false, + }, + + "likesCount": e["likesCount"] ?? 0, + "retweetsCount": e["retweetsCount"] ?? 0, + "commentsCount": e["commentsCount"] ?? 0, + + "isLikedByMe": e["isLikedByMe"] ?? false, + "isRepostedByMe": e["isRepostedByMe"] ?? false, + "isFollowedByMe": e["isFollowedByMe"] ?? false, + + "media": e["media"] ?? [], + "mentions": e["mentions"] ?? [], + + "isRepost": e["isRepost"] ?? false, + "isQuote": e["isQuote"] ?? false, + }; + } +} \ No newline at end of file diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart new file mode 100644 index 0000000..8b8c5f9 --- /dev/null +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart @@ -0,0 +1,75 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lam7a/features/common/providers/pagination_notifier.dart'; +import 'package:lam7a/features/common/states/pagination_state.dart'; +import 'package:lam7a/features/common/models/tweet_model.dart'; +import '../../services/profile_api_service.dart'; +import '../../utils/profile_tweet_mapper.dart'; + + +final profileRepliesProvider = NotifierProvider.family< + ProfileRepliesPaginationNotifier, + PaginationState, + String>( + ProfileRepliesPaginationNotifier.new, +); + +class ProfileRepliesPaginationNotifier + extends PaginationNotifier { + + final String userId; + ProfileRepliesPaginationNotifier(this.userId); + + ProfileApiService get _api => ref.read(profileApiServiceProvider); + + @override + PaginationState build() { + Future.microtask(loadInitial); + return const PaginationState(); + } + + @override + Future<(List, bool)> fetchPage(int page) async { + final raw = await _api.getProfileReplies(userId, page, pageSize); + + final items = raw + .map((e) => convertProfileJsonToTweetModel( + Map.from( + e['originalPostData'] ?? e, + ), + )) + .toList(); + + return (items, items.length == pageSize); + } + + Map normalizeTweetJson(Map e) { + return { + "id": e["postId"], + "text": e["text"], + "createdAt": e["date"], + + // 👇 REQUIRED BY TweetModel + "user": { + "id": e["userId"], + "username": e["username"], + "name": e["name"], + "avatar": e["avatar"], + "verified": e["verified"] ?? false, + }, + + "likesCount": e["likesCount"] ?? 0, + "retweetsCount": e["retweetsCount"] ?? 0, + "commentsCount": e["commentsCount"] ?? 0, + + "isLikedByMe": e["isLikedByMe"] ?? false, + "isRepostedByMe": e["isRepostedByMe"] ?? false, + "isFollowedByMe": e["isFollowedByMe"] ?? false, + + "media": e["media"] ?? [], + "mentions": e["mentions"] ?? [], + + "isRepost": e["isRepost"] ?? false, + "isQuote": e["isQuote"] ?? false, + }; + } +} \ No newline at end of file diff --git a/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart b/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart new file mode 100644 index 0000000..dc7ea2a --- /dev/null +++ b/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart @@ -0,0 +1,100 @@ +import 'package:lam7a/features/common/models/tweet_model.dart'; + +T? read(Map json, List keys) { + for (final k in keys) { + if (json[k] != null) return json[k] as T; + } + return null; +} + +TweetModel convertProfileJsonToTweetModel(Map json) { + final bool isRepost = json["isRepost"] == true; + final bool isQuote = json["isQuote"] == true; + final original = json["originalPostData"]; + + // ---------------- REPOST ---------------- + if (isRepost && original is Map) { + final inner = original; + + final originalTweet = TweetModel( + id: read(inner, ["postId"])!.toString(), + userId: read(inner, ["userId"])!.toString(), + username: read(inner, ["username"]) ?? "", + authorName: read(inner, ["name"]) ?? "", + authorProfileImage: read(inner, ["avatar"]), + body: inner["text"] ?? "", + likes: inner["likesCount"] ?? 0, + repost: inner["retweetsCount"] ?? 0, + comments: inner["commentsCount"] ?? 0, + date: DateTime.parse(read(inner, ["date"])!), + isRepost: false, + isQuote: false, + ); + + return TweetModel( + id: read(json, ["postId"])!.toString(), + userId: read(json, ["userId"])!.toString(), + username: read(json, ["username"]) ?? "", + authorName: read(json, ["name"]) ?? "", + authorProfileImage: read(json, ["avatar"]), + body: originalTweet.body, + likes: originalTweet.likes, + repost: originalTweet.repost, + comments: originalTweet.comments, + date: DateTime.parse(read(json, ["date"])!), + isRepost: true, + originalTweet: originalTweet, + ); + } + + // ---------------- QUOTE ---------------- + if (isQuote && original is Map) { + final parent = original; + + final parentTweet = TweetModel( + id: read(parent, ["postId"])!.toString(), + userId: read(parent, ["userId"])!.toString(), + username: read(parent, ["username"]) ?? "", + authorName: read(parent, ["name"]) ?? "", + authorProfileImage: read(parent, ["avatar"]), + body: parent["text"] ?? "", + likes: parent["likesCount"] ?? 0, + repost: parent["retweetsCount"] ?? 0, + comments: parent["commentsCount"] ?? 0, + date: DateTime.parse(read(parent, ["date"])!), + isRepost: false, + isQuote: false, + ); + + return TweetModel( + id: read(json, ["postId"])!.toString(), + userId: read(json, ["userId"])!.toString(), + username: read(json, ["username"]) ?? "", + authorName: read(json, ["name"]) ?? "", + authorProfileImage: read(json, ["avatar"]), + body: json["text"] ?? "", + likes: json["likesCount"] ?? 0, + repost: json["retweetsCount"] ?? 0, + comments: json["commentsCount"] ?? 0, + date: DateTime.parse(read(json, ["date"])!), + isQuote: true, + originalTweet: parentTweet, + ); + } + + // ---------------- NORMAL ---------------- + return TweetModel( + id: read(json, ["postId", "id"])!.toString(), + userId: read(json, ["userId"])!.toString(), + username: read(json, ["username"]) ?? "", + authorName: read(json, ["name"]) ?? "", + authorProfileImage: read(json, ["avatar"]), + body: json["text"] ?? "", + likes: json["likesCount"] ?? 0, + repost: json["retweetsCount"] ?? 0, + comments: json["commentsCount"] ?? 0, + date: DateTime.parse(read(json, ["date"])!), + isRepost: false, + isQuote: false, + ); +} diff --git a/lam7a/lib/features/tweet/ui/widgets/tweet_feed.dart b/lam7a/lib/features/tweet/ui/widgets/tweet_feed.dart index ea92daf..0b635df 100644 --- a/lam7a/lib/features/tweet/ui/widgets/tweet_feed.dart +++ b/lam7a/lib/features/tweet/ui/widgets/tweet_feed.dart @@ -8,8 +8,9 @@ import 'package:lam7a/features/tweet/ui/viewmodel/tweet_viewmodel.dart'; import 'package:lam7a/features/add_tweet/ui/view/add_tweet_screen.dart'; import 'package:lam7a/core/providers/authentication.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_likes_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_replies_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_pagination.dart'; class TweetFeed extends ConsumerStatefulWidget { From 6fafe81d023c4aad5f8b254dae9d03c6c571a8fc Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 20:42:44 +0200 Subject: [PATCH 5/9] clean and test --- .../repository/profile_repository.dart | 19 +- .../services/profile_api_service_impl.dart | 5 - .../profile/ui/view/edit_profile_page.dart | 15 +- .../ui/view/followers_following_page.dart | 9 +- .../profile/ui/view/profile_screen.dart | 295 +----------------- .../viewmodel/profile_likes_pagination.dart | 2 +- .../viewmodel/profile_posts_pagination.dart | 1 - .../ui/viewmodel/profile_posts_viewmodel.dart | 9 - .../viewmodel/profile_replies_pagination.dart | 1 - .../profile/ui/widgets/edit_profile_form.dart | 1 - .../profile/ui/widgets/follow_button.dart | 2 +- .../ui/widgets/profile_action_menu.dart | 1 - .../ui/widgets/profile_header_widget.dart | 15 +- .../profile/utils/profile_tweet_mapper.dart | 6 +- .../profile/helpers/fake_profile_api.dart | 98 ++++++ .../profile/helpers/profile_test_helpers.dart | 50 --- .../repository/profile_repository_test.dart | 70 ++--- .../profile_likes_pagination_test.dart | 45 +++ .../profile_posts_pagination_test.dart | 43 +++ .../profile_replies_pagination_test.dart | 44 +++ .../viewmodel/profile_viewmodel_test.dart | 33 -- .../widgets/blocked_profile_view_test.dart | 41 +++ .../widgets/edit_profile_page_test.dart | 46 +++ .../widgets/profile_header_widget_test.dart | 56 ---- 24 files changed, 366 insertions(+), 541 deletions(-) create mode 100644 lam7a/test/profile/helpers/fake_profile_api.dart delete mode 100644 lam7a/test/profile/helpers/profile_test_helpers.dart create mode 100644 lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart create mode 100644 lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart create mode 100644 lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart delete mode 100644 lam7a/test/profile/viewmodel/profile_viewmodel_test.dart create mode 100644 lam7a/test/profile/widgets/blocked_profile_view_test.dart create mode 100644 lam7a/test/profile/widgets/edit_profile_page_test.dart delete mode 100644 lam7a/test/profile/widgets/profile_header_widget_test.dart diff --git a/lam7a/lib/features/profile/repository/profile_repository.dart b/lam7a/lib/features/profile/repository/profile_repository.dart index a5ef80a..c305ece 100644 --- a/lam7a/lib/features/profile/repository/profile_repository.dart +++ b/lam7a/lib/features/profile/repository/profile_repository.dart @@ -19,19 +19,19 @@ class ProfileRepository { ProfileRepository(this._api); - // ---------------- GET PROFILE BY USERNAME ---------------- + //get profile/username Future getProfile(String username) async { final dto = await _api.getProfileByUsername(username); return _fromDto(dto); } - // ---------------- GET MY PROFILE ---------------- + Future getMyProfile() async { final dto = await _api.getMyProfile(); return _fromDto(dto); } - // ---------------- UPDATE MY PROFILE ---------------- + Future updateMyProfile( UserModel user, { String? avatarPath, @@ -61,25 +61,20 @@ class ProfileRepository { return _fromDto(dto); } - // ---------------- FOLLOW / UNFOLLOW ---------------- Future followUser(int id) => _api.followUser(id); Future unfollowUser(int id) => _api.unfollowUser(id); - // ------------ MUTE / UNMUTE ------------ Future muteUser(int id) => _api.muteUser(id); Future unmuteUser(int id) => _api.unmuteUser(id); - // ------------ BLOCK / UNBLOCK ---------- Future blockUser(int id) => _api.blockUser(id); Future unblockUser(int id) => _api.unblockUser(id); - // ---------------- GET FOLLOWERS / FOLLOWING ---------------- - /// These endpoints return a list of lightweight "user" objects (not full profile DTOs). - /// We parse them with FollowUserDto and convert to UserModel. + Future> getFollowers(int id) async { final list = await _api.getFollowers(id); - // list is List> + return list.map((raw) { final f = FollowUserDto.fromJson(Map.from(raw)); return UserModel( @@ -88,7 +83,6 @@ class ProfileRepository { name: f.name, bio: f.bio, profileImageUrl: f.profileImageUrl, - // conservative defaults for counts / other states followersCount: 0, followingCount: 0, stateFollow: (f.isFollowedByMe ?? false) @@ -123,7 +117,6 @@ class ProfileRepository { }).toList(); } - // ---------------- DTO → USER MODEL (full profile) ---------------- UserModel _fromDto(ProfileDto dto) { return UserModel( id: dto.id, @@ -142,7 +135,7 @@ class ProfileRepository { stateFollow: (dto.isFollowedByMe ?? false) ? ProfileStateOfFollow.following : ProfileStateOfFollow.notfollowing, - // if backend returns these fields for full profile + stateMute: (dto.toJson().containsKey('is_muted_by_me') && dto.toJson()['is_muted_by_me'] == true) ? ProfileStateOfMute.muted : ProfileStateOfMute.notmuted, 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 a18bfe8..97a28c2 100644 --- a/lam7a/lib/features/profile/services/profile_api_service_impl.dart +++ b/lam7a/lib/features/profile/services/profile_api_service_impl.dart @@ -30,9 +30,7 @@ class ProfileApiServiceImpl implements ProfileApiService { return ProfileDto.fromJson(_extractObject(res)); } - // -------------------- Helpers -------------------- - /// Extract JSON object `{ ... }` Map _extractObject(dynamic res) { if (res is Map && res.containsKey("data")) { return Map.from(res["data"]); @@ -40,7 +38,6 @@ class ProfileApiServiceImpl implements ProfileApiService { return Map.from(res); } - /// Extract JSON list `[ ... ]` List> _extractList(dynamic res) { if (res is Map && res.containsKey("data")) { return List>.from(res["data"]); @@ -59,7 +56,6 @@ class ProfileApiServiceImpl implements ProfileApiService { ); } - // -------------------- Uploads -------------------- @override Future uploadProfilePicture(String filePath) async { @@ -75,7 +71,6 @@ class ProfileApiServiceImpl implements ProfileApiService { return _extractUploadUrl(res); } - // -------------------- Follow System -------------------- @override Future followUser(int id) async { 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 77df220..3bcce94 100644 --- a/lam7a/lib/features/profile/ui/view/edit_profile_page.dart +++ b/lam7a/lib/features/profile/ui/view/edit_profile_page.dart @@ -75,7 +75,7 @@ class _EditProfilePageState extends ConsumerState { } bool _isValidWebsite(String website) { - if (website.isEmpty) return true; // optional field + if (website.isEmpty) return true; // No emojis or spaces final validChars = RegExp(r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=%]+$"); @@ -177,16 +177,7 @@ class _EditProfilePageState extends ConsumerState { } } - // Widget buildField(String label, TextEditingController c, {int maxLines = 1}) { - // return Padding( - // padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - // child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Text(label, style: const TextStyle(color: Colors.grey)), - // const SizedBox(height: 6), - // TextField(controller: c, maxLines: maxLines, decoration: InputDecoration(border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))), - // ]), - // ); - // } + Widget buildField(String label, TextEditingController c, {int maxLines = 1, int? maxLength, Key? fieldKey,}) { return Padding( @@ -200,7 +191,7 @@ class _EditProfilePageState extends ConsumerState { key: fieldKey, controller: c, maxLines: maxLines, - maxLength: maxLength, // <-- ADD THIS + maxLength: maxLength, decoration: InputDecoration( counterText: "", border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), diff --git a/lam7a/lib/features/profile/ui/view/followers_following_page.dart b/lam7a/lib/features/profile/ui/view/followers_following_page.dart index b601599..a0f18a7 100644 --- a/lam7a/lib/features/profile/ui/view/followers_following_page.dart +++ b/lam7a/lib/features/profile/ui/view/followers_following_page.dart @@ -106,7 +106,7 @@ Widget _buildTile(UserModel u) { child: !hasImage ? const Icon(Icons.person, color: Colors.white) : null, ), - const SizedBox(width: 8), // <-- Reduce this to control spacing! + const SizedBox(width: 8), // Username + Bio + Follow button Expanded( @@ -125,11 +125,6 @@ Widget _buildTile(UserModel u) { ), ), ), - // FollowButton( - // key: ValueKey('follow_button_${u.id}'), - // user: u, - // onFollowStateChanged: () => _loadData(), - // ), FollowButton( key: ValueKey('follow_button_${u.id}'), user: u, @@ -173,7 +168,7 @@ Widget _buildTile(UserModel u) { return WillPopScope( onWillPop: () async { Navigator.pop(context, _hasChanges); - return false; // prevent default pop + return false; }, child: Scaffold( key: const ValueKey('followers_following_scaffold'), diff --git a/lam7a/lib/features/profile/ui/view/profile_screen.dart b/lam7a/lib/features/profile/ui/view/profile_screen.dart index e76468c..649080e 100644 --- a/lam7a/lib/features/profile/ui/view/profile_screen.dart +++ b/lam7a/lib/features/profile/ui/view/profile_screen.dart @@ -1,292 +1,3 @@ -// // feature/profile/ui/view/profile_screen.dart -// import 'package:flutter/material.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; - -// import 'package:lam7a/core/models/user_model.dart'; -// import 'package:lam7a/core/providers/authentication.dart'; - -// import '../widgets/profile_header_widget.dart'; -// import '../widgets/profile_more_menu.dart'; -// import '../widgets/blocked_profile_view.dart'; - -// import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_viewmodel.dart'; -// import 'package:lam7a/features/tweet/ui/widgets/tweet_summary_widget.dart'; -// import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; - -// class ProfileScreen extends ConsumerWidget { -// const ProfileScreen({super.key}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final args = -// ModalRoute.of(context)!.settings.arguments as Map?; -// final username = args?['username'] as String?; - -// if (username == null || username.isEmpty) { -// return const Scaffold( -// body: Center(child: Text("No username provided")), -// ); -// } - -// final asyncUser = ref.watch(profileViewModelProvider(username)); - -// return asyncUser.when( -// loading: () => const Scaffold( -// key: const ValueKey('profile_loaded_scaffold'), -// body: Center(child: CircularProgressIndicator()), -// ), -// error: (err, _) => Scaffold( -// body: Center(child: Text("Error: $err")), -// ), -// data: (user) => _ProfileLoaded(user: user, username: username), -// ); -// } -// } - -// class _ProfileLoaded extends ConsumerStatefulWidget { -// final UserModel user; -// final String username; - -// const _ProfileLoaded({ -// required this.user, -// required this.username, -// }); - -// @override -// ConsumerState<_ProfileLoaded> createState() => _ProfileLoadedState(); -// } - -// class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { -// final ScrollController _scrollController = ScrollController(); -// bool _hideAvatar = false; - -// @override -// void initState() { -// super.initState(); -// _scrollController.addListener(_onScroll); -// } - -// void _onScroll() { -// final shouldHide = _scrollController.offset > 60; -// if (shouldHide != _hideAvatar) { -// setState(() => _hideAvatar = shouldHide); -// } -// } - -// @override -// void dispose() { -// _scrollController.dispose(); -// super.dispose(); -// } - -// @override -// Widget build(BuildContext context) { -// final myUser = ref.watch(authenticationProvider).user; -// final isOwnProfile = myUser?.id == widget.user.profileId; - -// Future refresh() async { -// ref.invalidate(profileViewModelProvider(widget.username)); -// ref.invalidate(profilePostsProvider(widget.user.profileId!.toString())); -// ref.invalidate(profileRepliesProvider(widget.user.profileId!.toString())); -// ref.invalidate(profileLikesProvider(widget.user.profileId!.toString())); -// } - -// if (widget.user.stateBlocked == ProfileStateBlocked.blocked) { -// return BlockedProfileView( -// username: widget.username, -// userId: widget.user.profileId!, -// onUnblock: refresh, -// ); -// } - -// const double avatarRadius = 46; - -// return Scaffold( -// body: RefreshIndicator( -// key: const ValueKey('profile_refresh_indicator'), -// onRefresh: refresh, -// child: NestedScrollView( -// key: const ValueKey('profile_nested_scroll'), -// controller: _scrollController, // ✅ IMPORTANT -// headerSliverBuilder: (_, __) => [ -// SliverAppBar( -// key: const ValueKey('profile_sliver_appbar'), -// pinned: true, -// expandedHeight: 120, -// backgroundColor: Colors.white, -// leading: IconButton( -// key: const ValueKey('profile_back_button'), -// icon: const Icon(Icons.arrow_back), -// onPressed: () => Navigator.pop(context), -// ), -// actions: [ -// if (!isOwnProfile) -// ProfileMoreMenu( -// key: const ValueKey('profile_more_menu'), -// user: widget.user, -// username: widget.username, -// onAction: () async { -// await refresh(); -// Navigator.pop(context, true); -// }, -// ), -// ], -// flexibleSpace: Stack( -// clipBehavior: Clip.none, -// children: [ -// Positioned.fill( -// child: (widget.user.bannerImageUrl != null && -// widget.user.bannerImageUrl!.isNotEmpty) -// ? Image.network( -// widget.user.bannerImageUrl!, -// key: const ValueKey('profile_banner'), -// fit: BoxFit.cover, -// ) -// : Container(color: Colors.grey.shade300), -// ), -// Positioned( -// bottom: -avatarRadius, -// left: 16, -// child: Offstage( -// offstage: _hideAvatar, // ✅ avatar disappears -// child: CircleAvatar( -// key: const ValueKey('profile_avatar_outer'), -// radius: avatarRadius, -// backgroundColor: Colors.white, -// child: CircleAvatar( -// key: const ValueKey('profile_avatar_inner'), -// radius: avatarRadius - 3, -// backgroundImage: -// (widget.user.profileImageUrl != null && -// widget.user.profileImageUrl!.isNotEmpty) -// ? NetworkImage( -// widget.user.profileImageUrl!) -// : const AssetImage( -// "assets/images/user_profile.png") -// as ImageProvider, -// ), -// ), -// ), -// ), -// ], -// ), -// ), -// const SliverToBoxAdapter(child: SizedBox(height: 10)), -// SliverToBoxAdapter( -// key: const ValueKey('profile_header_section'), -// child: ProfileHeaderWidget( -// key: const ValueKey('profile_header_widget'), -// user: widget.user, -// isOwnProfile: isOwnProfile, -// onEdited: refresh, -// ), -// ), -// ], -// body: DefaultTabController( -// length: 3, -// child: Column( -// children: [ -// const TabBar( -// key: ValueKey('profile_tabbar'), -// tabs: [ -// Tab(key: ValueKey('profile_tab_posts'), text: "Posts"), -// Tab(key: ValueKey('profile_tab_replies'), text: "Replies"), -// Tab(key: ValueKey('profile_tab_likes'), text: "Likes"), -// ], -// ), -// Expanded( -// child: TabBarView( -// key: const ValueKey('profile_tabbar_view'), -// children: [ -// _ProfilePostsTab( -// key: const ValueKey('profile_posts_tab'), -// userId: widget.user.profileId!.toString()), -// _ProfileRepliesTab( -// key: const ValueKey('profile_replies_tab'), -// userId: widget.user.profileId!.toString()), -// _ProfileLikesTab( -// key: const ValueKey('profile_likes_tab'), -// userId: widget.user.profileId!.toString()), -// ], -// ), -// ), -// ], -// ), -// ), -// ), -// ), -// ); -// } -// } - -// // ---------------- POSTS TAB ---------------- -// class _ProfilePostsTab extends ConsumerWidget { -// final String userId; -// const _ProfilePostsTab({super.key, required this.userId}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final asyncPosts = ref.watch(profilePostsProvider(userId)); - -// return asyncPosts.when( -// data: (tweets) => tweets.isEmpty -// ? const Center(child: Text("No posts yet")) -// : ListView.builder( -// itemCount: tweets.length, -// itemBuilder: (_, i) => -// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), -// ), -// loading: () => const Center(child: CircularProgressIndicator()), -// error: (_, __) => const Center(child: Text("Error loading posts")), -// ); -// } -// } - -// // ---------------- REPLIES TAB ---------------- -// class _ProfileRepliesTab extends ConsumerWidget { -// final String userId; -// const _ProfileRepliesTab({super.key, required this.userId}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final asyncReplies = ref.watch(profileRepliesProvider(userId)); - -// return asyncReplies.when( -// data: (tweets) => tweets.isEmpty -// ? const Center(child: Text("No replies yet")) -// : ListView.builder( -// itemCount: tweets.length, -// itemBuilder: (_, i) => -// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), -// ), -// loading: () => const Center(child: CircularProgressIndicator()), -// error: (_, __) => const Center(child: Text("Error loading replies")), -// ); -// } -// } - -// // ---------------- LIKES TAB ---------------- -// class _ProfileLikesTab extends ConsumerWidget { -// final String userId; -// const _ProfileLikesTab({super.key, required this.userId}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final asyncLikes = ref.watch(profileLikesProvider(userId)); - -// return asyncLikes.when( -// data: (tweets) => tweets.isEmpty -// ? const Center(child: Text("No liked posts")) -// : ListView.builder( -// itemCount: tweets.length, -// itemBuilder: (_, i) => -// TweetSummaryWidget(tweetData: tweets[i], tweetId: tweets[i].id), -// ), -// loading: () => const Center(child: CircularProgressIndicator()), -// error: (_, __) => const Center(child: Text("Error loading liked posts")), -// ); -// } -// } // feature/profile/ui/view/profile_screen.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -496,7 +207,7 @@ class _ProfileLoadedState extends ConsumerState<_ProfileLoaded> { } } -// ---------------- POSTS TAB ---------------- +//POSTS TAB class _ProfilePostsTab extends ConsumerWidget { final String userId; const _ProfilePostsTab({required this.userId}); @@ -523,7 +234,7 @@ class _ProfilePostsTab extends ConsumerWidget { } } -// ---------------- REPLIES TAB ---------------- +// REPLIES TAB class _ProfileRepliesTab extends ConsumerWidget { final String userId; const _ProfileRepliesTab({required this.userId}); @@ -550,7 +261,7 @@ class _ProfileRepliesTab extends ConsumerWidget { } } -// ---------------- LIKES TAB ---------------- +// LIKES TAB class _ProfileLikesTab extends ConsumerWidget { final String userId; const _ProfileLikesTab({required this.userId}); diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart index 70f9915..2faed52 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_likes_pagination.dart @@ -46,7 +46,7 @@ class ProfileLikesPaginationNotifier "text": e["text"], "createdAt": e["date"], - // 👇 REQUIRED BY TweetModel + "user": { "id": e["userId"], "username": e["username"], diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart index 6f302e0..a536c3b 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_pagination.dart @@ -47,7 +47,6 @@ class ProfilePostsPaginationNotifier "text": e["text"], "createdAt": e["date"], - // 👇 REQUIRED BY TweetModel "user": { "id": e["userId"], "username": e["username"], 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 35b1336..ef861dc 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_posts_viewmodel.dart @@ -11,16 +11,13 @@ T? read(Map json, List keys) { return null; } -/// Converts ANY kind of profile JSON into a proper TweetModel TweetModel convertProfileJsonToTweetModel(Map json) { final bool isRepost = json["isRepost"] == true; final bool isQuote = json["isQuote"] == true; final original = json["originalPostData"]; - // ------------------------------------------------------------------- // CASE 1: REPOST - // ------------------------------------------------------------------- if (isRepost && original is Map) { final inner = original; @@ -60,9 +57,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { ); } - // ------------------------------------------------------------------- // CASE 2: QUOTE TWEET - // ------------------------------------------------------------------- if (isQuote && original is Map) { final parent = original; @@ -104,9 +99,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { ); } - // ------------------------------------------------------------------- // CASE 3: NORMAL POST or REPLY - // ------------------------------------------------------------------- return TweetModel( id: read(json, ["post_id", "postId", "id"])!.toString(), userId: read(json, ["user_id", "userId"])!.toString(), @@ -126,9 +119,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { ); } -// --------------------------------------------------------------------- // PROVIDERS -// --------------------------------------------------------------------- final profilePostsProvider = FutureProvider.family, String>((ref, userId) async { diff --git a/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart b/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart index 8b8c5f9..9a7dcfa 100644 --- a/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart +++ b/lam7a/lib/features/profile/ui/viewmodel/profile_replies_pagination.dart @@ -48,7 +48,6 @@ class ProfileRepliesPaginationNotifier "text": e["text"], "createdAt": e["date"], - // 👇 REQUIRED BY TweetModel "user": { "id": e["userId"], "username": e["username"], 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 3c8385f..f9cd1db 100644 --- a/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart +++ b/lam7a/lib/features/profile/ui/widgets/edit_profile_form.dart @@ -122,7 +122,6 @@ class EditProfileFormState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column(children: [ TextField(key: const ValueKey('edit_profile_name_input'), controller: nameController, maxLength: 30, decoration: const InputDecoration(labelText: 'Display name')), - //TextField(controller: bioController, decoration: const InputDecoration(labelText: 'Bio')), TextField( key: const ValueKey('edit_profile_bio_input'), controller: bioController, diff --git a/lam7a/lib/features/profile/ui/widgets/follow_button.dart b/lam7a/lib/features/profile/ui/widgets/follow_button.dart index b22264a..d3125b4 100644 --- a/lam7a/lib/features/profile/ui/widgets/follow_button.dart +++ b/lam7a/lib/features/profile/ui/widgets/follow_button.dart @@ -102,7 +102,7 @@ class _FollowButtonState extends ConsumerState { padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 4, - ), // Adjusted padding + ), ), child: _loading ? const SizedBox( diff --git a/lam7a/lib/features/profile/ui/widgets/profile_action_menu.dart b/lam7a/lib/features/profile/ui/widgets/profile_action_menu.dart index 9b7d776..5db4b74 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_action_menu.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_action_menu.dart @@ -26,7 +26,6 @@ class ProfileActionMenu extends ConsumerWidget { ), ); } - // mute, block, share, etc. can call API via service provider }, itemBuilder: (_) => [ const PopupMenuItem(value: 'share', child: Text('Share')), diff --git a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart index 67da183..c0b2594 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_header_widget.dart @@ -23,7 +23,6 @@ class ProfileHeaderWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top-right action: Edit or Follow Padding( padding: const EdgeInsets.only(right: 16), child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -35,14 +34,14 @@ class ProfileHeaderWidget extends ConsumerWidget { minimumSize: const Size(0, 28), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - // Border color depends on theme + side: BorderSide( color: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, ), - // Background color optional + backgroundColor: Theme.of(context).brightness == Brightness.light ? Colors.white : Colors.black, @@ -112,7 +111,7 @@ class ProfileHeaderWidget extends ConsumerWidget { // Format the date properly Text( - (user.birthDate ?? '').split("T").first, // <-- FIX + (user.birthDate ?? '').split("T").first, // FIX ), ], ), @@ -153,14 +152,13 @@ class ProfileHeaderWidget extends ConsumerWidget { const SizedBox(height: 12), - // Following / Followers - navigates to FollowersFollowingPage + // Following / Followers -page Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(children: [ GestureDetector( key: const ValueKey('profile_following_button'), onTap: () async { - //Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 1))); final changed = await Navigator.push( context, MaterialPageRoute( @@ -172,7 +170,7 @@ class ProfileHeaderWidget extends ConsumerWidget { ); if (changed == true) { - onEdited?.call(); // refresh profile + onEdited?.call(); } }, child: RichText( @@ -188,7 +186,6 @@ class ProfileHeaderWidget extends ConsumerWidget { GestureDetector( key: const ValueKey('profile_followers_button'), onTap: () async { - //Navigator.push(context, MaterialPageRoute(builder: (_) => FollowersFollowingPage(userId: user.id ?? 0, initialTab: 0))); final changed = await Navigator.push( context, MaterialPageRoute( @@ -200,7 +197,7 @@ class ProfileHeaderWidget extends ConsumerWidget { ); if (changed == true) { - onEdited?.call(); // refresh profile + onEdited?.call(); } }, child: RichText( diff --git a/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart b/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart index dc7ea2a..033005c 100644 --- a/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart +++ b/lam7a/lib/features/profile/utils/profile_tweet_mapper.dart @@ -12,7 +12,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { final bool isQuote = json["isQuote"] == true; final original = json["originalPostData"]; - // ---------------- REPOST ---------------- + // REPOST if (isRepost && original is Map) { final inner = original; @@ -47,7 +47,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { ); } - // ---------------- QUOTE ---------------- + // QUOTE if (isQuote && original is Map) { final parent = original; @@ -82,7 +82,7 @@ TweetModel convertProfileJsonToTweetModel(Map json) { ); } - // ---------------- NORMAL ---------------- + // NORMAL return TweetModel( id: read(json, ["postId", "id"])!.toString(), userId: read(json, ["userId"])!.toString(), diff --git a/lam7a/test/profile/helpers/fake_profile_api.dart b/lam7a/test/profile/helpers/fake_profile_api.dart new file mode 100644 index 0000000..97f3cba --- /dev/null +++ b/lam7a/test/profile/helpers/fake_profile_api.dart @@ -0,0 +1,98 @@ +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import 'package:lam7a/features/profile/dtos/profile_dto.dart'; + +class FakeProfileApiService implements ProfileApiService { + List> posts = []; + List> likes = []; + List> replies = []; + + ProfileDto? profile; + + // -------- PROFILE -------- + @override + Future getProfileByUsername(String username) async { + return profile!; + } + + @override + Future getMyProfile() async { + return profile!; + } + + // -------- PAGINATION -------- + @override + Future>> getProfilePosts( + String userId, + int page, + int limit, + ) async { + return posts; + } + + @override + Future>> getProfileLikes( + String userId, + int page, + int limit, + ) async { + return likes; + } + + @override + Future>> getProfileReplies( + String userId, + int page, + int limit, + ) async { + return replies; + } + + // -------- UNUSED METHODS (safe no-op) -------- + @override + Future followUser(int id) async {} + + @override + Future unfollowUser(int id) async {} + + @override + Future muteUser(int id) async {} + + @override + Future unmuteUser(int id) async {} + + @override + Future blockUser(int id) async {} + + @override + Future unblockUser(int id) async {} + + @override + Future>> getFollowers(int id) async => []; + + @override + Future>> getFollowing(int id) async => []; + + @override + Future uploadProfilePicture(String path) async => path; + + @override + Future uploadBanner(String path) async => path; + + @override + Future updateMyProfile(Map body) async { + return ProfileDto( + id: 1, + userId: 1, + name: body['name'], + bio: body['bio'], + location: body['location'], + website: body['website'], + birthDate: body['birth_date'], + profileImageUrl: body['profile_image_url'], + bannerImageUrl: body['banner_image_url'], + followersCount: 0, + followingCount: 0, + ); + } + +} diff --git a/lam7a/test/profile/helpers/profile_test_helpers.dart b/lam7a/test/profile/helpers/profile_test_helpers.dart deleted file mode 100644 index 70ad724..0000000 --- a/lam7a/test/profile/helpers/profile_test_helpers.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:lam7a/features/profile/dtos/profile_dto.dart'; -import 'package:lam7a/features/profile/model/profile_model.dart'; - -ProfileDto makeTestDto() { - return ProfileDto( - id: 30, - userId: 30, - name: "hossam mohamed", - birthDate: "2005-07-23T00:00:00.000Z", - - profileImageUrl: "https://cdn/avatar.jpg", - bannerImageUrl: "https://cdn/banner.jpg", - - bio: "Developer", - location: "Cairo", - website: "", - isDeactivated: false, - createdAt: "2025-11-23T13:49:47.229Z", - updatedAt: "2025-11-23T18:33:13.328Z", - user: { - "username": "hossam.ho8814", - "followers_count": 3, - "following_count": 7, - }, - followersCount: 3, - followingCount: 7, - isFollowedByMe: false, - ); -} - - -ProfileModel makeTestModel() { - return ProfileModel( - id: 30, - userId: 30, - displayName: "hossam mohamed", - handle: "hossam.ho8814", - bio: "Developer", - avatarImage: "https://example.com/avatar.jpg", - bannerImage: "https://example.com/banner.jpg", - location: "Cairo", - birthday: "2005-07-23T00:00:00.000Z", - joinedDate: "2025-11-23T13:49:47.229Z", - website: "", - isVerified: false, - followersCount: 3, - followingCount: 7, - stateFollow: ProfileStateOfFollow.notfollowing, - ); -} diff --git a/lam7a/test/profile/repository/profile_repository_test.dart b/lam7a/test/profile/repository/profile_repository_test.dart index 7eab762..bf7f15f 100644 --- a/lam7a/test/profile/repository/profile_repository_test.dart +++ b/lam7a/test/profile/repository/profile_repository_test.dart @@ -1,56 +1,34 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lam7a/features/profile/repository/profile_repository.dart'; +import '../helpers/fake_profile_api.dart'; import 'package:lam7a/features/profile/services/profile_api_service.dart'; -import '../helpers/profile_test_helpers.dart'; - -class MockProfileApiService extends Mock implements ProfileApiService {} +import 'package:lam7a/features/profile/dtos/profile_dto.dart'; void main() { - late MockProfileApiService api; - late ProfileRepository repo; - - setUp(() { - api = MockProfileApiService(); - repo = ProfileRepository(api); - }); - - test('getProfile returns ProfileModel from DTO', () async { - final dto = makeTestDto(); - - when(() => api.getProfileByUsername("hossam.ho8814")) - .thenAnswer((_) async => dto); - - final model = await repo.getProfile("hossam.ho8814"); - - expect(model.displayName, "hossam mohamed"); - expect(model.handle, "hossam.ho8814"); - expect(model.followersCount, 3); - }); - - test('updateMyProfile uploads avatar & banner when needed', () async { - final dto = makeTestDto(); - - when(() => api.uploadProfilePicture(any())) - .thenAnswer((_) async => "https://cdn/avatar.jpg"); - - when(() => api.uploadBanner(any())) - .thenAnswer((_) async => "https://cdn/banner.jpg"); - - when(() => api.updateMyProfile(any())).thenAnswer((_) async => dto); - - final example = makeTestModel(); - - final result = await repo.updateMyProfile( - example, - avatarPath: "/local/avatar.png", - bannerPath: "/local/banner.png", + test('getProfile maps ProfileDto to UserModel correctly', () async { + final api = FakeProfileApiService() + ..profile = ProfileDto( + id: 1, + userId: 1, + name: 'Hossam', + user: {'username': 'hossam'}, + followersCount: 10, + followingCount: 5, + ); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], ); - verify(() => api.uploadProfilePicture("/local/avatar.png")).called(1); - verify(() => api.uploadBanner("/local/banner.png")).called(1); + final repo = container.read(profileRepositoryProvider); + final user = await repo.getProfile('hossam'); - expect(result.avatarImage, "https://cdn/avatar.jpg"); - expect(result.bannerImage, "https://cdn/banner.jpg"); + expect(user.name, 'Hossam'); + expect(user.username, 'hossam'); + expect(user.followersCount, 10); + expect(user.followingCount, 5); }); } diff --git a/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart new file mode 100644 index 0000000..05f7d99 --- /dev/null +++ b/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lam7a/features/profile/ui/viewmodel/profile_likes_pagination.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + test('loads initial liked posts', () async { + final fakeApi = FakeProfileApiService() + ..likes = [ + { + "postId": 5, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "Liked post", + "likesCount": 5, + "commentsCount": 1, + "retweetsCount": 0, + "isLikedByMe": true, + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profileLikesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + final state = container.read(profileLikesProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'Liked post'); + expect(state.hasMore, false); + }); +} diff --git a/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart new file mode 100644 index 0000000..8fed9d8 --- /dev/null +++ b/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_pagination.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + test('loads initial profile posts', () async { + final fakeApi = FakeProfileApiService() + ..posts = [ + { + "postId": 1, + "userId": "1", + "username": "test", + "text": "Hello", + "likesCount": 1, + "commentsCount": 0, + "retweetsCount": 0, + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + + addTearDown(container.dispose); + + // 🔹 Read notifier to trigger build() + final state = container.read(profilePostsProvider('1')); + + // 🔹 Allow async loadInitial() to finish + await Future.delayed(const Duration(milliseconds: 10)); + + final updatedState = container.read(profilePostsProvider('1')); + + expect(updatedState.items.length, 1); + expect(updatedState.items.first.body, 'Hello'); + }); +} diff --git a/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart new file mode 100644 index 0000000..e5daa04 --- /dev/null +++ b/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lam7a/features/profile/ui/viewmodel/profile_replies_pagination.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + test('loads initial profile replies', () async { + final fakeApi = FakeProfileApiService() + ..replies = [ + { + "postId": 10, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "This is a reply", + "likesCount": 2, + "commentsCount": 0, + "retweetsCount": 0, + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profileRepliesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'This is a reply'); + expect(state.hasMore, false); + }); +} diff --git a/lam7a/test/profile/viewmodel/profile_viewmodel_test.dart b/lam7a/test/profile/viewmodel/profile_viewmodel_test.dart deleted file mode 100644 index 6d2fc0d..0000000 --- a/lam7a/test/profile/viewmodel/profile_viewmodel_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; -import 'package:lam7a/features/profile/repository/profile_repository.dart'; -import 'package:riverpod/riverpod.dart'; -import '../helpers/profile_test_helpers.dart'; -import 'package:lam7a/features/profile/model/profile_model.dart'; - -class FakeRepo implements ProfileRepository { - final model = makeTestModel(); - - @override - Future getProfile(String username) async => model; - - @override noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -void main() { - test('profileViewModel returns expected ProfileModel', () async { - final container = ProviderContainer( - overrides: [ - profileRepositoryProvider.overrideWithValue(FakeRepo()), - ], - ); - - final result = - await container.read(profileViewModelProvider("hossam").future); - - expect(result.displayName, "hossam mohamed"); - expect(result.handle, "hossam.ho8814"); - - container.dispose(); - }); -} diff --git a/lam7a/test/profile/widgets/blocked_profile_view_test.dart b/lam7a/test/profile/widgets/blocked_profile_view_test.dart new file mode 100644 index 0000000..dd18f69 --- /dev/null +++ b/lam7a/test/profile/widgets/blocked_profile_view_test.dart @@ -0,0 +1,41 @@ +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/blocked_profile_view.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + testWidgets('renders blocked profile and unblocks user', + (tester) async { + bool unblocked = false; + + final fakeApi = FakeProfileApiService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp( + home: BlockedProfileView( + username: 'blocked_user', + userId: 99, + onUnblock: () => unblocked = true, + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('blocked_profile_screen')), + findsOneWidget); + expect(find.text('You blocked @blocked_user'), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('blocked_profile_unblock_button'))); + await tester.pumpAndSettle(); + + expect(unblocked, true); + }); +} diff --git a/lam7a/test/profile/widgets/edit_profile_page_test.dart b/lam7a/test/profile/widgets/edit_profile_page_test.dart new file mode 100644 index 0000000..459a9f8 --- /dev/null +++ b/lam7a/test/profile/widgets/edit_profile_page_test.dart @@ -0,0 +1,46 @@ +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/view/edit_profile_page.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + testWidgets('valid profile edit saves and pops', (tester) async { + final fakeApi = FakeProfileApiService(); + + final user = UserModel( + id: 1, + profileId: 1, + username: 'hossam', + name: 'Hossam Old', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp( + home: EditProfilePage(user: user), + ), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_name_field')), + 'Hossam New', + ); + + await tester.tap( + find.byKey(const ValueKey('edit_profile_save_button')), + ); + + await tester.pumpAndSettle(); + + // Page should be popped → Edit profile no longer visible + expect(find.text('Edit profile'), findsNothing); + }); +} diff --git a/lam7a/test/profile/widgets/profile_header_widget_test.dart b/lam7a/test/profile/widgets/profile_header_widget_test.dart deleted file mode 100644 index f352659..0000000 --- a/lam7a/test/profile/widgets/profile_header_widget_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_image_mock/network_image_mock.dart'; - -import 'package:lam7a/features/profile/ui/widgets/profile_header_widget.dart'; -import '../helpers/profile_test_helpers.dart'; - -void main() { - testWidgets("ProfileHeaderWidget shows name, handle & follower counts", - (tester) async { - final model = makeTestModel(); - - await mockNetworkImagesFor(() async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ProfileHeaderWidget( - profile: model, - isOwnProfile: true, - onEdited: () {}, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Basic info - expect(find.text("hossam mohamed"), findsOneWidget); - expect(find.text("@hossam.ho8814"), findsOneWidget); - - // Followers RichText finder - final followersRichText = find.byWidgetPredicate((widget) { - if (widget is RichText) { - final text = widget.text.toPlainText(); - return text.contains("3") && text.contains("Followers"); - } - return false; - }); - - // Following RichText finder - final followingRichText = find.byWidgetPredicate((widget) { - if (widget is RichText) { - final text = widget.text.toPlainText(); - return text.contains("7") && text.contains("Following"); - } - return false; - }); - - expect(followersRichText, findsOneWidget); - expect(followingRichText, findsOneWidget); - - expect(find.text("Edit profile"), findsOneWidget); - }); - }); -} From 60f97c1d4ba570768ee5875a606f1ef2e4487a18 Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 22:04:46 +0200 Subject: [PATCH 6/9] unit test --- .../profile/helpers/fake_profile_api.dart | 135 ++++++++----- .../repository/profile_repository_test.dart | 149 ++++++++++++++ .../widgets/edit_profile_page_test.dart | 184 ++++++++++++++++++ .../followers_following_page_test.dart | 139 +++++++++++++ 4 files changed, 559 insertions(+), 48 deletions(-) create mode 100644 lam7a/test/profile/widgets/followers_following_page_test.dart diff --git a/lam7a/test/profile/helpers/fake_profile_api.dart b/lam7a/test/profile/helpers/fake_profile_api.dart index 97f3cba..511e0cb 100644 --- a/lam7a/test/profile/helpers/fake_profile_api.dart +++ b/lam7a/test/profile/helpers/fake_profile_api.dart @@ -2,97 +2,136 @@ import 'package:lam7a/features/profile/services/profile_api_service.dart'; import 'package:lam7a/features/profile/dtos/profile_dto.dart'; class FakeProfileApiService implements ProfileApiService { + /// ---------- Configurable test data ---------- + ProfileDto? profile; + ProfileDto? myProfile; + + List> followers = []; + List> following = []; + List> posts = []; - List> likes = []; List> replies = []; + List> likes = []; - ProfileDto? profile; + /// ---------- Call tracking (for assertions) ---------- + bool followCalled = false; + bool unfollowCalled = false; + bool muteCalled = false; + bool unmuteCalled = false; + bool blockCalled = false; + bool unblockCalled = false; + bool updateCalled = false; - // -------- PROFILE -------- + /// ---------- PROFILE ---------- @override Future getProfileByUsername(String username) async { + if (profile == null) { + throw StateError('FakeProfileApiService.profile is null'); + } return profile!; } @override Future getMyProfile() async { - return profile!; + if (myProfile == null) { + throw StateError('FakeProfileApiService.myProfile is null'); + } + return myProfile!; } - // -------- PAGINATION -------- @override - Future>> getProfilePosts( - String userId, - int page, - int limit, - ) async { - return posts; + Future updateMyProfile(Map data) async { + updateCalled = true; + + return ProfileDto.fromJson({ + 'id': 1, + 'user_id': 1, + 'name': data['name'], + 'user': {'username': 'test'}, + }); } + /// ---------- MEDIA ---------- @override - Future>> getProfileLikes( - String userId, - int page, - int limit, - ) async { - return likes; + Future uploadProfilePicture(String path) async { + return 'https://fake.cdn/avatar.png'; } @override - Future>> getProfileReplies( - String userId, - int page, - int limit, - ) async { - return replies; + Future uploadBanner(String path) async { + return 'https://fake.cdn/banner.png'; } - // -------- UNUSED METHODS (safe no-op) -------- + /// ---------- FOLLOW / BLOCK ---------- @override - Future followUser(int id) async {} + Future followUser(int id) async { + followCalled = true; + } @override - Future unfollowUser(int id) async {} + Future unfollowUser(int id) async { + unfollowCalled = true; + } @override - Future muteUser(int id) async {} + Future muteUser(int id) async { + muteCalled = true; + } @override - Future unmuteUser(int id) async {} + Future unmuteUser(int id) async { + unmuteCalled = true; + } @override - Future blockUser(int id) async {} + Future blockUser(int id) async { + blockCalled = true; + } @override - Future unblockUser(int id) async {} + Future unblockUser(int id) async { + unblockCalled = true; + } + /// ---------- FOLLOWERS ---------- @override - Future>> getFollowers(int id) async => []; + Future>> getFollowers(int id) async { + return followers; + } @override - Future>> getFollowing(int id) async => []; + Future>> getFollowing(int id) async { + return following; + } + /// ---------- PAGINATION ---------- @override - Future uploadProfilePicture(String path) async => path; + Future>> getProfilePosts( + String userId, + int page, + int limit, + ) async { + return posts; + } @override - Future uploadBanner(String path) async => path; + Future>> getProfileReplies( + String userId, + int page, + int limit, + ) async { + return replies; + } @override - Future updateMyProfile(Map body) async { - return ProfileDto( - id: 1, - userId: 1, - name: body['name'], - bio: body['bio'], - location: body['location'], - website: body['website'], - birthDate: body['birth_date'], - profileImageUrl: body['profile_image_url'], - bannerImageUrl: body['banner_image_url'], - followersCount: 0, - followingCount: 0, - ); + Future>> getProfileLikes( + String userId, + int page, + int limit, + ) async { + return likes; } + + } diff --git a/lam7a/test/profile/repository/profile_repository_test.dart b/lam7a/test/profile/repository/profile_repository_test.dart index bf7f15f..0197232 100644 --- a/lam7a/test/profile/repository/profile_repository_test.dart +++ b/lam7a/test/profile/repository/profile_repository_test.dart @@ -4,6 +4,7 @@ import 'package:lam7a/features/profile/repository/profile_repository.dart'; import '../helpers/fake_profile_api.dart'; import 'package:lam7a/features/profile/services/profile_api_service.dart'; import 'package:lam7a/features/profile/dtos/profile_dto.dart'; +import 'package:lam7a/core/models/user_model.dart'; void main() { test('getProfile maps ProfileDto to UserModel correctly', () async { @@ -31,4 +32,152 @@ void main() { expect(user.followersCount, 10); expect(user.followingCount, 5); }); + + test('getMyProfile maps dto correctly', () async { + final api = FakeProfileApiService() + ..myProfile = ProfileDto( + id: 2, + userId: 2, + name: 'Me', + user: {'username': 'me'}, + ); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], + ); + + final repo = container.read(profileRepositoryProvider); + final user = await repo.getMyProfile(); + + expect(user.username, 'me'); + }); + + test('followUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.followUser(1); + expect(api.followCalled, true); + }); + + test('unfollowUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.unfollowUser(1); + expect(api.unfollowCalled, true); + }); + + test('muteUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.muteUser(1); + expect(api.muteCalled, true); + }); + + test('blockUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.blockUser(1); + expect(api.blockCalled, true); + }); + + + test('updateMyProfile uploads avatar and banner when local paths provided', () async { + final api = FakeProfileApiService() + ..profile = ProfileDto( + id: 1, + userId: 1, + name: 'Old', + user: {'username': 'test'}, + ); + + final repo = ProfileRepository(api); + + final updated = await repo.updateMyProfile( + UserModel(id: 1, profileId: 1, name: 'New'), + avatarPath: '/local/avatar.png', + bannerPath: '/local/banner.png', + ); + + expect(updated.name, 'New'); + }); + + test('updateMyProfile does not upload http urls', () async { + final api = FakeProfileApiService() + ..profile = ProfileDto( + id: 1, + userId: 1, + name: 'Old', + user: {'username': 'test'}, + profileImageUrl: 'http://existing/avatar.png', + bannerImageUrl: 'http://existing/banner.png', + ); + + final repo = ProfileRepository(api); + + final updated = await repo.updateMyProfile( + UserModel( + id: 1, + profileId: 1, + name: 'Updated', + profileImageUrl: 'http://existing/avatar.png', + bannerImageUrl: 'http://existing/banner.png', + ), + avatarPath: 'http://existing/avatar.png', + bannerPath: 'http://existing/banner.png', + ); + + expect(updated.profileImageUrl, 'http://existing/avatar.png'); + expect(updated.bannerImageUrl, 'http://existing/banner.png'); + }); + + test('unmuteUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.unmuteUser(1); + expect(api.unmuteCalled, true); + }); + + + test('unblockUser calls api', () async { + final api = FakeProfileApiService(); + final repo = ProfileRepository(api); + + await repo.unblockUser(1); + expect(api.unblockCalled, true); + }); + + test('fromDto defaults states to false when flags missing', () async { + final api = FakeProfileApiService() + ..profile = ProfileDto.fromJson({ + 'id': 1, + 'user_id': 1, + 'name': 'Test', + 'user': {'username': 'test'}, + }); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], + ); + + final repo = container.read(profileRepositoryProvider); + final user = await repo.getProfile('test'); + + expect(user.stateMute, ProfileStateOfMute.notmuted); + expect(user.stateBlocked, ProfileStateBlocked.notblocked); + expect(user.stateFollowingMe, ProfileStateFollowingMe.notfollowingme); + expect(user.followersCount, 0); + expect(user.followingCount, 0); + }); + + + } diff --git a/lam7a/test/profile/widgets/edit_profile_page_test.dart b/lam7a/test/profile/widgets/edit_profile_page_test.dart index 459a9f8..ca234f2 100644 --- a/lam7a/test/profile/widgets/edit_profile_page_test.dart +++ b/lam7a/test/profile/widgets/edit_profile_page_test.dart @@ -43,4 +43,188 @@ void main() { // Page should be popped → Edit profile no longer visible expect(find.text('Edit profile'), findsNothing); }); + + testWidgets('shows error when name is invalid', (tester) async { + final fakeApi = FakeProfileApiService(); + + final user = UserModel( + id: 1, + profileId: 1, + name: 'Old', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp( + home: EditProfilePage(user: user), + ), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_name_field')), + ' ', // invalid + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Name must be'), findsOneWidget); + }); + + testWidgets('shows error when birth year is too recent', (tester) async { + final fakeApi = FakeProfileApiService(); + + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp( + home: EditProfilePage(user: user), + ), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_birthdate_field')), + '2020-01-01', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Birth year must be'), findsOneWidget); + }); + + + testWidgets('close button pops page', (tester) async { + final user = UserModel(id: 1, profileId: 1, name: 'Name'); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => EditProfilePage(user: user), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_close_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(EditProfilePage), findsNothing); + }); + + + testWidgets('normalizes website without http', (tester) async { + final fakeApi = FakeProfileApiService(); + + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp( + home: EditProfilePage(user: user), + ), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_website_field')), + 'example.com', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pumpAndSettle(); + + expect(fakeApi.updateCalled, true); + }); + + testWidgets('shows error when website is invalid', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_website_field')), + 'bad url 😅', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('valid website'), findsOneWidget); + }); + + testWidgets('shows error when birth year is invalid format', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_birthdate_field')), + 'abcd-01-01', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Birth year'), findsOneWidget); + }); + + testWidgets('shows error when name is too short', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Ok Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_name_field')), + 'abc', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Name must be'), findsOneWidget); + }); + + + + } diff --git a/lam7a/test/profile/widgets/followers_following_page_test.dart b/lam7a/test/profile/widgets/followers_following_page_test.dart new file mode 100644 index 0000000..e720087 --- /dev/null +++ b/lam7a/test/profile/widgets/followers_following_page_test.dart @@ -0,0 +1,139 @@ +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/view/followers_following_page.dart'; +import 'package:lam7a/features/profile/services/profile_api_service.dart'; +import '../helpers/fake_profile_api.dart'; + +void main() { + Widget _wrap(Widget child, FakeProfileApiService api) { + return ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], + child: MaterialApp(home: child), + ); + } + + + testWidgets('shows empty followers and following states', (tester) async { + final api = FakeProfileApiService() + ..followers = [] + ..following = []; + + await tester.pumpWidget( + _wrap( + const FollowersFollowingPage(userId: 1), + api, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('followers_empty')), findsOneWidget); + + // Switch tab + await tester.tap(find.byKey(const ValueKey('following_tab'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('following_empty')), findsOneWidget); + }); + + testWidgets('renders followers list', (tester) async { + final api = FakeProfileApiService() + ..followers = [ + { + 'id': 10, + 'username': 'follower1', + 'name': 'Follower One', + } + ] + ..following = []; + + await tester.pumpWidget( + _wrap( + const FollowersFollowingPage(userId: 1), + api, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('followers_list')), findsOneWidget); + expect(find.byKey(const ValueKey('user_tile_10')), findsOneWidget); + expect(find.byKey(const ValueKey('user_name_10')), findsOneWidget); + }); + + + testWidgets('renders following list when switching tab', (tester) async { + final api = FakeProfileApiService() + ..followers = [] + ..following = [ + { + 'id': 20, + 'username': 'following1', + 'name': 'Following One', + } + ]; + + await tester.pumpWidget( + _wrap( + const FollowersFollowingPage(userId: 1), + api, + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('following_tab'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('following_list')), findsOneWidget); + expect(find.byKey(const ValueKey('user_tile_20')), findsOneWidget); + expect(find.byKey(const ValueKey('user_name_20')), findsOneWidget); + }); + + + testWidgets('pop returns false when no changes happened', (tester) async { + final api = FakeProfileApiService() + ..followers = [] + ..following = []; + + bool? popResult; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], + child: MaterialApp( + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + popResult = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const FollowersFollowingPage(userId: 1), + ), + ); + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(popResult, false); + }); +} From 719a7ad1bb86a8f1eae186ffc9ee7ab402fa893e Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Mon, 15 Dec 2025 22:27:00 +0200 Subject: [PATCH 7/9] tests --- .../profile_likes_pagination_test.dart | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart index 05f7d99..fc94b64 100644 --- a/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart +++ b/lam7a/test/profile/viewmodel/profile_likes_pagination_test.dart @@ -42,4 +42,169 @@ void main() { expect(state.items.first.body, 'Liked post'); expect(state.hasMore, false); }); + + test('handles empty likes list', () async { + final fakeApi = FakeProfileApiService()..likes = []; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(profileLikesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + final state = container.read(profileLikesProvider('1')); + + expect(state.items, isEmpty); + expect(state.hasMore, false); + }); + + test('sets hasMore = true when page is full', () async { + final fakeApi = FakeProfileApiService() + ..likes = List.generate( + 20, // assume pageSize = 20 + (i) => { + "postId": i, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "Post $i", + "date": DateTime.now().toIso8601String(), + }, + ); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(profileLikesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + final state = container.read(profileLikesProvider('1')); + + expect(state.items.length, 20); + expect(state.hasMore, true); + }); + + test('loadMore appends items', () async { + final fakeApi = FakeProfileApiService() + ..likes = [ + { + "postId": 1, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "First", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(profileLikesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + // Simulate second page + fakeApi.likes = [ + { + "postId": 2, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "Second", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.loadMore(); + + final state = container.read(profileLikesProvider('1')); + + expect(state.items.length, 2); + expect(state.items.last.body, 'Second'); + }); + + test('refresh reloads likes', () async { + final fakeApi = FakeProfileApiService() + ..likes = [ + { + "postId": 1, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "Old", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(profileLikesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + // Change backend response + fakeApi.likes = [ + { + "postId": 2, + "userId": "1", + "username": "tester", + "name": "Tester", + "text": "New", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.refresh(); + + final state = container.read(profileLikesProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'New'); + }); + + test('normalizeTweetJson maps fields correctly', () { + final notifier = ProfileLikesPaginationNotifier('1'); + + final result = notifier.normalizeTweetJson({ + "postId": 10, + "text": "Hello", + "date": "2024-01-01", + "userId": 1, + "username": "tester", + "name": "Tester", + "likesCount": 3, + }); + + expect(result['id'], 10); + expect(result['text'], 'Hello'); + expect(result['likesCount'], 3); + expect(result['user']['username'], 'tester'); + expect(result['isRepost'], false); + }); + + } From 6cf3fd16dbdfd934f23f67e7da519b5cc6709f50 Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Tue, 16 Dec 2025 00:13:21 +0200 Subject: [PATCH 8/9] unit tests for sevirals --- .../profile/ui/widgets/profile_card.dart | 1 + .../widgets/profile_notifications_sheet.dart | 1 + .../profile/ui/widgets/profile_tab_bar.dart | 1 + .../profile/helpers/fake_profile_api.dart | 34 +++- .../profile_posts_pagination_test.dart | 186 +++++++++++++++++ .../profile_replies_pagination_test.dart | 187 ++++++++++++++++++ .../widgets/edit_profile_page_test.dart | 176 +++++++++++++++++ .../profile/widgets/follow_button_test.dart | 87 ++++++++ .../followers_following_page_test.dart | 58 +++++- .../profile/widgets/profile_screen_test.dart | 129 ++++++++++++ .../widgets/profile_screen_test_helpers.dart | 17 ++ 11 files changed, 866 insertions(+), 11 deletions(-) create mode 100644 lam7a/test/profile/widgets/follow_button_test.dart create mode 100644 lam7a/test/profile/widgets/profile_screen_test.dart create mode 100644 lam7a/test/profile/widgets/profile_screen_test_helpers.dart diff --git a/lam7a/lib/features/profile/ui/widgets/profile_card.dart b/lam7a/lib/features/profile/ui/widgets/profile_card.dart index ef5a096..e4911b8 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_card.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_card.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // lib/features/profile/ui/widgets/profile_card.dart import 'package:flutter/material.dart'; diff --git a/lam7a/lib/features/profile/ui/widgets/profile_notifications_sheet.dart b/lam7a/lib/features/profile/ui/widgets/profile_notifications_sheet.dart index c60a8fa..49af5d6 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_notifications_sheet.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_notifications_sheet.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // lib/features/profile/ui/widgets/profile_notifications_sheet.dart import 'package:flutter/material.dart'; diff --git a/lam7a/lib/features/profile/ui/widgets/profile_tab_bar.dart b/lam7a/lib/features/profile/ui/widgets/profile_tab_bar.dart index 116997b..ccb73c1 100644 --- a/lam7a/lib/features/profile/ui/widgets/profile_tab_bar.dart +++ b/lam7a/lib/features/profile/ui/widgets/profile_tab_bar.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:flutter/material.dart'; class ProfileTabBar extends StatelessWidget { diff --git a/lam7a/test/profile/helpers/fake_profile_api.dart b/lam7a/test/profile/helpers/fake_profile_api.dart index 59007e8..1f36bfa 100644 --- a/lam7a/test/profile/helpers/fake_profile_api.dart +++ b/lam7a/test/profile/helpers/fake_profile_api.dart @@ -21,6 +21,10 @@ class FakeProfileApiService implements ProfileApiService { bool blockCalled = false; bool unblockCalled = false; bool updateCalled = false; + bool delayUpdate = false; + bool throwOnUpdate = false; + bool delayFollow = false; + /// ---------- PROFILE ---------- @override @@ -41,16 +45,31 @@ class FakeProfileApiService implements ProfileApiService { @override Future updateMyProfile(Map data) async { + if (delayUpdate) { + await Future.delayed(const Duration(seconds: 1)); + } + + if (throwOnUpdate) { + throw Exception('update failed'); + } + updateCalled = true; - return ProfileDto.fromJson({ - 'id': 1, - 'user_id': 1, - 'name': data['name'], - 'user': {'username': 'test'}, - }); + return ProfileDto( + id: 1, + userId: 1, + name: data['name'], + bio: data['bio'], + location: data['location'], + website: data['website'], + birthDate: data['birth_date'], + profileImageUrl: data['profile_image_url'], + bannerImageUrl: data['banner_image_url'], + user: {'username': 'test'}, + ); } + /// ---------- MEDIA ---------- @override Future uploadProfilePicture(String path) async { @@ -66,6 +85,9 @@ class FakeProfileApiService implements ProfileApiService { @override Future followUser(int id) async { followCalled = true; + if (delayFollow) { + await Future.delayed(const Duration(milliseconds: 50)); + } } @override diff --git a/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart index 8fed9d8..309e09a 100644 --- a/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart +++ b/lam7a/test/profile/viewmodel/profile_posts_pagination_test.dart @@ -40,4 +40,190 @@ void main() { expect(updatedState.items.length, 1); expect(updatedState.items.first.body, 'Hello'); }); + + test('handles empty posts list', () async { + final fakeApi = FakeProfileApiService()..posts = []; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profilePostsProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profilePostsProvider('1')); + + expect(state.items, isEmpty); + expect(state.hasMore, false); + }); + test('uses originalPostData when present', () async { + final fakeApi = FakeProfileApiService() + ..posts = [ + { + "originalPostData": { + "postId": 99, + "userId": "1", + "username": "orig", + "name": "Original", + "text": "Original post", + "date": DateTime.now().toIso8601String(), + } + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profilePostsProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profilePostsProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'Original post'); + }); + + test('sets hasMore true when page is full', () async { + final fakeApi = FakeProfileApiService() + ..posts = List.generate( + 20, // pageSize + (i) => { + "postId": i, + "userId": "1", + "username": "test", + "text": "Post $i", + "date": DateTime.now().toIso8601String(), + }, + ); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profilePostsProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profilePostsProvider('1')); + + expect(state.items.length, 20); + expect(state.hasMore, true); + }); + + test('loadMore appends posts', () async { + final fakeApi = FakeProfileApiService() + ..posts = [ + { + "postId": 1, + "userId": "1", + "username": "test", + "text": "First", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profilePostsProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + fakeApi.posts = [ + { + "postId": 2, + "userId": "1", + "username": "test", + "text": "Second", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.loadMore(); + + final state = container.read(profilePostsProvider('1')); + + expect(state.items.length, 2); + expect(state.items.last.body, 'Second'); + }); + + test('refresh reloads posts', () async { + final fakeApi = FakeProfileApiService() + ..posts = [ + { + "postId": 1, + "userId": "1", + "username": "test", + "text": "Old", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profilePostsProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + fakeApi.posts = [ + { + "postId": 2, + "userId": "1", + "username": "test", + "text": "New", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.refresh(); + + final state = container.read(profilePostsProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'New'); + }); + + test('normalizeTweetJson maps fields correctly', () { + final notifier = ProfilePostsPaginationNotifier('1'); + + final json = notifier.normalizeTweetJson({ + "postId": 10, + "text": "Hello", + "date": "2024-01-01", + "userId": 1, + "username": "tester", + "name": "Tester", + "likesCount": 3, + }); + + expect(json['id'], 10); + expect(json['text'], 'Hello'); + expect(json['likesCount'], 3); + expect(json['user']['username'], 'tester'); + expect(json['isRepost'], false); + }); + + } diff --git a/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart b/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart index e5daa04..6ca5370 100644 --- a/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart +++ b/lam7a/test/profile/viewmodel/profile_replies_pagination_test.dart @@ -41,4 +41,191 @@ void main() { expect(state.items.first.body, 'This is a reply'); expect(state.hasMore, false); }); + + test('handles empty replies list', () async { + final fakeApi = FakeProfileApiService()..replies = []; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profileRepliesProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items, isEmpty); + expect(state.hasMore, false); + }); + + test('uses originalPostData when present', () async { + final fakeApi = FakeProfileApiService() + ..replies = [ + { + "originalPostData": { + "postId": 99, + "userId": "1", + "username": "orig", + "name": "Original", + "text": "Original reply", + "date": DateTime.now().toIso8601String(), + } + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profileRepliesProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'Original reply'); + }); + + test('sets hasMore true when page is full', () async { + final fakeApi = FakeProfileApiService() + ..replies = List.generate( + 20, // pageSize + (i) => { + "postId": i, + "userId": "1", + "username": "tester", + "text": "Reply $i", + "date": DateTime.now().toIso8601String(), + }, + ); + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + container.read(profileRepliesProvider('1')); + await Future.delayed(Duration.zero); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items.length, 20); + expect(state.hasMore, true); + }); + + test('loadMore appends replies', () async { + final fakeApi = FakeProfileApiService() + ..replies = [ + { + "postId": 1, + "userId": "1", + "username": "tester", + "text": "First reply", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profileRepliesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + fakeApi.replies = [ + { + "postId": 2, + "userId": "1", + "username": "tester", + "text": "Second reply", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.loadMore(); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items.length, 2); + expect(state.items.last.body, 'Second reply'); + }); + + test('refresh reloads replies', () async { + final fakeApi = FakeProfileApiService() + ..replies = [ + { + "postId": 1, + "userId": "1", + "username": "tester", + "text": "Old reply", + "date": DateTime.now().toIso8601String(), + } + ]; + + final container = ProviderContainer( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + ); + addTearDown(container.dispose); + + final notifier = + container.read(profileRepliesProvider('1').notifier); + + notifier.loadInitial(); + await Future.delayed(Duration.zero); + + fakeApi.replies = [ + { + "postId": 2, + "userId": "1", + "username": "tester", + "text": "New reply", + "date": DateTime.now().toIso8601String(), + } + ]; + + await notifier.refresh(); + + final state = container.read(profileRepliesProvider('1')); + + expect(state.items.length, 1); + expect(state.items.first.body, 'New reply'); + }); + + test('normalizeTweetJson maps fields correctly', () { + final notifier = ProfileRepliesPaginationNotifier('1'); + + final json = notifier.normalizeTweetJson({ + "postId": 10, + "text": "Reply text", + "date": "2024-01-01", + "userId": 1, + "username": "tester", + "name": "Tester", + "likesCount": 3, + }); + + expect(json['id'], 10); + expect(json['text'], 'Reply text'); + expect(json['likesCount'], 3); + expect(json['user']['username'], 'tester'); + expect(json['isRepost'], false); + }); + + } diff --git a/lam7a/test/profile/widgets/edit_profile_page_test.dart b/lam7a/test/profile/widgets/edit_profile_page_test.dart index 459a9f8..674e33f 100644 --- a/lam7a/test/profile/widgets/edit_profile_page_test.dart +++ b/lam7a/test/profile/widgets/edit_profile_page_test.dart @@ -43,4 +43,180 @@ void main() { // Page should be popped → Edit profile no longer visible expect(find.text('Edit profile'), findsNothing); }); + + testWidgets('shows error when name is too short', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_name_field')), + 'abc', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Name must be'), findsOneWidget); + }); + + testWidgets('shows error when name is too long', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_name_field')), + 'a' * 31, + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Name must be'), findsOneWidget); + }); + testWidgets('shows error when website is invalid', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_website_field')), + 'not a url 😅', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('valid website'), findsOneWidget); + }); + + testWidgets('shows error when birth year is invalid format', (tester) async { + final fakeApi = FakeProfileApiService(); + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('edit_profile_birthdate_field')), + 'abcd-01-01', + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + expect(find.textContaining('Birth year must be'), findsOneWidget); + }); + + testWidgets('avatar picker updates image', (tester) async { + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_avatar_picker'))); + await tester.pump(); + + expect(find.byType(CircleAvatar), findsOneWidget); + }); + + testWidgets('banner picker updates image', (tester) async { + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_banner_picker'))); + await tester.pump(); + + expect(find.byType(Image), findsWidgets); + }); + +testWidgets('save button is disabled while saving', (tester) async { + final fakeApi = FakeProfileApiService() + ..delayUpdate = true; + + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + final button = + tester.widget(find.byKey(const ValueKey('edit_profile_save_button'))); + + expect(button.onPressed, isNull); +}); + +testWidgets('save button is disabled while saving', (tester) async { + final fakeApi = FakeProfileApiService() + ..delayUpdate = true; + + final user = UserModel(id: 1, profileId: 1, name: 'Valid Name'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(fakeApi), + ], + child: MaterialApp(home: EditProfilePage(user: user)), + ), + ); + + await tester.tap(find.byKey(const ValueKey('edit_profile_save_button'))); + await tester.pump(); + + final button = tester.widget( + find.byKey(const ValueKey('edit_profile_save_button')), + ); + + expect(button.onPressed, isNull); +}); + + } diff --git a/lam7a/test/profile/widgets/follow_button_test.dart b/lam7a/test/profile/widgets/follow_button_test.dart new file mode 100644 index 0000000..a00e0a4 --- /dev/null +++ b/lam7a/test/profile/widgets/follow_button_test.dart @@ -0,0 +1,87 @@ +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/follow_button.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import 'package:lam7a/core/models/auth_state.dart'; +import 'package:lam7a/core/providers/authentication.dart'; + +void main() { + final baseUser = UserModel( + id: 1, + profileId: 1, + username: 'alice', + name: 'Alice', + ); + + testWidgets('shows "Follow" when not following and not following me', + (tester) async { + final user = baseUser.copyWith( + stateFollow: ProfileStateOfFollow.notfollowing, + stateFollowingMe: ProfileStateFollowingMe.notfollowingme, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authenticationProvider.overrideWithValue( + const AuthState(isAuthenticated: true, user: null), + ), + ], + child: MaterialApp( + home: Scaffold(body: FollowButton(user: user)), + ), + ), + ); + + expect(find.byKey(const ValueKey('follow_button')), findsOneWidget); + expect(find.byKey(const ValueKey('follow_button_label')), findsOneWidget); + expect(find.text('Follow'), findsOneWidget); + }); + + testWidgets('shows "Follow Back" when following me but not following', + (tester) async { + final user = baseUser.copyWith( + stateFollow: ProfileStateOfFollow.notfollowing, + stateFollowingMe: ProfileStateFollowingMe.followingme, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authenticationProvider.overrideWithValue( + const AuthState(isAuthenticated: true, user: null), + ), + ], + child: MaterialApp( + home: Scaffold(body: FollowButton(user: user)), + ), + ), + ); + + expect(find.text('Follow Back'), findsOneWidget); + }); + + testWidgets('shows "Following" when already following', (tester) async { + final user = baseUser.copyWith( + stateFollow: ProfileStateOfFollow.following, + stateFollowingMe: ProfileStateFollowingMe.notfollowingme, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + authenticationProvider.overrideWithValue( + const AuthState(isAuthenticated: true, user: null), + ), + ], + child: MaterialApp( + home: Scaffold(body: FollowButton(user: user)), + ), + ), + ); + + expect(find.text('Following'), findsOneWidget); + }); +} diff --git a/lam7a/test/profile/widgets/followers_following_page_test.dart b/lam7a/test/profile/widgets/followers_following_page_test.dart index e720087..7483718 100644 --- a/lam7a/test/profile/widgets/followers_following_page_test.dart +++ b/lam7a/test/profile/widgets/followers_following_page_test.dart @@ -16,7 +16,6 @@ void main() { ); } - testWidgets('shows empty followers and following states', (tester) async { final api = FakeProfileApiService() ..followers = [] @@ -33,7 +32,6 @@ void main() { expect(find.byKey(const ValueKey('followers_empty')), findsOneWidget); - // Switch tab await tester.tap(find.byKey(const ValueKey('following_tab'))); await tester.pumpAndSettle(); @@ -47,6 +45,7 @@ void main() { 'id': 10, 'username': 'follower1', 'name': 'Follower One', + 'bio': 'Hello', } ] ..following = []; @@ -63,9 +62,9 @@ void main() { expect(find.byKey(const ValueKey('followers_list')), findsOneWidget); expect(find.byKey(const ValueKey('user_tile_10')), findsOneWidget); expect(find.byKey(const ValueKey('user_name_10')), findsOneWidget); + expect(find.text('Follower One'), findsOneWidget); }); - testWidgets('renders following list when switching tab', (tester) async { final api = FakeProfileApiService() ..followers = [] @@ -91,10 +90,9 @@ void main() { expect(find.byKey(const ValueKey('following_list')), findsOneWidget); expect(find.byKey(const ValueKey('user_tile_20')), findsOneWidget); - expect(find.byKey(const ValueKey('user_name_20')), findsOneWidget); + expect(find.text('Following One'), findsOneWidget); }); - testWidgets('pop returns false when no changes happened', (tester) async { final api = FakeProfileApiService() ..followers = [] @@ -136,4 +134,54 @@ void main() { expect(popResult, false); }); + + testWidgets('tapping user tile marks page as changed', (tester) async { + final api = FakeProfileApiService() + ..followers = [ + {'id': 1, 'username': 'user1', 'name': 'User One'} + ] + ..following = []; + + bool? result; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileApiServiceProvider.overrideWithValue(api), + ], + child: MaterialApp( + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const FollowersFollowingPage(userId: 1), + ), + ); + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('user_tile_1'))); + await tester.pumpAndSettle(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(result, true); + }); + + + } diff --git a/lam7a/test/profile/widgets/profile_screen_test.dart b/lam7a/test/profile/widgets/profile_screen_test.dart new file mode 100644 index 0000000..d8f113c --- /dev/null +++ b/lam7a/test/profile/widgets/profile_screen_test.dart @@ -0,0 +1,129 @@ +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/view/profile_screen.dart'; +import 'package:lam7a/features/profile/ui/widgets/blocked_profile_view.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_viewmodel.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_posts_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_replies_pagination.dart'; +import 'package:lam7a/features/profile/ui/viewmodel/profile_likes_pagination.dart'; +import 'package:lam7a/core/providers/authentication.dart'; +import 'package:lam7a/features/common/states/pagination_state.dart'; +import 'package:lam7a/features/profile/ui/widgets/profile_header_widget.dart'; + + + +final testUser = UserModel( + id: 1, + profileId: 1, + username: 'hossam', + name: 'Hossam', +); + + + + +void main() { + testWidgets('shows message when no username provided', (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp(home: ProfileScreen()), + ), + ); + + expect(find.text('No username provided'), findsOneWidget); + }); + + testWidgets('shows loading while profile loads', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileViewModelProvider('hossam') + .overrideWithValue(const AsyncValue.loading()), + ], + child: MaterialApp( + onGenerateRoute: (_) => MaterialPageRoute( + settings: const RouteSettings(arguments: {'username': 'hossam'}), + builder: (_) => const ProfileScreen(), + ), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error message', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileViewModelProvider('hossam') + .overrideWithValue( + AsyncValue.error('boom', StackTrace.empty), + ), + ], + child: MaterialApp( + onGenerateRoute: (_) => MaterialPageRoute( + settings: const RouteSettings(arguments: {'username': 'hossam'}), + builder: (_) => const ProfileScreen(), + ), + ), + ), + ); + + expect(find.textContaining('Error:'), findsOneWidget); + }); + + testWidgets('renders blocked profile view', (tester) async { + final blockedUser = + testUser.copyWith(stateBlocked: ProfileStateBlocked.blocked); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileViewModelProvider('hossam') + .overrideWithValue(AsyncValue.data(blockedUser)), + ], + child: MaterialApp( + onGenerateRoute: (_) => MaterialPageRoute( + settings: const RouteSettings(arguments: {'username': 'hossam'}), + builder: (_) => const ProfileScreen(), + ), + ), + ), + ); + + expect(find.byType(BlockedProfileView), findsOneWidget); + }); + + +testWidgets('back button pops screen', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + profileViewModelProvider('hossam') + .overrideWithValue(AsyncValue.data(testUser)), + ], + child: MaterialApp( + home: Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + settings: const RouteSettings(arguments: {'username': 'hossam'}), + builder: (_) => const ProfileScreen(), + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.byType(ProfileScreen), findsNothing); +}); + + + + +} diff --git a/lam7a/test/profile/widgets/profile_screen_test_helpers.dart b/lam7a/test/profile/widgets/profile_screen_test_helpers.dart new file mode 100644 index 0000000..b759023 --- /dev/null +++ b/lam7a/test/profile/widgets/profile_screen_test_helpers.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import 'package:lam7a/features/common/states/pagination_state.dart'; +import 'package:lam7a/features/common/models/tweet_model.dart'; + +final testUser = UserModel( + id: 1, + profileId: 1, + username: 'hossam', + name: 'Hossam', +); + +PaginationState emptyPagination() => + const PaginationState(items: [], hasMore: false, isLoading: false); + +PaginationState loadingPagination() => + const PaginationState(items: [], hasMore: false, isLoading: true); From ed7f6df802a14fa376afa68b32d4a9bdd3a1b91a Mon Sep 17 00:00:00 2001 From: HossamMo123 Date: Tue, 16 Dec 2025 03:15:32 +0200 Subject: [PATCH 9/9] bug fix for edit --- .../repository/profile_repository.dart | 8 + .../profile/services/profile_api_service.dart | 4 + .../services/profile_api_service_impl.dart | 11 ++ .../profile/ui/view/edit_profile_page.dart | 57 ++++++- .../ui/viewmodel/profile_posts_viewmodel.dart | 1 + .../profile/ui/widgets/edit_profile_form.dart | 55 +++++- .../utils/profile_tweet_mapper_test.dart | 115 +++++++++++++ .../widgets/profile_more_menu_test.dart | 159 ++++++++++++++++++ 8 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 lam7a/test/profile/utils/profile_tweet_mapper_test.dart create mode 100644 lam7a/test/profile/widgets/profile_more_menu_test.dart 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); + }); +}